diff --git a/.clang-format b/.clang-format index 5869e58..8c7534a 100644 --- a/.clang-format +++ b/.clang-format @@ -3,3 +3,12 @@ ColumnLimit: 100 DerivePointerAlignment: false PointerAlignment: Left SortIncludes: CaseSensitive +AllowShortFunctionsOnASingleLine: Empty +AllowShortBlocksOnASingleLine: Never +AllowShortIfStatementsOnASingleLine: Never +AllowShortLoopsOnASingleLine: false +AllowShortLambdasOnASingleLine: None +InsertBraces: true +SeparateDefinitionBlocks: Always +MaxEmptyLinesToKeep: 1 +KeepEmptyLinesAtTheStartOfBlocks: false diff --git a/.clang-tidy b/.clang-tidy index 22df76c..4b54401 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -6,6 +6,6 @@ Checks: >- -portability-avoid-pragma-once, -readability-magic-numbers, -readability-identifier-length -WarningsAsErrors: '' -HeaderFilterRegex: 'cpp/(include|core|cpu)/.*' +WarningsAsErrors: "" +HeaderFilterRegex: "cpp/(include|core|cpu)/.*" FormatStyle: file diff --git a/.cmake-format.yaml b/.cmake-format.yaml new file mode 100644 index 0000000..b7de0d0 --- /dev/null +++ b/.cmake-format.yaml @@ -0,0 +1,5 @@ +format: + line_width: 100 + tab_size: 2 +markup: + enable_markup: false diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2dbe7f7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +[*.{py,pyi}] +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.github/workflows/live-shutdown-windows.yml b/.github/workflows/live-shutdown-windows.yml new file mode 100644 index 0000000..62f7de5 --- /dev/null +++ b/.github/workflows/live-shutdown-windows.yml @@ -0,0 +1,80 @@ +name: Windows CLI and live-session checks + +on: + pull_request: + paths: + - .github/workflows/live-shutdown-windows.yml + - CMakeLists.txt + - cpp/** + - python/** + - docs/tutorials/** + - docs/development/tutorial-command-validation.md + - environments/**/README.md + - viewer/README.md + - pyproject.toml + - uv.lock + push: + branches: [master, marpaia/17, marpaia/24] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: windows-live-shutdown-${{ github.ref }} + cancel-in-progress: true + +jobs: + shutdown: + runs-on: windows-2025 + timeout-minutes: 20 + defaults: + run: + shell: pwsh + env: + CMAKE_BUILD_PARALLEL_LEVEL: "2" + # Exercise the host reference engine without requiring a GPU toolkit. + CMAKE_ARGS: -DCM_ENABLE_METAL=OFF -DCM_ENABLE_CUDA=OFF -DCM_BUILD_TESTS=OFF + MICROSIMULATOR_COMMAND_REPORT: build/shutdown-evidence/tutorial-commands + steps: + - name: Check out source + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Set up uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: true + python-version: "3.12" + + - name: Build CPU extension and install locked test dependencies + run: uv sync --locked --group dev + + - name: Record platform, shell, interpreter, and backend + run: | + New-Item -ItemType Directory -Force build/shutdown-evidence | Out-Null + $PSVersionTable | Out-File build/shutdown-evidence/platform.txt + [System.Environment]::OSVersion | Out-File -Append build/shutdown-evidence/platform.txt + uv run --no-sync python --version 2>&1 | Tee-Object -Append build/shutdown-evidence/platform.txt + uv run --no-sync microsimulator devices --json | Tee-Object -Append build/shutdown-evidence/platform.txt + + - name: Verify Stop, console Ctrl+C, worker draining, and same-port restart + run: >- + uv run --no-sync python -m pytest + python/tests/test_viewer_server.py python/tests/test_viewer_shutdown.py + -v --tb=short --junitxml=build/shutdown-evidence/tests.xml + + - name: Execute the documented PowerShell CLI commands + run: >- + uv run --no-sync python -m pytest python/tests/test_tutorial_commands.py + -v --tb=short --junitxml=build/shutdown-evidence/tutorial-commands.xml + + - name: Upload shutdown evidence + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: windows-live-shutdown-${{ github.sha }} + path: build/shutdown-evidence + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..751c995 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,53 @@ +name: Code quality + +on: + pull_request: + push: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: quality-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: true + - uses: actions/setup-node@v6 + with: + node-version: "24" + - name: Install pinned pnpm + run: npm install --global pnpm@10.12.1 + - run: pnpm --dir viewer install --frozen-lockfile + - name: Check all maintained files + run: uv run --isolated --locked --only-group quality python scripts/quality.py check + - name: Test quality tooling + run: uv run --isolated --locked --only-group quality python -m unittest discover -s tests/quality + - name: Type-check viewer + run: pnpm --dir viewer check + + python-types: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + CMAKE_ARGS: -DCM_ENABLE_METAL=OFF -DCM_ENABLE_CUDA=OFF -DCM_BUILD_TESTS=OFF + CMAKE_BUILD_PARALLEL_LEVEL: "2" + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: true + - run: uv sync --locked --group dev + - run: uv run --locked --no-sync pyright diff --git a/.gitignore b/.gitignore index 56980f3..b8fcfe5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ __pycache__/ .mypy_cache/ compile_commands.json results/ +feedback/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c1a63dc..4095968 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,27 +20,23 @@ repos: - id: trailing-whitespace exclude: ^docs/tutorials/devices/ - # Pinned to the version the project depends on, so a hook run and a local - # `ruff check` enforce the same rule set. Formatting is deliberately absent: - # the linter's rules, including import order, are what this tree follows. - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.16.3 - hooks: - - id: ruff - args: [--fix] - - - repo: https://github.com/pre-commit/mirrors-clang-format - rev: v19.1.7 - hooks: - - id: clang-format - types_or: [c++, c, cuda, objective-c++, metal] - files: ^cpp/ - - repo: local hooks: + - id: format + name: Format maintained files + entry: uv run --isolated --locked --only-group quality python scripts/quality.py format + language: system + types: [text] + require_serial: true + - id: quality + name: Check formatting, lint, and complexity + entry: uv run --isolated --locked --only-group quality python scripts/quality.py check + language: system + types: [text] + require_serial: true - id: pyright name: pyright - entry: uv run pyright + entry: uv run --locked pyright language: system pass_filenames: false types: [python] diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..c93fa82 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +build/ +dist/ +node_modules/ +.venv/ +uv.lock +**/pnpm-lock.yaml diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..bd07196 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "proseWrap": "preserve", + "embeddedLanguageFormatting": "off" +} diff --git a/.taplo.toml b/.taplo.toml new file mode 100644 index 0000000..5bb1488 --- /dev/null +++ b/.taplo.toml @@ -0,0 +1,3 @@ +[formatting] +column_width = 100 +reorder_keys = false diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fe1ac80 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +# Repository conventions + +Follow [CONTRIBUTING.md](CONTRIBUTING.md) for formatting, logical grouping, function limits, and validation. Run the shared quality commands rather than system formatter versions. Preserve numerical operation order and backend ownership when extracting helpers. Keep existing user work intact. diff --git a/CMakeLists.txt b/CMakeLists.txt index ff62ae9..2ad6a7a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,8 +4,7 @@ project( MicroSimulator VERSION 0.1.0 DESCRIPTION "Microfluidic simulation of microbial populations, flow, and solute transport" - LANGUAGES CXX -) + LANGUAGES CXX) option(CM_BUILD_PYTHON "Build the nanobind Python extension" ON) option(CM_BUILD_TESTS "Build C++ tests" ON) @@ -17,12 +16,21 @@ if(APPLE) endif() option(CM_ENABLE_METAL "Build the native Apple Metal backend" ${CM_METAL_DEFAULT}) -add_library(cm_core STATIC +add_library( + cm_core STATIC cpp/core/checkpoint.cpp cpp/core/contact_graph.cpp cpp/core/constraints.cpp cpp/core/coupled_rates.cpp cpp/core/mechanics_integration.cpp + cpp/core/numerics.cpp + cpp/core/stokes_flow.cpp + cpp/core/fluid_geometry.cpp + cpp/core/solute_transport.cpp + cpp/core/transport_geometry.cpp + cpp/core/culture_simulation.cpp + cpp/core/growth.cpp + cpp/core/occupancy.cpp cpp/core/signals.cpp cpp/core/species.cpp cpp/core/world_state.cpp @@ -33,12 +41,14 @@ add_library(cm_core STATIC cpp/cpu/cpu_coupled.cpp cpp/cpu/cpu_flow.cpp cpp/cpu/cpu_mechanics.cpp - cpp/cpu/cpu_species.cpp -) + cpp/cpu/cpu_species.cpp) add_library(cm::core ALIAS cm_core) target_compile_features(cm_core PUBLIC cxx_std_23) -target_include_directories(cm_core PUBLIC cpp/include PRIVATE cpp) +target_include_directories( + cm_core + PUBLIC cpp/include + PRIVATE cpp) set_target_properties(cm_core PROPERTIES POSITION_INDEPENDENT_CODE ON) if(CM_ENABLE_METAL) @@ -58,111 +68,104 @@ if(CM_ENABLE_METAL) set(CM_METAL_MECHANICS_HEADER "${CM_GENERATED_DIR}/cm/metal/mechanics_source.hpp") set(CM_METAL_SIGNALS_HEADER "${CM_GENERATED_DIR}/cm/metal/signals_source.hpp") set(CM_METAL_SPECIES_HEADER "${CM_GENERATED_DIR}/cm/metal/species_source.hpp") + set(CM_METAL_OCCUPANCY_HEADER "${CM_GENERATED_DIR}/cm/metal/occupancy_source.hpp") + set(CM_METAL_NUMERICS_HEADER "${CM_GENERATED_DIR}/cm/metal/numerics_source.hpp") + add_custom_command( + OUTPUT "${CM_METAL_NUMERICS_HEADER}" + COMMAND + "${CMAKE_COMMAND}" "-DINPUT=${CMAKE_CURRENT_SOURCE_DIR}/cpp/metal/kernels/numerics.metal" + "-DOUTPUT=${CM_METAL_NUMERICS_HEADER}" "-DSYMBOL=numerics_source" -P + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/EmbedMetalSource.cmake" + DEPENDS cpp/metal/kernels/numerics.metal cmake/EmbedMetalSource.cmake + VERBATIM) + add_custom_command( + OUTPUT "${CM_METAL_OCCUPANCY_HEADER}" + COMMAND + "${CMAKE_COMMAND}" "-DINPUT=${CMAKE_CURRENT_SOURCE_DIR}/cpp/metal/kernels/occupancy.metal" + "-DOUTPUT=${CM_METAL_OCCUPANCY_HEADER}" "-DSYMBOL=occupancy_source" -P + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/EmbedMetalSource.cmake" + DEPENDS cpp/metal/kernels/occupancy.metal cmake/EmbedMetalSource.cmake + VERBATIM) add_custom_command( OUTPUT "${CM_METAL_GROWTH_HEADER}" - COMMAND "${CMAKE_COMMAND}" - "-DINPUT=${CMAKE_CURRENT_SOURCE_DIR}/cpp/metal/kernels/growth.metal" - "-DOUTPUT=${CM_METAL_GROWTH_HEADER}" - "-DSYMBOL=growth_source" - -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/EmbedMetalSource.cmake" - DEPENDS - cpp/metal/kernels/growth.metal - cmake/EmbedMetalSource.cmake - VERBATIM - ) + COMMAND + "${CMAKE_COMMAND}" "-DINPUT=${CMAKE_CURRENT_SOURCE_DIR}/cpp/metal/kernels/growth.metal" + "-DOUTPUT=${CM_METAL_GROWTH_HEADER}" "-DSYMBOL=growth_source" -P + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/EmbedMetalSource.cmake" + DEPENDS cpp/metal/kernels/growth.metal cmake/EmbedMetalSource.cmake + VERBATIM) add_custom_command( OUTPUT "${CM_METAL_FLOW_HEADER}" - COMMAND "${CMAKE_COMMAND}" - "-DINPUT=${CMAKE_CURRENT_SOURCE_DIR}/cpp/metal/kernels/flow.metal" - "-DOUTPUT=${CM_METAL_FLOW_HEADER}" - "-DSYMBOL=flow_source" - -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/EmbedMetalSource.cmake" - DEPENDS - cpp/metal/kernels/flow.metal - cmake/EmbedMetalSource.cmake - VERBATIM - ) + COMMAND + "${CMAKE_COMMAND}" "-DINPUT=${CMAKE_CURRENT_SOURCE_DIR}/cpp/metal/kernels/flow.metal" + "-DOUTPUT=${CM_METAL_FLOW_HEADER}" "-DSYMBOL=flow_source" -P + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/EmbedMetalSource.cmake" + DEPENDS cpp/metal/kernels/flow.metal cmake/EmbedMetalSource.cmake + VERBATIM) add_custom_command( OUTPUT "${CM_METAL_CONTACTS_HEADER}" - COMMAND "${CMAKE_COMMAND}" - "-DINPUT=${CMAKE_CURRENT_SOURCE_DIR}/cpp/metal/kernels/contacts.metal" - "-DOUTPUT=${CM_METAL_CONTACTS_HEADER}" - "-DSYMBOL=contacts_source" - -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/EmbedMetalSource.cmake" - DEPENDS - cpp/metal/kernels/contacts.metal - cmake/EmbedMetalSource.cmake - VERBATIM - ) + COMMAND + "${CMAKE_COMMAND}" "-DINPUT=${CMAKE_CURRENT_SOURCE_DIR}/cpp/metal/kernels/contacts.metal" + "-DOUTPUT=${CM_METAL_CONTACTS_HEADER}" "-DSYMBOL=contacts_source" -P + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/EmbedMetalSource.cmake" + DEPENDS cpp/metal/kernels/contacts.metal cmake/EmbedMetalSource.cmake + VERBATIM) add_custom_command( OUTPUT "${CM_METAL_COUPLED_RATES_HEADER}" - COMMAND "${CMAKE_COMMAND}" + COMMAND + "${CMAKE_COMMAND}" "-DINPUT=${CMAKE_CURRENT_SOURCE_DIR}/cpp/metal/kernels/grid_transport.metal;${CMAKE_CURRENT_SOURCE_DIR}/cpp/metal/kernels/coupled_rates.metal" - "-DOUTPUT=${CM_METAL_COUPLED_RATES_HEADER}" - "-DSYMBOL=coupled_rates_source" - -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/EmbedMetalSource.cmake" - DEPENDS - cpp/metal/kernels/grid_transport.metal - cpp/metal/kernels/coupled_rates.metal - cmake/EmbedMetalSource.cmake - VERBATIM - ) + "-DOUTPUT=${CM_METAL_COUPLED_RATES_HEADER}" "-DSYMBOL=coupled_rates_source" -P + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/EmbedMetalSource.cmake" + DEPENDS cpp/metal/kernels/grid_transport.metal cpp/metal/kernels/coupled_rates.metal + cmake/EmbedMetalSource.cmake + VERBATIM) add_custom_command( OUTPUT "${CM_METAL_MECHANICS_HEADER}" - COMMAND "${CMAKE_COMMAND}" - "-DINPUT=${CMAKE_CURRENT_SOURCE_DIR}/cpp/metal/kernels/mechanics.metal" - "-DOUTPUT=${CM_METAL_MECHANICS_HEADER}" - "-DSYMBOL=mechanics_source" - -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/EmbedMetalSource.cmake" - DEPENDS - cpp/metal/kernels/mechanics.metal - cmake/EmbedMetalSource.cmake - VERBATIM - ) + COMMAND + "${CMAKE_COMMAND}" "-DINPUT=${CMAKE_CURRENT_SOURCE_DIR}/cpp/metal/kernels/mechanics.metal" + "-DOUTPUT=${CM_METAL_MECHANICS_HEADER}" "-DSYMBOL=mechanics_source" -P + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/EmbedMetalSource.cmake" + DEPENDS cpp/metal/kernels/mechanics.metal cmake/EmbedMetalSource.cmake + VERBATIM) add_custom_command( OUTPUT "${CM_METAL_SIGNALS_HEADER}" - COMMAND "${CMAKE_COMMAND}" + COMMAND + "${CMAKE_COMMAND}" "-DINPUT=${CMAKE_CURRENT_SOURCE_DIR}/cpp/metal/kernels/grid_transport.metal;${CMAKE_CURRENT_SOURCE_DIR}/cpp/metal/kernels/signals.metal" - "-DOUTPUT=${CM_METAL_SIGNALS_HEADER}" - "-DSYMBOL=signals_source" - -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/EmbedMetalSource.cmake" - DEPENDS - cpp/metal/kernels/grid_transport.metal - cpp/metal/kernels/signals.metal - cmake/EmbedMetalSource.cmake - VERBATIM - ) + "-DOUTPUT=${CM_METAL_SIGNALS_HEADER}" "-DSYMBOL=signals_source" -P + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/EmbedMetalSource.cmake" + DEPENDS cpp/metal/kernels/grid_transport.metal cpp/metal/kernels/signals.metal + cmake/EmbedMetalSource.cmake + VERBATIM) add_custom_command( OUTPUT "${CM_METAL_SPECIES_HEADER}" - COMMAND "${CMAKE_COMMAND}" - "-DINPUT=${CMAKE_CURRENT_SOURCE_DIR}/cpp/metal/kernels/species.metal" - "-DOUTPUT=${CM_METAL_SPECIES_HEADER}" - "-DSYMBOL=species_source" - -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/EmbedMetalSource.cmake" - DEPENDS - cpp/metal/kernels/species.metal - cmake/EmbedMetalSource.cmake - VERBATIM - ) - - target_sources(cm_core PRIVATE - cpp/metal/metal_backend.mm - cpp/metal/metal_flow.mm - "${CM_METAL_CONTACTS_HEADER}" - "${CM_METAL_COUPLED_RATES_HEADER}" - "${CM_METAL_GROWTH_HEADER}" - "${CM_METAL_FLOW_HEADER}" - "${CM_METAL_MECHANICS_HEADER}" - "${CM_METAL_SIGNALS_HEADER}" - "${CM_METAL_SPECIES_HEADER}" - ) + COMMAND + "${CMAKE_COMMAND}" "-DINPUT=${CMAKE_CURRENT_SOURCE_DIR}/cpp/metal/kernels/species.metal" + "-DOUTPUT=${CM_METAL_SPECIES_HEADER}" "-DSYMBOL=species_source" -P + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/EmbedMetalSource.cmake" + DEPENDS cpp/metal/kernels/species.metal cmake/EmbedMetalSource.cmake + VERBATIM) + + target_sources( + cm_core + PRIVATE cpp/metal/metal_backend.mm + cpp/metal/metal_flow.mm + cpp/metal/metal_occupancy.mm + cpp/metal/metal_numerics.mm + "${CM_METAL_NUMERICS_HEADER}" + "${CM_METAL_OCCUPANCY_HEADER}" + "${CM_METAL_CONTACTS_HEADER}" + "${CM_METAL_COUPLED_RATES_HEADER}" + "${CM_METAL_GROWTH_HEADER}" + "${CM_METAL_FLOW_HEADER}" + "${CM_METAL_MECHANICS_HEADER}" + "${CM_METAL_SIGNALS_HEADER}" + "${CM_METAL_SPECIES_HEADER}") target_include_directories(cm_core PRIVATE "${CM_GENERATED_DIR}") target_compile_definitions(cm_core PRIVATE CM_HAS_METAL=1) target_compile_options(cm_core PRIVATE "$<$:-fobjc-arc>") - target_link_libraries(cm_core PRIVATE - "${CM_FOUNDATION_FRAMEWORK}" - "${CM_METAL_FRAMEWORK}" - ) + target_link_libraries(cm_core PRIVATE "${CM_FOUNDATION_FRAMEWORK}" "${CM_METAL_FRAMEWORK}") set_target_properties(cm_core PROPERTIES OBJCXX_STANDARD 23 OBJCXX_STANDARD_REQUIRED ON) endif() @@ -174,45 +177,85 @@ if(CM_ENABLE_CUDA) enable_language(CUDA) find_package(CUDAToolkit REQUIRED) - target_sources(cm_core PRIVATE - cpp/cuda/cuda_backend.cu - cpp/cuda/cuda_flow.cu - cpp/cuda/kernels/contacts.cu - cpp/cuda/kernels/coupled_rates.cu - cpp/cuda/kernels/growth.cu - cpp/cuda/kernels/flow.cu - cpp/cuda/kernels/mechanics.cu - cpp/cuda/kernels/signals.cu - cpp/cuda/kernels/species.cu - ) + target_sources( + cm_core + PRIVATE cpp/cuda/cuda_backend.cu + cpp/cuda/cuda_flow.cu + cpp/cuda/cuda_occupancy.cu + cpp/cuda/cuda_numerics.cu + cpp/cuda/kernels/occupancy.cu + cpp/cuda/kernels/contacts.cu + cpp/cuda/kernels/coupled_rates.cu + cpp/cuda/kernels/growth.cu + cpp/cuda/kernels/flow.cu + cpp/cuda/kernels/mechanics.cu + cpp/cuda/kernels/signals.cu + cpp/cuda/kernels/species.cu) target_compile_definitions(cm_core PRIVATE CM_HAS_CUDA=1) + set_source_files_properties(cpp/cuda/kernels/occupancy.cu PROPERTIES COMPILE_OPTIONS + "--fmad=false") + set_source_files_properties(cpp/cuda/cuda_numerics.cu PROPERTIES COMPILE_OPTIONS "--fmad=false") target_link_libraries(cm_core PRIVATE CUDA::cudart) - set_target_properties(cm_core PROPERTIES - CUDA_STANDARD 20 - CUDA_STANDARD_REQUIRED ON - ) + set_target_properties(cm_core PROPERTIES CUDA_STANDARD 20 CUDA_STANDARD_REQUIRED ON) endif() if(MSVC) target_compile_options(cm_core PRIVATE /W4 /permissive-) else() - target_compile_options(cm_core PRIVATE - "$<$:-Wall;-Wextra;-Wpedantic;-Wconversion>" - "$<$:-Wall;-Wextra;-Wpedantic;-Wconversion>" - ) + target_compile_options( + cm_core PRIVATE "$<$:-Wall;-Wextra;-Wpedantic;-Wconversion>" + "$<$:-Wall;-Wextra;-Wpedantic;-Wconversion>") endif() if(CM_BUILD_PYTHON) - find_package(Python 3.12 COMPONENTS Interpreter Development.Module REQUIRED) + find_package( + Python 3.12 + COMPONENTS Interpreter Development.Module + REQUIRED) find_package(nanobind CONFIG REQUIRED) - nanobind_add_module(_core cpp/python/bindings.cpp) + nanobind_add_module(_core cpp/python/bindings.cpp cpp/python/occupancy_bindings.cpp + cpp/python/culture_bindings.cpp) target_link_libraries(_core PRIVATE cm::core) install(TARGETS _core LIBRARY DESTINATION microsimulator) endif() if(CM_BUILD_TESTS) + add_executable(cm_culture_growth_conformance_test + tests/conformance/culture_growth_conformance_test.cpp) + target_link_libraries(cm_culture_growth_conformance_test PRIVATE cm::core) + add_test(NAME culture_growth_conformance COMMAND cm_culture_growth_conformance_test) + set_tests_properties(culture_growth_conformance PROPERTIES LABELS conformance) + add_executable(cm_culture_simulation_conformance_test + tests/conformance/culture_simulation_conformance_test.cpp) + target_link_libraries(cm_culture_simulation_conformance_test PRIVATE cm::core) + add_test(NAME culture_simulation_conformance COMMAND cm_culture_simulation_conformance_test) + set_tests_properties(culture_simulation_conformance PROPERTIES LABELS conformance) + add_executable(cm_solute_transport_conformance_test + tests/conformance/solute_transport_conformance_test.cpp) + target_link_libraries(cm_solute_transport_conformance_test PRIVATE cm::core) + add_test(NAME solute_transport_conformance COMMAND cm_solute_transport_conformance_test) + set_tests_properties(solute_transport_conformance PROPERTIES LABELS conformance) + add_executable(cm_fluid_geometry_test tests/conformance/fluid_geometry_test.cpp) + target_link_libraries(cm_fluid_geometry_test PRIVATE cm::core) + target_include_directories(cm_fluid_geometry_test PRIVATE cpp) + add_test(NAME fluid_geometry COMMAND cm_fluid_geometry_test) + set_tests_properties(fluid_geometry PROPERTIES LABELS conformance) include(CTest) + add_executable(cm_stokes_flow_conformance_test tests/conformance/stokes_flow_conformance_test.cpp) + target_link_libraries(cm_stokes_flow_conformance_test PRIVATE cm::core) + add_test(NAME stokes_flow_conformance COMMAND cm_stokes_flow_conformance_test) + set_tests_properties(stokes_flow_conformance PROPERTIES LABELS conformance) + add_executable(cm_capsule_body_conformance_test + tests/conformance/capsule_body_conformance_test.cpp) + target_link_libraries(cm_capsule_body_conformance_test PRIVATE cm::core) + target_include_directories(cm_capsule_body_conformance_test PRIVATE cpp) + add_test(NAME capsule_body_conformance COMMAND cm_capsule_body_conformance_test) + set_tests_properties(capsule_body_conformance PROPERTIES LABELS conformance) + add_executable(cm_occupancy_conformance_test tests/conformance/occupancy_conformance_test.cpp) + target_link_libraries(cm_occupancy_conformance_test PRIVATE cm::core) + add_test(NAME occupancy_conformance COMMAND cm_occupancy_conformance_test) + set_tests_properties(occupancy_conformance PROPERTIES LABELS conformance SKIP_RETURN_CODE 77) add_executable(cm_world_state_test tests/cpp/world_state_test.cpp) target_link_libraries(cm_world_state_test PRIVATE cm::core) add_test(NAME world_state COMMAND cm_world_state_test) @@ -261,96 +304,73 @@ if(CM_BUILD_TESTS) set_tests_properties(growth_conformance PROPERTIES LABELS conformance) add_executable(cm_backend_contract_conformance_test - tests/conformance/backend_contract_conformance_test.cpp - ) + tests/conformance/backend_contract_conformance_test.cpp) target_link_libraries(cm_backend_contract_conformance_test PRIVATE cm::core) add_test(NAME backend_contract_conformance COMMAND cm_backend_contract_conformance_test) set_tests_properties(backend_contract_conformance PROPERTIES LABELS conformance) - add_executable(cm_species_conformance_test - tests/conformance/species_conformance_test.cpp - ) + add_executable(cm_species_conformance_test tests/conformance/species_conformance_test.cpp) target_link_libraries(cm_species_conformance_test PRIVATE cm::core) add_test(NAME species_conformance COMMAND cm_species_conformance_test) set_tests_properties(species_conformance PROPERTIES LABELS conformance) - add_executable(cm_signal_grid_conformance_test - tests/conformance/signal_grid_conformance_test.cpp - ) + add_executable(cm_signal_grid_conformance_test tests/conformance/signal_grid_conformance_test.cpp) target_link_libraries(cm_signal_grid_conformance_test PRIVATE cm::core) add_test(NAME signal_grid_conformance COMMAND cm_signal_grid_conformance_test) set_tests_properties(signal_grid_conformance PROPERTIES LABELS conformance) add_executable(cm_coupled_rates_conformance_test - tests/conformance/coupled_rates_conformance_test.cpp - ) + tests/conformance/coupled_rates_conformance_test.cpp) target_link_libraries(cm_coupled_rates_conformance_test PRIVATE cm::core) add_test(NAME coupled_rates_conformance COMMAND cm_coupled_rates_conformance_test) set_tests_properties(coupled_rates_conformance PROPERTIES LABELS conformance) - add_executable(cm_flow_conformance_test - tests/conformance/flow_conformance_test.cpp - ) + add_executable(cm_flow_conformance_test tests/conformance/flow_conformance_test.cpp) target_link_libraries(cm_flow_conformance_test PRIVATE cm::core) target_include_directories(cm_flow_conformance_test PRIVATE tests/conformance) add_test(NAME flow_conformance COMMAND cm_flow_conformance_test) set_tests_properties(flow_conformance PROPERTIES LABELS conformance) - add_executable(cm_lifecycle_conformance_test - tests/conformance/lifecycle_conformance_test.cpp - ) + add_executable(cm_lifecycle_conformance_test tests/conformance/lifecycle_conformance_test.cpp) target_link_libraries(cm_lifecycle_conformance_test PRIVATE cm::core) add_test(NAME lifecycle_conformance COMMAND cm_lifecycle_conformance_test) set_tests_properties(lifecycle_conformance PROPERTIES LABELS conformance) - add_executable(cm_trajectory_conformance_test - tests/conformance/trajectory_conformance_test.cpp - ) + add_executable(cm_trajectory_conformance_test tests/conformance/trajectory_conformance_test.cpp) target_link_libraries(cm_trajectory_conformance_test PRIVATE cm::core) add_test(NAME trajectory_conformance COMMAND cm_trajectory_conformance_test) set_tests_properties(trajectory_conformance PROPERTIES LABELS conformance) - add_executable(cm_contact_conformance_test - tests/conformance/contact_conformance_test.cpp - ) + add_executable(cm_contact_conformance_test tests/conformance/contact_conformance_test.cpp) target_link_libraries(cm_contact_conformance_test PRIVATE cm::core) add_test(NAME contact_conformance COMMAND cm_contact_conformance_test) set_tests_properties(contact_conformance PROPERTIES LABELS conformance) - add_executable(cm_constraint_conformance_test - tests/conformance/constraint_conformance_test.cpp - ) + add_executable(cm_constraint_conformance_test tests/conformance/constraint_conformance_test.cpp) target_link_libraries(cm_constraint_conformance_test PRIVATE cm::core) add_test(NAME constraint_conformance COMMAND cm_constraint_conformance_test) set_tests_properties(constraint_conformance PROPERTIES LABELS conformance) - add_executable(cm_mechanics_conformance_test - tests/conformance/mechanics_conformance_test.cpp - ) + add_executable(cm_mechanics_conformance_test tests/conformance/mechanics_conformance_test.cpp) target_link_libraries(cm_mechanics_conformance_test PRIVATE cm::core) add_test(NAME mechanics_conformance COMMAND cm_mechanics_conformance_test) set_tests_properties(mechanics_conformance PROPERTIES LABELS conformance) add_executable(cm_constraint_mechanics_conformance_test - tests/conformance/constraint_mechanics_conformance_test.cpp - ) + tests/conformance/constraint_mechanics_conformance_test.cpp) target_link_libraries(cm_constraint_mechanics_conformance_test PRIVATE cm::core) add_test(NAME constraint_mechanics_conformance COMMAND cm_constraint_mechanics_conformance_test) set_tests_properties(constraint_mechanics_conformance PROPERTIES LABELS conformance) if(CM_ENABLE_CUDA) - add_executable(cm_cuda_runtime_gate_test - tests/conformance/cuda_runtime_gate_test.cpp - ) + add_executable(cm_cuda_runtime_gate_test tests/conformance/cuda_runtime_gate_test.cpp) target_link_libraries(cm_cuda_runtime_gate_test PRIVATE cm::core) add_test(NAME cuda_runtime_gate COMMAND cm_cuda_runtime_gate_test) set_property(TEST cuda_runtime_gate PROPERTY LABELS conformance cuda) endif() if(CM_ENABLE_METAL) - add_executable(cm_metal_runtime_gate_test - tests/conformance/metal_runtime_gate_test.cpp - ) + add_executable(cm_metal_runtime_gate_test tests/conformance/metal_runtime_gate_test.cpp) target_link_libraries(cm_metal_runtime_gate_test PRIVATE cm::core) add_test(NAME metal_runtime_gate COMMAND cm_metal_runtime_gate_test) set_property(TEST metal_runtime_gate PROPERTY LABELS conformance metal) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..958b7d9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,60 @@ +# Contributing + +## Formatting and checks + +Install the viewer's pinned tools with `pnpm --dir viewer install --frozen-lockfile`. The quality commands install their locked Python tools in an isolated environment and do not build the simulator or replace the development environment. + +```console +uv run --isolated --locked --only-group quality python scripts/quality.py format +uv run --isolated --locked --only-group quality python scripts/quality.py check +uv run --isolated --locked --only-group quality python scripts/quality.py complexity +uv run --isolated --locked --only-group quality python -m unittest discover -s tests/quality +``` + +Append repository-relative filenames or directories to any quality command to select files. Full runs include tracked files and nonignored new files. `format` applies safe Ruff fixes and formatting; `check` never rewrites files. Install commit hooks with `uv run --isolated --locked --only-group quality pre-commit install`. Hooks use the same commands on staged files; CI checks the entire maintained tree. The `quality` status is the required merge check to enable in repository branch protection. + +clang-format handles native code, including CUDA and Metal. Ruff handles Python lint and formatting. Prettier handles viewer code, JSON, YAML, HTML, CSS, and Markdown; it preserves prose wrapping and leaves embedded code to the appropriate language formatter. Taplo, shfmt, and cmake-format handle TOML, shell, and CMake. Generated output, dependency directories, and package-manager lockfiles are excluded. + +Python tools are pinned in the `quality` dependency group and `uv.lock`; Prettier is pinned in the viewer manifest and lockfile. Upgrade tools explicitly, regenerate locks, run formatting, and review the resulting diff. Use the same pinned versions in editor format-on-save settings, with repository configuration enabled. `.editorconfig` supplies whitespace defaults. + +## Readable code + +Write one coherent operation per paragraph of code. Separate validation, setup, computation, and result construction with a blank line. Keep statements that jointly perform one operation together. Explain numerical assumptions and invariants in comments; avoid comments that merely repeat the code. + +This applies to short functions too. Give a loop breathing room after setup, separate a guard from the operation it protects, and separate the final return from the completed computation. For example: + +```cpp +std::vector intersect_polygon(std::vector p, const ConvexPolygon& q) { + const auto n = normal(q); + const double epsilon = polygon_epsilon(q.vertices); + + for (std::size_t i = 0; i < q.vertices.size() && !p.empty(); ++i) { + const auto& a = q.vertices[i]; + const auto& b = q.vertices[(i + 1) % q.vertices.size()]; + const auto edge = body_difference(b, a); + + if (body_dot(edge, edge) <= epsilon * epsilon) { + continue; + } + + const auto outward = body_normalized(body_cross(edge, n)); + p = clip_polygon(p, {outward, body_dot(outward, a), 0}); + } + + return p; +} +``` + +The shared formatter applies this spacing mechanically using syntax trees: it inserts a blank line before loops and conditionals, after completed control-flow statements, and before a return that follows other work. It keeps attached comments with their statements and leaves one-line bodies, strings, macros, and unsupported syntax alone. `else` and `catch` remain attached to their statements. The check command enforces the same spacing in hooks and CI. Readability review still checks semantic phases within uninterrupted calculations, which syntax alone cannot identify. + +Native code uses two-space indentation, 100 columns, explicit control-flow braces, and multiline nonempty function and lambda bodies. Python uses four-space indentation and 100 columns. Review brace insertion as a code change, particularly around macros and preprocessor branches. + +Every native, Python, JavaScript, and TypeScript function must have at most 100 nonblank, noncomment lines and cyclomatic complexity at most 15, as measured by pinned Lizard. The same limits apply to tests, examples, bindings, and GPU kernels. Suppressions and inherited exception lists are not permitted. The structural check maps CUDA and Metal to the C++ reader and reports the original file and line. + +Extract helpers around meaningful operations. Keep their inputs and outputs explicit, keep ownership local, and preserve arithmetic order, device execution, exception behavior, and state transitions. Do not hide a large function in nested functions or manufacture parameter objects just to satisfy a metric. Split long tests by scenario and binding registration by public feature. A passing metric is a review aid, not proof of clarity. + +## Behavioral validation + +Run `uv run --locked pyright`, the Python tests, and `pnpm --dir viewer check`, `pnpm --dir viewer test`, and `pnpm --dir viewer build` for affected application code. Native changes also require the relevant build and conformance tests described in [testing and validation](docs/development/validation.md). CUDA compilation does not establish CUDA runtime behavior. + +The existing `.clang-tidy` configuration is available for deeper analysis against a build's compile database; its broad diagnostic set is separate from the shared formatting and complexity gate. diff --git a/README.md b/README.md index 4a3c5be..d9cd749 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,12 @@ Models run on a C++23 engine with native CPU, Apple Metal, and NVIDIA CUDA backe - **Device geometry:** describe traps, channels, and pillars with mechanical walls and solid masks that constrain cells and route solutes through the fluid space. - **Flow and transport:** solve depth-averaged Hele-Shaw/Darcy flow in shallow devices or resolved Stokes-Brinkman flow on a staggered grid, then use the velocity field for conservative advection, diffusion, and reactions. +- **Coupled culture:** compose pressure/rate-driven Stokes flow, conservative chemical transport, and nutrient-limited cell growth. - **Growth and signaling:** combine nutrient uptake with conserved biochemical biomass, rod growth and division, contact mechanics, lineage, intracellular circuits, and diffusible signals. - **Cells in flow:** model free-cell drift and rotation, explicitly attached populations, stationary biomass resistance, and model-defined outlet removal. - **Reproducible experiments:** inspect live simulations, resume versioned checkpoints, run parameter sweeps, and export Parquet/Zarr datasets for quantitative analysis. -The [microfluidics modeling guide](docs/microfluidics.md) explains how these pieces fit together. Cell motion uses a kinematic approximation followed by contact relaxation; stationary resistance is an empirical closure. The [flow benchmarks](docs/tutorials/flow-solvers.md#numerical-evidence) and [nutrient study](docs/tutorials/nutrient-validation.md) document numerical checks and refinement studies. Experimental calibration remains specific to each model. +The [microfluidics modeling guide](docs/microfluidics.md) explains how these pieces fit together. The device tutorials use kinematic cell motion followed by contact relaxation and empirical stationary resistance. The [fluid and culture guide](docs/models/fluid-culture.md) covers hydrodynamic body coupling and its resolution and performance limits. The [flow benchmarks](docs/tutorials/flow-solvers.md#numerical-evidence) and [nutrient study](docs/tutorials/nutrient-validation.md) document numerical checks and refinement studies. Experimental calibration remains specific to each model. ## Quick start @@ -26,52 +27,56 @@ cd MicroSimulator uv sync --group dev uv run microsimulator devices uv run microsimulator run \ - --model examples/microfluidic_trap.py \ + --model examples/mother_machine.py \ --backend cpu \ --seed 42 \ --steps 100 \ - --dt 0.02 \ - --output results/trap.json + --dt 0.025 \ + --output results/mother-machine.json ``` -This runs a cell trap supplied with nutrient through a flowing channel and saves a restartable checkpoint. Follow [getting started](docs/tutorials/getting-started.md) to set up the viewer, or continue with [microfluidic devices](docs/tutorials/microfluidics.md) to explore walls, transport, growth, and washout. +This runs a mother machine with cells confined in closed-ended growth channels and saves a restartable checkpoint. The short run checks startup and transport at the default biological timescale. Follow the [mother-machine guide](docs/tutorials/mother-machine.md) to watch growth and washout in the viewer, including an explicit accelerated-growth option for interactive demonstrations. ## Examples -| Explore | Start with | -| --- | --- | -| Nutrient delivery, colony growth, and washout in a trap | [Microfluidic trap](examples/microfluidic_trap.py) | -| Flow around pillars, attached founders, and released daughters | [Pillar channel](examples/tutorials/pillar_channel.py) | -| A quorum-sensing clock in a flowing device | [Danino clock](examples/tutorials/danino_clock.py) | -| A single biopixel trap with documented dimensions and CAD provenance | [Biopixel tutorial](docs/tutorials/microfluidics.md#a-source-backed-prindle-biopixel-example) | -| Nutrient penetration, biomass gain, and conservation | [Controlled nutrient study](docs/tutorials/nutrient-validation.md) | +| Explore | Start with | +| ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| Mother-cell retention by confinement, division, and descendant washout | [Mother machine](docs/tutorials/mother-machine.md) | +| Nutrient delivery, colony growth, and washout in a trap | [Microfluidic trap](examples/microfluidic_trap.py) | +| Perfusion, nutrient transport, and cell growth | [Growing media](examples/growing_media.py) | +| Flow around pillars, attached founders, and released daughters | [Pillar channel](examples/tutorials/pillar_channel.py) | +| A quorum-sensing clock in a flowing device | [Danino clock](examples/tutorials/danino_clock.py) | +| A single biopixel trap with documented dimensions and CAD provenance | [Biopixel tutorial](docs/tutorials/microfluidics.md#a-source-backed-prindle-biopixel-example) | +| Nutrient penetration, biomass gain, and conservation | [Controlled nutrient study](docs/tutorials/nutrient-validation.md) | The [tutorial suite](docs/tutorials/README.md) also covers growth, gene circuits, signaling, plasmids, contacts, and analysis. Device examples state their geometry, units, and modeling assumptions alongside the runnable code. ## Backend status -| Backend | Status | Role | -| --- | --- | --- | -| CPU | Feature complete | Portable execution and numerical reference | -| Apple Metal | Feature complete | Native Apple GPU execution | -| NVIDIA CUDA | Under active development | Native NVIDIA GPU execution | +| Backend | Status | Role | +| ----------- | ------------------------ | ------------------------------------------ | +| CPU | Feature complete | Portable execution and numerical reference | +| Apple Metal | Feature complete | Native Apple GPU execution | +| NVIDIA CUDA | Under active development | Native NVIDIA GPU execution | -Both flow solvers have native CPU, Metal, and CUDA implementations. CPU and Metal support the complete current modeling workflow. CUDA compilation is checked, while NVIDIA runtime and application validation remain required for supported status. The [validation policy](docs/development/validation.md) defines the hardware and application acceptance criteria. +The flow solvers and coupled culture model have native CPU, Metal, and CUDA implementations. CPU and Metal support the complete current modeling workflow. CUDA compilation is checked, while NVIDIA runtime and application validation remain required for supported status. The [validation policy](docs/development/validation.md) defines the hardware and application acceptance criteria. ## Documentation -| Topic | Entry point | -| --- | --- | -| Microfluidics | [Devices, flow, transport, and biology](docs/microfluidics.md) | -| Tutorials | [Modeling tutorials](docs/tutorials/README.md) | -| Architecture and numerics | [Design documents](docs/README.md#architecture-and-numerics) | -| HPC environments | [CPU, Metal, and CUDA setup](docs/README.md#execution-environments) | +| Topic | Entry point | +| -------------------------- | ---------------------------------------------------------------------- | +| Microfluidics | [Devices, flow, transport, and biology](docs/microfluidics.md) | +| Tutorials | [Modeling tutorials](docs/tutorials/README.md) | +| Architecture and numerics | [Design documents](docs/README.md#architecture-and-numerics) | +| HPC environments | [CPU, Metal, and CUDA setup](docs/README.md#execution-environments) | | Analysis and visualization | [Research output workflows](docs/README.md#analysis-and-visualization) | -| CellModeller compatibility | [Scope and evidence](docs/README.md#cellmodeller-compatibility) | -| Development | [Testing and validation](docs/development/validation.md) | +| CellModeller compatibility | [Scope and evidence](docs/README.md#cellmodeller-compatibility) | +| Development | [Testing and validation](docs/development/validation.md) | The complete documentation index is available at [`docs/README.md`](docs/README.md). +See [CONTRIBUTING.md](CONTRIBUTING.md) for the shared formatting command, commit hooks, readability conventions, and code quality checks. + ## Origins MicroSimulator began as a rewrite of [CellModeller](https://github.com/cellmodeller/CellModeller) and has developed into an independent microfluidics simulation system. It builds on that lineage of individual-based cell modeling with its own device, flow, transport, and experiment workflows. The [compatibility guide](docs/compatibility/README.md) documents supported CellModeller models and intentional numerical differences. Users of the former CellModeller2 package can follow the [rename guide](docs/compatibility/microsimulator-rename.md). diff --git a/cmake/EmbedMetalSource.cmake b/cmake/EmbedMetalSource.cmake index 9fb8fd7..1bc9492 100644 --- a/cmake/EmbedMetalSource.cmake +++ b/cmake/EmbedMetalSource.cmake @@ -1,7 +1,9 @@ # INPUT names one Metal source, or several to concatenate in order. A Metal # library is compiled from source at runtime with no include path, so a source # that shares helpers with another receives them by concatenation here. -if(NOT DEFINED INPUT OR NOT DEFINED OUTPUT OR NOT DEFINED SYMBOL) +if(NOT DEFINED INPUT + OR NOT DEFINED OUTPUT + OR NOT DEFINED SYMBOL) message(FATAL_ERROR "EmbedMetalSource.cmake requires INPUT, OUTPUT, and SYMBOL") endif() if(NOT SYMBOL MATCHES "^[A-Za-z_][A-Za-z0-9_]*$") @@ -10,7 +12,8 @@ endif() get_filename_component(CM_OUTPUT_DIRECTORY "${OUTPUT}" DIRECTORY) file(MAKE_DIRECTORY "${CM_OUTPUT_DIRECTORY}") -file(WRITE "${OUTPUT}" "#pragma once\n\nnamespace cm::metal {\ninline constexpr char ${SYMBOL}[] = R\"CM_METAL(") +file(WRITE "${OUTPUT}" + "#pragma once\n\nnamespace cm::metal {\ninline constexpr char ${SYMBOL}[] = R\"CM_METAL(") foreach(CM_METAL_INPUT IN LISTS INPUT) file(READ "${CM_METAL_INPUT}" CM_METAL_SOURCE) if(CM_METAL_SOURCE MATCHES "CM_METAL\\(" OR CM_METAL_SOURCE MATCHES "\\)CM_METAL") @@ -18,6 +21,8 @@ foreach(CM_METAL_INPUT IN LISTS INPUT) endif() file(APPEND "${OUTPUT}" "${CM_METAL_SOURCE}") endforeach() -file(APPEND "${OUTPUT}" [=[)CM_METAL"; +file( + APPEND "${OUTPUT}" + [=[)CM_METAL"; } // namespace cm::metal ]=]) diff --git a/compatibility/legacy-renderers-v1.json b/compatibility/legacy-renderers-v1.json index f0d5724..46c478b 100644 --- a/compatibility/legacy-renderers-v1.json +++ b/compatibility/legacy-renderers-v1.json @@ -20,9 +20,7 @@ }, { "id": "signal_grid", - "classes": [ - "GLGridRenderer" - ], + "classes": ["GLGridRenderer"], "bundled_example_call_sites": 4, "disposition": "replaced", "replacement": "microsimulator-scene-v1 signal grids and viewer slices", @@ -30,9 +28,7 @@ }, { "id": "sphere_cells", - "classes": [ - "GLSphereRenderer" - ], + "classes": ["GLSphereRenderer"], "bundled_example_call_sites": 0, "disposition": "deliberately_retired", "replacement": null, @@ -40,10 +36,7 @@ }, { "id": "plant_cells", - "classes": [ - "GLPlantRenderer", - "GLPlantSignalRenderer" - ], + "classes": ["GLPlantRenderer", "GLPlantSignalRenderer"], "bundled_example_call_sites": 0, "disposition": "deliberately_retired", "replacement": null, @@ -51,9 +44,7 @@ }, { "id": "periodic_cell_images", - "classes": [ - "GLBacteriumRendererWithPeriodicImages" - ], + "classes": ["GLBacteriumRendererWithPeriodicImages"], "bundled_example_call_sites": 0, "disposition": "deliberately_retired", "replacement": null, @@ -61,9 +52,7 @@ }, { "id": "dynamic_collision_mesh", - "classes": [ - "GLWillsMeshRenderer" - ], + "classes": ["GLWillsMeshRenderer"], "bundled_example_call_sites": 0, "disposition": "deliberately_retired", "replacement": null, @@ -71,9 +60,7 @@ }, { "id": "static_triangle_mesh", - "classes": [ - "GLStaticMeshRenderer" - ], + "classes": ["GLStaticMeshRenderer"], "bundled_example_call_sites": 0, "disposition": "deliberately_retired", "replacement": null, diff --git a/compatibility/legacy-trajectories-v1.json b/compatibility/legacy-trajectories-v1.json index 39a4af4..205b98e 100644 --- a/compatibility/legacy-trajectories-v1.json +++ b/compatibility/legacy-trajectories-v1.json @@ -24,21 +24,9 @@ "cell_type_counts": { "0": 1 }, - "centroid": [ - 0.0, - 0.0, - 0.0 - ], - "coordinate_maximum": [ - 0.0, - 0.0, - 0.0 - ], - "coordinate_minimum": [ - 0.0, - 0.0, - 0.0 - ], + "centroid": [0.0, 0.0, 0.0], + "coordinate_maximum": [0.0, 0.0, 0.0], + "coordinate_minimum": [0.0, 0.0, 0.0], "maximum_centroid_distance": 0.0, "maximum_length": 3.5, "mechanics_substeps": 0, @@ -56,21 +44,9 @@ "cell_type_counts": { "0": 2 }, - "centroid": [ - 0.0, - 0.0, - 0.0 - ], - "coordinate_maximum": [ - 1.125, - 0.0, - 0.0 - ], - "coordinate_minimum": [ - -1.125, - 0.0, - 0.0 - ], + "centroid": [0.0, 0.0, 0.0], + "coordinate_maximum": [1.125, 0.0, 0.0], + "coordinate_minimum": [-1.125, 0.0, 0.0], "maximum_centroid_distance": 1.125, "maximum_length": 1.25, "mechanics_substeps": 2, @@ -88,20 +64,10 @@ "cell_type_counts": { "0": 2 }, - "centroid": [ - 0.0, - 0.0, - 0.0 - ], - "coordinate_maximum": [ - 1.1787338256835938, - 9.769724783836864e-06, - 0.0 - ], + "centroid": [0.0, 0.0, 0.0], + "coordinate_maximum": [1.1787338256835938, 9.769724783836864e-6, 0.0], "coordinate_minimum": [ - -1.1787338256835938, - -9.769724783836864e-06, - 0.0 + -1.1787338256835938, -9.769724783836864e-6, 0.0 ], "maximum_centroid_distance": 1.178733825724081, "maximum_length": 1.369591236114502, @@ -120,20 +86,10 @@ "cell_type_counts": { "0": 2 }, - "centroid": [ - -1.8298625946044922e-05, - -8.076312951743603e-09, - 0.0 - ], - "coordinate_maximum": [ - 1.3879988193511963, - 8.990339119918644e-05, - 0.0 - ], + "centroid": [-1.8298625946044922e-5, -8.076312951743603e-9, 0.0], + "coordinate_maximum": [1.3879988193511963, 8.990339119918644e-5, 0.0], "coordinate_minimum": [ - -1.3880354166030884, - -8.991954382508993e-05, - 0.0 + -1.3880354166030884, -8.991954382508993e-5, 0.0 ], "maximum_centroid_distance": 1.3880171208892362, "maximum_length": 1.7977631092071533, @@ -152,20 +108,10 @@ "cell_type_counts": { "0": 3 }, - "centroid": [ - -0.5973072250684103, - -0.00037796197769542533, - 0.0 - ], - "coordinate_maximum": [ - 1.8659186363220215, - 0.001655560452491045, - 0.0 - ], + "centroid": [-0.5973072250684103, -0.00037796197769542533, 0.0], + "coordinate_maximum": [1.8659186363220215, 0.001655560452491045, 0.0], "coordinate_minimum": [ - -2.7637038230895996, - -0.003918887581676245, - 0.0 + -2.7637038230895996, -0.003918887581676245, 0.0 ], "maximum_centroid_distance": 2.463226700780119, "maximum_length": 2.8184587955474854, @@ -184,21 +130,9 @@ "cell_type_counts": { "0": 4 }, - "centroid": [ - 0.029776811599731445, - -0.005447677336633205, - 0.0 - ], - "coordinate_maximum": [ - 4.045950889587402, - 0.15823085606098175, - 0.0 - ], - "coordinate_minimum": [ - -3.8627309799194336, - -0.340427041053772, - 0.0 - ], + "centroid": [0.029776811599731445, -0.005447677336633205, 0.0], + "coordinate_maximum": [4.045950889587402, 0.15823085606098175, 0.0], + "coordinate_minimum": [-3.8627309799194336, -0.340427041053772, 0.0], "maximum_centroid_distance": 4.016446305201695, "maximum_length": 2.508676767349243, "mechanics_substeps": 2, @@ -216,14 +150,7 @@ "model": "ex1_simpleGrowth2D.py", "model_sha256": "caa2caecd49b580d70a2c084f2702989c15d92d785099fc9ff32325d1ab0fde7", "role": "growing 2D colony", - "sample_steps": [ - 0, - 1, - 2, - 5, - 10, - 20 - ], + "sample_steps": [0, 1, 2, 5, 10, 20], "seed": 12345 }, { @@ -234,21 +161,9 @@ "cell_type_counts": { "0": 1 }, - "centroid": [ - 0.0, - 0.0, - 0.5 - ], - "coordinate_maximum": [ - 0.0, - 0.0, - 0.5 - ], - "coordinate_minimum": [ - 0.0, - 0.0, - 0.5 - ], + "centroid": [0.0, 0.0, 0.5], + "coordinate_maximum": [0.0, 0.0, 0.5], + "coordinate_minimum": [0.0, 0.0, 0.5], "maximum_centroid_distance": 0.0, "maximum_length": 3.5, "mechanics_substeps": 0, @@ -266,21 +181,9 @@ "cell_type_counts": { "0": 2 }, - "centroid": [ - 0.0, - 0.0, - 0.5 - ], - "coordinate_maximum": [ - 1.125, - 0.0, - 0.5 - ], - "coordinate_minimum": [ - -1.125, - 0.0, - 0.5 - ], + "centroid": [0.0, 0.0, 0.5], + "coordinate_maximum": [1.125, 0.0, 0.5], + "coordinate_minimum": [-1.125, 0.0, 0.5], "maximum_centroid_distance": 1.125, "maximum_length": 1.25, "mechanics_substeps": 2, @@ -298,20 +201,12 @@ "cell_type_counts": { "0": 2 }, - "centroid": [ - 0.0, - 0.0, - 0.5003534257411957 - ], + "centroid": [0.0, 0.0, 0.5003534257411957], "coordinate_maximum": [ - 1.153852939605713, - 2.0638692149077542e-05, - 0.5005413293838501 + 1.153852939605713, 2.0638692149077542e-5, 0.5005413293838501 ], "coordinate_minimum": [ - -1.153852939605713, - -2.0638692149077542e-05, - 0.5001655220985413 + -1.153852939605713, -2.0638692149077542e-5, 0.5001655220985413 ], "maximum_centroid_distance": 1.15385295509024, "maximum_length": 1.310958743095398, @@ -331,19 +226,13 @@ "0": 2 }, "centroid": [ - -5.960464477539062e-07, - -5.529727786779404e-10, - 0.5003534257411957 + -5.960464477539062e-7, -5.529727786779404e-10, 0.5003534257411957 ], "coordinate_maximum": [ - 1.2536574602127075, - 0.00012057296407874674, - 0.5006194710731506 + 1.2536574602127075, 0.00012057296407874674, 0.5006194710731506 ], "coordinate_minimum": [ - -1.253658652305603, - -0.00012057407002430409, - 0.5000873804092407 + -1.253658652305603, -0.00012057407002430409, 0.5000873804092407 ], "maximum_centroid_distance": 1.2536580902868115, "maximum_length": 1.5117474794387817, @@ -363,19 +252,13 @@ "0": 2 }, "centroid": [ - -4.708766937255859e-06, - -1.04482751339674e-08, - 0.5004732757806778 + -4.708766937255859e-6, -1.04482751339674e-8, 0.5004732757806778 ], "coordinate_maximum": [ - 1.4553123712539673, - 0.000564709072932601, - 0.500961422920227 + 1.4553123712539673, 0.000564709072932601, 0.500961422920227 ], "coordinate_minimum": [ - -1.4553217887878418, - -0.0005647299694828689, - 0.49998512864112854 + -1.4553217887878418, -0.0005647299694828689, 0.49998512864112854 ], "maximum_centroid_distance": 1.4553172714553826, "maximum_length": 1.9120343923568726, @@ -395,19 +278,13 @@ "0": 2 }, "centroid": [ - -5.0902366638183594e-05, - -1.8812716007232666e-06, - 0.5024485141038895 + -5.0902366638183594e-5, -1.8812716007232666e-6, 0.5024485141038895 ], "coordinate_maximum": [ - 2.0128273963928223, - 0.01711047813296318, - 0.5059943199157715 + 2.0128273963928223, 0.01711047813296318, 0.5059943199157715 ], "coordinate_minimum": [ - -2.0129292011260986, - -0.017114240676164627, - 0.49890270829200745 + -2.0129292011260986, -0.017114240676164627, 0.49890270829200745 ], "maximum_centroid_distance": 2.01295416023317, "maximum_length": 3.0500614643096924, @@ -426,14 +303,7 @@ "model": "Tutorial_1/Tutorial_1c.py", "model_sha256": "dcbae9f175a3e6e3be2de32e7a88e147b321d0513e36a12c8314c119488fd170", "role": "constrained 3D colony", - "sample_steps": [ - 0, - 1, - 2, - 5, - 10, - 20 - ], + "sample_steps": [0, 1, 2, 5, 10, 20], "seed": 23456 }, { @@ -445,21 +315,9 @@ "0": 1, "1": 1 }, - "centroid": [ - 0.0, - 0.0, - 0.0 - ], - "coordinate_maximum": [ - 5.0, - 0.0, - 0.0 - ], - "coordinate_minimum": [ - -5.0, - 0.0, - 0.0 - ], + "centroid": [0.0, 0.0, 0.0], + "coordinate_maximum": [5.0, 0.0, 0.0], + "coordinate_minimum": [-5.0, 0.0, 0.0], "maximum_centroid_distance": 5.0, "maximum_length": 1.899999976158142, "mechanics_substeps": 0, @@ -478,20 +336,10 @@ "0": 2, "1": 2 }, - "centroid": [ - 0.0069710612297058105, - 4.670699127018452e-06, - 0.0 - ], - "coordinate_maximum": [ - 6.333746433258057, - 0.0005081231356598437, - 0.0 - ], + "centroid": [0.0069710612297058105, 4.670699127018452e-6, 0.0], + "coordinate_maximum": [6.333746433258057, 0.0005081231356598437, 0.0], "coordinate_minimum": [ - -6.333854675292969, - -0.0004728875937871635, - 0.0 + -6.333854675292969, -0.0004728875937871635, 0.0 ], "maximum_centroid_distance": 6.340825754506288, "maximum_length": 2.001861095428467, @@ -511,21 +359,9 @@ "0": 4, "1": 4 }, - "centroid": [ - 0.04046517610549927, - 0.00021415343508124352, - 0.0 - ], - "coordinate_maximum": [ - 8.323914527893066, - 0.32601243257522583, - 0.0 - ], - "coordinate_minimum": [ - -8.278538703918457, - -0.29300951957702637, - 0.0 - ], + "centroid": [0.04046517610549927, 0.00021415343508124352, 0.0], + "coordinate_maximum": [8.323914527893066, 0.32601243257522583, 0.0], + "coordinate_minimum": [-8.278538703918457, -0.29300951957702637, 0.0], "maximum_centroid_distance": 8.319543408050217, "maximum_length": 2.141984462738037, "mechanics_substeps": 2, @@ -544,21 +380,9 @@ "0": 8, "1": 8 }, - "centroid": [ - 0.05471285060048103, - -0.004247135017067194, - 0.0 - ], - "coordinate_maximum": [ - 9.8298921585083, - 0.8633608222007751, - 0.0 - ], - "coordinate_minimum": [ - -10.011058807373047, - -1.1405298709869385, - 0.0 - ], + "centroid": [0.05471285060048103, -0.004247135017067194, 0.0], + "coordinate_maximum": [9.8298921585083, 0.8633608222007751, 0.0], + "coordinate_minimum": [-10.011058807373047, -1.1405298709869385, 0.0], "maximum_centroid_distance": 10.076708109601105, "maximum_length": 2.2706499099731445, "mechanics_substeps": 2, @@ -578,21 +402,9 @@ "1": 16, "2": 1 }, - "centroid": [ - 0.08140314478547342, - 0.05852880857644543, - 0.0 - ], - "coordinate_maximum": [ - 11.269049644470215, - 2.3627817630767822, - 0.0 - ], - "coordinate_minimum": [ - -11.602177619934082, - -2.456211805343628, - 0.0 - ], + "centroid": [0.08140314478547342, 0.05852880857644543, 0.0], + "coordinate_maximum": [11.269049644470215, 2.3627817630767822, 0.0], + "coordinate_minimum": [-11.602177619934082, -2.456211805343628, 0.0], "maximum_centroid_distance": 11.735984955552807, "maximum_length": 4.281031131744385, "mechanics_substeps": 2, @@ -612,21 +424,9 @@ "1": 31, "2": 3 }, - "centroid": [ - 0.08155597001314163, - -0.03697235658764839, - 0.0 - ], - "coordinate_maximum": [ - 11.751053810119629, - 4.202713489532471, - 0.0 - ], - "coordinate_minimum": [ - -13.064791679382324, - -4.97340726852417, - 0.0 - ], + "centroid": [0.08155597001314163, -0.03697235658764839, 0.0], + "coordinate_maximum": [11.751053810119629, 4.202713489532471, 0.0], + "coordinate_minimum": [-13.064791679382324, -4.97340726852417, 0.0], "maximum_centroid_distance": 13.306134648234275, "maximum_length": 4.323599815368652, "mechanics_substeps": 3, @@ -644,14 +444,7 @@ "model": "Conjugation.py", "model_sha256": "f91b9b4ea3cb5bef159c958d9762737e8d35b295d8e5ad1c2761121098c79c07", "role": "neighbor-dependent model", - "sample_steps": [ - 0, - 20, - 40, - 60, - 80, - 100 - ], + "sample_steps": [0, 20, 40, 60, 80, 100], "seed": 34567 }, { @@ -662,36 +455,18 @@ "cell_type_counts": { "0": 1 }, - "centroid": [ - 0.0, - 0.0, - 0.0 - ], - "coordinate_maximum": [ - 0.0, - 0.0, - 0.0 - ], - "coordinate_minimum": [ - 0.0, - 0.0, - 0.0 - ], + "centroid": [0.0, 0.0, 0.0], + "coordinate_maximum": [0.0, 0.0, 0.0], + "coordinate_minimum": [0.0, 0.0, 0.0], "maximum_centroid_distance": 0.0, "maximum_length": 3.5, "mechanics_substeps": 0, "minimum_length": 3.5, "neighbor_pair_count": 0, "signals": [], - "species_maximum": [ - 0.0 - ], - "species_minimum": [ - 0.0 - ], - "species_sum": [ - 0.0 - ], + "species_maximum": [0.0], + "species_minimum": [0.0], + "species_sum": [0.0], "step": 0, "total_length": 3.5 }, @@ -700,36 +475,18 @@ "cell_type_counts": { "0": 2 }, - "centroid": [ - 0.0, - 0.0, - 0.0 - ], - "coordinate_maximum": [ - 1.125, - 0.0, - 0.0 - ], - "coordinate_minimum": [ - -1.125, - 0.0, - 0.0 - ], + "centroid": [0.0, 0.0, 0.0], + "coordinate_maximum": [1.125, 0.0, 0.0], + "coordinate_minimum": [-1.125, 0.0, 0.0], "maximum_centroid_distance": 1.125, "maximum_length": 1.25, "mechanics_substeps": 2, "minimum_length": 1.25, "neighbor_pair_count": 0, "signals": [], - "species_maximum": [ - 0.009999999776482582 - ], - "species_minimum": [ - 0.009999999776482582 - ], - "species_sum": [ - 0.019999999552965164 - ], + "species_maximum": [0.009999999776482582], + "species_minimum": [0.009999999776482582], + "species_sum": [0.019999999552965164], "step": 1, "total_length": 2.5 }, @@ -738,20 +495,10 @@ "cell_type_counts": { "0": 2 }, - "centroid": [ - 0.0, - 0.0, - 0.0 - ], - "coordinate_maximum": [ - 1.1337215900421143, - 2.257150526929763e-06, - 0.0 - ], + "centroid": [0.0, 0.0, 0.0], + "coordinate_maximum": [1.1337215900421143, 2.257150526929763e-6, 0.0], "coordinate_minimum": [ - -1.1337215900421143, - -2.257150526929763e-06, - 0.0 + -1.1337215900421143, -2.257150526929763e-6, 0.0 ], "maximum_centroid_distance": 1.1337215900443611, "maximum_length": 1.2707350254058838, @@ -759,15 +506,9 @@ "minimum_length": 1.270639181137085, "neighbor_pair_count": 0, "signals": [], - "species_maximum": [ - 0.019909102469682693 - ], - "species_minimum": [ - 0.019908685237169266 - ], - "species_sum": [ - 0.03981778770685196 - ], + "species_maximum": [0.019909102469682693], + "species_minimum": [0.019908685237169266], + "species_sum": [0.03981778770685196], "step": 2, "total_length": 2.5413742065429688 }, @@ -776,20 +517,10 @@ "cell_type_counts": { "0": 2 }, - "centroid": [ - 0.0, - 0.0, - 0.0 - ], - "coordinate_maximum": [ - 1.164511799812317, - 1.1096070011262782e-05, - 0.0 - ], + "centroid": [0.0, 0.0, 0.0], + "coordinate_maximum": [1.164511799812317, 1.1096070011262782e-5, 0.0], "coordinate_minimum": [ - -1.164511799812317, - -1.1096070011262782e-05, - 0.0 + -1.164511799812317, -1.1096070011262782e-5, 0.0 ], "maximum_centroid_distance": 1.1645117998651815, "maximum_length": 1.333212971687317, @@ -797,15 +528,9 @@ "minimum_length": 1.3327124118804932, "neighbor_pair_count": 0, "signals": [], - "species_maximum": [ - 0.04911075532436371 - ], - "species_minimum": [ - 0.049105264246463776 - ], - "species_sum": [ - 0.09821601957082748 - ], + "species_maximum": [0.04911075532436371], + "species_minimum": [0.049105264246463776], + "species_sum": [0.09821601957082748], "step": 5, "total_length": 2.66592538356781 }, @@ -814,20 +539,10 @@ "cell_type_counts": { "0": 2 }, - "centroid": [ - 0.0, - 0.0, - 0.0 - ], - "coordinate_maximum": [ - 1.219545602798462, - 3.069480226258747e-05, - 0.0 - ], + "centroid": [0.0, 0.0, 0.0], + "coordinate_maximum": [1.219545602798462, 3.069480226258747e-5, 0.0], "coordinate_minimum": [ - -1.219545602798462, - -3.069480226258747e-05, - 0.0 + -1.219545602798462, -3.069480226258747e-5, 0.0 ], "maximum_centroid_distance": 1.2195456031847414, "maximum_length": 1.444106101989746, @@ -835,15 +550,9 @@ "minimum_length": 1.4428004026412964, "neighbor_pair_count": 0, "signals": [], - "species_maximum": [ - 0.09598188102245331 - ], - "species_minimum": [ - 0.09595528990030289 - ], - "species_sum": [ - 0.1919371709227562 - ], + "species_maximum": [0.09598188102245331], + "species_minimum": [0.09595528990030289], + "species_sum": [0.1919371709227562], "step": 10, "total_length": 2.8869065046310425 }, @@ -852,20 +561,12 @@ "cell_type_counts": { "0": 2 }, - "centroid": [ - 0.0, - 0.0, - 0.0 - ], + "centroid": [0.0, 0.0, 0.0], "coordinate_maximum": [ - 1.3436744213104248, - 0.00010022102651419118, - 0.0 + 1.3436744213104248, 0.00010022102651419118, 0.0 ], "coordinate_minimum": [ - -1.3436744213104248, - -0.00010022102651419118, - 0.0 + -1.3436744213104248, -0.00010022102651419118, 0.0 ], "maximum_centroid_distance": 1.343674425048032, "maximum_length": 1.6944535970687866, @@ -873,15 +574,9 @@ "minimum_length": 1.6909832954406738, "neighbor_pair_count": 0, "signals": [], - "species_maximum": [ - 0.18285886943340302 - ], - "species_minimum": [ - 0.1827358901500702 - ], - "species_sum": [ - 0.3655947595834732 - ], + "species_maximum": [0.18285886943340302], + "species_minimum": [0.1827358901500702], + "species_sum": [0.3655947595834732], "step": 20, "total_length": 3.3854368925094604 } @@ -890,14 +585,7 @@ "model": "ex2_constGene.py", "model_sha256": "84fa75c1916552ec8c7ad5d5775473f8263df4ea6bb98d9926a8bf79dd319f12", "role": "species model", - "sample_steps": [ - 0, - 1, - 2, - 5, - 10, - 20 - ], + "sample_steps": [0, 1, 2, 5, 10, 20], "seed": 45678 }, { @@ -909,21 +597,9 @@ "0": 1, "1": 1 }, - "centroid": [ - 0.0, - 0.0, - 0.0 - ], - "coordinate_maximum": [ - 3.0, - 0.0, - 0.0 - ], - "coordinate_minimum": [ - -3.0, - 0.0, - 0.0 - ], + "centroid": [0.0, 0.0, 0.0], + "coordinate_maximum": [3.0, 0.0, 0.0], + "coordinate_minimum": [-3.0, 0.0, 0.0], "maximum_centroid_distance": 3.0, "maximum_length": 3.5, "mechanics_substeps": 0, @@ -941,18 +617,9 @@ "sum": 0.0 } ], - "species_maximum": [ - 0.0, - 0.0 - ], - "species_minimum": [ - 0.0, - 0.0 - ], - "species_sum": [ - 0.0, - 0.0 - ], + "species_maximum": [0.0, 0.0], + "species_minimum": [0.0, 0.0], + "species_sum": [0.0, 0.0], "step": 0, "total_length": 7.0 }, @@ -962,21 +629,9 @@ "0": 1, "1": 1 }, - "centroid": [ - 0.0, - 0.0, - 0.0 - ], - "coordinate_maximum": [ - 3.0, - 0.0, - 0.0 - ], - "coordinate_minimum": [ - -3.0, - 0.0, - 0.0 - ], + "centroid": [0.0, 0.0, 0.0], + "coordinate_maximum": [3.0, 0.0, 0.0], + "coordinate_minimum": [-3.0, 0.0, 0.0], "maximum_centroid_distance": 3.0, "maximum_length": 3.5, "mechanics_substeps": 1, @@ -994,18 +649,9 @@ "sum": 0.0 } ], - "species_maximum": [ - 0.009999999776482582, - 0.009999999776482582 - ], - "species_minimum": [ - 0.0, - 0.0 - ], - "species_sum": [ - 0.009999999776482582, - 0.009999999776482582 - ], + "species_maximum": [0.009999999776482582, 0.009999999776482582], + "species_minimum": [0.0, 0.0], + "species_sum": [0.009999999776482582, 0.009999999776482582], "step": 1, "total_length": 7.0 }, @@ -1015,21 +661,9 @@ "0": 1, "1": 1 }, - "centroid": [ - 0.0, - 0.0, - 0.0 - ], - "coordinate_maximum": [ - 3.0, - 0.0, - 0.0 - ], - "coordinate_minimum": [ - -3.0, - 0.0, - 0.0 - ], + "centroid": [0.0, 0.0, 0.0], + "coordinate_maximum": [3.0, 0.0, 0.0], + "coordinate_minimum": [-3.0, 0.0, 0.0], "maximum_centroid_distance": 3.0, "maximum_length": 3.503499984741211, "mechanics_substeps": 1, @@ -1037,28 +671,19 @@ "neighbor_pair_count": 0, "signals": [ { - "l2_norm": 1.7463142524153607e-05, - "maximum": 1.656699168961495e-05, - "sum": 2.2089322101237485e-05 + "l2_norm": 1.7463142524153607e-5, + "maximum": 1.656699168961495e-5, + "sum": 2.2089322101237485e-5 }, { - "l2_norm": 1.7463142524153607e-05, - "maximum": 1.656699168961495e-05, - "sum": 2.2089322101237485e-05 + "l2_norm": 1.7463142524153607e-5, + "maximum": 1.656699168961495e-5, + "sum": 2.2089322101237485e-5 } ], - "species_maximum": [ - 0.019970137625932693, - 0.019970137625932693 - ], - "species_minimum": [ - 0.0, - 0.0 - ], - "species_sum": [ - 0.019970137625932693, - 0.019970137625932693 - ], + "species_maximum": [0.019970137625932693, 0.019970137625932693], + "species_minimum": [0.0, 0.0], + "species_sum": [0.019970137625932693, 0.019970137625932693], "step": 2, "total_length": 7.006999969482422 }, @@ -1068,21 +693,9 @@ "0": 1, "1": 1 }, - "centroid": [ - 0.0, - 0.0, - 0.0 - ], - "coordinate_maximum": [ - 3.0, - 0.0, - 0.0 - ], - "coordinate_minimum": [ - -3.0, - 0.0, - 0.0 - ], + "centroid": [0.0, 0.0, 0.0], + "coordinate_maximum": [3.0, 0.0, 0.0], + "coordinate_minimum": [-3.0, 0.0, 0.0], "maximum_centroid_distance": 3.0, "maximum_length": 3.5070035457611084, "mechanics_substeps": 1, @@ -1090,28 +703,19 @@ "neighbor_pair_count": 0, "signals": [ { - "l2_norm": 5.229090056052733e-05, - "maximum": 4.96054271934554e-05, - "sum": 6.620271194240912e-05 + "l2_norm": 5.229090056052733e-5, + "maximum": 4.96054271934554e-5, + "sum": 6.620271194240912e-5 }, { - "l2_norm": 5.229089998509932e-05, - "maximum": 4.96054271934554e-05, - "sum": 6.620271040691517e-05 + "l2_norm": 5.229089998509932e-5, + "maximum": 4.96054271934554e-5, + "sum": 6.620271040691517e-5 } ], - "species_maximum": [ - 0.0299104955047369, - 0.0299104955047369 - ], - "species_minimum": [ - 3.0543598761312296e-09, - 3.0543598761312296e-09 - ], - "species_sum": [ - 0.029910498559096776, - 0.029910498559096776 - ], + "species_maximum": [0.0299104955047369, 0.0299104955047369], + "species_minimum": [3.0543598761312296e-9, 3.0543598761312296e-9], + "species_sum": [0.029910498559096776, 0.029910498559096776], "step": 3, "total_length": 7.014007091522217 }, @@ -1121,21 +725,9 @@ "0": 1, "1": 1 }, - "centroid": [ - 0.0, - 0.0, - 0.0 - ], - "coordinate_maximum": [ - 3.0, - 0.0, - 0.0 - ], - "coordinate_minimum": [ - -3.0, - 0.0, - 0.0 - ], + "centroid": [0.0, 0.0, 0.0], + "coordinate_maximum": [3.0, 0.0, 0.0], + "coordinate_minimum": [-3.0, 0.0, 0.0], "maximum_centroid_distance": 3.0, "maximum_length": 3.5140209197998047, "mechanics_substeps": 1, @@ -1153,18 +745,9 @@ "sum": 0.00022024168430575505 } ], - "species_maximum": [ - 0.04970220476388931, - 0.04970220476388931 - ], - "species_minimum": [ - 3.046945451501415e-08, - 3.046945451501415e-08 - ], - "species_sum": [ - 0.04970223523334383, - 0.04970223523334383 - ], + "species_maximum": [0.04970220476388931, 0.04970220476388931], + "species_minimum": [3.046945451501415e-8, 3.046945451501415e-8], + "species_sum": [0.04970223523334383, 0.04970223523334383], "step": 5, "total_length": 7.028041839599609 }, @@ -1174,21 +757,9 @@ "0": 1, "1": 1 }, - "centroid": [ - 0.0, - 0.0, - 0.0 - ], - "coordinate_maximum": [ - 3.0, - 0.0, - 0.0 - ], - "coordinate_minimum": [ - -3.0, - 0.0, - 0.0 - ], + "centroid": [0.0, 0.0, 0.0], + "coordinate_maximum": [3.0, 0.0, 0.0], + "coordinate_minimum": [-3.0, 0.0, 0.0], "maximum_centroid_distance": 3.0, "maximum_length": 3.5316262245178223, "mechanics_substeps": 1, @@ -1206,18 +777,9 @@ "sum": 0.0009862298223455167 } ], - "species_maximum": [ - 0.09866603463888168, - 0.09866603463888168 - ], - "species_minimum": [ - 3.6341577924758894e-07, - 3.634158360910078e-07 - ], - "species_sum": [ - 0.09866639805466093, - 0.09866639805471777 - ], + "species_maximum": [0.09866603463888168, 0.09866603463888168], + "species_minimum": [3.6341577924758894e-7, 3.634158360910078e-7], + "species_sum": [0.09866639805466093, 0.09866639805471777], "step": 10, "total_length": 7.0632524490356445 } @@ -1226,14 +788,7 @@ "model": "Tutorial_3/Tutorial_3.py", "model_sha256": "50cadae5ffac5ae7bb8bad8dfbd952461640514ac099d6a56d868a2704771693", "role": "coupled signaling model", - "sample_steps": [ - 0, - 1, - 2, - 3, - 5, - 10 - ], + "sample_steps": [0, 1, 2, 3, 5, 10], "seed": 56789 } ], diff --git a/cpp/core/capsule_bodies.hpp b/cpp/core/capsule_bodies.hpp new file mode 100644 index 0000000..85520dd --- /dev/null +++ b/cpp/core/capsule_bodies.hpp @@ -0,0 +1,211 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "cm/stokes_flow.hpp" + +namespace cm::detail { +using BodyVector = std::array; + +inline double body_dot(const BodyVector& a, const BodyVector& b) { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +inline BodyVector body_rotate(const std::array& q, const BodyVector& v) { + const BodyVector t{2 * (q[2] * v[2] - q[3] * v[1]), 2 * (q[3] * v[0] - q[1] * v[2]), + 2 * (q[1] * v[1] - q[2] * v[0])}; + + return {v[0] + q[0] * t[0] + q[2] * t[2] - q[3] * t[1], + v[1] + q[0] * t[1] + q[3] * t[0] - q[1] * t[2], + v[2] + q[0] * t[2] + q[1] * t[1] - q[2] * t[0]}; +} + +inline std::pair body_endpoints(const CapsuleBody& body) { + const auto axis = body_rotate(body.orientation, {1, 0, 0}); + auto a = body.position, b = body.position; + + for (std::size_t d = 0; d < 3; ++d) { + a[d] -= 0.5 * body.length * axis[d]; + b[d] += 0.5 * body.length * axis[d]; + } + + return {a, b}; +} + +inline std::pair body_closest_segments(const BodyVector& a0, + const BodyVector& a1, + const BodyVector& b0, + const BodyVector& b1) { + BodyVector da{}, db{}, r{}; + + for (std::size_t d = 0; d < 3; ++d) { + da[d] = a1[d] - a0[d]; + db[d] = b1[d] - b0[d]; + r[d] = a0[d] - b0[d]; + } + + const double a = body_dot(da, da), b = body_dot(da, db), c = body_dot(da, r), + e = body_dot(db, db), f = body_dot(db, r); + double s = 0, t = 0; + + if (a == 0 && e > 0) { + t = std::clamp(f / e, 0.0, 1.0); + } else if (e == 0 && a > 0) { + s = std::clamp(-c / a, 0.0, 1.0); + } else if (a > 0 && e > 0) { + const double denominator = a * e - b * b; + + if (denominator > 1e-14 * a * e) { + s = std::clamp((b * f - c * e) / denominator, 0.0, 1.0); + } + + t = (b * s + f) / e; + + if (t < 0) { + t = 0; + s = std::clamp(-c / a, 0.0, 1.0); + } else if (t > 1) { + t = 1; + s = std::clamp((b - c) / a, 0.0, 1.0); + } + } + + BodyVector pa{}, pb{}; + + for (std::size_t d = 0; d < 3; ++d) { + pa[d] = a0[d] + s * da[d]; + pb[d] = b0[d] + t * db[d]; + } + + return {pa, pb}; +} + +inline double body_segment_box_distance_squared(const BodyVector& start, const BodyVector& end, + const BodyVector& lo, const BodyVector& hi) { + BodyVector delta{}; + std::vector cuts{0, 1}; + + for (std::size_t a = 0; a < 3; ++a) { + delta[a] = end[a] - start[a]; + + if (delta[a] != 0) { + for (double bound : {lo[a], hi[a]}) { + const double t = (bound - start[a]) / delta[a]; + + if (t > 0 && t < 1) { + cuts.push_back(t); + } + } + } + } + + std::sort(cuts.begin(), cuts.end()); + auto value = [&](double t) { + double d = 0; + + for (std::size_t a = 0; a < 3; ++a) { + const double x = start[a] + t * delta[a]; + const double r = x - std::clamp(x, lo[a], hi[a]); + d += r * r; + } + + return d; + }; + double best = std::min(value(0), value(1)); + + for (std::size_t i = 1; i < cuts.size(); ++i) { + const double t = (cuts[i] + cuts[i - 1]) / 2; + double aa = 0, bb = 0; + + for (std::size_t a = 0; a < 3; ++a) { + const double x = start[a] + t * delta[a]; + + if (x >= lo[a] && x <= hi[a]) { + continue; + } + + const double bound = x < lo[a] ? lo[a] : hi[a]; + aa += delta[a] * delta[a]; + bb += delta[a] * (start[a] - bound); + } + + best = std::min(best, value(cuts[i])); + + if (aa > 0) { + best = std::min(best, value(std::clamp(-bb / aa, cuts[i - 1], cuts[i]))); + } + } + + return best; +} + +// Bao et al., arXiv:1505.07529, section 2.2. +// For r in [0,1], returns phi(r-3),...,phi(r+2). The rationalized root avoids +// cancellation at integer nodes. This C3 six-point delta obeys all four moments. +inline std::array immersed_delta_weights(double r) { + const double K = 59.0 / 60 - std::sqrt(29.0) / 20; + const double r2 = r * r, r3 = r2 * r; + // Derive beta from the sum-of-squares condition; the extra r multiplying + // (K+r^2) in the arXiv HTML rendering of equation 2.16 is inconsistent with it. + const double beta = 9.0 / 4 - 1.5 * (K + r2) + (22.0 / 3 - 7 * K) * r - 7.0 / 3 * r3; + const double a = (3 * K - 1) * r + r3, b = (4 - 3 * K) * r - r3; + const double gamma = -11 * r2 / 32 + 3 * (2 * K + r2) * r2 / 32 + a * a / 72 + b * b / 18; + const double f = -2 * gamma / (beta + std::sqrt(std::max(0.0, beta * beta - 112 * gamma))); + + return {f, + -3 * f - 1.0 / 16 + (K + r2) / 8 + a / 12, + 2 * f + 0.25 + b / 6, + 2 * f + 5.0 / 8 - (K + r2) / 4, + -3 * f + 0.25 - b / 6, + f - 1.0 / 16 + (K + r2) / 8 - a / 12}; +} + +struct BodyMarker { + BodyVector offset; + std::uint32_t body; + double axial_fraction; +}; + +inline std::vector capsule_surface_markers(const std::vector& bodies, + double spacing) { + std::vector markers; + + for (std::size_t body = 0; body < bodies.size(); ++body) { + const auto& b = bodies[body]; + auto ring = [&](double x, double radius) { + const unsigned count = + std::max(4U, static_cast(std::ceil(2 * std::numbers::pi * radius / spacing))); + + for (unsigned j = 0; j < count; ++j) { + const double theta = 2 * std::numbers::pi * (double(j) + 0.5) / count; + markers.push_back( + {body_rotate(b.orientation, {x, radius * std::cos(theta), radius * std::sin(theta)}), + static_cast(body), + b.length == 0 ? std::copysign(0.5, x) : std::clamp(x / b.length, -0.5, 0.5)}); + } + }; + const unsigned cylinders = static_cast(std::ceil(b.length / spacing)); + + for (unsigned i = 0; i < cylinders; ++i) { + ring(b.length * ((double(i) + 0.5) / cylinders - 0.5), b.radius); + } + + const unsigned caps = + std::max(1U, static_cast(std::ceil(std::numbers::pi * b.radius / (2 * spacing)))); + + for (unsigned i = 0; i < caps; ++i) { + const double theta = (double(i) + 0.5) * std::numbers::pi / (2 * caps); + + for (int sign : {-1, 1}) { + ring(sign * (0.5 * b.length + b.radius * std::cos(theta)), b.radius * std::sin(theta)); + } + } + } + + return markers; +} +} // namespace cm::detail diff --git a/cpp/core/capsule_contacts.hpp b/cpp/core/capsule_contacts.hpp new file mode 100644 index 0000000..1f6eed2 --- /dev/null +++ b/cpp/core/capsule_contacts.hpp @@ -0,0 +1,257 @@ +#pragma once + +#include + +#include "capsule_bodies.hpp" + +namespace cm::detail { +inline constexpr std::uint32_t fluid_wall_body = std::numeric_limits::max(); + +struct CapsuleContact { + std::uint32_t first, second; + BodyVector normal, first_offset, second_offset; + double gap; + double required_speed{0}; +}; + +inline BodyVector body_cross(const BodyVector& a, const BodyVector& b) { + return {a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]}; +} + +inline std::array contact_velocity_row(const CapsuleContact& contact, bool second, + double h) { + const auto& offset = second ? contact.second_offset : contact.first_offset; + const auto torque = body_cross(offset, contact.normal); + const double sign = second ? 1 : -1; + + return {sign * contact.normal[0], sign * contact.normal[1], sign * contact.normal[2], + sign * torque[0] / h, sign * torque[1] / h, sign * torque[2] / h}; +} + +inline double contact_growth_speed(const CapsuleBody& body, const BodyVector& offset, + const BodyVector& normal) { + const auto axis = body_rotate(body.orientation, {1, 0, 0}); + const double axial = body_dot(axis, offset); + const double fraction = + body.length == 0 ? std::copysign(0.5, axial) : std::clamp(axial / body.length, -0.5, 0.5); + + return body_dot(axis, normal) * fraction * body.length_rate; +} + +inline double contact_separation_speed(const CapsuleContact& contact, const FluidFlowResult& flow) { + double result = 0; + + for (bool second : {false, true}) { + const auto index = second ? contact.second : contact.first; + + if (index == fluid_wall_body) { + continue; + } + + const auto coefficients = contact_velocity_row(contact, second, 1); + const auto& motion = flow.bodies[index]; + + for (std::size_t a = 0; a < 3; ++a) { + result += + coefficients[a] * motion.velocity[a] + coefficients[3 + a] * motion.angular_velocity[a]; + } + } + + return result; +} + +inline std::pair body_closest_box(const BodyVector& start, + const BodyVector& end, + const BodyVector& lo, + const BodyVector& hi) { + BodyVector delta{}; + std::vector cuts{0, 1}; + + for (std::size_t a = 0; a < 3; ++a) { + delta[a] = end[a] - start[a]; + + if (delta[a] != 0) { + for (double bound : {lo[a], hi[a]}) { + const double t = (bound - start[a]) / delta[a]; + + if (t > 0 && t < 1) { + cuts.push_back(t); + } + } + } + } + + std::sort(cuts.begin(), cuts.end()); + double best = std::numeric_limits::infinity(); + std::pair points; + auto evaluate = [&](double t) { + BodyVector p{}, q{}; + double distance = 0; + + for (std::size_t a = 0; a < 3; ++a) { + p[a] = start[a] + t * delta[a]; + q[a] = std::clamp(p[a], lo[a], hi[a]); + distance += (p[a] - q[a]) * (p[a] - q[a]); + } + + if (distance < best) { + best = distance; + points = {p, q}; + } + }; + evaluate(0); + evaluate(1); + + for (std::size_t i = 1; i < cuts.size(); ++i) { + const double t = (cuts[i - 1] + cuts[i]) / 2; + double aa = 0, bb = 0; + + for (std::size_t a = 0; a < 3; ++a) { + const double x = start[a] + t * delta[a]; + + if (x >= lo[a] && x <= hi[a]) { + continue; + } + + const double bound = x < lo[a] ? lo[a] : hi[a]; + aa += delta[a] * delta[a]; + bb += delta[a] * (start[a] - bound); + } + + evaluate(cuts[i]); + + if (aa > 0) { + evaluate(std::clamp(-bb / aa, cuts[i - 1], cuts[i])); + } + } + + return points; +} + +inline void append_capsule_contact(std::vector& contacts, const FluidGridSpec& grid, + const std::vector& bodies, double minimum_gap, + double dt, double margin, std::uint32_t a, std::uint32_t b, + const BodyVector& pa, const BodyVector& pb, + double second_radius) { + BodyVector normal{}, first_offset{}, second_offset{}; + double distance = 0; + + for (std::size_t d = 0; d < 3; ++d) { + normal[d] = pb[d] - pa[d]; + distance += normal[d] * normal[d]; + } + + distance = std::sqrt(distance); + const double gap = distance - bodies[a].radius - second_radius; + + if (gap > minimum_gap + margin) { + return; + } + + if (distance == 0 || gap < -1e-10 * grid.spacing) { + throw std::invalid_argument("fluid contact geometry overlaps"); + } + + for (std::size_t d = 0; d < 3; ++d) { + normal[d] /= distance; + first_offset[d] = pa[d] - bodies[a].position[d] + bodies[a].radius * normal[d]; + + if (b != fluid_wall_body) { + second_offset[d] = pb[d] - bodies[b].position[d] - second_radius * normal[d]; + } + } + + CapsuleContact contact{a, b, normal, first_offset, second_offset, gap}; + contact.required_speed = + (minimum_gap - gap) / dt + contact_growth_speed(bodies[a], first_offset, normal); + + if (b != fluid_wall_body) { + contact.required_speed -= contact_growth_speed(bodies[b], second_offset, normal); + } + + for (const auto& old : contacts) { + if (old.first == a && old.second == b) { + double difference = 0; + + for (std::size_t d = 0; d < 3; ++d) { + difference += std::pow(old.first_offset[d] - first_offset[d], 2); + } + + if (difference < 1e-20 * grid.spacing * grid.spacing && + body_dot(old.normal, normal) > 1 - 1e-10) { + return; + } + } + } + + contacts.push_back(contact); +} + +inline std::vector capsule_contacts(const FluidGridSpec& grid, + const std::vector& bodies, + double minimum_gap, double dt, double margin) { + std::vector contacts; + auto append = [&](std::uint32_t a, std::uint32_t b, const BodyVector& pa, const BodyVector& pb, + double second_radius) { + append_capsule_contact(contacts, grid, bodies, minimum_gap, dt, margin, a, b, pa, pb, + second_radius); + }; + const BodyVector origin{grid.origin.x, grid.origin.y, grid.origin.z}; + const std::array dimensions{grid.shape.x, grid.shape.y, grid.shape.z}; + + for (std::uint32_t a = 0; a < bodies.size(); ++a) { + const auto [a0, a1] = body_endpoints(bodies[a]); + + for (std::uint32_t b = a + 1; b < bodies.size(); ++b) { + const auto [b0, b1] = body_endpoints(bodies[b]); + const auto [pa, pb] = body_closest_segments(a0, a1, b0, b1); + append(a, b, pa, pb, bodies[b].radius); + + // Endpoints supply both members of a parallel-rod contact manifold. + for (const auto& endpoint : {a0, a1}) { + auto [p, q] = body_closest_segments(endpoint, endpoint, b0, b1); + append(a, b, p, q, bodies[b].radius); + } + + for (const auto& endpoint : {b0, b1}) { + auto [p, q] = body_closest_segments(a0, a1, endpoint, endpoint); + append(a, b, p, q, bodies[b].radius); + } + } + + for (std::size_t axis = 0; axis < 3; ++axis) { + for (bool upper : {false, true}) { + const double boundary = + origin[axis] + (upper ? double(dimensions[axis]) - 0.5 : -0.5) * grid.spacing; + + for (const auto& endpoint : {a0, a1}) { + auto wall = endpoint; + wall[axis] = boundary; + append(a, fluid_wall_body, endpoint, wall, 0); + } + } + } + + for (std::size_t s = 0; s < grid.obstacles.size(); ++s) { + if (grid.obstacles[s]) { + auto index = s; + const auto z = index % dimensions[2]; + index /= dimensions[2]; + const auto y = index % dimensions[1], x = index / dimensions[1]; + const std::array coordinate{x, y, z}; + BodyVector lo{}, hi{}; + + for (std::size_t d = 0; d < 3; ++d) { + lo[d] = origin[d] + (double(coordinate[d]) - 0.5) * grid.spacing; + hi[d] = lo[d] + grid.spacing; + } + + const auto [p, q] = body_closest_box(a0, a1, lo, hi); + append(a, fluid_wall_body, p, q, 0); + } + } + } + + return contacts; +} +} // namespace cm::detail diff --git a/cpp/core/checkpoint.cpp b/cpp/core/checkpoint.cpp index 2dda8bc..1d7b722 100644 --- a/cpp/core/checkpoint.cpp +++ b/cpp/core/checkpoint.cpp @@ -5,27 +5,51 @@ namespace cm { +namespace { +void validate_culture_state(const SimulationCheckpoint& checkpoint) { + if (checkpoint.culture) { + if (checkpoint.signal_grid || checkpoint.coupled_rate_plan || + !checkpoint.constraints.planes.empty() || !checkpoint.constraints.spheres.empty() || + !checkpoint.constraints.boxes.empty() || !checkpoint.constraints.cylinders.empty()) { + throw std::invalid_argument( + "fluid flow cannot share legacy signals or mechanical checkpoint.constraints"); + } + + checkpoint.culture->validate(checkpoint.world); + } +} +} // namespace + void SimulationCheckpoint::validate() const { if (schema_version != checkpoint_schema_version) { throw std::invalid_argument("unsupported MicroSimulator checkpoint schema version"); } + if (!std::isfinite(time) || time < 0.0) { throw std::invalid_argument("checkpoint time must be finite and non-negative"); } + world.validate(); constraints.validate(); species_rate_plan.validate(); + + validate_culture_state(*this); + if (species_rate_plan.species_count() != world.species_count) { throw std::invalid_argument("checkpoint rate plan and world species counts disagree"); } + if (signal_grid.has_value()) { signal_grid->validate(); } + if (coupled_rate_plan.has_value()) { coupled_rate_plan->validate(); + if (!signal_grid.has_value()) { throw std::invalid_argument("checkpoint coupled rate plan requires a signal grid"); } + if (coupled_rate_plan->species_count() != world.species_count || coupled_rate_plan->signal_count() != signal_grid->spec.signal_count) { throw std::invalid_argument("checkpoint coupled rate plan counts disagree with state"); diff --git a/cpp/core/constraints.cpp b/cpp/core/constraints.cpp index 498b2cf..c865dfe 100644 --- a/cpp/core/constraints.cpp +++ b/cpp/core/constraints.cpp @@ -24,7 +24,9 @@ void validate_plane(const PlaneConstraint& plane) { if (plane.id == invalid_constraint_id || !finite(plane.point) || !finite(plane.inward_normal)) { throw std::invalid_argument("checkpoint plane contains an invalid field"); } + validate_coefficient(plane.coefficient); + if (std::abs(norm(plane.inward_normal) - 1.0F) > 1.0e-5F) { throw std::invalid_argument("checkpoint plane inward normal is not normalized"); } @@ -35,12 +37,15 @@ void validate_sphere(const SphereConstraint& sphere) { !std::isfinite(sphere.radius) || sphere.radius <= 0.0F) { throw std::invalid_argument("checkpoint sphere contains invalid geometry"); } + validate_coefficient(sphere.coefficient); + switch (sphere.allowed_region) { case ConstraintRegion::outside: case ConstraintRegion::inside: return; } + throw std::invalid_argument("checkpoint sphere uses an unknown allowed region"); } @@ -54,12 +59,15 @@ void validate_box(const BoxConstraint& box) { !positive_finite_extents(box.half_extents)) { throw std::invalid_argument("checkpoint box contains invalid geometry"); } + validate_coefficient(box.coefficient); + switch (box.allowed_region) { case ConstraintRegion::outside: case ConstraintRegion::inside: return; } + throw std::invalid_argument("checkpoint box uses an unknown allowed region"); } @@ -69,12 +77,15 @@ void validate_cylinder(const CylinderConstraint& cylinder) { !std::isfinite(cylinder.half_height) || cylinder.half_height <= 0.0F) { throw std::invalid_argument("checkpoint cylinder contains invalid geometry"); } + validate_coefficient(cylinder.coefficient); + switch (cylinder.allowed_region) { case ConstraintRegion::outside: case ConstraintRegion::inside: return; } + throw std::invalid_argument("checkpoint cylinder uses an unknown allowed region"); } @@ -85,27 +96,35 @@ void validate_constraint_state(ConstraintId next_id, std::span std::numeric_limits::max() - total) { throw std::overflow_error("checkpoint constraint count overflow"); } + total += count; } + std::unordered_set ids; ids.reserve(total); const auto check_ordered = [&ids, next_id](const auto& constraints, auto&& validate, const char* kind) { ConstraintId previous = invalid_constraint_id; + for (const auto& constraint : constraints) { validate(constraint); + if (constraint.id <= previous || constraint.id >= next_id) { throw std::invalid_argument(std::string("checkpoint ") + kind + " identifiers are not ordered and allocated"); } + if (!ids.insert(constraint.id).second) { throw std::invalid_argument("checkpoint contains a duplicate constraint identifier"); } + previous = constraint.id; } }; @@ -120,6 +139,7 @@ std::size_t checked_offset_count(std::size_t cell_count) { cell_count == std::numeric_limits::max()) { throw std::overflow_error("external contact graph exceeds the slot index space"); } + return cell_count + 1; } @@ -127,18 +147,22 @@ void validate_contact(const ExternalContact& contact, std::size_t cell_count) { if (contact.cell_id == invalid_cell_id || contact.constraint_id == invalid_constraint_id) { throw std::invalid_argument("external contact has an invalid identity"); } + if (contact.cell_slot >= cell_count) { throw std::invalid_argument("external contact cell slot is invalid"); } + if (contact.constraint_kind > ExternalConstraintKind::cylinder || contact.location > RodContactLocation::interior) { throw std::invalid_argument("external contact contains an invalid tag"); } + if (!finite(contact.point_on_cell) || !finite(contact.normal) || !std::isfinite(contact.signed_separation) || !std::isfinite(contact.weight) || contact.weight <= 0.0F) { throw std::invalid_argument("external contact contains a non-finite or invalid field"); } + if (std::abs(norm(contact.normal) - 1.0F) > 1.0e-4F) { throw std::invalid_argument("external contact normal is not unit length"); } @@ -163,6 +187,7 @@ ConstraintId ConstraintSet::allocate_id() { if (next_id_ == invalid_constraint_id || next_id_ == std::numeric_limits::max()) { throw std::overflow_error("constraint identifier space exhausted"); } + return next_id_++; } @@ -170,10 +195,13 @@ ConstraintId ConstraintSet::add_plane(const PlaneConstraintInit& plane) { if (!finite(plane.point) || !finite(plane.inward_normal)) { throw std::invalid_argument("plane fields must be finite"); } + const auto normal_magnitude = norm(plane.inward_normal); + if (!std::isfinite(normal_magnitude) || normal_magnitude <= 0.0F) { throw std::invalid_argument("plane inward normal must be non-zero"); } + validate_coefficient(plane.coefficient); const auto id = allocate_id(); planes_.push_back({ @@ -182,6 +210,7 @@ ConstraintId ConstraintSet::add_plane(const PlaneConstraintInit& plane) { .inward_normal = plane.inward_normal * (1.0F / normal_magnitude), .coefficient = plane.coefficient, }); + return id; } @@ -189,6 +218,7 @@ ConstraintId ConstraintSet::add_sphere(const SphereConstraintInit& sphere) { if (!finite(sphere.center) || !std::isfinite(sphere.radius) || sphere.radius <= 0.0F) { throw std::invalid_argument("sphere geometry must be finite with a positive radius"); } + validate_coefficient(sphere.coefficient); const auto id = allocate_id(); spheres_.push_back({ @@ -198,6 +228,7 @@ ConstraintId ConstraintSet::add_sphere(const SphereConstraintInit& sphere) { .coefficient = sphere.coefficient, .allowed_region = sphere.allowed_region, }); + return id; } @@ -205,6 +236,7 @@ ConstraintId ConstraintSet::add_box(const BoxConstraintInit& box) { if (!finite(box.center) || !positive_finite_extents(box.half_extents)) { throw std::invalid_argument("box geometry must be finite with positive half extents"); } + validate_coefficient(box.coefficient); const auto id = allocate_id(); boxes_.push_back({ @@ -214,6 +246,7 @@ ConstraintId ConstraintSet::add_box(const BoxConstraintInit& box) { .coefficient = box.coefficient, .allowed_region = box.allowed_region, }); + return id; } @@ -223,6 +256,7 @@ ConstraintId ConstraintSet::add_cylinder(const CylinderConstraintInit& cylinder) throw std::invalid_argument( "cylinder geometry must be finite with a positive radius and half height"); } + validate_coefficient(cylinder.coefficient); const auto id = allocate_id(); cylinders_.push_back({ @@ -233,6 +267,7 @@ ConstraintId ConstraintSet::add_cylinder(const CylinderConstraintInit& cylinder) .coefficient = cylinder.coefficient, .allowed_region = cylinder.allowed_region, }); + return id; } @@ -244,11 +279,17 @@ bool ConstraintSet::empty() const noexcept { return planes_.empty() && spheres_.empty() && boxes_.empty() && cylinders_.empty(); } -std::span ConstraintSet::planes() const& noexcept { return planes_; } +std::span ConstraintSet::planes() const& noexcept { + return planes_; +} -std::span ConstraintSet::spheres() const& noexcept { return spheres_; } +std::span ConstraintSet::spheres() const& noexcept { + return spheres_; +} -std::span ConstraintSet::boxes() const& noexcept { return boxes_; } +std::span ConstraintSet::boxes() const& noexcept { + return boxes_; +} std::span ConstraintSet::cylinders() const& noexcept { return cylinders_; @@ -263,6 +304,7 @@ ConstraintSetCheckpoint ConstraintSet::checkpoint() const { .cylinders = cylinders_, }; result.validate(); + return result; } @@ -274,6 +316,7 @@ void validate_constraint_contact_parameters(const ConstraintContactParameters& p if (!std::isfinite(parameters.activation_margin) || parameters.activation_margin < 0.0F) { throw std::invalid_argument("constraint activation margin must be finite and non-negative"); } + if (!std::isfinite(parameters.degeneracy_epsilon) || parameters.degeneracy_epsilon <= 0.0F) { throw std::invalid_argument("constraint degeneracy epsilon must be finite and positive"); } @@ -288,23 +331,31 @@ ExternalContactGraph::ExternalContactGraph(std::size_t cell_count, validate_contact(contact, cell_count_); ++incidence_offsets_[static_cast(contact.cell_slot) + 1]; } + for (std::size_t index = 1; index < incidence_offsets_.size(); ++index) { incidence_offsets_[index] += incidence_offsets_[index - 1]; } incidence_contact_indices_.resize(contacts_.size()); auto cursors = incidence_offsets_; + for (std::size_t index = 0; index < contacts_.size(); ++index) { const auto slot = static_cast(contacts_[index].cell_slot); incidence_contact_indices_[cursors[slot]++] = index; } } -std::size_t ExternalContactGraph::cell_count() const noexcept { return cell_count_; } +std::size_t ExternalContactGraph::cell_count() const noexcept { + return cell_count_; +} -std::size_t ExternalContactGraph::size() const noexcept { return contacts_.size(); } +std::size_t ExternalContactGraph::size() const noexcept { + return contacts_.size(); +} -bool ExternalContactGraph::empty() const noexcept { return contacts_.empty(); } +bool ExternalContactGraph::empty() const noexcept { + return contacts_.empty(); +} std::span ExternalContactGraph::contacts() const& noexcept { return contacts_; @@ -312,11 +363,14 @@ std::span ExternalContactGraph::contacts() const& noexcep std::span ExternalContactGraph::incident_contact_indices(Slot slot) const& { const auto index = static_cast(slot); + if (index >= cell_count_) { throw std::out_of_range("external contact incidence slot is out of range"); } + const auto begin = incidence_offsets_[index]; const auto end = incidence_offsets_[index + 1]; + return std::span(incidence_contact_indices_).subspan(begin, end - begin); } diff --git a/cpp/core/contact_graph.cpp b/cpp/core/contact_graph.cpp index 1b12b31..18756c7 100644 --- a/cpp/core/contact_graph.cpp +++ b/cpp/core/contact_graph.cpp @@ -30,6 +30,7 @@ std::size_t checked_offset_count(std::size_t cell_count) { cell_count == std::numeric_limits::max()) { throw std::overflow_error("contact graph exceeds the slot index space"); } + return cell_count + 1; } @@ -38,18 +39,22 @@ void validate_contact(const CellContact& contact, std::size_t cell_count) { contact.first_id >= contact.second_id) { throw std::invalid_argument("contact cell identifiers are not canonical"); } + if (contact.first_slot >= cell_count || contact.second_slot >= cell_count || contact.first_slot == contact.second_slot) { throw std::invalid_argument("contact slots are invalid"); } + if (contact.ordinal > 1) { throw std::invalid_argument("cell contact ordinal exceeds the capsule-pair contract"); } + if (!finite(contact.point_on_first) || !finite(contact.normal) || !std::isfinite(contact.signed_separation) || !std::isfinite(contact.weight) || contact.weight <= 0.0F) { throw std::invalid_argument("contact contains a non-finite or invalid field"); } + if (std::abs(norm(contact.normal) - 1.0F) > 1.0e-4F) { throw std::invalid_argument("contact normal is not unit length"); } @@ -61,10 +66,12 @@ void validate_contact_parameters(const ContactParameters& parameters) { if (!std::isfinite(parameters.activation_margin) || parameters.activation_margin < 0.0F) { throw std::invalid_argument("contact activation margin must be finite and non-negative"); } + if (!std::isfinite(parameters.parallel_sine_threshold) || parameters.parallel_sine_threshold < 0.0F || parameters.parallel_sine_threshold > 1.0F) { throw std::invalid_argument("contact parallel threshold must be between zero and one"); } + if (!std::isfinite(parameters.degeneracy_epsilon) || parameters.degeneracy_epsilon <= 0.0F) { throw std::invalid_argument("contact degeneracy epsilon must be finite and positive"); } @@ -77,6 +84,7 @@ std::vector find_cell_contact_candidates(const WorldState& sta std::vector bounds; bounds.reserve(geometry.size()); const auto margin_per_cell = static_cast(parameters.activation_margin) * 0.5; + for (std::size_t index = 0; index < geometry.size(); ++index) { const auto half_length = static_cast(geometry.lengths[index]) * 0.5; const auto padding = static_cast(geometry.radii[index]) + margin_per_cell; @@ -100,36 +108,44 @@ std::vector find_cell_contact_candidates(const WorldState& sta .maximum_z = center_z + extent_z, }); } + std::ranges::sort(bounds, [](const CapsuleBounds& left, const CapsuleBounds& right) { return std::tuple{left.minimum_x, left.id} < std::tuple{right.minimum_x, right.id}; }); std::vector active; std::vector candidates; + for (const auto& current : bounds) { const auto expired = std::ranges::remove_if(active, [¤t](const CapsuleBounds* candidate) { return candidate->maximum_x < current.minimum_x; }); active.erase(expired.begin(), expired.end()); + for (const auto* candidate : active) { const auto overlaps_y = candidate->maximum_y >= current.minimum_y && current.maximum_y >= candidate->minimum_y; const auto overlaps_z = candidate->maximum_z >= current.minimum_z && current.maximum_z >= candidate->minimum_z; + if (!overlaps_y || !overlaps_z) { continue; } + candidates.push_back(candidate->id < current.id ? ContactCandidate{candidate->slot, current.slot} : ContactCandidate{current.slot, candidate->slot}); } + active.push_back(¤t); } + std::ranges::sort( candidates, [&geometry](const ContactCandidate& left, const ContactCandidate& right) { return std::tuple{geometry.ids[left.first_slot], geometry.ids[left.second_slot]} < std::tuple{geometry.ids[right.first_slot], geometry.ids[right.second_slot]}; }); + return candidates; } @@ -147,12 +163,14 @@ ContactGraph::ContactGraph(std::size_t cell_count, std::vector cont ++incidence_offsets_[static_cast(contact.first_slot) + 1]; ++incidence_offsets_[static_cast(contact.second_slot) + 1]; } + for (std::size_t index = 1; index < incidence_offsets_.size(); ++index) { incidence_offsets_[index] += incidence_offsets_[index - 1]; } incidence_contact_indices_.resize(contacts_.size() * 2); auto cursors = incidence_offsets_; + for (std::size_t index = 0; index < contacts_.size(); ++index) { const auto& contact = contacts_[index]; incidence_contact_indices_[cursors[contact.first_slot]++] = index; @@ -160,10 +178,12 @@ ContactGraph::ContactGraph(std::size_t cell_count, std::vector cont } std::vector> neighbors(cell_count_); + for (const auto& contact : contacts_) { neighbors[contact.first_slot].push_back(contact.second_id); neighbors[contact.second_slot].push_back(contact.first_id); } + for (std::size_t slot = 0; slot < cell_count_; ++slot) { auto& ids = neighbors[slot]; std::ranges::sort(ids); @@ -174,31 +194,45 @@ ContactGraph::ContactGraph(std::size_t cell_count, std::vector cont } } -std::size_t ContactGraph::cell_count() const noexcept { return cell_count_; } +std::size_t ContactGraph::cell_count() const noexcept { + return cell_count_; +} -std::size_t ContactGraph::size() const noexcept { return contacts_.size(); } +std::size_t ContactGraph::size() const noexcept { + return contacts_.size(); +} -bool ContactGraph::empty() const noexcept { return contacts_.empty(); } +bool ContactGraph::empty() const noexcept { + return contacts_.empty(); +} -std::span ContactGraph::contacts() const& noexcept { return contacts_; } +std::span ContactGraph::contacts() const& noexcept { + return contacts_; +} std::span ContactGraph::incident_contact_indices(Slot slot) const& { const auto index = static_cast(slot); + if (index >= cell_count_) { throw std::out_of_range("contact incidence slot is out of range"); } + const auto begin = incidence_offsets_[index]; const auto end = incidence_offsets_[index + 1]; + return std::span(incidence_contact_indices_).subspan(begin, end - begin); } std::span ContactGraph::neighbor_ids(Slot slot) const& { const auto index = static_cast(slot); + if (index >= cell_count_) { throw std::out_of_range("contact neighbor slot is out of range"); } + const auto begin = neighbor_offsets_[index]; const auto end = neighbor_offsets_[index + 1]; + return std::span(neighbor_ids_).subspan(begin, end - begin); } diff --git a/cpp/core/convex_polyhedron.hpp b/cpp/core/convex_polyhedron.hpp new file mode 100644 index 0000000..bfafbd9 --- /dev/null +++ b/cpp/core/convex_polyhedron.hpp @@ -0,0 +1,436 @@ +#pragma once + +#include +#include + +#include "capsule_contacts.hpp" + +namespace cm::detail { +struct ClipPlane { + BodyVector normal; + double offset; + std::uint64_t tag; +}; + +struct ConvexPolygon { + std::vector vertices; + std::uint64_t tag; +}; + +struct ConvexPolyhedron { + std::vector faces; +}; + +struct PolyhedronMeasure { + double volume{0}; + BodyVector centroid{}; +}; + +inline BodyVector body_difference(const BodyVector& a, const BodyVector& b) { + return {a[0] - b[0], a[1] - b[1], a[2] - b[2]}; +} + +inline double polygon_epsilon(const std::vector& polygon) { + double scale = 0; + + for (const auto& p : polygon) { + for (double x : p) { + scale = std::max(scale, std::abs(x)); + } + } + + return 256 * std::numeric_limits::epsilon() * scale; +} + +inline void clean_polygon(std::vector& polygon) { + const double epsilon = polygon_epsilon(polygon); + std::vector clean; + + for (const auto& p : polygon) { + if (!clean.empty()) { + const auto delta = body_difference(p, clean.back()); + + if (body_dot(delta, delta) <= epsilon * epsilon) { + continue; + } + } + + clean.push_back(p); + } + + if (clean.size() > 1) { + const auto delta = body_difference(clean.front(), clean.back()); + + if (body_dot(delta, delta) <= epsilon * epsilon) { + clean.pop_back(); + } + } + + BodyVector oriented_area{}; + + for (std::size_t i = 2; i < clean.size(); ++i) { + const auto cross = + body_cross(body_difference(clean[i - 1], clean[0]), body_difference(clean[i], clean[0])); + + for (unsigned d = 0; d < 3; ++d) { + oriented_area[d] += cross[d]; + } + } + + bool changed = true; + + while (changed && clean.size() > 2) { + changed = false; + + for (std::size_t i = 0; i < clean.size(); ++i) { + const auto a = body_difference(clean[i], clean[(i + clean.size() - 1) % clean.size()]); + const auto b = body_difference(clean[(i + 1) % clean.size()], clean[i]); + const auto cross = body_cross(a, b); + + // All input and clipped faces are convex. Intersections of nearly + // parallel planes can produce two roundoff-separated copies of a corner + // with a reversed turn. Retaining that spurious edge creates an inward + // halfspace when matching faces, removing a macroscopic face area. + if (body_dot(cross, oriented_area) < 0 || + body_dot(cross, cross) <= epsilon * epsilon * (body_dot(a, a) + body_dot(b, b))) { + clean.erase(clean.begin() + static_cast(i)); + changed = true; + break; + } + } + } + + polygon = std::move(clean); +} + +inline double plane_distance(const ClipPlane& plane, const BodyVector& p) { + const double distance = body_dot(plane.normal, p) - plane.offset; + double scale = std::abs(plane.offset); + + for (unsigned d = 0; d < 3; ++d) { + scale += std::abs(plane.normal[d] * p[d]); + } + + return std::abs(distance) <= 64 * std::numeric_limits::epsilon() * scale ? 0 : distance; +} + +inline BodyVector body_normalized(BodyVector a) { + const double norm = std::sqrt(body_dot(a, a)); + + if (!(norm > 0) || !std::isfinite(norm)) { + throw std::invalid_argument("degenerate fluid geometry normal"); + } + + for (double& x : a) { + x /= norm; + } + + return a; +} + +inline double polygon_area(const std::vector& polygon) { + if (polygon.size() < 3) { + return 0; + } + + BodyVector area{}; + + for (std::size_t i = 2; i < polygon.size(); ++i) { + const auto cross = body_cross(body_difference(polygon[i - 1], polygon[0]), + body_difference(polygon[i], polygon[0])); + + for (std::size_t d = 0; d < 3; ++d) { + area[d] += cross[d] / 2; + } + } + + return std::sqrt(body_dot(area, area)); +} + +inline PolyhedronMeasure measure_polyhedron(const ConvexPolyhedron& poly) { + PolyhedronMeasure result; + BodyVector reference{}; + std::size_t count = 0; + + for (const auto& face : poly.faces) { + for (const auto& p : face.vertices) { + for (std::size_t d = 0; d < 3; ++d) { + reference[d] += p[d]; + } + + ++count; + } + } + + if (!count) { + return result; + } + + for (double& x : reference) { + x /= double(count); + } + + for (const auto& face : poly.faces) { + for (std::size_t i = 2; i < face.vertices.size(); ++i) { + const auto& a = face.vertices[0]; + const auto& b = face.vertices[i - 1]; + const auto& c = face.vertices[i]; + const double v = + body_dot(body_difference(a, reference), + body_cross(body_difference(b, reference), body_difference(c, reference))) / + 6; + result.volume += v; + + for (std::size_t d = 0; d < 3; ++d) { + result.centroid[d] += v * (reference[d] + a[d] + b[d] + c[d]) / 4; + } + } + } + + if (result.volume > 0) { + for (double& x : result.centroid) { + x /= result.volume; + } + } + + return result; +} + +inline std::vector clip_polygon(const std::vector& polygon, + const ClipPlane& plane, + std::vector* intersections = nullptr) { + std::vector result; + + if (polygon.empty()) { + return result; + } + + auto previous = polygon.back(); + double dp = plane_distance(plane, previous); + + for (const auto& current : polygon) { + const double dc = plane_distance(plane, current); + + if ((dp <= 0) != (dc <= 0)) { + const double t = dp / (dp - dc); + BodyVector point{}; + + for (std::size_t d = 0; d < 3; ++d) { + point[d] = previous[d] + t * (current[d] - previous[d]); + } + + result.push_back(point); + + if (intersections) { + intersections->push_back(point); + } + } + + if (dc <= 0) { + result.push_back(current); + } + + previous = current; + dp = dc; + } + + clean_polygon(result); + + return result; +} + +inline ConvexPolyhedron clip_polyhedron(const ConvexPolyhedron& poly, const ClipPlane& plane) { + ConvexPolyhedron result; + std::vector intersections; + + for (const auto& face : poly.faces) { + auto clipped = clip_polygon(face.vertices, plane, &intersections); + const double epsilon = polygon_epsilon(clipped); + + if (polygon_area(clipped) > epsilon * epsilon) { + result.faces.push_back({std::move(clipped), face.tag}); + } + } + + std::vector cap; + const double epsilon = polygon_epsilon(intersections); + + for (const auto& point : intersections) { + bool duplicate = false; + + for (const auto& previous : cap) { + const auto delta = body_difference(point, previous); + + if (body_dot(delta, delta) <= epsilon * epsilon) { + duplicate = true; + break; + } + } + + if (!duplicate) { + cap.push_back(point); + } + } + + if (cap.size() >= 3) { + BodyVector center{}; + + for (const auto& p : cap) { + for (std::size_t d = 0; d < 3; ++d) { + center[d] += p[d] / double(cap.size()); + } + } + + const auto normal = body_normalized(plane.normal); + const BodyVector axis = std::abs(normal[0]) < 0.9 ? BodyVector{1, 0, 0} : BodyVector{0, 1, 0}; + const auto u = body_normalized(body_cross(normal, axis)); + const auto v = body_cross(normal, u); + std::sort(cap.begin(), cap.end(), [&](const auto& a, const auto& b) { + const auto da = body_difference(a, center), db = body_difference(b, center); + + return std::atan2(body_dot(da, v), body_dot(da, u)) < + std::atan2(body_dot(db, v), body_dot(db, u)); + }); + clean_polygon(cap); + + if (polygon_area(cap) > epsilon * epsilon) { + result.faces.push_back({std::move(cap), plane.tag}); + } + } + + return result; +} + +inline ConvexPolyhedron box_polyhedron(const BodyVector& lo, const BodyVector& hi, + std::array tags = {}) { + std::array p; + + for (unsigned i = 0; i < 8; ++i) { + p[i] = {(i & 4) ? hi[0] : lo[0], (i & 2) ? hi[1] : lo[1], (i & 1) ? hi[2] : lo[2]}; + } + + constexpr std::array, 6> indices{ + {{0, 1, 3, 2}, {4, 6, 7, 5}, {0, 4, 5, 1}, {2, 3, 7, 6}, {0, 2, 6, 4}, {1, 5, 7, 3}}}; + ConvexPolyhedron poly; + + for (std::size_t f = 0; f < 6; ++f) { + ConvexPolygon face; + face.tag = tags[f]; + + for (auto i : indices[f]) { + face.vertices.push_back(p[i]); + } + + poly.faces.push_back(std::move(face)); + } + + return poly; +} + +struct CapsulePolyhedron { + ConvexPolyhedron poly; + std::vector planes; + double surface_error_bound{0}; +}; + +inline void add_capsule_face(CapsulePolyhedron& result, const CapsuleBody& body, + std::vector vertices, BodyVector inside_reference) { + const double epsilon = polygon_epsilon(vertices); + + if (polygon_area(vertices) <= epsilon * epsilon) { + return; + } + + auto normal = body_normalized(body_cross(body_difference(vertices[1], vertices[0]), + body_difference(vertices[2], vertices[0]))); + + if (body_dot(normal, body_difference(vertices[0], inside_reference)) < 0) { + std::reverse(vertices.begin(), vertices.end()); + + for (double& x : normal) { + x = -x; + } + } + + const double offset = body_dot(normal, body_difference(vertices[0], inside_reference)); + result.surface_error_bound = std::max(result.surface_error_bound, body.radius - offset); + + for (auto& p : vertices) { + p = body_rotate(body.orientation, p); + + for (std::size_t d = 0; d < 3; ++d) { + p[d] += body.position[d]; + } + } + + normal = body_rotate(body.orientation, normal); + const auto tag = std::uint64_t{1} << 63 | result.planes.size(); + result.planes.push_back({normal, body_dot(normal, vertices[0]), tag}); + result.poly.faces.push_back({std::move(vertices), tag}); +} + +// A convex capsule approximation with exactly the analytical cross-sectional +// area and capsule volume. Transverse and axial corrections tend to one under +// angular refinement. Their maximum surface error is reported explicitly. +inline CapsulePolyhedron capsule_polyhedron(const CapsuleBody& body, unsigned resolution) { + if (resolution < 8 || resolution % 4 != 0 || resolution > 1024) { + throw std::invalid_argument( + "capsule surface resolution must be a multiple of four in [8,1024]"); + } + + const unsigned latitude = resolution / 4; + const double pi = std::numbers::pi; + const double area = 0.5 * resolution * std::sin(2 * pi / resolution); + const double transverse = std::sqrt(pi / area); + double sphere_volume = 0; + + for (unsigned i = 0; i < latitude; ++i) { + const double a = double(i) * pi / (2 * latitude), b = double(i + 1) * pi / (2 * latitude); + const double r0 = std::sin(a), r1 = std::sin(b), dx = std::cos(a) - std::cos(b); + sphere_volume += 2 * dx * area * (r0 * r0 + r0 * r1 + r1 * r1) / 3; + } + + const double axial = (4 * pi / 3) / (sphere_volume * transverse * transverse); + CapsulePolyhedron result; + result.surface_error_bound = body.radius * (std::max(transverse, axial) - 1); + auto ring_point = [&](int sign, unsigned latitude_index, unsigned longitude, bool offset_length) { + const double theta = double(latitude_index) * pi / (2 * latitude); + const double phi = double(longitude % resolution) * 2 * pi / resolution; + + return BodyVector{ + sign * (axial * body.radius * std::cos(theta) + (offset_length ? body.length / 2 : 0)), + transverse * body.radius * std::sin(theta) * std::cos(phi), + transverse * body.radius * std::sin(theta) * std::sin(phi)}; + }; + auto add_face = [&](std::vector vertices, BodyVector inside_reference) { + add_capsule_face(result, body, std::move(vertices), inside_reference); + }; + + for (int sign : {-1, 1}) { + for (unsigned i = 0; i < latitude; ++i) { + for (unsigned j = 0; j < resolution; ++j) { + std::vector face; + face.push_back(ring_point(sign, i, j, true)); + + if (i != 0) { + face.push_back(ring_point(sign, i, j + 1, true)); + } + + face.push_back(ring_point(sign, i + 1, j + 1, true)); + face.push_back(ring_point(sign, i + 1, j, true)); + add_face(std::move(face), {sign * body.length / 2, 0, 0}); + } + } + } + + if (body.length > 0) { + for (unsigned j = 0; j < resolution; ++j) { + add_face({ring_point(-1, latitude, j, true), ring_point(-1, latitude, j + 1, true), + ring_point(1, latitude, j + 1, true), ring_point(1, latitude, j, true)}, + {0, 0, 0}); + } + } + + return result; +} +} // namespace cm::detail diff --git a/cpp/core/coupled_rates.cpp b/cpp/core/coupled_rates.cpp index 60abf1f..5eae818 100644 --- a/cpp/core/coupled_rates.cpp +++ b/cpp/core/coupled_rates.cpp @@ -6,6 +6,8 @@ #include #include +#include "rate_operations.hpp" + namespace cm { namespace { @@ -23,6 +25,51 @@ void validate_input(std::uint32_t input, std::size_t instruction_index, const ch } } +void validate_instruction(const RateInstruction& instruction, std::size_t index, + std::size_t species_count, std::size_t signal_count) { + if (detail::is_cell_property_operation(instruction.operation)) { + return; + } + + if (detail::is_unary_operation(instruction.operation)) { + validate_input(instruction.first, index, "unary"); + + return; + } + + if (detail::is_binary_operation(instruction.operation)) { + validate_input(instruction.first, index, "first"); + validate_input(instruction.second, index, "second"); + + return; + } + + switch (instruction.operation) { + case RateOp::constant: + if (!std::isfinite(instruction.value)) { + throw std::invalid_argument("coupled rate constants must be finite"); + } + break; + case RateOp::species: + if (instruction.first >= species_count) { + throw std::invalid_argument("coupled rate instruction uses an unknown species"); + } + break; + case RateOp::signal: + if (instruction.first >= signal_count) { + throw std::invalid_argument("coupled rate instruction uses an unknown signal"); + } + break; + case RateOp::select: + validate_input(instruction.first, index, "condition"); + validate_input(instruction.second, index, "true"); + validate_input(instruction.third, index, "false"); + break; + default: + throw std::invalid_argument("coupled rate plan uses an unknown operation"); + } +} + } // namespace CoupledRatePlan::CoupledRatePlan(std::size_t species_count, std::size_t signal_count, @@ -37,9 +84,13 @@ CoupledRatePlan::CoupledRatePlan(std::size_t species_count, std::size_t signal_c validate(); } -std::size_t CoupledRatePlan::species_count() const noexcept { return species_count_; } +std::size_t CoupledRatePlan::species_count() const noexcept { + return species_count_; +} -std::size_t CoupledRatePlan::signal_count() const noexcept { return signal_count_; } +std::size_t CoupledRatePlan::signal_count() const noexcept { + return signal_count_; +} std::span CoupledRatePlan::instructions() const& noexcept { return instructions_; @@ -57,76 +108,27 @@ void CoupledRatePlan::validate() const { checked_index(species_count_, "species count"); checked_index(signal_count_, "signal count"); checked_index(instructions_.size(), "instruction count"); + if (signal_count_ == 0) { throw std::invalid_argument("a coupled rate plan needs at least one signal"); } + if (species_outputs_.size() != species_count_) { throw std::invalid_argument("coupled species output count must match the species count"); } + if (signal_outputs_.size() != signal_count_) { throw std::invalid_argument("coupled signal output count must match the signal count"); } + if (instructions_.empty()) { throw std::invalid_argument("a coupled rate plan needs instructions"); } for (std::size_t index = 0; index < instructions_.size(); ++index) { const auto& instruction = instructions_[index]; - switch (instruction.operation) { - case RateOp::constant: - if (!std::isfinite(instruction.value)) { - throw std::invalid_argument("coupled rate constants must be finite"); - } - break; - case RateOp::species: - if (instruction.first >= species_count_) { - throw std::invalid_argument("coupled rate instruction uses an unknown species"); - } - break; - case RateOp::signal: - if (instruction.first >= signal_count_) { - throw std::invalid_argument("coupled rate instruction uses an unknown signal"); - } - break; - case RateOp::position_x: - case RateOp::position_y: - case RateOp::position_z: - case RateOp::cell_length: - case RateOp::cell_radius: - case RateOp::growth_rate: - case RateOp::cell_type: - case RateOp::cell_volume: - case RateOp::cell_volume_change_rate: - case RateOp::cell_surface_area: - break; - case RateOp::negate: - case RateOp::exponential: - case RateOp::logarithm: - validate_input(instruction.first, index, "unary"); - break; - case RateOp::add: - case RateOp::subtract: - case RateOp::multiply: - case RateOp::divide: - case RateOp::power: - case RateOp::minimum: - case RateOp::maximum: - case RateOp::less: - case RateOp::less_equal: - case RateOp::greater: - case RateOp::greater_equal: - case RateOp::equal: - validate_input(instruction.first, index, "first"); - validate_input(instruction.second, index, "second"); - break; - case RateOp::select: - validate_input(instruction.first, index, "condition"); - validate_input(instruction.second, index, "true"); - validate_input(instruction.third, index, "false"); - break; - default: - throw std::invalid_argument("coupled rate plan uses an unknown operation"); - } + + validate_instruction(instruction, index, species_count_, signal_count_); } for (const auto output : species_outputs_) { @@ -134,6 +136,7 @@ void CoupledRatePlan::validate() const { throw std::invalid_argument("coupled species output uses an unknown instruction"); } } + for (const auto output : signal_outputs_) { if (output >= instructions_.size()) { throw std::invalid_argument("coupled signal output uses an unknown instruction"); diff --git a/cpp/core/culture_simulation.cpp b/cpp/core/culture_simulation.cpp new file mode 100644 index 0000000..a30da5c --- /dev/null +++ b/cpp/core/culture_simulation.cpp @@ -0,0 +1,1100 @@ +#include +#include + +#include "capsule_bodies.hpp" +#include "culture_simulation_internal.hpp" + +namespace cm { +namespace { +void require(bool ok, const char* message) { + if (!ok) { + throw std::invalid_argument(message); + } +} + +bool nonnegative(double x) { + return std::isfinite(x) && x >= 0; +} + +bool positive(double x) { + return std::isfinite(x) && x > 0; +} + +std::vector bodies(const std::vector& cells) { + std::vector result; + + for (const auto& cell : cells) { + result.push_back(cell.body); + } + + return result; +} + +std::array orientation(Vec3 direction) { + const double norm = + std::sqrt(double(direction.x) * direction.x + double(direction.y) * direction.y + + double(direction.z) * direction.z); + const double x = direction.x / norm, y = direction.y / norm, z = direction.z / norm; + + if (x < -1 + 1e-14) { + return {0, 0, 1, 0}; + } + + std::array q{1 + x, 0, -z, y}; + double qnorm = 0; + + for (double a : q) { + qnorm += a * a; + } + + for (double& a : q) { + a /= std::sqrt(qnorm); + } + + return q; +} + +std::vector levels(const CultureCellState& cell) { + std::vector c; + + for (double amount : cell.species_amounts) { + const float value = static_cast(amount / cell.biochemical_volume); + require(std::isfinite(value) && value >= 0, + "culture intracellular concentration is not representable"); + c.push_back(value); + } + + return c; +} + +void sync(WorldState& world, const CultureCellState& cell) { + const auto axis = detail::body_rotate(cell.body.orientation, {1, 0, 0}); + world.set_cell_geometry( + cell.body.id, + {float(cell.body.position[0]), float(cell.body.position[1]), float(cell.body.position[2])}, + {float(axis[0]), float(axis[1]), float(axis[2])}, float(cell.body.length)); + world.set_species(cell.body.id, levels(cell)); +} +} // namespace + +namespace { +void validate_culture_events(const CultureConfiguration& config, std::size_t species_count) { + double previous = 0; + + for (const auto& e : config.events) { + require(std::isfinite(e.time) && e.time > previous, + "culture events must be strictly increasing and positive"); + auto candidate = config; + candidate.events.clear(); + candidate.ports = e.ports; + candidate.reservoirs = e.reservoirs; + candidate.validate(species_count); + require(e.ports.size() == config.ports.size(), "event must preserve hydraulic ports"); + + for (std::size_t i = 0; i < config.ports.size(); ++i) { + require(e.ports[i].name == config.ports[i].name && e.ports[i].axis == config.ports[i].axis && + e.ports[i].upper == config.ports[i].upper && + e.ports[i].sites == config.ports[i].sites, + "event cannot change port geometry"); + } + + previous = e.time; + } +} + +void validate_reserve_configuration(const CultureConfiguration& config, std::size_t species_count) { + std::set requirements; + + for (const auto& r : config.biomass_requirements) { + require(r.species < species_count && positive(r.amount_per_biomass) && + requirements.insert(r.species).second, + "invalid or duplicate culture biomass requirement"); + } + + std::set> unique_exchange; + + for (const auto& e : config.exchange) { + require(e.body_id != 0 && e.solute < config.solutes.size() && e.species < species_count && + nonnegative(e.uptake_velocity) && nonnegative(e.secretion_rate), + "invalid culture cell exchange"); + require(unique_exchange.insert({e.body_id, e.solute}).second, + "duplicate body/solute culture exchange"); + } +} + +void validate_chemical_boundaries(const CultureConfiguration& config) { + std::set names; + + for (const auto& s : config.solutes) { + require(!s.name.empty() && names.insert(s.name).second && nonnegative(s.diffusion), + "invalid culture solute"); + } + + names.clear(); + + for (const auto& port : config.ports) { + require(!port.name.empty() && names.insert(port.name).second && std::isfinite(port.value), + "invalid culture port"); + } + + std::set supplied; + + for (const auto& reservoir : config.reservoirs) { + require(names.contains(reservoir.port) && supplied.insert(reservoir.port).second && + reservoir.concentrations.size() == config.solutes.size(), + "invalid culture reservoir"); + + for (double c : reservoir.concentrations) { + require(nonnegative(c), "invalid culture reservoir concentration"); + } + } + + require(config.solutes.empty() || supplied.size() == config.ports.size(), + "every culture port needs reservoir concentrations"); +} + +void validate_culture_cell(const CultureCellState& cell, const CellSnapshot& physical, + std::size_t species_count, std::set& ids) { + cell.body.validate(); + require(cell.body.id == physical.id && ids.insert(cell.body.id).second && + positive(cell.biochemical_volume) && positive(float(cell.biochemical_volume)) && + cell.species_amounts.size() == species_count, + "invalid culture cell state"); + require(float(cell.body.length) == physical.length && + float(cell.body.radius) == physical.radius && + float(cell.body.position[0]) == physical.position.x && + float(cell.body.position[1]) == physical.position.y && + float(cell.body.position[2]) == physical.position.z, + "culture pose differs from world geometry"); + + for (double m : cell.species_amounts) { + require(nonnegative(m), "invalid culture intracellular amount"); + } + + require(levels(cell) == physical.species, + "culture intracellular amounts differ from world concentrations"); +} + +void validate_culture_ledgers(const CultureCheckpoint& checkpoint) { + for (const auto& cell : checkpoint.cells) { + require(cell.uptake_totals.size() == checkpoint.configuration.solutes.size() && + nonnegative(cell.realized_specific_rate) && nonnegative(cell.biomass_produced), + "invalid culture growth ledger"); + + for (double amount : cell.uptake_totals) { + require(nonnegative(amount), "invalid culture uptake total"); + } + } + + for (double m : checkpoint.extracellular_amounts) { + require(nonnegative(m), "invalid culture extracellular amount"); + } + + require(checkpoint.reservoir_totals.size() == checkpoint.configuration.ports.size(), + "culture reservoir ledger size mismatch"); + + for (std::size_t p = 0; p < checkpoint.reservoir_totals.size(); ++p) { + const auto& t = checkpoint.reservoir_totals[p]; + require(t.port == checkpoint.configuration.ports[p].name && t.body_id == 0 && + t.amounts.size() == checkpoint.configuration.solutes.size(), + "invalid culture reservoir ledger"); + + for (double m : t.amounts) { + require(std::isfinite(m), "nonfinite culture reservoir ledger"); + } + } +} + +} // namespace + +void CultureConfiguration::validate(std::size_t species_count) const { + grid.validate(); + fluid.validate(); + solver.validate(); + stepping.validate(); + geometry.validate(); + require(positive(biomass_per_geometric_volume) && maximum_substeps > 0 && maximum_retries <= 64, + "invalid culture integration parameters"); + require(positive(coupling_tolerance) && coupling_tolerance < 0.01 && + maximum_coupling_iterations > 0 && maximum_coupling_iterations <= 10000, + "invalid culture coupling parameters"); + std::set modeled; + + for (const auto& m : growth) { + m.validate(solutes.size()); + require(modeled.insert(m.cell_id).second, "duplicate growth binding"); + } + + require(growth.empty() || (exchange.empty() && biomass_requirements.empty()), + "kinetic growth cannot also use reserve-budget exchanges"); + validate_culture_events(*this, species_count); + + validate_reserve_configuration(*this, species_count); + + validate_chemical_boundaries(*this); +} + +void CultureCheckpoint::validate(const WorldStateCheckpoint& world) const { + configuration.validate(world.species_count); + require(nonnegative(time) && event_index <= configuration.events.size(), + "invalid culture event position"); + + for (std::size_t i = 0; i < configuration.events.size(); ++i) { + require((configuration.events[i].time <= time) == (i < event_index), + "culture event cursor differs from time"); + } + + require(cells.size() == world.cells.size(), "culture cell count differs from world"); + std::set ids; + + for (std::size_t i = 0; i < cells.size(); ++i) { + validate_culture_cell(cells[i], world.cells[i], world.species_count, ids); + } + + for (const auto& m : configuration.growth) { + require(ids.contains(m.cell_id), "growth model refers to absent cell"); + const auto found = std::find_if(world.cells.begin(), world.cells.end(), [&](const auto& c) { + return c.id == m.cell_id; + }); + require(found->growth_rate == 0, "kinetic growth owns the cell growth rate"); + } + + for (const auto& e : configuration.exchange) { + require(ids.contains(e.body_id), "culture exchange refers to an absent cell"); + } + + validate_culture_ledgers(*this); +} + +namespace detail { +CultureSimulation::CultureSimulation(const WorldState& world, + const CultureConfiguration& configuration, + const std::vector& concentrations, + const std::vector& biochemical_volumes, + const BackendInfo& backend) + : flow_(backend.kind, backend.device_index), + transport_(backend.kind, backend.device_index), + growth_(backend.kind, backend.device_index) { + configuration.validate(world.species_count()); + data_.configuration = configuration; + require(biochemical_volumes.empty() || biochemical_volumes.size() == world.size(), + "biochemical volume count mismatch"); + require(concentrations.size() == configuration.solutes.size(), + "initial culture concentration count mismatch"); + + for (double c : concentrations) { + require(nonnegative(c), "invalid initial culture concentration"); + } + + for (const auto& cell : world.cells()) { + CultureCellState value; + value.body.id = cell.id; + value.body.position = {cell.position.x, cell.position.y, cell.position.z}; + value.body.orientation = orientation(cell.direction); + value.body.length = cell.length; + value.body.radius = cell.radius; + value.body.fixed = cell.fixed; + value.biochemical_volume = + biochemical_volumes.empty() + ? configuration.biomass_per_geometric_volume * value.body.geometric_volume() + : biochemical_volumes[cell.slot]; + require(positive(value.biochemical_volume), "invalid initial biochemical volume"); + + for (float c : cell.species) { + require(nonnegative(c), "culture intracellular species must be nonnegative"); + value.species_amounts.push_back(c * value.biochemical_volume); + } + + for (const auto& m : configuration.growth) { + if (m.cell_id == cell.id && biochemical_volumes.empty()) { + value.biochemical_volume = m.volume_ratio * value.body.geometric_volume(); + } + } + + value.species_amounts.clear(); + + for (float c : cell.species) { + value.species_amounts.push_back(c * value.biochemical_volume); + } + + value.uptake_totals.resize(configuration.solutes.size()); + data_.cells.push_back(std::move(value)); + } + + geometry_ = std::make_shared(configuration.grid, bodies(data_.cells), + configuration.geometry); + + for (const auto& f : geometry_->fragments()) { + for (double c : concentrations) { + data_.extracellular_amounts.push_back(c * f.volume); + } + } + + for (const auto& p : configuration.ports) { + data_.reservoir_totals.push_back({p.name, 0, std::vector(concentrations.size())}); + } + + data_.last_report.flow = + flow_ + .solve_bodies(configuration.grid, configuration.fluid, configuration.ports, + bodies(data_.cells), configuration.solver) + .report; + data_.validate(world.checkpoint()); +} + +CultureSimulation::CultureSimulation(const WorldState& world, const CultureCheckpoint& checkpoint, + const BackendInfo& backend) + : data_(checkpoint), + flow_(backend.kind, backend.device_index), + transport_(backend.kind, backend.device_index), + growth_(backend.kind, backend.device_index) { + data_.validate(world.checkpoint()); + geometry_ = std::make_shared(data_.configuration.grid, bodies(data_.cells), + data_.configuration.geometry); + require(data_.extracellular_amounts.size() == + geometry_->fragments().size() * data_.configuration.solutes.size(), + "culture checkpoint fragment amount count mismatch"); +} + +void CultureSimulation::set_species(CellId id, std::span values) { + auto found = std::find_if(data_.cells.begin(), data_.cells.end(), [&](const auto& c) { + return c.body.id == id; + }); + require(found != data_.cells.end() && values.size() == found->species_amounts.size(), + "invalid culture cell species update"); + std::vector amounts; + + for (float c : values) { + require(nonnegative(c), "culture cell species must be nonnegative"); + amounts.push_back(c * found->biochemical_volume); + } + + found->species_amounts = std::move(amounts); +} + +void CultureSimulation::set_force(CellId id, std::array force, + std::array torque) { + auto found = std::find_if(data_.cells.begin(), data_.cells.end(), [&](const auto& c) { + return c.body.id == id; + }); + require(found != data_.cells.end(), "unknown culture cell id"); + auto candidate = found->body; + candidate.force_n = force; + candidate.torque_nm = torque; + candidate.validate(); + found->body = candidate; +} + +namespace { +void prepare_reserve_growth(std::vector& trial, const WorldState& trial_world, + const CultureConfiguration& config, double maximum_dt) { + for (auto& cell : trial) { + const auto attributes = trial_world.cell(cell.body.id); + require(attributes.growth_rate >= 0, "culture growth rate must be nonnegative"); + cell.body.fixed = attributes.fixed; + cell.body.length_rate = attributes.growth_rate * cell.body.length; + double available = INFINITY; + + for (const auto& requirement : config.biomass_requirements) { + available = std::min(available, (1 - 16 * std::numeric_limits::epsilon()) * + cell.species_amounts[requirement.species] / + requirement.amount_per_biomass); + } + + const double area = std::numbers::pi * cell.body.radius * cell.body.radius * + config.biomass_per_geometric_volume; + cell.body.length_rate = std::min(cell.body.length_rate, available / (area * maximum_dt)); + } +} + +void apply_reserve_growth(std::vector& trial, WorldState& trial_world, + const CultureConfiguration& config, const FluidBodyStep& proposal, + std::vector& old_lengths, std::vector& old_volumes, + std::vector& new_volumes) { + for (std::size_t i = 0; i < trial.size(); ++i) { + auto& cell = trial[i]; + const double old_b = cell.biochemical_volume; + const double increment = config.biomass_per_geometric_volume * std::numbers::pi * + cell.body.radius * cell.body.radius * + (proposal.bodies[i].length - cell.body.length); + + for (const auto& requirement : config.biomass_requirements) { + cell.species_amounts[requirement.species] -= requirement.amount_per_biomass * increment; + + if (cell.species_amounts[requirement.species] < 0) { + throw std::runtime_error("culture growth exceeded intracellular amount budget"); + } + } + + // Species kinetics sees the true geometric pose and separate old/new + // biochemical volumes; no geometry is altered to encode biomass. + trial_world.set_species(cell.body.id, levels(cell)); + old_lengths.push_back(float(cell.body.length)); + old_volumes.push_back(float(old_b)); + cell.body = proposal.bodies[i]; + cell.biochemical_volume += increment; + new_volumes.push_back(float(cell.biochemical_volume)); + const auto axis = body_rotate(cell.body.orientation, {1, 0, 0}); + trial_world.set_cell_geometry( + cell.body.id, + {float(cell.body.position[0]), float(cell.body.position[1]), float(cell.body.position[2])}, + {float(axis[0]), float(axis[1]), float(axis[2])}, float(cell.body.length)); + } +} + +std::vector reserve_exchange_laws( + const CultureConfiguration& config, const std::vector& trial, + const std::map& cell_index, double step_dt) { + std::map, double> requested_secretion; + + for (const auto& e : config.exchange) { + requested_secretion[{e.body_id, e.species}] += e.secretion_rate; + } + + std::vector exchange; + + // Each body/solute pair has one intracellular destination, making the + // transfer ledger unambiguous even when several species are present. + for (const auto& e : config.exchange) { + const auto& cell = trial.at(cell_index.at(e.body_id)); + const double demand = requested_secretion.at({e.body_id, e.species}) * step_dt; + const double fraction = + demand == 0 ? 1 : std::min(1.0, cell.species_amounts[e.species] / demand); + exchange.push_back({e.body_id, e.solute, e.uptake_velocity, e.secretion_rate * fraction}); + } + + return exchange; +} + +void apply_reserve_transfers(const CultureConfiguration& config, + std::vector& trial, + const std::map& cell_index, + const SoluteTransportResult& transported) { + for (const auto& transfer : transported.cells) { + for (const auto& e : config.exchange) { + if (e.body_id == transfer.body_id) { + auto& m = trial[cell_index.at(e.body_id)].species_amounts[e.species]; + m += transfer.amounts[e.solute]; + + if (!nonnegative(m)) { + throw std::runtime_error("culture secretion exceeded intracellular amount budget"); + } + } + } + } +} + +void sample_growth_inputs(const CultureCheckpoint& staged, const FluidGeometry& old_geometry, + const std::map& slots, + std::vector& input) { + const auto& config = staged.configuration; + + for (std::size_t k = 0; k < config.growth.size(); ++k) { + const auto& model = config.growth[k]; + auto& in = input[k]; + in.biochemical_volume = staged.cells[slots.at(model.cell_id)].biochemical_volume; + in.concentrations.resize(model.requirements.size()); + in.uptake.resize(model.requirements.size()); + + for (const auto& f : old_geometry.faces()) { + if (f.body_id == model.cell_id) { + in.surface_area += f.area; + + for (std::size_t j = 0; j < model.requirements.size(); ++j) { + in.concentrations[j] += f.area * + staged.extracellular_amounts[f.first * config.solutes.size() + + model.requirements[j].solute] / + old_geometry.fragments()[f.first].volume; + } + } + } + + require(in.surface_area > 0, "growth cell has no accessible surface"); + + for (auto& c : in.concentrations) { + c /= in.surface_area; + } + } +} + +void average_growth_surfaces(const CultureConfiguration& config, const FluidGeometry& old_geometry, + const FluidGeometry& geometry, std::vector& input) { + for (std::size_t k = 0; k < input.size(); ++k) { + input[k].surface_area = 0; + + for (const auto* mesh : {&old_geometry, &geometry}) { + for (const auto& f : mesh->faces()) { + if (f.body_id == config.growth[k].cell_id) { + input[k].surface_area += 0.5 * f.area; + } + } + } + } +} + +std::vector kinetic_exchange_laws( + const CultureConfiguration& config, const std::vector& linearized) { + std::vector exchange; + + for (std::size_t k = 0; k < config.growth.size(); ++k) { + for (std::size_t j = 0; j < config.growth[k].requirements.size(); ++j) { + exchange.push_back({config.growth[k].cell_id, config.growth[k].requirements[j].solute, + linearized[k].uptake_velocities[j], 0}); + } + } + + return exchange; +} + +std::map update_growth_inputs(const CultureConfiguration& config, + const SoluteTransportResult& transported, + std::vector& input) { + std::map transfer_index; + + for (std::size_t i = 0; i < transported.cells.size(); ++i) { + transfer_index[transported.cells[i].body_id] = i; + } + + for (std::size_t k = 0; k < input.size(); ++k) { + const auto& model = config.growth[k]; + const auto index = transfer_index.at(model.cell_id); + + for (std::size_t j = 0; j < model.requirements.size(); ++j) { + const auto s = model.requirements[j].solute; + input[k].concentrations[j] = transported.surfaces[index].concentrations[s]; + input[k].uptake[j] = transported.cells[index].amounts[s]; + } + } + + return transfer_index; +} + +void apply_growth_extents(const CultureConfiguration& config, const FluidGeometry& geometry, + std::vector& trial, + const std::map& slots, + const std::map& transfer_index, + const std::vector& input, const std::vector& extents, + SoluteTransportResult& transported, double dt) { + // Enforce the shared reaction extent exactly. Only the converged solver + // residual/working-precision excess is returned, locally at that surface. + // No unmatched substrate is lost or silently stored inside a cell. + for (std::size_t k = 0; k < input.size(); ++k) { + const auto& model = config.growth[k]; + auto& cell = trial[slots.at(model.cell_id)]; + const auto ti = transfer_index.at(model.cell_id); + double area = 0; + + for (const auto& face : geometry.faces()) { + if (face.body_id == model.cell_id) { + area += face.area; + } + } + + require(area > 0, "growth cell has no final accessible surface"); + + for (std::size_t j = 0; j < model.requirements.size(); ++j) { + const auto& requirement = model.requirements[j]; + const auto s = requirement.solute; + const double consumed = extents[k] / requirement.biomass_yield; + const double returned = std::max(0.0, transported.cells[ti].amounts[s] - consumed); + + for (const auto& face : geometry.faces()) { + if (face.body_id == model.cell_id) { + transported.amounts[face.first * config.solutes.size() + s] += + returned * face.area / area; + } + } + + transported.cells[ti].amounts[s] = consumed; + cell.uptake_totals[s] += consumed; + } + + cell.biochemical_volume += extents[k] / model.biomass_density; + cell.biomass_produced += extents[k]; + cell.realized_specific_rate = + extents[k] / (input[k].biochemical_volume * model.biomass_density * dt); + } +} + +WorldState advance_kinetic_species(const WorldState& world, const CultureCheckpoint& staged, + std::vector& trial, ComputeBackend& backend, + const SpeciesRatePlan& plan, double dt) { + auto candidate_world = world; + std::vector old_lengths, old_volumes, new_volumes; + + for (std::size_t i = 0; i < trial.size(); ++i) { + old_lengths.push_back(float(staged.cells[i].body.length)); + old_volumes.push_back(float(staged.cells[i].biochemical_volume)); + new_volumes.push_back(float(trial[i].biochemical_volume)); + // Kinetics receives old concentration and explicit old/new biomass. + candidate_world.set_species(trial[i].body.id, levels(staged.cells[i])); + const auto axis = body_rotate(trial[i].body.orientation, {1, 0, 0}); + candidate_world.set_cell_geometry( + trial[i].body.id, + {float(trial[i].body.position[0]), float(trial[i].body.position[1]), + float(trial[i].body.position[2])}, + {float(axis[0]), float(axis[1]), float(axis[2])}, float(trial[i].body.length)); + } + + const bool zero = std::all_of(plan.outputs().begin(), plan.outputs().end(), [&](auto o) { + return plan.instructions()[o].operation == RateOp::constant && + plan.instructions()[o].value == 0; + }); + + if (!zero) { + backend.advance_species(candidate_world, plan, old_lengths, float(dt), + {old_volumes, new_volumes}); + + for (auto& cell : trial) { + const auto snapshot = candidate_world.cell(cell.body.id); + + for (std::size_t s = 0; s < cell.species_amounts.size(); ++s) { + cell.species_amounts[s] = snapshot.species[s] * cell.biochemical_volume; + + if (!nonnegative(cell.species_amounts[s])) { + throw std::runtime_error("invalid intracellular reaction amount"); + } + } + } + } + + for (const auto& cell : trial) { + sync(candidate_world, cell); + } + + return candidate_world; +} + +void inherit_growth_models(CultureConfiguration& config, CellId parent, CellId first, + CellId second) { + std::vector daughters; + + for (auto& model : config.growth) { + if (model.cell_id == parent) { + auto other = model; + model.cell_id = first; + other.cell_id = second; + daughters.push_back(other); + } + } + + config.growth.insert(config.growth.end(), daughters.begin(), daughters.end()); +} + +void inherit_exchange_laws(CultureConfiguration& config, CellId parent, CellId first, CellId second, + double fraction) { + std::vector inherited; + + for (auto& e : config.exchange) { + if (e.body_id == parent) { + auto other = e; + e.body_id = first; + other.body_id = second; + e.secretion_rate *= fraction; + other.secretion_rate -= e.secretion_rate; + inherited.push_back(other); + } + } + + config.exchange.insert(config.exchange.end(), inherited.begin(), inherited.end()); +} + +} // namespace + +double CultureSimulation::reserve_step(CultureCheckpoint& staged, + std::shared_ptr& staged_geometry, + WorldState& staged_world, ComputeBackend& backend, + const SpeciesRatePlan& plan, double maximum_dt, + double remaining, bool zero_plan) { + const auto& config = staged.configuration; + auto trial = staged.cells; + auto trial_world = staged_world; + + prepare_reserve_growth(trial, trial_world, config, maximum_dt); + + const auto proposal = + flow_.propose_body_step(config.grid, config.fluid, config.ports, bodies(trial), maximum_dt, + config.solver, config.stepping); + const double step_dt = proposal.accepted_dt; + require(step_dt > 0 && step_dt <= remaining && remaining - step_dt < remaining, + "culture step cannot advance time"); + std::vector old_lengths, old_volumes, new_volumes; + + apply_reserve_growth(trial, trial_world, config, proposal, old_lengths, old_volumes, new_volumes); + + if (!zero_plan) { + backend.advance_species(trial_world, plan, old_lengths, float(step_dt), + {old_volumes, new_volumes}); + + for (auto& cell : trial) { + const auto snapshot = trial_world.cell(cell.body.id); + + for (std::size_t s = 0; s < cell.species_amounts.size(); ++s) { + const double m = snapshot.species[s] * cell.biochemical_volume; + + if (!nonnegative(m)) { + throw std::runtime_error("culture intracellular reaction produced an invalid amount"); + } + + cell.species_amounts[s] = m; + } + } + } + + auto new_geometry = std::make_shared(config.grid, bodies(trial), config.geometry); + std::map cell_index; + + for (std::size_t i = 0; i < trial.size(); ++i) { + cell_index[trial[i].body.id] = i; + } + + const auto exchange = reserve_exchange_laws(config, trial, cell_index, step_dt); + + const auto transported = transport_.step( + *staged_geometry, *new_geometry, proposal.flow.field, config.ports, config.solutes, + config.reservoirs, staged.extracellular_amounts, step_dt, exchange, config.solver); + + apply_reserve_transfers(config, trial, cell_index, transported); + + for (const auto& cell : trial) { + sync(trial_world, cell); + } + + staged.cells = std::move(trial); + staged_world = std::move(trial_world); + staged_geometry = std::move(new_geometry); + staged.extracellular_amounts = transported.amounts; + + for (std::size_t p = 0; p < transported.reservoirs.size(); ++p) { + for (std::size_t s = 0; s < config.solutes.size(); ++s) { + staged.reservoir_totals[p].amounts[s] += transported.reservoirs[p].amounts[s]; + } + } + + staged.last_report.flow = proposal.flow.report; + staged.last_report.transport = transported.report; + + return step_dt; +} + +namespace { +void apply_culture_events(CultureCheckpoint& staged) { + const auto& config = staged.configuration; + + while (staged.event_index < config.events.size() && + config.events[staged.event_index].time <= staged.time) { + const auto& e = config.events[staged.event_index++]; + staged.configuration.ports = e.ports; + staged.configuration.reservoirs = e.reservoirs; + } +} +} // namespace + +void CultureSimulation::step(WorldState& world, ComputeBackend& backend, + const SpeciesRatePlan& plan, double dt) { + require(nonnegative(dt), "invalid culture step interval"); + + if (dt == 0) { + return; + } + + auto staged = data_; + auto staged_geometry = geometry_; + auto staged_world = world; + const auto& config = staged.configuration; + staged.last_report = {}; + double remaining = dt; + const bool zero_plan = + std::all_of(plan.outputs().begin(), plan.outputs().end(), [&](auto output) { + const auto& instruction = plan.instructions()[output]; + + return instruction.operation == RateOp::constant && instruction.value == 0; + }); + + while (remaining > 0) { + if (staged.last_report.substeps >= config.maximum_substeps) { + throw std::runtime_error("culture maximum_substeps exhausted; entire step rejected"); + } + + apply_culture_events(staged); + + double maximum_dt = remaining; + + if (staged.event_index < config.events.size()) { + maximum_dt = std::min(maximum_dt, config.events[staged.event_index].time - staged.time); + } + + bool accepted = false; + + for (std::uint32_t retry = 0; retry <= config.maximum_retries && !accepted; ++retry) { + try { + if (!config.growth.empty()) { + const double advanced = + kinetic_step(staged, staged_geometry, staged_world, backend, plan, maximum_dt); + staged.time += advanced; + remaining -= advanced; + ++staged.last_report.substeps; + accepted = true; + continue; + } + + const double step_dt = reserve_step(staged, staged_geometry, staged_world, backend, plan, + maximum_dt, remaining, zero_plan); + ++staged.last_report.substeps; + remaining -= step_dt; + staged.time += step_dt; + accepted = true; + } catch (const std::runtime_error&) { + if (retry == config.maximum_retries) { + throw; + } + + maximum_dt *= 0.5; + ++staged.last_report.retries; + } + } + } + + apply_culture_events(staged); + + staged.validate(staged_world.checkpoint()); + + for (std::size_t i = 0; i < staged.cells.size(); ++i) { + if (std::any_of(config.growth.begin(), config.growth.end(), [&](const auto& m) { + return m.cell_id == staged.cells[i].body.id; + })) { + staged.cells[i].realized_specific_rate = + (staged.cells[i].biochemical_volume - data_.cells[i].biochemical_volume) / + (data_.cells[i].biochemical_volume * dt); + } + } + + world = std::move(staged_world); + data_ = std::move(staged); + geometry_ = std::move(staged_geometry); +} + +double CultureSimulation::kinetic_step(CultureCheckpoint& staged, + std::shared_ptr& old_geometry, + WorldState& world, ComputeBackend& backend, + const SpeciesRatePlan& plan, double dt) { + const auto& config = staged.configuration; + std::map slots; + + for (std::size_t i = 0; i < staged.cells.size(); ++i) { + slots[staged.cells[i].body.id] = i; + require(world.cell(staged.cells[i].body.id).growth_rate == 0, + "kinetic culture requires growth models instead of prescribed elongation"); + } + + std::vector input(config.growth.size()); + std::vector rates(staged.cells.size()); + + sample_growth_inputs(staged, *old_geometry, slots, input); + + double previous_residual = INFINITY; + + for (std::uint32_t iteration = 0; iteration < config.maximum_coupling_iterations; ++iteration) { + auto trial = staged.cells; + + for (std::size_t i = 0; i < trial.size(); ++i) { + trial[i].body.fixed = world.cell(trial[i].body.id).fixed; + trial[i].body.length_rate = rates[i]; + } + + const auto proposal = flow_.propose_body_step( + config.grid, config.fluid, config.ports, bodies(trial), dt, config.solver, config.stepping); + + if (proposal.accepted_dt < dt) { + dt = proposal.accepted_dt; + require(dt > 0, "culture cannot advance time"); + std::fill(rates.begin(), rates.end(), 0); + continue; + } + + for (std::size_t i = 0; i < trial.size(); ++i) { + trial[i].body = proposal.bodies[i]; + } + + auto geometry = std::make_shared(config.grid, bodies(trial), config.geometry); + + average_growth_surfaces(config, *old_geometry, *geometry, input); + + const auto linearized = growth_.evaluate(config.growth, input, dt); + const auto exchange = kinetic_exchange_laws(config, linearized); + + auto chemical_parameters = config.solver; + chemical_parameters.relative_tolerance = + std::min(chemical_parameters.relative_tolerance, 0.01 * config.coupling_tolerance); + chemical_parameters.absolute_tolerance = 0; + auto transported = transport_.step( + *old_geometry, *geometry, proposal.flow.field, config.ports, config.solutes, + config.reservoirs, staged.extracellular_amounts, dt, exchange, chemical_parameters); + const auto transfer_index = update_growth_inputs(config, transported, input); + + const auto realized = growth_.evaluate(config.growth, input, dt); + std::vector extents(input.size()), next_rates = rates; + double residual = 0; + + for (std::size_t k = 0; k < input.size(); ++k) { + const auto& model = config.growth[k]; + const auto index = slots.at(model.cell_id); + // Bound GPU roundoff by the actual double-precision chemical ledger. + double extent = realized[k].biomass_gain; + + for (std::size_t j = 0; j < model.requirements.size(); ++j) { + extent = std::min(extent, input[k].uptake[j] * model.requirements[j].biomass_yield); + } + + extents[k] = extent; + next_rates[index] = + extent / (model.biomass_density * model.volume_ratio * dt * std::numbers::pi * + trial[index].body.radius * trial[index].body.radius); + residual = std::max(residual, realized[k].stoichiometric_residual); + const double rate_scale = + std::max({std::abs(next_rates[index]), std::abs(rates[index]), 1e-30}); + residual = std::max(residual, std::abs(next_rates[index] - rates[index]) / rate_scale); + + for (std::size_t j = 0; j < model.requirements.size(); ++j) { + const double a = linearized[k].uptake_velocities[j], b = realized[k].uptake_velocities[j]; + residual = std::max(residual, std::abs(a - b) / std::max({a, b, 1e-30})); + } + } + + if (residual <= config.coupling_tolerance) { + apply_growth_extents(config, *geometry, trial, slots, transfer_index, input, extents, + transported, dt); + + auto candidate_world = advance_kinetic_species(world, staged, trial, backend, plan, dt); + + staged.cells = std::move(trial); + staged.extracellular_amounts = std::move(transported.amounts); + + for (std::size_t p = 0; p < transported.reservoirs.size(); ++p) { + for (std::size_t s = 0; s < config.solutes.size(); ++s) { + staged.reservoir_totals[p].amounts[s] += transported.reservoirs[p].amounts[s]; + } + } + + staged.last_report.flow = proposal.flow.report; + staged.last_report.transport = transported.report; + world = std::move(candidate_world); + old_geometry = std::move(geometry); + + return dt; + } + + const double damping = residual > previous_residual ? 0.5 : 1.0; + + for (std::size_t i = 0; i < rates.size(); ++i) { + rates[i] += damping * (next_rates[i] - rates[i]); + } + + previous_residual = residual; + } + + throw std::runtime_error("culture uptake/growth/geometry iteration did not converge"); +} + +std::pair CultureSimulation::divide(WorldState& world, CellId parent, + double fraction) { + require(std::isfinite(fraction) && fraction > 0 && fraction < 1, + "invalid culture division fraction"); + auto data = data_; + auto checkpoint = world.checkpoint(); + auto found = std::find_if(data.cells.begin(), data.cells.end(), [&](const auto& c) { + return c.body.id == parent; + }); + require(found != data.cells.end(), "unknown culture division parent"); + const auto index = std::size_t(found - data.cells.begin()); + const auto original = *found; + auto first = original, second = original; + const double cap = 4 * original.body.radius / 3; + first.body.length = fraction * (original.body.length + cap) - cap; + second.body.length = (1 - fraction) * (original.body.length + cap) - cap; + require(first.body.length >= 0 && second.body.length >= 0, + "parent is too short for volume-conserving division"); + require(checkpoint.next_id < std::numeric_limits::max() - 1, + "culture cell identifier space exhausted"); + first.body.id = checkpoint.next_id++; + second.body.id = checkpoint.next_id++; + const double gap = + data.configuration.stepping.minimum_gap_m / data.configuration.grid.length_unit_m + + 1e-5 * data.configuration.grid.spacing; + const double separation = + (first.body.length + second.body.length) / 2 + 2 * original.body.radius + gap; + const auto axis = body_rotate(original.body.orientation, {1, 0, 0}); + + for (unsigned d = 0; d < 3; ++d) { + first.body.position[d] -= (1 - fraction) * separation * axis[d]; + second.body.position[d] += fraction * separation * axis[d]; + first.body.force_n[d] *= fraction; + second.body.force_n[d] -= first.body.force_n[d]; + first.body.torque_nm[d] *= fraction; + second.body.torque_nm[d] -= first.body.torque_nm[d]; + } + + first.biochemical_volume = original.biochemical_volume * fraction; + second.biochemical_volume = original.biochemical_volume - first.biochemical_volume; + + for (std::size_t s = 0; s < first.species_amounts.size(); ++s) { + first.species_amounts[s] *= fraction; + second.species_amounts[s] -= first.species_amounts[s]; + } + + first.biomass_produced = original.biomass_produced * fraction; + second.biomass_produced = original.biomass_produced - first.biomass_produced; + + for (std::size_t s = 0; s < first.uptake_totals.size(); ++s) { + first.uptake_totals[s] = original.uptake_totals[s] * fraction; + second.uptake_totals[s] = original.uptake_totals[s] - first.uptake_totals[s]; + } + + inherit_growth_models(data.configuration, parent, first.body.id, second.body.id); + data.cells[index] = first; + data.cells.push_back(second); + auto new_geometry = std::make_shared(data.configuration.grid, bodies(data.cells), + data.configuration.geometry); + SignalGridVelocityField zero; + const auto shape = data.configuration.grid.shape; + zero.x_faces.resize((std::size_t(shape.x) + 1) * shape.y * shape.z); + zero.y_faces.resize(std::size_t(shape.x) * (shape.y + 1) * shape.z); + zero.z_faces.resize(std::size_t(shape.x) * shape.y * (shape.z + 1)); + auto tracers = data.configuration.solutes; + + for (auto& s : tracers) { + s.diffusion = 0; + } + + // A geometric remap has no physical duration, pressure history or reservoir + // exchange. The unit interval below only parameterizes conservative remapping. + const auto remap = transport_.step(*geometry_, *new_geometry, zero, {}, tracers, {}, + data.extracellular_amounts, 1, {}, data.configuration.solver); + data.extracellular_amounts = remap.amounts; + auto snapshot = checkpoint.cells[index]; + snapshot.id = first.body.id; + snapshot.length = float(first.body.length); + snapshot.position = {float(first.body.position[0]), float(first.body.position[1]), + float(first.body.position[2])}; + snapshot.species = levels(first); + checkpoint.cells[index] = snapshot; + snapshot.id = second.body.id; + snapshot.slot = static_cast(checkpoint.cells.size()); + snapshot.length = float(second.body.length); + snapshot.position = {float(second.body.position[0]), float(second.body.position[1]), + float(second.body.position[2])}; + snapshot.species = levels(second); + checkpoint.cells.push_back(snapshot); + checkpoint.lineage.push_back({first.body.id, parent}); + checkpoint.lineage.push_back({second.body.id, parent}); + inherit_exchange_laws(data.configuration, parent, first.body.id, second.body.id, fraction); + auto staged_world = WorldState(checkpoint); + data.validate(checkpoint); + world = std::move(staged_world); + data_ = std::move(data); + geometry_ = std::move(new_geometry); + + return {first.body.id, second.body.id}; +} +} // namespace detail +} // namespace cm diff --git a/cpp/core/culture_simulation_internal.hpp b/cpp/core/culture_simulation_internal.hpp new file mode 100644 index 0000000..798bf3e --- /dev/null +++ b/cpp/core/culture_simulation_internal.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include "cm/backend.hpp" +#include "cm/culture_simulation.hpp" + +namespace cm::detail { +class CultureSimulation { + public: + CultureSimulation(const WorldState&, const CultureConfiguration&, + const std::vector& concentrations, + const std::vector& biochemical_volumes, const BackendInfo&); + CultureSimulation(const WorldState&, const CultureCheckpoint&, const BackendInfo&); + void step(WorldState&, ComputeBackend&, const SpeciesRatePlan&, double dt); + std::pair divide(WorldState&, CellId, double fraction); + void set_species(CellId, std::span); + void set_force(CellId, std::array, std::array); + + [[nodiscard]] const CultureCheckpoint& checkpoint() const { + return data_; + } + + [[nodiscard]] const FluidGeometry& geometry() const { + return *geometry_; + } + + private: + double reserve_step(CultureCheckpoint&, std::shared_ptr&, WorldState&, + ComputeBackend&, const SpeciesRatePlan&, double maximum_dt, double remaining, + bool zero_plan); + double kinetic_step(CultureCheckpoint&, std::shared_ptr&, WorldState&, + ComputeBackend&, const SpeciesRatePlan&, double); + CultureCheckpoint data_; + std::shared_ptr geometry_; + StokesFlowSolver flow_; + SoluteTransportSolver transport_; + GrowthExecutor growth_; +}; +} // namespace cm::detail diff --git a/cpp/core/flexible_gmres.hpp b/cpp/core/flexible_gmres.hpp index 393d125..741389c 100644 --- a/cpp/core/flexible_gmres.hpp +++ b/cpp/core/flexible_gmres.hpp @@ -30,28 +30,104 @@ struct FlexibleKrylovResult { double relative_residual; }; +inline void apply_givens_rotations(std::vector>& h, std::vector& cosine, + std::vector& sine, std::vector& g, + std::uint32_t j) { + for (std::uint32_t i = 0; i < j; ++i) { + const double upper = cosine[i] * h[i][j] + sine[i] * h[i + 1][j]; + h[i + 1][j] = -sine[i] * h[i][j] + cosine[i] * h[i + 1][j]; + h[i][j] = upper; + } + + const double diagonal = std::hypot(h[j][j], h[j + 1][j]); + + if (diagonal == 0) { + throw std::runtime_error("FGMRES Arnoldi breakdown"); + } + + cosine[j] = h[j][j] / diagonal; + sine[j] = h[j + 1][j] / diagonal; + h[j][j] = diagonal; + h[j + 1][j] = 0; + g[j + 1] = -sine[j] * g[j]; + g[j] *= cosine[j]; +} + +inline std::vector solve_krylov_coefficients(const std::vector>& h, + const std::vector& g, + std::uint32_t used) { + std::vector weights(used); + + for (std::uint32_t i = used; i-- > 0;) { + double value = g[i]; + + for (std::uint32_t j = i + 1; j < used; ++j) { + value -= h[i][j] * weights[j]; + } + + weights[i] = value / h[i][i]; + } + + return weights; +} + +template +void orthogonalize_arnoldi(const FlexibleKrylovOperations& op, + const std::vector& basis, Vector& work, + std::vector>& h, std::uint32_t j) { + // Twice-modified Gram-Schmidt limits loss of orthogonality in binary32. + for (unsigned pass = 0; pass < 2; ++pass) { + for (std::uint32_t i = 0; i <= j; ++i) { + const double projection = op.dot(basis[i], work); + h[i][j] += projection; + op.axpy(work, -projection, basis[i]); + } + } +} + template FlexibleKrylovResult flexible_gmres(const FlexibleKrylovOperations& op, const Vector& rhs, double tolerance, - std::uint32_t max_iterations) { + std::uint32_t max_iterations, + std::uint32_t restart = 40) { + if (restart == 0) { + throw std::invalid_argument("FGMRES restart must be positive"); + } + auto solution = op.make_zero(); auto residual = op.make_zero(); auto work = op.make_zero(); const double rhs_norm = std::sqrt(op.dot(rhs, rhs)); - if (!std::isfinite(rhs_norm)) throw std::runtime_error("FGMRES non-finite right-hand side"); - if (rhs_norm == 0) return {std::move(solution), 0, 0}; + + if (!std::isfinite(rhs_norm)) { + throw std::runtime_error("FGMRES non-finite right-hand side"); + } + + if (rhs_norm == 0) { + return {std::move(solution), 0, 0}; + } + std::uint32_t iterations = 0; - constexpr std::uint32_t restart = 40; + while (true) { op.apply(solution, work); op.copy(rhs, residual); op.axpy(residual, -1, work); const double beta = std::sqrt(op.dot(residual, residual)); - if (!std::isfinite(beta)) throw std::runtime_error("FGMRES non-finite true residual"); - if (beta <= tolerance * rhs_norm) return {std::move(solution), iterations, beta / rhs_norm}; - if (iterations >= max_iterations) - throw std::runtime_error("resolved-flow FGMRES did not converge: relative residual " + + + if (!std::isfinite(beta)) { + throw std::runtime_error("FGMRES non-finite true residual"); + } + + if (beta <= tolerance * rhs_norm) { + return {std::move(solution), iterations, beta / rhs_norm}; + } + + if (iterations >= max_iterations) { + throw std::runtime_error("FGMRES did not converge: relative residual " + std::to_string(beta / rhs_norm)); + } + const auto count = std::min(restart, max_iterations - iterations); std::vector basis, directions; auto first = op.make_zero(); @@ -61,49 +137,44 @@ FlexibleKrylovResult flexible_gmres(const FlexibleKrylovOperations cosine(count), sine(count), g(count + 1); g[0] = beta; std::uint32_t used = 0; + for (std::uint32_t j = 0; j < count; ++j) { auto direction = op.make_zero(); op.precondition(basis[j], direction); op.apply(direction, work); - // Twice-modified Gram-Schmidt limits loss of orthogonality in binary32. - for (unsigned pass = 0; pass < 2; ++pass) { - for (std::uint32_t i = 0; i <= j; ++i) { - const double projection = op.dot(basis[i], work); - h[i][j] += projection; - op.axpy(work, -projection, basis[i]); - } - } + + orthogonalize_arnoldi(op, basis, work, h, j); + h[j + 1][j] = std::sqrt(op.dot(work, work)); - if (!std::isfinite(h[j + 1][j])) throw std::runtime_error("FGMRES non-finite Arnoldi vector"); + + if (!std::isfinite(h[j + 1][j])) { + throw std::runtime_error("FGMRES non-finite Arnoldi vector"); + } + const bool happy = h[j + 1][j] <= 1e-14; auto next = op.make_zero(); - if (!happy) op.axpy(next, 1 / h[j + 1][j], work); + + if (!happy) { + op.axpy(next, 1 / h[j + 1][j], work); + } + basis.push_back(std::move(next)); directions.push_back(std::move(direction)); - for (std::uint32_t i = 0; i < j; ++i) { - const double upper = cosine[i] * h[i][j] + sine[i] * h[i + 1][j]; - h[i + 1][j] = -sine[i] * h[i][j] + cosine[i] * h[i + 1][j]; - h[i][j] = upper; - } - const double diagonal = std::hypot(h[j][j], h[j + 1][j]); - if (diagonal == 0) throw std::runtime_error("FGMRES Arnoldi breakdown"); - cosine[j] = h[j][j] / diagonal; - sine[j] = h[j + 1][j] / diagonal; - h[j][j] = diagonal; - h[j + 1][j] = 0; - g[j + 1] = -sine[j] * g[j]; - g[j] *= cosine[j]; + + apply_givens_rotations(h, cosine, sine, g, j); ++iterations; used = j + 1; - if (happy || std::abs(g[j + 1]) <= tolerance * rhs_norm) break; + + if (happy || std::abs(g[j + 1]) <= tolerance * rhs_norm) { + break; + } } - std::vector weights(used); - for (std::uint32_t i = used; i-- > 0;) { - double value = g[i]; - for (std::uint32_t j = i + 1; j < used; ++j) value -= h[i][j] * weights[j]; - weights[i] = value / h[i][i]; + + const auto weights = solve_krylov_coefficients(h, g, used); + + for (std::uint32_t i = 0; i < used; ++i) { + op.axpy(solution, weights[i], directions[i]); } - for (std::uint32_t i = 0; i < used; ++i) op.axpy(solution, weights[i], directions[i]); } } diff --git a/cpp/core/flow_system.hpp b/cpp/core/flow_system.hpp index 0891595..e5dc454 100644 --- a/cpp/core/flow_system.hpp +++ b/cpp/core/flow_system.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -18,13 +19,19 @@ namespace cm::detail { class FlowGridLayout { public: - FlowGridLayout(const SignalGridSpec& spec, FlowAxis flow_axis) + template + FlowGridLayout(const Grid& spec, FlowAxis flow_axis) : dimensions_{spec.shape.x, spec.shape.y, spec.shape.z}, - spacing_{spec.spacing.x, spec.spacing.y, spec.spacing.z}, flow_axis_(static_cast(flow_axis)) { - face_counts_[0] = spec.x_face_count(); - face_counts_[1] = spec.y_face_count(); - face_counts_[2] = spec.z_face_count(); + if constexpr (std::is_arithmetic_v) { + spacing_ = {spec.spacing, spec.spacing, spec.spacing}; + } else { + spacing_ = {spec.spacing.x, spec.spacing.y, spec.spacing.z}; + } + + face_counts_[0] = (std::size_t(spec.shape.x) + 1) * spec.shape.y * spec.shape.z; + face_counts_[1] = std::size_t(spec.shape.x) * (std::size_t(spec.shape.y) + 1) * spec.shape.z; + face_counts_[2] = std::size_t(spec.shape.x) * spec.shape.y * (std::size_t(spec.shape.z) + 1); face_offsets_[1] = face_counts_[0]; face_offsets_[2] = face_counts_[0] + face_counts_[1]; total_face_count_ = face_offsets_[2] + face_counts_[2]; @@ -34,9 +41,13 @@ class FlowGridLayout { return dimensions_; } - [[nodiscard]] const std::array& spacing() const noexcept { return spacing_; } + [[nodiscard]] const std::array& spacing() const noexcept { + return spacing_; + } - [[nodiscard]] std::size_t flow_axis() const noexcept { return flow_axis_; } + [[nodiscard]] std::size_t flow_axis() const noexcept { + return flow_axis_; + } [[nodiscard]] std::size_t site_count() const noexcept { return static_cast(dimensions_[0]) * dimensions_[1] * dimensions_[2]; @@ -54,7 +65,9 @@ class FlowGridLayout { return face_offsets_; } - [[nodiscard]] std::size_t total_face_count() const noexcept { return total_face_count_; } + [[nodiscard]] std::size_t total_face_count() const noexcept { + return total_face_count_; + } [[nodiscard]] std::size_t site_index(std::uint32_t x, std::uint32_t y, std::uint32_t z) const noexcept { @@ -65,12 +78,14 @@ class FlowGridLayout { const auto z = static_cast(index % dimensions_[2]); index /= dimensions_[2]; const auto y = static_cast(index % dimensions_[1]); + return {static_cast(index / dimensions_[1]), y, z}; } [[nodiscard]] std::array face_dimensions(std::size_t component) const noexcept { auto result = dimensions_; ++result[component]; + return result; } @@ -79,9 +94,11 @@ class FlowGridLayout { if (component == 0) { return (static_cast(x) * dimensions_[1] + y) * dimensions_[2] + z; } + if (component == 1) { return (static_cast(x) * (dimensions_[1] + 1) + y) * dimensions_[2] + z; } + return (static_cast(x) * dimensions_[1] + y) * (dimensions_[2] + 1) + z; } @@ -93,14 +110,17 @@ class FlowGridLayout { [[nodiscard]] std::pair> face_coordinates( std::size_t index) const noexcept { std::size_t component = 0; + while (component < 2 && index >= face_offsets_[component] + face_counts_[component]) { ++component; } + auto local = index - face_offsets_[component]; const auto face_dims = face_dimensions(component); const auto z = static_cast(local % face_dims[2]); local /= face_dims[2]; const auto y = static_cast(local % face_dims[1]); + return {component, {static_cast(local / face_dims[1]), y, z}}; } @@ -108,31 +128,38 @@ class FlowGridLayout { const std::array& face, int side) const noexcept { auto site = face; + if (side < 0) { if (face[component] == 0) { return std::nullopt; } + --site[component]; } else if (face[component] >= dimensions_[component]) { return std::nullopt; } + return site_index(site[0], site[1], site[2]); } [[nodiscard]] std::optional neighbor_site(std::size_t index, std::size_t axis, int offset) const noexcept { auto site = site_coordinates(index); + if (offset < 0) { if (site[axis] == 0) { return std::nullopt; } + --site[axis]; } else { if (site[axis] + 1 >= dimensions_[axis]) { return std::nullopt; } + ++site[axis]; } + return site_index(site[0], site[1], site[2]); } @@ -141,17 +168,21 @@ class FlowGridLayout { const auto [component, original] = face_coordinates(index); auto face = original; const auto face_dims = face_dimensions(component); + if (offset < 0) { if (face[axis] == 0) { return std::nullopt; } + --face[axis]; } else { if (face[axis] + 1 >= face_dims[axis]) { return std::nullopt; } + ++face[axis]; } + return face_index(component, face[0], face[1], face[2]); } @@ -166,6 +197,7 @@ class FlowGridLayout { [[nodiscard]] inline float harmonic_mean(float first, float second) noexcept { const auto sum = first + second; + return sum > 0.0F ? 2.0F * first * second / sum : 0.0F; } @@ -176,13 +208,16 @@ class ShallowFlowReduction { ShallowFlowReduction(const SignalGridSpec& spec, std::span mobility, FlowAxis axis) : original_(spec), original_layout_(spec, axis), grid_(spec) { validate_flow_grid(spec, axis); + if (axis == FlowAxis::z) { throw std::invalid_argument( "shallow flow is depth-integrated along z; flow axis must be x or y"); } + if (!mobility.empty() && mobility.size() != spec.site_count()) { throw std::invalid_argument("flow mobility must hold one value per grid site"); } + grid_.shape.z = 1; grid_.velocity_field.reset(); grid_.reaction.reset(); @@ -194,88 +229,175 @@ class ShallowFlowReduction { conductance_.assign(columns, 0.0F); std::uint32_t common_bottom = spec.shape.z; float max_height = 0.0F; + for (std::uint32_t x = 0; x < spec.shape.x; ++x) { for (std::uint32_t y = 0; y < spec.shape.y; ++y) { - const auto column = static_cast(x) * spec.shape.y + y; - bool started = false, ended = false; - float value = 1.0F; - for (std::uint32_t z = 0; z < spec.shape.z; ++z) { - const auto site = original_layout_.site_index(x, y, z); - if (!mobility.empty() && (!std::isfinite(mobility[site]) || mobility[site] < 0.0F)) { - throw std::invalid_argument("flow mobility must be finite and non-negative"); - } - if (spec.solid_site(site)) { - if (started) ended = true; - continue; - } - if (ended) - throw std::invalid_argument( - "shallow flow needs contiguous fluid columns; use resolved flow"); - const float next = mobility.empty() ? 1.0F : mobility[site]; - if (!started) { - if (common_bottom == spec.shape.z) common_bottom = z; - if (z != common_bottom) - throw std::invalid_argument( - "shallow flow needs a common planar floor; use resolved flow"); - value = next; - } else if (std::abs(next - value) > 2.0e-6F * std::max(std::abs(value), std::abs(next))) { - throw std::invalid_argument( - "shallow-flow mobility must be constant through each depth column"); - } - started = true; - heights_[column] += spec.spacing.z; - } - if (started) { - grid_.obstacles[column] = 0; - conductance_[column] = heights_[column] * value; - max_height = std::max(max_height, heights_[column]); - } + initialize_column(x, y, mobility, common_bottom, max_height); } } + bottom_ = common_bottom; + if (mobility.empty() && max_height > 0.0F) { for (std::size_t i = 0; i < columns; ++i) { const auto relative_height = heights_[i] / max_height; conductance_[i] *= relative_height * relative_height; } } - for (std::size_t i = 0; i < columns; ++i) - if (conductance_[i] == 0.0F) grid_.obstacles[i] = 1; + + for (std::size_t i = 0; i < columns; ++i) { + if (conductance_[i] == 0.0F) { + grid_.obstacles[i] = 1; + } + } + validate_flow_grid(grid_, axis); } - const SignalGridSpec& grid() const { return grid_; } - const FlowGridLayout& original_layout() const { return original_layout_; } - const std::vector& conductance() const { return conductance_; } + const SignalGridSpec& grid() const { + return grid_; + } + + const FlowGridLayout& original_layout() const { + return original_layout_; + } + + const std::vector& conductance() const { + return conductance_; + } std::vector lift(std::span flux) const { const auto axis = static_cast(original_layout_.flow_axis()); const FlowGridLayout reduced(grid_, axis); - if (flux.size() != reduced.total_face_count()) + + if (flux.size() != reduced.total_face_count()) { throw std::logic_error("shallow flux size mismatch"); + } + std::vector velocity(original_layout_.total_face_count(), 0.0F); + for (std::size_t index = 0; index < velocity.size(); ++index) { const auto [component, face] = original_layout_.face_coordinates(index); - if (component == 2) continue; + + if (component == 2) { + continue; + } + const auto lower = original_layout_.adjacent_site(component, face, -1); const auto upper = original_layout_.adjacent_site(component, face, 1); - if ((lower && original_.solid_site(*lower)) || (upper && original_.solid_site(*upper))) + + if ((lower && original_.solid_site(*lower)) || (upper && original_.solid_site(*upper))) { continue; - if ((!lower || !upper) && component != original_layout_.flow_axis()) continue; + } + + if ((!lower || !upper) && component != original_layout_.flow_axis()) { + continue; + } + const auto low_column = lower ? *lower / original_.shape.z : *upper / original_.shape.z; const auto high_column = upper ? *upper / original_.shape.z : low_column; const auto height = std::min(heights_[low_column], heights_[high_column]); - if (height > 0.0F) + + if (height > 0.0F) { velocity[index] = flux[reduced.face_index(component, face[0], face[1], 0)] / height; + } } + + lift_vertical_velocity(velocity); + + return velocity; + } + + std::vector open_inlet_faces() const { + std::vector result(original_layout_.total_face_count(), 0); + + for (std::size_t index = 0; index < result.size(); ++index) { + const auto [component, face] = original_layout_.face_coordinates(index); + + if (component != original_layout_.flow_axis() || face[component] != 0) { + continue; + } + + const auto site = original_layout_.adjacent_site(component, face, 1); + + if (site && !original_.solid_site(*site) && conductance_[*site / original_.shape.z] > 0.0F) { + result[index] = 1; + } + } + + return result; + } + + private: + void initialize_column(std::uint32_t x, std::uint32_t y, std::span mobility, + std::uint32_t& common_bottom, float& max_height) { + const auto& spec = original_; + const auto column = static_cast(x) * spec.shape.y + y; + bool started = false, ended = false; + float value = 1.0F; + + for (std::uint32_t z = 0; z < spec.shape.z; ++z) { + const auto site = original_layout_.site_index(x, y, z); + + if (!mobility.empty() && (!std::isfinite(mobility[site]) || mobility[site] < 0.0F)) { + throw std::invalid_argument("flow mobility must be finite and non-negative"); + } + + if (spec.solid_site(site)) { + if (started) { + ended = true; + } + + continue; + } + + if (ended) { + throw std::invalid_argument( + "shallow flow needs contiguous fluid columns; use resolved flow"); + } + + const float next = mobility.empty() ? 1.0F : mobility[site]; + + if (!started) { + if (common_bottom == spec.shape.z) { + common_bottom = z; + } + + if (z != common_bottom) { + throw std::invalid_argument( + "shallow flow needs a common planar floor; use resolved flow"); + } + + value = next; + } else if (std::abs(next - value) > 2.0e-6F * std::max(std::abs(value), std::abs(next))) { + throw std::invalid_argument( + "shallow-flow mobility must be constant through each depth column"); + } + + started = true; + heights_[column] += spec.spacing.z; + } + + if (started) { + grid_.obstacles[column] = 0; + conductance_[column] = heights_[column] * value; + max_height = std::max(max_height, heights_[column]); + } + } + + void lift_vertical_velocity(std::vector& velocity) const { // Integrate horizontal divergence upward, spreading the small column // pressure residual uniformly over depth so both floor and roof stay closed. for (std::uint32_t x = 0; x < original_.shape.x; ++x) { for (std::uint32_t y = 0; y < original_.shape.y; ++y) { std::vector horizontal; double sum = 0.0; + for (std::uint32_t z = bottom_; z < original_.shape.z; ++z) { - if (original_.solid_site(original_layout_.site_index(x, y, z))) break; + if (original_.solid_site(original_layout_.site_index(x, y, z))) { + break; + } + const double dx = (velocity[original_layout_.face_index(0, x + 1, y, z)] - velocity[original_layout_.face_index(0, x, y, z)]) / original_.spacing.x; @@ -285,9 +407,14 @@ class ShallowFlowReduction { horizontal.push_back(dx + dy); sum += dx + dy; } - if (horizontal.empty()) continue; + + if (horizontal.empty()) { + continue; + } + const double mean = sum / static_cast(horizontal.size()); double vertical = 0.0; + for (std::size_t k = 0; k + 1 < horizontal.size(); ++k) { vertical -= original_.spacing.z * (horizontal[k] - mean); velocity[original_layout_.face_index( @@ -295,22 +422,8 @@ class ShallowFlowReduction { } } } - return velocity; - } - - std::vector open_inlet_faces() const { - std::vector result(original_layout_.total_face_count(), 0); - for (std::size_t index = 0; index < result.size(); ++index) { - const auto [component, face] = original_layout_.face_coordinates(index); - if (component != original_layout_.flow_axis() || face[component] != 0) continue; - const auto site = original_layout_.adjacent_site(component, face, 1); - if (site && !original_.solid_site(*site) && conductance_[*site / original_.shape.z] > 0.0F) - result[index] = 1; - } - return result; } - private: const SignalGridSpec& original_; FlowGridLayout original_layout_; SignalGridSpec grid_; @@ -328,38 +441,49 @@ class DepthAveragedFlowSystem { diagonal_(layout_.site_count()), right_hand_side_(layout_.site_count()) { validate_flow_grid(spec_, axis); + if (!mobility.empty()) { if (mobility.size() != layout_.site_count()) { throw std::invalid_argument("flow mobility must hold one value per grid site"); } + std::copy(mobility.begin(), mobility.end(), mobility_.begin()); } + for (std::size_t site = 0; site < mobility_.size(); ++site) { if (!std::isfinite(mobility_[site]) || mobility_[site] < 0.0F) { throw std::invalid_argument("flow mobility values must be finite and non-negative"); } + if (spec_.solid_site(site)) { mobility_[site] = 0.0F; } } bool open_inlet = false; + for (std::size_t site = 0; site < layout_.site_count(); ++site) { const auto value = mobility_[site]; + if (value == 0.0F) { continue; } + const auto coordinates = layout_.site_coordinates(site); + for (std::size_t component = 0; component < 3; ++component) { const auto inverse_square = 1.0F / (layout_.spacing()[component] * layout_.spacing()[component]); + for (const auto offset : {-1, 1}) { const auto neighbor = layout_.neighbor_site(site, component, offset); + if (neighbor.has_value()) { diagonal_[site] += harmonic_mean(value, mobility_[*neighbor]) * inverse_square; } } } + if (coordinates[layout_.flow_axis()] == 0) { const auto boundary = 2.0F * value / @@ -368,20 +492,31 @@ class DepthAveragedFlowSystem { right_hand_side_[site] = boundary; open_inlet = true; } + if (coordinates[layout_.flow_axis()] + 1 == layout_.dimensions()[layout_.flow_axis()]) { diagonal_[site] += 2.0F * value / (layout_.spacing()[layout_.flow_axis()] * layout_.spacing()[layout_.flow_axis()]); } } + if (!open_inlet) { throw std::invalid_argument("the flow inlet boundary is entirely blocked"); } } - [[nodiscard]] const FlowGridLayout& layout() const noexcept { return layout_; } - [[nodiscard]] const std::vector& mobility() const noexcept { return mobility_; } - [[nodiscard]] const std::vector& diagonal() const noexcept { return diagonal_; } + [[nodiscard]] const FlowGridLayout& layout() const noexcept { + return layout_; + } + + [[nodiscard]] const std::vector& mobility() const noexcept { + return mobility_; + } + + [[nodiscard]] const std::vector& diagonal() const noexcept { + return diagonal_; + } + [[nodiscard]] const std::vector& right_hand_side() const noexcept { return right_hand_side_; } @@ -390,24 +525,36 @@ class DepthAveragedFlowSystem { if (input.size() != layout_.site_count()) { throw std::invalid_argument("depth-averaged flow vector has the wrong size"); } + output.assign(input.size(), 0.0); + for (std::size_t site = 0; site < input.size(); ++site) { if (diagonal_[site] == 0.0F) { continue; } + double result = 0.0; const auto coordinates = layout_.site_coordinates(site); const auto axis = layout_.flow_axis(); const double boundary = 2.0 * mobility_[site] / (static_cast(layout_.spacing()[axis]) * layout_.spacing()[axis]); - if (coordinates[axis] == 0) result += boundary * input[site]; - if (coordinates[axis] + 1 == layout_.dimensions()[axis]) result += boundary * input[site]; + + if (coordinates[axis] == 0) { + result += boundary * input[site]; + } + + if (coordinates[axis] + 1 == layout_.dimensions()[axis]) { + result += boundary * input[site]; + } + for (std::size_t component = 0; component < 3; ++component) { const auto inverse_square = 1.0 / (static_cast(layout_.spacing()[component]) * layout_.spacing()[component]); + for (const auto offset : {-1, 1}) { const auto neighbor = layout_.neighbor_site(site, component, offset); + if (neighbor.has_value()) { const auto conductance = static_cast(harmonic_mean(mobility_[site], mobility_[*neighbor])) * @@ -416,6 +563,7 @@ class DepthAveragedFlowSystem { } } } + output[site] = result; } } @@ -424,13 +572,16 @@ class DepthAveragedFlowSystem { if (pressure.size() != layout_.site_count()) { throw std::invalid_argument("depth-averaged pressure vector has the wrong size"); } + std::vector result(layout_.total_face_count(), 0.0F); + for (std::size_t face_index = 0; face_index < result.size(); ++face_index) { const auto [component, face] = layout_.face_coordinates(face_index); const auto lower = layout_.adjacent_site(component, face, -1); const auto upper = layout_.adjacent_site(component, face, 1); const auto spacing = static_cast(layout_.spacing()[component]); double value = 0.0; + if (lower.has_value() && upper.has_value()) { const auto face_mobility = harmonic_mean(mobility_[*lower], mobility_[*upper]); value = @@ -440,8 +591,10 @@ class DepthAveragedFlowSystem { } else if (component == layout_.flow_axis() && lower.has_value()) { value = 2.0 * static_cast(mobility_[*lower]) * pressure[*lower] / spacing; } + result[face_index] = static_cast(value); } + return result; } @@ -449,20 +602,25 @@ class DepthAveragedFlowSystem { std::vector result(layout_.total_face_count(), 0); const auto component = layout_.flow_axis(); const auto face_dims = layout_.face_dimensions(component); + for (std::uint32_t x = 0; x < face_dims[0]; ++x) { for (std::uint32_t y = 0; y < face_dims[1]; ++y) { for (std::uint32_t z = 0; z < face_dims[2]; ++z) { std::array face{x, y, z}; + if (face[component] != 0) { continue; } + const auto upper = layout_.adjacent_site(component, face, 1); + if (upper.has_value() && mobility_[*upper] > 0.0F) { result[layout_.face_index(component, x, y, z)] = 1; } } } } + return result; } @@ -487,23 +645,11 @@ class ResolvedFlowSystem { diagonal_(layout_.total_face_count()), force_(layout_.total_face_count()) { validate_flow_grid(spec_, axis); - if (!drag.empty()) { - if (drag.size() != layout_.site_count()) { - throw std::invalid_argument("resolved-flow drag must hold one value per grid site"); - } - std::copy(drag.begin(), drag.end(), drag_.begin()); - } - for (std::size_t site = 0; site < layout_.site_count(); ++site) { - fluid_[site] = spec_.solid_site(site) ? 0 : 1; - if (!std::isfinite(drag_[site]) || drag_[site] < 0.0F) { - throw std::invalid_argument("resolved-flow drag values must be finite and non-negative"); - } - if (fluid_[site] == 0) { - drag_[site] = 0.0F; - } - } + + initialize_drag(drag); bool open_inlet = false; + for (std::size_t index = 0; index < layout_.total_face_count(); ++index) { const auto [component, face] = layout_.face_coordinates(index); const auto lower = layout_.adjacent_site(component, face, -1); @@ -512,74 +658,83 @@ class ResolvedFlowSystem { const auto upper_fluid = upper.has_value() && fluid_[*upper] != 0; exists_[index] = lower_fluid || upper_fluid ? 1 : 0; auto active = lower_fluid && upper_fluid; + if (component == layout_.flow_axis() && (face[component] == 0 || face[component] == layout_.dimensions()[component])) { active = lower_fluid || upper_fluid; } + active_[index] = active ? 1 : 0; + if (!active) { continue; } - float sum = 0.0F; - float count = 0.0F; - if (lower_fluid) { - sum += drag_[*lower]; - count += 1.0F; - } - if (upper_fluid) { - sum += drag_[*upper]; - count += 1.0F; - } - face_drag_[index] = sum / count; - diagonal_[index] = face_drag_[index]; - for (std::size_t axis_index = 0; axis_index < 3; ++axis_index) { - if (layout_.dimensions()[axis_index] > 1) { - diagonal_[index] += - 2.0F / (layout_.spacing()[axis_index] * layout_.spacing()[axis_index]); - } - } - if (component == layout_.flow_axis()) { - // Solve for the pressure correction about the known linear inlet-to- - // outlet profile. This algebraic shift avoids subtracting nearly equal - // O(1) pressures to recover a small viscous forcing in binary32. - force_[index] = 1.0F / ((static_cast(layout_.dimensions()[component]) + 1.0F) * - layout_.spacing()[component]); - if (face[component] == 0) open_inlet = true; - } + + open_inlet = + initialize_active_face(index, component, face, lower, upper, lower_fluid, upper_fluid) || + open_inlet; } + if (!open_inlet) { throw std::invalid_argument("the resolved-flow inlet boundary is entirely blocked"); } } - [[nodiscard]] const FlowGridLayout& layout() const noexcept { return layout_; } - [[nodiscard]] const std::vector& fluid() const noexcept { return fluid_; } - [[nodiscard]] const std::vector& active() const noexcept { return active_; } - [[nodiscard]] const std::vector& exists() const noexcept { return exists_; } - [[nodiscard]] const std::vector& face_drag() const noexcept { return face_drag_; } - [[nodiscard]] const std::vector& diagonal() const noexcept { return diagonal_; } - [[nodiscard]] const std::vector& force() const noexcept { return force_; } + [[nodiscard]] const FlowGridLayout& layout() const noexcept { + return layout_; + } + + [[nodiscard]] const std::vector& fluid() const noexcept { + return fluid_; + } + + [[nodiscard]] const std::vector& active() const noexcept { + return active_; + } + + [[nodiscard]] const std::vector& exists() const noexcept { + return exists_; + } + + [[nodiscard]] const std::vector& face_drag() const noexcept { + return face_drag_; + } + + [[nodiscard]] const std::vector& diagonal() const noexcept { + return diagonal_; + } + + [[nodiscard]] const std::vector& force() const noexcept { + return force_; + } void apply_momentum(std::span input, std::vector& output) const { if (input.size() != layout_.total_face_count()) { throw std::invalid_argument("resolved-flow face vector has the wrong size"); } + output.assign(input.size(), 0.0); + for (std::size_t index = 0; index < input.size(); ++index) { if (active_[index] == 0) { continue; } + const auto component = layout_.face_coordinates(index).first; auto result = static_cast(face_drag_[index]) * input[index]; + for (std::size_t axis = 0; axis < 3; ++axis) { if (layout_.dimensions()[axis] == 1) { continue; } + const auto inverse_square = 1.0 / (static_cast(layout_.spacing()[axis]) * layout_.spacing()[axis]); + for (const auto offset : {-1, 1}) { const auto neighbor_index = layout_.neighbor_face(index, axis, offset); auto neighbor = 0.0; + if (axis == component) { neighbor = neighbor_index.has_value() ? input[*neighbor_index] : input[index]; } else if (neighbor_index.has_value() && exists_[*neighbor_index] != 0) { @@ -587,9 +742,11 @@ class ResolvedFlowSystem { } else { neighbor = -input[index]; } + result -= (neighbor - input[index]) * inverse_square; } } + output[index] = result; } } @@ -598,11 +755,14 @@ class ResolvedFlowSystem { if (pressure.size() != layout_.site_count()) { throw std::invalid_argument("resolved-flow pressure vector has the wrong size"); } + std::vector result(layout_.total_face_count(), 0.0); + for (std::size_t index = 0; index < result.size(); ++index) { if (active_[index] == 0) { continue; } + const auto [component, face] = layout_.face_coordinates(index); const auto lower = layout_.adjacent_site(component, face, -1); const auto upper = layout_.adjacent_site(component, face, 1); @@ -610,6 +770,7 @@ class ResolvedFlowSystem { const auto upper_value = upper.has_value() && fluid_[*upper] != 0 ? pressure[*upper] : 0.0; result[index] = (upper_value - lower_value) / layout_.spacing()[component]; } + return result; } @@ -617,12 +778,16 @@ class ResolvedFlowSystem { if (velocity.size() != layout_.total_face_count()) { throw std::invalid_argument("resolved-flow velocity vector has the wrong size"); } + std::vector result(layout_.site_count(), 0.0); + for (std::size_t site = 0; site < result.size(); ++site) { if (fluid_[site] == 0) { continue; } + const auto coordinates = layout_.site_coordinates(site); + for (std::size_t component = 0; component < 3; ++component) { auto upper = coordinates; ++upper[component]; @@ -633,17 +798,21 @@ class ResolvedFlowSystem { (velocity[upper_face] - velocity[lower_face]) / layout_.spacing()[component]; } } + return result; } [[nodiscard]] std::vector pressure_diagonal() const { std::vector result(layout_.site_count(), 0.0F); + for (std::size_t site = 0; site < result.size(); ++site) { if (fluid_[site] != 0) { const auto coordinates = layout_.site_coordinates(site); + for (std::size_t axis = 0; axis < 3; ++axis) { auto upper = coordinates; ++upper[axis]; + for (const auto face : {layout_.face_index(axis, coordinates[0], coordinates[1], coordinates[2]), layout_.face_index(axis, upper[0], upper[1], upper[2])}) { @@ -655,38 +824,47 @@ class ResolvedFlowSystem { } } } + return result; } [[nodiscard]] std::vector open_inlet_faces() const { std::vector result(layout_.total_face_count(), 0); const auto component = layout_.flow_axis(); + for (std::size_t index = layout_.face_offsets()[component]; index < layout_.face_offsets()[component] + layout_.face_counts()[component]; ++index) { const auto [face_component, face] = layout_.face_coordinates(index); + if (face_component == component && face[component] == 0 && active_[index] != 0) { result[index] = 1; } } + return result; } [[nodiscard]] std::uint32_t minimum_gap_voxels() const { std::uint32_t shortest = 0; + for (std::size_t axis = 0; axis < 3; ++axis) { if (axis == layout_.flow_axis() || layout_.dimensions()[axis] <= 1) { continue; } + const auto first_axis = (axis + 1) % 3; const auto second_axis = (axis + 2) % 3; + for (std::uint32_t first = 0; first < layout_.dimensions()[first_axis]; ++first) { for (std::uint32_t second = 0; second < layout_.dimensions()[second_axis]; ++second) { std::uint32_t run = 0; + for (std::uint32_t along = 0; along < layout_.dimensions()[axis]; ++along) { std::array coordinates{}; coordinates[axis] = along; coordinates[first_axis] = first; coordinates[second_axis] = second; + if (fluid_[layout_.site_index(coordinates[0], coordinates[1], coordinates[2])] != 0) { ++run; } else if (run != 0) { @@ -694,16 +872,81 @@ class ResolvedFlowSystem { run = 0; } } + if (run != 0) { shortest = shortest == 0 ? run : std::min(shortest, run); } } } } + return shortest; } private: + bool initialize_active_face(std::size_t index, std::size_t component, + const std::array& face, + std::optional lower, std::optional upper, + bool lower_fluid, bool upper_fluid) { + float sum = 0.0F; + float count = 0.0F; + + if (lower_fluid) { + sum += drag_[*lower]; + count += 1.0F; + } + + if (upper_fluid) { + sum += drag_[*upper]; + count += 1.0F; + } + + face_drag_[index] = sum / count; + diagonal_[index] = face_drag_[index]; + + for (std::size_t axis_index = 0; axis_index < 3; ++axis_index) { + if (layout_.dimensions()[axis_index] > 1) { + diagonal_[index] += 2.0F / (layout_.spacing()[axis_index] * layout_.spacing()[axis_index]); + } + } + + if (component == layout_.flow_axis()) { + // Solve for the pressure correction about the known linear inlet-to- + // outlet profile. This algebraic shift avoids subtracting nearly equal + // O(1) pressures to recover a small viscous forcing in binary32. + force_[index] = 1.0F / ((static_cast(layout_.dimensions()[component]) + 1.0F) * + layout_.spacing()[component]); + + if (face[component] == 0) { + return true; + } + } + + return false; + } + + void initialize_drag(std::span drag) { + if (!drag.empty()) { + if (drag.size() != layout_.site_count()) { + throw std::invalid_argument("resolved-flow drag must hold one value per grid site"); + } + + std::copy(drag.begin(), drag.end(), drag_.begin()); + } + + for (std::size_t site = 0; site < layout_.site_count(); ++site) { + fluid_[site] = spec_.solid_site(site) ? 0 : 1; + + if (!std::isfinite(drag_[site]) || drag_[site] < 0.0F) { + throw std::invalid_argument("resolved-flow drag values must be finite and non-negative"); + } + + if (fluid_[site] == 0) { + drag_[site] = 0.0F; + } + } + } + const SignalGridSpec& spec_; FlowGridLayout layout_; std::vector fluid_; @@ -730,29 +973,38 @@ struct ScaledVelocity { if (values.size() != layout.total_face_count() || open_inlet.size() != values.size()) { throw std::invalid_argument("flow velocity scaling arrays have inconsistent sizes"); } + double inlet_sum = 0.0; std::size_t inlet_count = 0; float peak = 0.0F; + for (std::size_t index = 0; index < values.size(); ++index) { if (!std::isfinite(values[index])) { throw std::runtime_error("flow solve produced a non-finite velocity"); } + peak = std::max(peak, std::abs(values[index])); + if (open_inlet[index] != 0) { inlet_sum += values[index]; ++inlet_count; } } + if (inlet_count == 0) { throw std::logic_error("flow solve has no open inlet faces"); } + const auto solved_mean = static_cast(inlet_sum / static_cast(inlet_count)); + if (peak == 0.0F || solved_mean <= 1.0e-9F * peak) { throw std::runtime_error("the device carries no through-flow: the outlet is unreachable"); } + const auto factor = requested_mean / solved_mean; std::vector scaled(values.size()); float scaled_peak = 0.0F; + for (std::size_t index = 0; index < values.size(); ++index) { scaled[index] = values[index] * factor; scaled_peak = std::max(scaled_peak, std::abs(scaled[index])); @@ -767,10 +1019,13 @@ struct ScaledVelocity { field.z_faces.assign(scaled.begin() + static_cast(offsets[2]), scaled.end()); auto candidate = spec; candidate.velocity_field = field; + for (auto& advection : candidate.advection) { advection = {}; } + candidate.validate(); + return {.field = std::move(field), .solved_mean = solved_mean, .max_speed = scaled_peak, diff --git a/cpp/core/fluid_geometry.cpp b/cpp/core/fluid_geometry.cpp new file mode 100644 index 0000000..de4984e --- /dev/null +++ b/cpp/core/fluid_geometry.cpp @@ -0,0 +1,770 @@ +#include "cm/fluid_geometry.hpp" + +#include +#include +#include + +#include "convex_polyhedron.hpp" +#include "flow_system.hpp" + +namespace cm { +namespace { +using namespace detail; +constexpr std::uint64_t body_tag = std::uint64_t{1} << 63; + +struct Bounds { + BodyVector lo, hi; +}; + +Bounds bounds(const ConvexPolyhedron& p) { + Bounds b{{INFINITY, INFINITY, INFINITY}, {-INFINITY, -INFINITY, -INFINITY}}; + + for (const auto& f : p.faces) { + for (const auto& v : f.vertices) { + for (unsigned d = 0; d < 3; ++d) { + b.lo[d] = std::min(b.lo[d], v[d]); + b.hi[d] = std::max(b.hi[d], v[d]); + } + } + } + + return b; +} + +bool overlap(const Bounds& a, const Bounds& b, double tolerance = 0) { + for (unsigned d = 0; d < 3; ++d) { + if (a.lo[d] > b.hi[d] + tolerance || a.hi[d] < b.lo[d] - tolerance) { + return false; + } + } + + return true; +} + +BodyVector normal(const ConvexPolygon& p) { + BodyVector area{}; + + for (std::size_t i = 2; i < p.vertices.size(); ++i) { + const auto c = body_cross(body_difference(p.vertices[i - 1], p.vertices[0]), + body_difference(p.vertices[i], p.vertices[0])); + + for (unsigned d = 0; d < 3; ++d) { + area[d] += c[d]; + } + } + + return body_normalized(area); +} + +std::pair polygon_measure(const std::vector& p) { + double area = 0; + BodyVector centroid{}; + + for (std::size_t i = 2; i < p.size(); ++i) { + const auto c = body_cross(body_difference(p[i - 1], p[0]), body_difference(p[i], p[0])); + const double a = 0.5 * std::sqrt(body_dot(c, c)); + area += a; + + for (unsigned d = 0; d < 3; ++d) { + centroid[d] += a * (p[0][d] + p[i - 1][d] + p[i][d]) / 3; + } + } + + if (area > 0) { + for (double& v : centroid) { + v /= area; + } + } + + return {area, centroid}; +} + +std::vector intersect_polygon(std::vector p, const ConvexPolygon& q) { + const auto n = normal(q); + const double epsilon = polygon_epsilon(q.vertices); + + for (std::size_t i = 0; i < q.vertices.size() && !p.empty(); ++i) { + const auto& a = q.vertices[i]; + const auto& b = q.vertices[(i + 1) % q.vertices.size()]; + const auto edge = body_difference(b, a); + + if (body_dot(edge, edge) <= epsilon * epsilon) { + continue; + } + + const auto outward = body_normalized(body_cross(edge, n)); + p = clip_polygon(p, {outward, body_dot(outward, a), 0}); + } + + return p; +} + +std::uint64_t poly_bytes(const ConvexPolyhedron& p) { + std::uint64_t bytes = sizeof(p); + + for (const auto& f : p.faces) { + bytes += sizeof(f) + f.vertices.size() * sizeof(BodyVector); + } + + return bytes; +} +} // namespace + +struct FluidGeometry::Impl { + FluidGridSpec grid; + std::vector bodies; + std::vector pieces; + std::vector piece_fragment; + std::vector fragments; + std::vector faces; + FluidGeometryReport report; + std::uint32_t surface_resolution{0}; + + struct FaceRef { + std::uint32_t fragment; + const ConvexPolygon* polygon; + BodyVector normal, centroid, unmatched_moment; + Bounds bound; + double area, unmatched; + }; + + std::vector local_body_coordinates(const BodyVector& origin) const { + auto local_bodies = bodies; + std::set ids; + + for (auto& body : local_bodies) { + body.validate(); + + if (!ids.insert(body.id).second) { + throw std::invalid_argument("duplicate fluid geometry body id"); + } + + for (unsigned d = 0; d < 3; ++d) { + body.position[d] -= origin[d]; + } + } + + return local_bodies; + } + + void build_surfaces(const std::vector& local_bodies, + const FluidGeometryParameters& p, std::vector& surfaces, + std::vector& surface_bounds, + std::map& tag_owner, const auto& charge) { + const double h = grid.spacing; + std::uint64_t next_tag = body_tag; + + for (std::size_t i = 0; i < local_bodies.size(); ++i) { + charge(std::uint64_t(p.surface_resolution) * p.surface_resolution * 512); + auto surface = capsule_polyhedron(local_bodies[i], p.surface_resolution); + + if (surface.surface_error_bound > p.maximum_surface_error_fraction * h) { + throw std::invalid_argument( + "culture capsule surface error exceeds configured limit; refine surface_resolution"); + } + + report.maximum_surface_error = + std::max(report.maximum_surface_error, surface.surface_error_bound); + + for (std::size_t f = 0; f < surface.planes.size(); ++f) { + surface.planes[f].tag = next_tag; + surface.poly.faces[f].tag = next_tag; + tag_owner[next_tag++] = i; + } + + surface_bounds.push_back(bounds(surface.poly)); + surfaces.push_back(std::move(surface)); + } + } + + void validate_surface_gaps(const std::vector& surfaces) const { + // The surface approximation must not close a physical gap. Reject uncertain + // gaps so the caller can refine; never bridge fluid fragments by a cutoff. + const auto contacts = capsule_contacts(grid, bodies, 2 * report.maximum_surface_error, 1, 0); + + for (const auto& c : contacts) { + const double error = + surfaces[c.first].surface_error_bound + + (c.second == fluid_wall_body ? 0 : surfaces[c.second].surface_error_bound); + + if (c.gap <= error) { + throw std::invalid_argument( + "fluid geometry gap is smaller than surface error; refine surface_resolution"); + } + } + } + + static bool outside_surface(const ConvexPolyhedron& poly, const CapsulePolyhedron& surface) { + bool outside = false; + + for (const auto& plane : surface.planes) { + double minimum = INFINITY; + + for (const auto& face : poly.faces) { + for (const auto& point : face.vertices) { + minimum = std::min(minimum, body_dot(plane.normal, point) - plane.offset); + } + } + + if (minimum >= 0) { + outside = true; + break; + } + } + + return outside; + } + + static void subtract_surface(std::vector& fluid, + const CapsulePolyhedron& surface, + std::vector& next, const auto& charge) { + for (auto& poly : fluid) { + if (outside_surface(poly, surface)) { + next.push_back(std::move(poly)); + continue; + } + + for (const auto& plane : surface.planes) { + if (poly.faces.empty()) { + break; + } + + ClipPlane reverse = plane; + + for (double& x : reverse.normal) { + x = -x; + } + + reverse.offset = -reverse.offset; + auto cut = clip_polyhedron(poly, reverse); + + if (measure_polyhedron(cut).volume > 0) { + charge(poly_bytes(cut) * 3); + next.push_back(std::move(cut)); + } + + poly = clip_polyhedron(poly, plane); + } + } + } + + static std::vector voxel_fluid(const Bounds& box, + const std::array& tags, + const std::vector& surfaces, + const std::vector& surface_bounds, + const auto& charge) { + std::vector fluid{box_polyhedron(box.lo, box.hi, tags)}; + + for (std::size_t body = 0; body < surfaces.size() && !fluid.empty(); ++body) { + if (!overlap(box, surface_bounds[body])) { + continue; + } + + const auto& surface = surfaces[body]; + std::vector next; + + subtract_surface(fluid, surface, next, charge); + + fluid = std::move(next); + } + + return fluid; + } + + std::size_t build_fragments(const FlowGridLayout& layout, + const std::vector& surfaces, + const std::vector& surface_bounds, const auto& charge) { + const auto& spec = grid; + const double h = grid.spacing; + std::size_t fluid_sites = 0; + + for (std::size_t site = 0; site < grid.site_count(); ++site) { + if (spec.solid_site(site)) { + continue; + } + + ++fluid_sites; + const auto xyz = layout.site_coordinates(site); + Bounds box; + std::array tags; + + for (unsigned d = 0; d < 3; ++d) { + box.lo[d] = (double(xyz[d]) - 0.5) * h; + box.hi[d] = box.lo[d] + h; + auto upper = xyz; + ++upper[d]; + tags[2 * d] = layout.face_index(d, xyz[0], xyz[1], xyz[2]); + tags[2 * d + 1] = layout.face_index(d, upper[0], upper[1], upper[2]); + } + + auto fluid = voxel_fluid(box, tags, surfaces, surface_bounds, charge); + + for (auto& poly : fluid) { + const auto m = measure_polyhedron(poly); + + if (!(m.volume > 0) || !std::isfinite(m.volume)) { + throw std::runtime_error("invalid extracellular fragment volume"); + } + + charge(poly_bytes(poly) * 3 + 256); + + if (fragments.size() >= fluid_boundary) { + throw std::length_error("too many culture fragments"); + } + + fragments.push_back({static_cast(site), 0, m.volume, m.centroid}); + report.fluid_volume += m.volume; + pieces.push_back(std::move(poly)); + } + } + + return fluid_sites; + } + + void validate_volume(std::size_t fluid_sites) { + const double h = grid.spacing; + report.expected_fluid_volume = double(fluid_sites) * h * h * h; + + for (const auto& body : bodies) { + report.expected_fluid_volume -= body.geometric_volume(); + } + + report.volume_error = report.fluid_volume - report.expected_fluid_volume; + + if (std::abs(report.volume_error) > 1e-10 * double(fluid_sites) * h * h * h) { + throw std::runtime_error("extracellular geometry failed volume conservation"); + } + } + + std::map> group_piece_faces(double area_tolerance) const { + std::map> groups; + + for (std::uint32_t i = 0; i < pieces.size(); ++i) { + for (const auto& face : pieces[i].faces) { + const auto [area, center] = polygon_measure(face.vertices); + + if (area <= area_tolerance) { + continue; + } + + BodyVector moment; + + for (unsigned d = 0; d < 3; ++d) { + moment[d] = area * center[d]; + } + + groups[face.tag].push_back( + {i, &face, normal(face), center, moment, bounds({{face}}), area, area}); + } + } + + return groups; + } + + void match_internal_faces(std::uint64_t tag, std::vector& group, double h, + double area_tolerance, const auto& append_face) const { + for (std::size_t i = 0; i < group.size(); ++i) { + for (std::size_t j = i + 1; j < group.size(); ++j) { + auto& a = group[i]; + auto& b = group[j]; + + if ((tag >= body_tag && fragments[a.fragment].site != fragments[b.fragment].site) || + body_dot(a.normal, b.normal) > -0.999999999 || !overlap(a.bound, b.bound, 1e-12 * h)) { + continue; + } + + const auto intersection = intersect_polygon(a.polygon->vertices, *b.polygon); + const auto [area, center] = polygon_measure(intersection); + + if (area <= area_tolerance) { + continue; + } + + a.unmatched -= area; + b.unmatched -= area; + + for (unsigned d = 0; d < 3; ++d) { + a.unmatched_moment[d] -= area * center[d]; + b.unmatched_moment[d] -= area * center[d]; + } + + append_face(a.fragment, b.fragment, area, center, a.normal, tag); + } + } + } + + CellId coincident_surface_body(const FaceRef& a, const BodyVector& center, + const std::vector& surfaces, double h) const { + CellId body = 0; + + // A membrane can coincide with a grid face. Identify its surface + // plane explicitly instead of labelling it as an impermeable wall. + for (std::size_t k = 0; k < surfaces.size(); ++k) { + if (!std::all_of(surfaces[k].planes.begin(), surfaces[k].planes.end(), + [&](const auto& plane) { + return plane_distance(plane, center) <= 1e-10 * h; + })) { + continue; + } + + for (const auto& plane : surfaces[k].planes) { + if (body_dot(a.normal, plane.normal) < -1 + 1e-12 && + std::abs(body_dot(plane.normal, center) - plane.offset) < 1e-10 * h) { + body = bodies[k].id; + } + } + } + + return body; + } + + CellId unmatched_face_body(const FaceRef& a, std::uint64_t tag, const BodyVector& center, + const FlowGridLayout& layout, + const std::vector& surfaces, + const std::map& tag_owner, + double h) const { + const auto& spec = grid; + CellId body = 0; + + if (tag >= body_tag) { + const auto owner = tag_owner.at(tag); + body = bodies[owner].id; + const auto& planes = surfaces[owner].planes; + const auto plane = std::find_if(planes.begin(), planes.end(), [&](const auto& q) { + return q.tag == tag; + }); + + if (body_dot(a.normal, plane->normal) > 0) { + throw std::runtime_error( + "unmatched internal extracellular partition: area=" + std::to_string(a.unmatched) + + ", fragment=" + std::to_string(a.fragment) + ", tag=" + std::to_string(tag)); + } + } else { + const auto [axis, xyz] = layout.face_coordinates(tag); + const auto other = layout.adjacent_site(axis, xyz, a.normal[axis] > 0 ? 1 : -1); + + if (other && !spec.solid_site(*other)) { + body = coincident_surface_body(a, center, surfaces, h); + + if (!body) { + throw std::runtime_error( + "unmatched open extracellular grid face: area=" + std::to_string(a.unmatched) + + ", site=" + std::to_string(fragments[a.fragment].site) + + ", face=" + std::to_string(tag)); + } + } + } + + return body; + } + + void append_boundary_faces(std::uint64_t tag, const std::vector& group, + const FlowGridLayout& layout, + const std::vector& surfaces, + const std::map& tag_owner, double h, + const auto& append_face) const { + for (const auto& a : group) { + if (a.unmatched < -1e-9 * h * h) { + throw std::runtime_error("overlapping extracellular faces"); + } + + if (a.unmatched <= 1e-10 * h * h) { + continue; + } + + BodyVector center; + + for (unsigned d = 0; d < 3; ++d) { + center[d] = a.unmatched_moment[d] / a.unmatched; + } + + const auto body = unmatched_face_body(a, tag, center, layout, surfaces, tag_owner, h); + + append_face(a.fragment, fluid_boundary, a.unmatched, center, a.normal, tag, body); + } + } + + void assign_components(const BodyVector& origin, const auto& find_root) { + std::map components; + + for (std::uint32_t i = 0; i < fragments.size(); ++i) { + const auto r = find_root(i); + auto [it, inserted] = + components.try_emplace(r, static_cast(components.size())); + fragments[i].component = it->second; + + for (unsigned d = 0; d < 3; ++d) { + fragments[i].centroid[d] += origin[d]; + } + } + + report.component_count = static_cast(components.size()); + + for (auto& face : faces) { + for (unsigned d = 0; d < 3; ++d) { + face.centroid[d] += origin[d]; + } + } + } + + void merge_fragments(std::vector& root, const auto& find_root) { + // Merge only pieces connected by a positive-area face within one voxel. + // The convex decomposition remains available for exact overlap integrals. + std::iota(root.begin(), root.end(), 0); + + for (const auto& face : faces) { + if (face.second != fluid_boundary && + fragments[face.first].site == fragments[face.second].site) { + root[find_root(face.first)] = find_root(face.second); + } + } + + std::map merged_ids; + std::vector merged; + piece_fragment.resize(fragments.size()); + + for (std::uint32_t i = 0; i < fragments.size(); ++i) { + auto [it, inserted] = + merged_ids.try_emplace(find_root(i), static_cast(merged.size())); + + if (inserted) { + merged.push_back({fragments[i].site, fragments[i].component}); + } + + const auto j = it->second; + piece_fragment[i] = j; + merged[j].volume += fragments[i].volume; + + for (unsigned d = 0; d < 3; ++d) { + merged[j].centroid[d] += fragments[i].volume * fragments[i].centroid[d]; + } + } + + for (auto& f : merged) { + for (double& x : f.centroid) { + x /= f.volume; + } + } + + for (auto& face : faces) { + face.first = piece_fragment[face.first]; + + if (face.second != fluid_boundary) { + face.second = piece_fragment[face.second]; + } + } + + std::erase_if(faces, [](const auto& face) { + return face.first == face.second; + }); + fragments = std::move(merged); + } + + void validate_overlap_grid(const Impl& b) const { + const auto& a = *this; + + if (a.grid.shape.x != b.grid.shape.x || a.grid.shape.y != b.grid.shape.y || + a.grid.shape.z != b.grid.shape.z || a.grid.origin.x != b.grid.origin.x || + a.grid.origin.y != b.grid.origin.y || a.grid.origin.z != b.grid.origin.z || + a.grid.spacing != b.grid.spacing || a.grid.obstacles != b.grid.obstacles) { + throw std::invalid_argument("culture overlap requires the same device grid"); + } + } + + bool identical_surface(const Impl& b) const { + const auto& a = *this; + bool identical = + a.bodies.size() == b.bodies.size() && a.surface_resolution == b.surface_resolution; + + for (std::size_t i = 0; i < a.bodies.size() && identical; ++i) { + identical = a.bodies[i].position == b.bodies[i].position && + a.bodies[i].orientation == b.bodies[i].orientation && + a.bodies[i].length == b.bodies[i].length && + a.bodies[i].radius == b.bodies[i].radius; + } + + return identical; + } + + Impl(const FluidGridSpec& g, const std::vector& b, const FluidGeometryParameters& p) + : grid(g), bodies(b), surface_resolution(p.surface_resolution) { + grid.validate(); + p.validate(); + const double h = grid.spacing, area_tolerance = 1e-14 * h * h; + // Work in coordinates relative to the grid origin. This avoids subtracting + // large absolute coordinates in clipping and volume integration. + const BodyVector origin{grid.origin.x, grid.origin.y, grid.origin.z}; + const auto local_bodies = local_body_coordinates(origin); + + std::vector surfaces; + std::vector surface_bounds; + std::map tag_owner; + report.estimated_memory_bytes = grid.site_count() * 256; + auto charge = [&](std::uint64_t bytes) { + if (bytes > p.memory_limit_bytes || + report.estimated_memory_bytes > p.memory_limit_bytes - bytes) { + throw std::length_error("fluid geometry exceeds memory_limit_bytes"); + } + + report.estimated_memory_bytes += bytes; + }; + charge(0); + + build_surfaces(local_bodies, p, surfaces, surface_bounds, tag_owner, charge); + + validate_surface_gaps(surfaces); + + const auto& spec = grid; + const FlowGridLayout layout(spec, FlowAxis::x); + + if (layout.total_face_count() >= fluid_boundary) { + throw std::length_error("fluid geometry exceeds uint32 face indexing"); + } + + const auto fluid_sites = build_fragments(layout, surfaces, surface_bounds, charge); + + validate_volume(fluid_sites); + + auto groups = group_piece_faces(area_tolerance); + + std::vector root(fragments.size()); + std::iota(root.begin(), root.end(), 0); + auto find_root = [&](std::uint32_t i) { + while (i != root[i]) { + root[i] = root[root[i]]; + i = root[i]; + } + + return i; + }; + auto append_face = [&](std::uint32_t a, std::uint32_t b, double area, BodyVector center, + BodyVector n, std::uint64_t tag, CellId body = 0) { + FluidFace face{a, b, area, center, n, body}; + + if (tag < body_tag) { + face.grid_face = static_cast(tag); + face.axis = static_cast(layout.face_coordinates(tag).first); + } + + charge(sizeof(FluidFace) * 2); + faces.push_back(face); + + if (b != fluid_boundary) { + root[find_root(a)] = find_root(b); + } + }; + + for (auto& [tag, group] : groups) { + match_internal_faces(tag, group, h, area_tolerance, append_face); + + append_boundary_faces(tag, group, layout, surfaces, tag_owner, h, append_face); + } + + assign_components(origin, find_root); + + merge_fragments(root, find_root); + } +}; + +void FluidGeometryParameters::validate() const { + if (surface_resolution < 8 || surface_resolution % 4 || surface_resolution > 1024 || + !std::isfinite(maximum_surface_error_fraction) || maximum_surface_error_fraction <= 0 || + maximum_surface_error_fraction > 0.25 || memory_limit_bytes == 0) { + throw std::invalid_argument("invalid fluid geometry parameters"); + } +} + +FluidGeometry::FluidGeometry(const FluidGridSpec& g, const std::vector& b, + const FluidGeometryParameters& p) + : impl_(std::make_shared(g, b, p)) {} + +const FluidGridSpec& FluidGeometry::grid() const noexcept { + return impl_->grid; +} + +const std::vector& FluidGeometry::bodies() const noexcept { + return impl_->bodies; +} + +const std::vector& FluidGeometry::fragments() const noexcept { + return impl_->fragments; +} + +const std::vector& FluidGeometry::faces() const noexcept { + return impl_->faces; +} + +const FluidGeometryReport& FluidGeometry::report() const noexcept { + return impl_->report; +} + +std::vector FluidGeometry::overlaps(const FluidGeometry& other) const { + const auto& a = *impl_; + const auto& b = *other.impl_; + + a.validate_overlap_grid(b); + + const bool identical = a.identical_surface(b); + + std::vector result; + + if (identical) { + for (std::uint32_t i = 0; i < a.fragments.size(); ++i) { + result.push_back({i, i, a.fragments[i].volume}); + } + + return result; + } + + std::vector> by_site(a.grid.site_count()); + std::vector b_bounds; + std::vector> b_planes; + + for (std::uint32_t i = 0; i < b.pieces.size(); ++i) { + by_site[b.fragments[b.piece_fragment[i]].site].push_back(i); + b_bounds.push_back(bounds(b.pieces[i])); + std::vector planes; + + for (const auto& face : b.pieces[i].faces) { + const auto n = normal(face); + planes.push_back({n, body_dot(n, face.vertices[0]), face.tag}); + } + + b_planes.push_back(std::move(planes)); + } + + std::map, double> volumes; + + for (std::uint32_t i = 0; i < a.pieces.size(); ++i) { + const auto box = bounds(a.pieces[i]); + + for (auto j : by_site[a.fragments[a.piece_fragment[i]].site]) { + if (!overlap(box, b_bounds[j])) { + continue; + } + + auto intersection = a.pieces[i]; + + for (const auto& plane : b_planes[j]) { + intersection = clip_polyhedron(intersection, plane); + + if (intersection.faces.empty()) { + break; + } + } + + const double v = measure_polyhedron(intersection).volume; + + if (v > 0) { + volumes[{a.piece_fragment[i], b.piece_fragment[j]}] += v; + } + } + } + + for (const auto& [pair, v] : volumes) { + result.push_back({pair.first, pair.second, v}); + } + + return result; +} +} // namespace cm diff --git a/cpp/core/fluid_multigrid.hpp b/cpp/core/fluid_multigrid.hpp new file mode 100644 index 0000000..8cc6b91 --- /dev/null +++ b/cpp/core/fluid_multigrid.hpp @@ -0,0 +1,146 @@ +#pragma once +#include + +#include "numerics_multigrid.hpp" + +namespace cm::detail { +// Galerkin aggregation of the SPD velocity block by MAC component. +// Pressure and port constraints remain in the outer FGMRES operator. +using VelocityCoordinate = std::array; // component, x, y, z + +inline CsrMatrix velocity_coarse_inverse(const CsrMatrix& a, + const std::vector& inverse_diagonal) { + const auto n = a.size(); + // Dense Cholesky at the coarsest level. Only the inverse coefficients are + // prepared on the host; every application is native on the selected device. + std::vector l(std::size_t(n) * n); + + for (std::uint32_t i = 0; i < n; ++i) { + for (auto j = a.offsets[i]; j < a.offsets[i + 1]; ++j) { + l[std::size_t(i) * n + a.columns[j]] = a.values[j]; + } + } + + for (std::uint32_t i = 0; i < n; ++i) { + for (std::uint32_t j = 0; j <= i; ++j) { + auto& x = l[std::size_t(i) * n + j]; + + for (std::uint32_t k = 0; k < j; ++k) { + x -= l[std::size_t(i) * n + k] * l[std::size_t(j) * n + k]; + } + + if (i == j) { + if (!(x > 1e-12 / inverse_diagonal[i])) { + throw std::invalid_argument("fluid velocity has unconstrained rigid motion"); + } + + x = std::sqrt(x); + } else { + x /= l[std::size_t(j) * n + j]; + } + } + } + + CsrMatrix::Rows inverse(n); + + for (std::uint32_t column = 0; column < n; ++column) { + std::vector x(n); + + for (std::uint32_t i = 0; i < n; ++i) { + double v = i == column ? 1 : 0; + + for (std::uint32_t j = 0; j < i; ++j) { + v -= l[std::size_t(i) * n + j] * x[j]; + } + + x[i] = v / l[std::size_t(i) * n + i]; + } + + for (std::uint32_t i = n; i-- > 0;) { + for (std::uint32_t j = i + 1; j < n; ++j) { + x[i] -= l[std::size_t(j) * n + i] * x[j]; + } + + x[i] /= l[std::size_t(i) * n + i]; + inverse[i][column] = x[i]; + } + } + + return CsrMatrix(inverse); +} + +inline std::vector build_velocity_hierarchy( + CsrMatrix a, std::vector coordinates) { + std::vector levels; + + if (!a.size()) { + return levels; + } + + while (true) { + MultigridLevel level; + const auto n = a.size(); + level.inverse_diagonal.resize(n); + + for (std::uint32_t i = 0; i < n; ++i) { + for (auto j = a.offsets[i]; j < a.offsets[i + 1]; ++j) { + if (a.columns[j] == i) { + level.inverse_diagonal[i] = 1 / a.values[j]; + } + } + } + + if (n <= 96) { + level.inverse = velocity_coarse_inverse(a, level.inverse_diagonal); + level.a = std::move(a); + levels.push_back(std::move(level)); + + return levels; + } + + std::map aggregates; + std::vector coarse_coordinates; + std::vector parent(n); + + for (std::uint32_t i = 0; i < n; ++i) { + auto c = coordinates[i]; + + for (std::size_t k = 1; k < 4; ++k) { + c[k] /= 2; + } + + auto [it, inserted] = aggregates.emplace(c, static_cast(aggregates.size())); + parent[i] = it->second; + + if (inserted) { + coarse_coordinates.push_back(c); + } + } + + const auto nc = aggregates.size(); + + if (nc >= n) { + throw std::runtime_error("fluid multigrid failed to coarsen"); + } + + CsrMatrix::Rows coarse(nc), restriction(nc), prolongation(n); + + for (std::uint32_t i = 0; i < n; ++i) { + restriction[parent[i]][i] = 1; + prolongation[i][parent[i]] = 1; + + for (auto k = a.offsets[i]; k < a.offsets[i + 1]; ++k) { + coarse[parent[i]][parent[a.columns[k]]] += a.values[k]; + } + } + + level.a = std::move(a); + level.restriction = CsrMatrix(restriction); + level.prolongation = CsrMatrix(prolongation); + levels.push_back(std::move(level)); + a = CsrMatrix(coarse); + coordinates = std::move(coarse_coordinates); + } +} + +} // namespace cm::detail diff --git a/cpp/core/growth.cpp b/cpp/core/growth.cpp new file mode 100644 index 0000000..2d2c50c --- /dev/null +++ b/cpp/core/growth.cpp @@ -0,0 +1,193 @@ +#include "cm/growth.hpp" + +#include +#include +#include +#include + +#include "numerics_device.hpp" + +namespace cm { +namespace { +void validate_growth_requirements(const CellGrowthModel& model, std::size_t count) { + auto positive = [](double x) { + return std::isfinite(x) && x > 0; + }; + std::set seen; + + for (const auto& r : model.requirements) { + if (r.solute >= count || !seen.insert(r.solute).second || !positive(r.half_saturation) || + !positive(r.biomass_yield)) { + throw std::invalid_argument("invalid growth requirement"); + } + } +} + +void evaluate_growth_device(detail::NumericsDevice& d, const std::vector& offsets, + const std::vector& cells, const std::vector& requirements, + const std::vector& environment, const std::vector& uptake, + std::vector& result) { + // Fail explicitly if the backend working precision cannot represent the model. + for (float x : cells) { + if (!std::isfinite(x)) { + throw std::invalid_argument("growth value not representable"); + } + } + + for (float x : requirements) { + if (!(x > 0) || !std::isfinite(x)) { + throw std::invalid_argument("growth parameter not representable"); + } + } + + auto b0 = d.upload(offsets), b1 = d.upload(cells), b2 = d.upload(requirements), + b3 = d.upload(environment), b4 = d.upload(uptake); + auto b5 = d.allocate(environment.size() * sizeof(float)), + b6 = d.allocate(result.size() * 5 * sizeof(float)); + d.dispatch(detail::NumericsKernel::growth, {static_cast(result.size()), 0}, + {b0, b1, b2, b3, b4, b5, b6}); + auto alpha = d.download(b5, environment.size()), + values = d.download(b6, result.size() * 5); + + for (std::size_t i = 0; i < result.size(); ++i) { + auto& out = result[i]; + out.uptake_velocities.assign(alpha.begin() + offsets[i], alpha.begin() + offsets[i + 1]); + out.biomass_gain = values[5 * i]; + out.biochemical_volume_gain = values[5 * i + 1]; + out.geometric_volume_gain = values[5 * i + 2]; + out.specific_rate = values[5 * i + 3]; + out.stoichiometric_residual = values[5 * i + 4]; + } +} + +GrowthEvaluation evaluate_growth_cpu(const CellGrowthModel& m, const GrowthInput& in, double dt, + double limitation, double extent, double largest) { + GrowthEvaluation out; + const double mass = in.biochemical_volume * m.biomass_density; + + for (std::size_t j = 0; j < m.requirements.size(); ++j) { + const auto& r = m.requirements[j]; + double c = in.concentrations[j]; + out.uptake_velocities.push_back( + m.requirements.size() == 1 + ? m.mu_max * mass / (r.biomass_yield * in.surface_area * (r.half_saturation + c)) + : (c > 0 ? m.mu_max * mass * limitation / (r.biomass_yield * in.surface_area * c) : 0)); + } + + out.biomass_gain = extent; + out.biochemical_volume_gain = extent / m.biomass_density; + out.geometric_volume_gain = out.biochemical_volume_gain / m.volume_ratio; + out.specific_rate = extent / (mass * dt); + out.stoichiometric_residual = largest > 0 ? (largest - extent) / largest : 0; + + return out; +} + +void validate_growth_results(const std::vector& result) { + for (const auto& out : result) { + for (double x : out.uptake_velocities) { + if (!std::isfinite(x) || x < 0) { + throw std::runtime_error("invalid growth uptake coefficient"); + } + } + + if (!std::isfinite(out.biochemical_volume_gain) || out.biochemical_volume_gain < 0 || + !std::isfinite(out.specific_rate)) { + throw std::runtime_error("invalid realized growth"); + } + } +} +} // namespace + +void CellGrowthModel::validate(std::size_t count) const { + auto positive = [](double x) { + return std::isfinite(x) && x > 0; + }; + + if (!cell_id || !std::isfinite(mu_max) || mu_max < 0 || !positive(biomass_density) || + !positive(volume_ratio) || requirements.empty() || + (kind != GrowthKind::monod && kind != GrowthKind::essential) || + (kind == GrowthKind::monod && requirements.size() != 1)) { + throw std::invalid_argument("invalid cell growth model"); + } + + validate_growth_requirements(*this, count); +} + +struct GrowthExecutor::Impl { + std::unique_ptr device; + + Impl(BackendKind kind, std::uint32_t index) : device(detail::make_numerics_device(kind, index)) {} +}; + +GrowthExecutor::GrowthExecutor(BackendKind k, std::uint32_t i) + : impl_(std::make_unique(k, i)) {} + +GrowthExecutor::~GrowthExecutor() = default; + +namespace { +void validate_growth_input(const CellGrowthModel& m, const GrowthInput& in) { + if (in.concentrations.size() != m.requirements.size() || + in.uptake.size() != m.requirements.size() || !(in.biochemical_volume > 0) || + !(in.surface_area > 0)) { + throw std::invalid_argument("invalid growth input"); + } +} +} // namespace + +std::vector GrowthExecutor::evaluate(const std::vector& models, + const std::vector& input, + double dt) { + if (models.size() != input.size() || !std::isfinite(dt) || dt <= 0) { + throw std::invalid_argument("invalid growth evaluation dimensions or interval"); + } + + std::vector result(models.size()); + std::vector offsets{0}; + std::vector cells, requirements, environment, uptake; + + for (std::size_t i = 0; i < models.size(); ++i) { + const auto& m = models[i]; + const auto& in = input[i]; + m.validate(std::numeric_limits::max()); + + validate_growth_input(m, in); + + cells.insert(cells.end(), {float(in.biochemical_volume * m.biomass_density), float(m.mu_max), + float(in.surface_area), float(m.biomass_density), + float(m.volume_ratio), float(dt)}); + double limitation = 1, extent = INFINITY, largest = 0; + + for (std::size_t j = 0; j < m.requirements.size(); ++j) { + const auto& r = m.requirements[j]; + double c = in.concentrations[j], u = in.uptake[j]; + + if (!std::isfinite(c) || c < 0 || !std::isfinite(u) || u < 0) { + throw std::invalid_argument("invalid growth concentration or uptake"); + } + + requirements.insert(requirements.end(), {float(r.half_saturation), float(r.biomass_yield)}); + environment.push_back(float(c)); + uptake.push_back(float(u)); + limitation = std::min(limitation, c / (r.half_saturation + c)); + extent = std::min(extent, u * r.biomass_yield); + largest = std::max(largest, u * r.biomass_yield); + } + + offsets.push_back(static_cast(environment.size())); + + if (!impl_->device) { + result[i] = evaluate_growth_cpu(m, in, dt, limitation, extent, largest); + } + } + + if (impl_->device && !models.empty()) { + evaluate_growth_device(*impl_->device, offsets, cells, requirements, environment, uptake, + result); + } + + validate_growth_results(result); + + return result; +} +} // namespace cm diff --git a/cpp/core/mechanics_integration.cpp b/cpp/core/mechanics_integration.cpp index 3f9b7ce..431d798 100644 --- a/cpp/core/mechanics_integration.cpp +++ b/cpp/core/mechanics_integration.cpp @@ -24,13 +24,16 @@ bool finite(const Vec3& value) { Vec3 rotate_axis_angle(Vec3 direction, Vec3 rotation, float max_rotation) { const auto magnitude = norm(rotation); + if (magnitude <= 1.0e-12F || max_rotation == 0.0F) { return direction; } + const auto angle = std::min(magnitude, max_rotation); const auto axis = rotation * (1.0F / magnitude); const auto cosine = std::cos(angle); const auto sine = std::sin(angle); + return normalized(direction * cosine + cross(axis, direction) * sine + axis * (dot(axis, direction) * (1.0F - cosine))); } @@ -41,17 +44,53 @@ void validate_mechanics_integration_parameters(const MechanicsIntegrationParamet } } +namespace { +GeometryUpdate propose_geometry_update(const CellSnapshot& cell, const CellCorrection& correction, + const MechanicsIntegrationParameters& parameters, + bool has_desired_increment, float desired_increment_value) { + if (!finite(correction.translation) || !finite(correction.rotation) || + !std::isfinite(correction.length)) { + throw std::invalid_argument("mechanics correction must be finite"); + } + + const auto desired_increment = !has_desired_increment ? 0.0F : desired_increment_value; + + if (!std::isfinite(desired_increment) || desired_increment < 0.0F) { + throw std::invalid_argument("desired length increments must be finite and non-negative"); + } + + const auto applied_length_increment = + !has_desired_increment ? 0.0F + : (cell.fixed ? desired_increment + : std::max(0.0F, desired_increment + correction.length)); + const auto new_position = cell.fixed ? cell.position : cell.position + correction.translation; + const auto new_direction = cell.fixed ? cell.direction + : rotate_axis_angle(cell.direction, correction.rotation, + parameters.max_rotation_radians); + const auto new_length = cell.length + applied_length_increment; + + if (!finite(new_position) || !finite(new_direction) || !std::isfinite(new_length)) { + throw std::overflow_error("mechanics integration produced non-finite geometry"); + } + + return {cell.slot, new_position, new_direction, new_length}; +} +} // namespace + void integrate_mechanics_result(WorldState& state, const MechanicsSolveResult& result, const MechanicsIntegrationParameters& parameters, std::span desired_length_increments) { validate_mechanics_integration_parameters(parameters); state.validate(); + if (result.corrections.size() != state.size()) { throw std::invalid_argument("mechanics correction count does not match the world state"); } + if (!desired_length_increments.empty() && desired_length_increments.size() != state.size()) { throw std::invalid_argument("desired length increment count does not match the world state"); } + if (parameters.require_convergence && result.report.status != SolverStatus::converged) { throw std::runtime_error("mechanics corrections require a converged solver result"); } @@ -59,32 +98,14 @@ void integrate_mechanics_result(WorldState& state, const MechanicsSolveResult& r const auto cells = state.cells(); std::vector updates; updates.reserve(cells.size()); + for (std::size_t index = 0; index < cells.size(); ++index) { const auto& cell = cells[index]; const auto& correction = result.corrections[index]; - if (!finite(correction.translation) || !finite(correction.rotation) || - !std::isfinite(correction.length)) { - throw std::invalid_argument("mechanics correction must be finite"); - } - const auto desired_increment = - desired_length_increments.empty() ? 0.0F : desired_length_increments[index]; - if (!std::isfinite(desired_increment) || desired_increment < 0.0F) { - throw std::invalid_argument("desired length increments must be finite and non-negative"); - } - const auto applied_length_increment = - desired_length_increments.empty() - ? 0.0F - : (cell.fixed ? desired_increment - : std::max(0.0F, desired_increment + correction.length)); - const auto new_position = cell.fixed ? cell.position : cell.position + correction.translation; - const auto new_direction = cell.fixed ? cell.direction - : rotate_axis_angle(cell.direction, correction.rotation, - parameters.max_rotation_radians); - const auto new_length = cell.length + applied_length_increment; - if (!finite(new_position) || !finite(new_direction) || !std::isfinite(new_length)) { - throw std::overflow_error("mechanics integration produced non-finite geometry"); - } - updates.push_back({cell.slot, new_position, new_direction, new_length}); + + updates.push_back(propose_geometry_update( + cell, correction, parameters, !desired_length_increments.empty(), + desired_length_increments.empty() ? 0.0F : desired_length_increments[index])); } for (const auto& update : updates) { diff --git a/cpp/core/numerics.cpp b/cpp/core/numerics.cpp new file mode 100644 index 0000000..0d0101f --- /dev/null +++ b/cpp/core/numerics.cpp @@ -0,0 +1,266 @@ +#include + +#include "numerics_ilu.hpp" +#include "numerics_linear.hpp" + +namespace cm { +namespace { +void require(bool condition, const char* message) { + if (!condition) { + throw std::invalid_argument(message); + } +} + +bool positive(double x) { + return std::isfinite(x) && x > 0; +} +} // namespace + +namespace detail { +double norm2(const std::vector& x) { + return std::sqrt(std::inner_product(x.begin(), x.end(), x.begin(), 0.0)); +} + +detail::FlexibleKrylovResult> solve_cpu( + const SparseMatrix& matrix, const std::vector& rhs, + const LinearSolveParameters& parameters, const std::vector& hierarchy, + bool constrained) { + using Vector = std::vector; + detail::FlexibleKrylovOperations op; + op.make_zero = [&] { + return Vector(rhs.size()); + }; + op.copy = [](const Vector& x, Vector& y) { + y = x; + }; + op.axpy = [](Vector& y, double a, const Vector& x) { + for (std::size_t i = 0; i < x.size(); ++i) { + y[i] += a * x[i]; + } + }; + op.dot = [](const Vector& x, const Vector& y) { + return std::inner_product(x.begin(), x.end(), y.begin(), 0.0); + }; + op.apply = [&](const Vector& x, Vector& y) { + matrix.apply(x, y); + }; + detail::SparseMultigrid multigrid(detail::CpuSparseAlgebra{}, hierarchy); + std::optional ilu; + + if (constrained) { + ilu.emplace(matrix.offsets, matrix.columns, matrix.values); + } + + op.precondition = [&](const Vector& x, Vector& y) { + if (ilu) { + ilu->apply(x, y); + } else { + y = x; + multigrid.apply(x, y); + } + }; + const double n = norm2(rhs); + const double tolerance = + n == 0 ? parameters.relative_tolerance + : std::max(parameters.relative_tolerance, parameters.absolute_tolerance / n); + + return detail::flexible_gmres(op, rhs, tolerance, parameters.max_iterations, 120); +} + +detail::FlexibleKrylovResult> solve_gpu( + detail::NumericsDevice& device, const SparseMatrix& matrix, const std::vector& rhs, + const LinearSolveParameters& parameters, const std::vector& hierarchy, + bool constrained) { + using namespace detail; + using Vector = NumericsBufferPtr; + const auto count = checked_index(rhs.size()); + const auto offsets = device.upload(matrix.offsets), columns = device.upload(matrix.columns); + auto floats = [](const std::vector& data) { + std::vector result(data.begin(), data.end()); + + for (float x : result) { + if (!std::isfinite(x)) { + throw std::overflow_error("linear solver float32 representation overflow"); + } + } + + return result; + }; + const auto values = device.upload(floats(matrix.values)), input = device.upload(floats(rhs)); + constexpr std::uint32_t group_size = 128; + const auto groups = (count + group_size - 1) / group_size; + const auto reduction = device.allocate(std::size_t(groups) * sizeof(float)); + const auto row_sums = device.upload(floats(matrix.row_sums)); + FlexibleKrylovOperations op; + op.make_zero = [&] { + auto v = device.allocate(std::size_t(count) * sizeof(float)); + device.dispatch(NumericsKernel::fill, {.count = count}, {v}); + + return v; + }; + op.copy = [&](const Vector& x, Vector& y) { + device.dispatch(NumericsKernel::copy, {.count = count}, {x, y}); + }; + op.axpy = [&](Vector& y, double a, const Vector& x) { + device.dispatch(NumericsKernel::axpy, {.count = count, .scalar = float(a)}, {x, y}); + }; + op.dot = [&](const Vector& x, const Vector& y) { + device.dispatch(NumericsKernel::dot, {.count = count}, {x, y, reduction}); + auto partial = device.download(reduction, groups); + + return std::accumulate(partial.begin(), partial.end(), 0.0); + }; + op.apply = [&](const Vector& x, Vector& y) { + if (matrix.symmetric) { + device.dispatch(NumericsKernel::apply, {.count = count}, {offsets, columns, values, x, y}); + } else { + device.dispatch(NumericsKernel::conservative_apply, {.count = count}, + {offsets, columns, values, x, y, row_sums}); + } + }; + SparseMultigrid multigrid(GpuSparseAlgebra{device}, hierarchy); + GpuSparseAlgebra::Matrix factors{}; + NumericsBufferPtr diagonals; + + if (constrained) { + const IncompleteLu ilu(matrix.offsets, matrix.columns, matrix.values); + factors = GpuSparseAlgebra{device}.upload(ilu.factors); + diagonals = device.upload(ilu.diagonals); + } + + op.precondition = [&](const Vector& x, Vector& y) { + if (constrained) { + device.dispatch(NumericsKernel::ilu, {count, 0}, + {factors.offsets, factors.columns, factors.values, diagonals, x, y}); + } else { + op.copy(x, y); + multigrid.apply(x, y); + } + }; + const double n = norm2(rhs); + const double tolerance = + n == 0 ? parameters.relative_tolerance + : std::max(parameters.relative_tolerance, parameters.absolute_tolerance / n); + auto result = flexible_gmres(op, input, tolerance, parameters.max_iterations, 120); + const auto output = device.download(result.solution, count); + + return {std::vector(output.begin(), output.end()), result.iterations, + result.relative_residual}; +} +} // namespace detail + +void LinearSolveParameters::validate() const { + require(positive(relative_tolerance) && relative_tolerance < 1 && + std::isfinite(absolute_tolerance) && absolute_tolerance >= 0, + "invalid linear solver tolerances"); + require(max_iterations > 0 && memory_limit_bytes > 0, "invalid linear solver resource limits"); +} + +std::unique_ptr detail::make_numerics_device(BackendKind kind, + std::uint32_t index) { + switch (kind) { + case BackendKind::cpu: + require(index == 0, "CPU numerics exposes only device zero"); + return nullptr; + case BackendKind::metal: +#ifdef CM_HAS_METAL + return detail::make_metal_numerics_device(index); +#else + throw std::runtime_error("Metal numerics was not built"); +#endif + case BackendKind::cuda: +#ifdef CM_HAS_CUDA + return detail::make_cuda_numerics_device(index); +#else + throw std::runtime_error("CUDA numerics was not built"); +#endif + } + + throw std::invalid_argument("unknown numerics backend"); +} + +namespace { +void validate_linear_solution(const std::vector& solution) { + for (std::size_t i = 0; i < solution.size(); ++i) { + if (!std::isfinite(solution[i])) { + throw std::runtime_error("nonfinite sparse linear solution"); + } + } +} +} // namespace + +detail::NumericsLinearResult detail::solve_numerics_linear( + NumericsDevice* device, NumericsLinearRows rows, std::vector rhs, + const LinearSolveParameters& parameters) { + parameters.validate(); + require(rows.size() == rhs.size(), "sparse linear system dimensions mismatch"); + + if (rhs.empty()) { + return {}; + } + + require(rhs.size() * std::uint64_t{12288} <= parameters.memory_limit_bytes, + "sparse linear system exceeds memory_limit_bytes"); + SparseMatrix matrix(rows, false); + + for (std::size_t i = 0; i < rhs.size(); ++i) { + rhs[i] *= matrix.scale[i]; + } + + // GPU Krylov vectors use float32. Defect correction retains a double + // authoritative solution and checks the original operator, while every + // correction solve still executes on the selected native device. + auto working = parameters; + + if (device) { + working.relative_tolerance = std::max(working.relative_tolerance, 1e-4); + working.absolute_tolerance = 0; + } + + auto solved = device ? solve_gpu(*device, matrix, rhs, working, {}, true) + : solve_cpu(matrix, rhs, parameters, {}, true); + const double norm = norm2(rhs); + const double target = + std::max(parameters.absolute_tolerance, parameters.relative_tolerance * norm); + double absolute = INFINITY; + + for (unsigned refinement = 0; refinement < 8; ++refinement) { + std::vector residual(rhs.size()); + matrix.apply(solved.solution, residual); + double scale = 0; + + for (std::size_t i = 0; i < rhs.size(); ++i) { + residual[i] = rhs[i] - residual[i]; + scale = std::max(scale, std::abs(residual[i])); + } + + absolute = norm2(residual); + + if (std::isfinite(absolute) && absolute <= 1.05 * target) { + break; + } + + if (!device || !positive(scale) || refinement == 7) { + throw std::runtime_error("scalar linear true residual failed; candidate rejected"); + } + + for (double& r : residual) { + r /= scale; + } + + auto correction = solve_gpu(*device, matrix, residual, working, {}, true); + solved.iterations += correction.iterations; + + for (std::size_t i = 0; i < rhs.size(); ++i) { + solved.solution[i] += scale * correction.solution[i]; + } + } + + NumericsLinearResult result{solved.solution, solved.iterations, norm == 0 ? 0 : absolute / norm, + absolute}; + + validate_linear_solution(result.solution); + + return result; +} +} // namespace cm diff --git a/cpp/core/numerics_device.hpp b/cpp/core/numerics_device.hpp new file mode 100644 index 0000000..ebe6322 --- /dev/null +++ b/cpp/core/numerics_device.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include +#include + +#include "cm/numerics.hpp" +#include "cm/types.hpp" + +namespace cm::detail { + +// Native vector operations shared by fluid, transport, and growth. Reduction partials +// cross the device boundary during a solve; all Krylov vectors stay resident. +enum class NumericsKernel { fill, copy, axpy, dot, apply, jacobi, ilu, growth, conservative_apply }; +inline constexpr const char* numerics_kernel_names[] = { + "numerics_fill", "numerics_copy", "numerics_axpy", "numerics_dot", "numerics_apply", + "numerics_jacobi", "numerics_ilu", "nutrient_growth", "conservative_apply"}; + +struct NumericsParameters { + std::uint32_t count{0}; + float scalar{0}; +}; + +struct NumericsBuffer { + virtual ~NumericsBuffer() = default; +}; + +using NumericsBufferPtr = std::shared_ptr; + +class NumericsDevice { + public: + virtual ~NumericsDevice() = default; + virtual NumericsBufferPtr allocate(std::size_t bytes, const void* data = nullptr) = 0; + virtual void read(const NumericsBufferPtr&, void*, std::size_t bytes) = 0; + virtual void dispatch(NumericsKernel, const NumericsParameters&, + std::initializer_list) = 0; + + template + NumericsBufferPtr upload(const std::vector& values) { + return allocate(values.size() * sizeof(T), values.empty() ? nullptr : values.data()); + } + + template + std::vector download(const NumericsBufferPtr& buffer, std::size_t count) { + std::vector result(count); + + if (count) { + read(buffer, result.data(), count * sizeof(T)); + } + + return result; + } +}; + +std::unique_ptr make_metal_numerics_device(std::uint32_t); +std::unique_ptr make_cuda_numerics_device(std::uint32_t); +std::unique_ptr make_numerics_device(BackendKind, std::uint32_t); +using NumericsLinearRows = std::vector>>; + +struct NumericsLinearResult { + std::vector solution; + std::uint32_t iterations{0}; + double relative_residual{0}, absolute_residual{0}; +}; + +NumericsLinearResult solve_numerics_linear(NumericsDevice*, NumericsLinearRows rows, + std::vector rhs, const LinearSolveParameters&); + +} // namespace cm::detail diff --git a/cpp/core/numerics_ilu.hpp b/cpp/core/numerics_ilu.hpp new file mode 100644 index 0000000..299e467 --- /dev/null +++ b/cpp/core/numerics_ilu.hpp @@ -0,0 +1,157 @@ +#pragma once + +#include +#include + +#include "numerics_sparse.hpp" + +namespace cm::detail { +// Threshold incomplete factorization of the entire coupled operator. Dropping +// and pivot safeguards affect only preconditioning, never the physical matrix. +struct IncompleteLu { + using Row = std::vector>; + CsrMatrix factors; + std::vector diagonals; + + IncompleteLu(const std::vector& offsets, const std::vector& columns, + const std::vector& values) { + const auto n = offsets.size() - 1; + std::vector upper(n); + std::vector diagonal(n); + std::vector row(n); + std::vector stamp(n, std::numeric_limits::max()); + std::vector touched; + diagonals.resize(n); + constexpr std::size_t fill = 80; + constexpr double drop = 1e-4; + + for (std::uint32_t i = 0; i < n; ++i) { + touched.clear(); + std::priority_queue, std::greater<>> pending; + auto add = [&](std::uint32_t j, double value) { + if (stamp[j] != i) { + stamp[j] = i; + row[j] = value; + touched.push_back(j); + + if (j < i) { + pending.push(j); + } + } else { + row[j] += value; + } + }; + double scale = 0; + + for (auto j = offsets[i]; j < offsets[i + 1]; ++j) { + add(columns[j], values[j]); + scale = std::max(scale, std::abs(values[j])); + } + + while (!pending.empty()) { + const auto j = pending.top(); + pending.pop(); + + if (std::abs(row[j]) < drop * scale) { + row[j] = 0; + continue; + } + + const double multiplier = row[j] / diagonal[j]; + row[j] = multiplier; + + for (auto [k, value] : upper[j]) { + add(k, -multiplier * value); + } + } + + double pivot = stamp[i] == i ? row[i] : 0; + + if (std::abs(pivot) < 1e-6 * scale) { + pivot = std::copysign(1e-6 * scale, pivot == 0 ? -1 : pivot); + } + + diagonal[i] = pivot; + + for (bool lower : {true, false}) { + auto candidates = select_fill(row, touched, i, lower, drop * scale, fill); + + for (auto [j, value] : candidates) { + factors.columns.push_back(j); + factors.values.push_back(value); + } + + if (lower) { + diagonals[i] = static_cast(factors.values.size()); + factors.columns.push_back(i); + factors.values.push_back(pivot); + } else { + upper[i] = std::move(candidates); + } + } + + factors.offsets.push_back(static_cast(factors.values.size())); + } + } + + static Row select_fill(const std::vector& row, const std::vector& touched, + std::uint32_t i, bool lower, double threshold, std::size_t fill) { + Row candidates; + + for (auto j : touched) { + if (lower ? j >= i : j <= i) { + continue; + } + + if (!std::isfinite(row[j])) { + throw std::runtime_error("nonfinite sparse preconditioner"); + } + + if (std::abs(row[j]) >= threshold) { + candidates.emplace_back(j, row[j]); + } + } + + auto magnitude = [](auto a, auto b) { + if (std::abs(a.second) != std::abs(b.second)) { + return std::abs(a.second) > std::abs(b.second); + } + + return a.first < b.first; + }; + + if (candidates.size() > fill) { + std::nth_element(candidates.begin(), candidates.begin() + fill, candidates.end(), magnitude); + candidates.resize(fill); + } + + std::sort(candidates.begin(), candidates.end()); + + return candidates; + } + + void apply(const std::vector& rhs, std::vector& x) const { + const auto n = factors.size(); + + for (std::uint32_t i = 0; i < n; ++i) { + double value = rhs[i]; + + for (auto j = factors.offsets[i]; j < diagonals[i]; ++j) { + value -= factors.values[j] * x[factors.columns[j]]; + } + + x[i] = value; + } + + for (std::uint32_t i = n; i-- > 0;) { + double value = x[i]; + + for (auto j = diagonals[i] + 1; j < factors.offsets[i + 1]; ++j) { + value -= factors.values[j] * x[factors.columns[j]]; + } + + x[i] = value / factors.values[diagonals[i]]; + } + } +}; +} // namespace cm::detail diff --git a/cpp/core/numerics_linear.hpp b/cpp/core/numerics_linear.hpp new file mode 100644 index 0000000..9be6c45 --- /dev/null +++ b/cpp/core/numerics_linear.hpp @@ -0,0 +1,123 @@ +#pragma once +#include + +#include "flexible_gmres.hpp" +#include "numerics_multigrid.hpp" + +namespace cm::detail { +inline std::uint32_t checked_index(std::size_t n) { + if (n >= std::numeric_limits::max()) { + throw std::length_error("sparse matrix exceeds uint32 indexing"); + } + + return static_cast(n); +} + +using SparseRow = std::vector>; + +struct SparseMatrix { + std::vector offsets{0}, columns; + std::vector values, scale, row_sums; + bool symmetric; + + explicit SparseMatrix(std::vector& rows, bool symmetric_scaling = true) + : symmetric(symmetric_scaling) { + compute_scaling(rows); + + for (std::size_t i = 0; i < rows.size(); ++i) { + auto& row = rows[i]; + std::sort(row.begin(), row.end()); + + for (std::size_t k = 0; k < row.size();) { + const auto j = row[k].first; + double a = 0; + + do { + a += row[k++].second; + } while (k < row.size() && row[k].first == j); + + if (a != 0) { + columns.push_back(j); + values.push_back(a * scale[i] * (symmetric ? scale[j] : 1)); + } + } + + double row_sum = 0; + + for (auto k = offsets.back(); k < columns.size(); ++k) { + row_sum += values[k]; + } + + row_sums.push_back(row_sum); + offsets.push_back(checked_index(columns.size())); + } + } + + void compute_scaling(const std::vector& rows) { + scale.resize(rows.size(), 1); + std::vector diagonal(rows.size()); + + for (std::size_t i = 0; i < rows.size(); ++i) { + for (auto [j, a] : rows[i]) { + if (i == j) { + diagonal[i] += a; + } + } + } + + // Symmetric equilibration; pressure and rate constraints use the diagonal + // approximation to their velocity Schur complement. The operator itself + // remains unchanged, including all inter-component couplings. + for (auto& d : diagonal) { + d = std::abs(d); + } + + // The second Schur level covers rigid-body velocities coupled through + // marker forces. Use simultaneous updates so ordering cannot change scaling. + for (unsigned pass = 0; pass < 3; ++pass) { + auto next = diagonal; + + for (std::size_t i = 0; i < rows.size(); ++i) { + if (diagonal[i] == 0) { + for (auto [j, a] : rows[i]) { + if (diagonal[j] > 0) { + next[i] += a * a / diagonal[j]; + } + } + } + } + + diagonal = std::move(next); + } + + for (std::size_t i = 0; i < rows.size(); ++i) { + if (!(std::isfinite(diagonal[i]) && diagonal[i] > 0)) { + throw std::invalid_argument("unconstrained linear degree of freedom"); + } + + scale[i] = symmetric ? 1 / std::sqrt(diagonal[i]) : 1 / diagonal[i]; + } + } + + void apply(const std::vector& x, std::vector& y) const { + for (std::size_t i = 0; i < y.size(); ++i) { + double sum = symmetric ? 0 : row_sums[i] * x[i]; + + for (auto k = offsets[i]; k < offsets[i + 1]; ++k) { + sum += values[k] * (symmetric ? x[columns[k]] : x[columns[k]] - x[i]); + } + + y[i] = sum; + } + } +}; + +double norm2(const std::vector&); +FlexibleKrylovResult> solve_cpu(const SparseMatrix&, const std::vector&, + const LinearSolveParameters&, + const std::vector&, bool); +FlexibleKrylovResult> solve_gpu(NumericsDevice&, const SparseMatrix&, + const std::vector&, + const LinearSolveParameters&, + const std::vector&, bool); +} // namespace cm::detail diff --git a/cpp/core/numerics_multigrid.hpp b/cpp/core/numerics_multigrid.hpp new file mode 100644 index 0000000..6106afb --- /dev/null +++ b/cpp/core/numerics_multigrid.hpp @@ -0,0 +1,74 @@ +#pragma once +#include "numerics_sparse.hpp" + +namespace cm::detail { +struct MultigridLevel { + CsrMatrix a, restriction, prolongation, inverse; + std::vector inverse_diagonal; +}; + +template +class SparseMultigrid { + using Vector = typename Algebra::Vector; + using Matrix = typename Algebra::Matrix; + + struct Level { + std::uint32_t n; + Matrix a, restriction, prolongation, inverse; + Vector diagonal, rhs, x, work, residual; + }; + + Algebra algebra_; + std::vector levels_; + + void cycle(std::size_t index) { + auto& l = levels_[index]; + algebra_.fill(l.x, l.n); + + if (index + 1 == levels_.size()) { + algebra_.apply(l.inverse, l.rhs, l.x); + + return; + } + + auto smooth = [&] { + for (unsigned sweep = 0; sweep < 4; ++sweep) { + algebra_.apply(l.a, l.x, l.work); + algebra_.jacobi(l.rhs, l.work, l.diagonal, l.x, l.n); + } + }; + smooth(); + algebra_.apply(l.a, l.x, l.work); + algebra_.copy(l.rhs, l.residual, l.n); + algebra_.axpy(l.work, l.residual, -1, l.n); + algebra_.apply(l.restriction, l.residual, levels_[index + 1].rhs); + cycle(index + 1); + algebra_.apply(l.prolongation, levels_[index + 1].x, l.work); + algebra_.axpy(l.work, l.x, 1, l.n); + smooth(); + } + + public: + SparseMultigrid(Algebra algebra, const std::vector& data) : algebra_(algebra) { + for (const auto& l : data) { + const auto n = l.a.size(); + levels_.push_back({n, algebra_.upload(l.a), algebra_.upload(l.restriction), + algebra_.upload(l.prolongation), algebra_.upload(l.inverse), + algebra_.upload(l.inverse_diagonal), algebra_.make(n), algebra_.make(n), + algebra_.make(n), algebra_.make(n)}); + } + } + + // Apply to the leading block; the caller preserves any remaining components. + void apply(const Vector& rhs, Vector& result) { + if (levels_.empty()) { + return; + } + + auto& l = levels_[0]; + algebra_.copy(rhs, l.rhs, l.n); + cycle(0); + algebra_.copy(l.x, result, l.n); + } +}; +} // namespace cm::detail diff --git a/cpp/core/numerics_sparse.hpp b/cpp/core/numerics_sparse.hpp new file mode 100644 index 0000000..7f3f938 --- /dev/null +++ b/cpp/core/numerics_sparse.hpp @@ -0,0 +1,137 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +#include "numerics_device.hpp" + +namespace cm::detail { +struct CsrMatrix { + std::vector offsets{0}, columns; + std::vector values; + using Rows = std::vector>; + + explicit CsrMatrix(const Rows& rows = {}) { + for (const auto& row : rows) { + for (auto [j, a] : row) { + if (a != 0) { + columns.push_back(j); + values.push_back(a); + } + } + + offsets.push_back(static_cast(values.size())); + } + } + + std::uint32_t size() const { + return static_cast(offsets.size() - 1); + } +}; + +struct CpuSparseAlgebra { + using Vector = std::vector; + using Matrix = CsrMatrix; + + Vector make(std::uint32_t n) const { + return Vector(n); + } + + Vector upload(const std::vector& x) const { + return x; + } + + Matrix upload(const CsrMatrix& a) const { + return a; + } + + void fill(Vector& x, std::uint32_t n) const { + std::fill_n(x.begin(), n, 0); + } + + void copy(const Vector& x, Vector& y, std::uint32_t n) const { + std::copy_n(x.begin(), n, y.begin()); + } + + void axpy(const Vector& x, Vector& y, double v, std::uint32_t n) const { + for (std::uint32_t i = 0; i < n; ++i) { + y[i] += v * x[i]; + } + } + + void apply(const Matrix& a, const Vector& x, Vector& y) const { + for (std::uint32_t i = 0; i < a.size(); ++i) { + double v = 0; + + for (auto j = a.offsets[i]; j < a.offsets[i + 1]; ++j) { + v += a.values[j] * x[a.columns[j]]; + } + + y[i] = v; + } + } + + void jacobi(const Vector& rhs, const Vector& ax, const Vector& d, Vector& x, + std::uint32_t n) const { + for (std::uint32_t i = 0; i < n; ++i) { + x[i] += 0.5 * d[i] * (rhs[i] - ax[i]); + } + } +}; + +struct GpuSparseAlgebra { + using Vector = NumericsBufferPtr; + + struct Matrix { + Vector offsets, columns, values; + std::uint32_t n; + }; + + NumericsDevice& device; + + Vector make(std::uint32_t n) const { + return device.allocate(std::size_t(n) * sizeof(float)); + } + + Vector upload(const std::vector& x) const { + std::vector f(x.begin(), x.end()); + + for (float v : f) { + if (!std::isfinite(v)) { + throw std::overflow_error("sparse preconditioner overflow"); + } + } + + return device.upload(f); + } + + Matrix upload(const CsrMatrix& a) const { + return {device.upload(a.offsets), device.upload(a.columns), upload(a.values), a.size()}; + } + + void fill(Vector& x, std::uint32_t n) const { + device.dispatch(NumericsKernel::fill, {n, 0}, {x}); + } + + void copy(const Vector& x, Vector& y, std::uint32_t n) const { + device.dispatch(NumericsKernel::copy, {n, 0}, {x, y}); + } + + void axpy(const Vector& x, Vector& y, double v, std::uint32_t n) const { + device.dispatch(NumericsKernel::axpy, {n, float(v)}, {x, y}); + } + + void apply(const Matrix& a, const Vector& x, Vector& y) const { + device.dispatch(NumericsKernel::apply, {a.n, 0}, {a.offsets, a.columns, a.values, x, y}); + } + + void jacobi(const Vector& rhs, const Vector& ax, const Vector& d, Vector& x, + std::uint32_t n) const { + device.dispatch(NumericsKernel::jacobi, {n, 0.5F}, {rhs, ax, d, x}); + } +}; + +} // namespace cm::detail diff --git a/cpp/core/occupancy.cpp b/cpp/core/occupancy.cpp new file mode 100644 index 0000000..5038f9e --- /dev/null +++ b/cpp/core/occupancy.cpp @@ -0,0 +1,527 @@ +#include "cm/occupancy.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "occupancy_device.hpp" + +namespace cm { +namespace { +using detail::OccupancyKernel; +using detail::OccupancyParameters; +constexpr auto closed = std::numeric_limits::max(); + +std::uint32_t count32(std::size_t count) { + if (count >= closed) { + throw std::overflow_error("occupancy arrays exceed uint32 indexing"); + } + + return static_cast(count); +} + +void require(bool condition, const char* message) { + if (!condition) { + throw std::invalid_argument(message); + } +} + +void finite(float value, const char* name) { + require(std::isfinite(value), name); +} + +void vector_valid(const std::vector& values, bool nonnegative = true) { + count32(values.size()); + + for (float value : values) { + require(std::isfinite(value) && (!nonnegative || value >= 0), + "occupancy vectors must be finite and nonnegative"); + } +} + +void storage_valid(const std::vector& amount, const std::vector& volume) { + vector_valid(amount); + vector_valid(volume); + require(amount.size() == volume.size(), "amount and volume size mismatch"); + + for (std::size_t i = 0; i < amount.size(); ++i) { + require(volume[i] > 0 || amount[i] == 0, "nonnegative amounts require accessible storage"); + } +} + +double sum(const std::vector& values) { + return std::accumulate(values.begin(), values.end(), 0.0); +} + +void check_balance(const OccupancyBalance& balance) { + const double scale = std::max( + {1.0, std::abs(balance.before), std::abs(balance.after), + std::abs(balance.source) + std::abs(balance.reaction) + std::abs(balance.boundary)}); + + if (!std::isfinite(balance.residual()) || std::abs(balance.residual()) > 5e-6 * scale) { + throw std::runtime_error("occupancy amount ledger exceeds conservation tolerance"); + } +} + +std::vector checked_result(detail::OccupancyDevice& device, + const detail::OccupancyBufferPtr& buffer, std::size_t count) { + auto result = device.download(buffer, count); + + for (float value : result) { + if (!std::isfinite(value) || value < 0) { + throw std::invalid_argument( + "occupancy operation produced invalid values; no clipping is permitted"); + } + } + + return result; +} + +struct Graph { + std::vector offsets, indices; + + explicit Graph(const std::vector>& rows) : offsets{0} { + for (const auto& row : rows) { + indices.insert(indices.end(), row.begin(), row.end()); + offsets.push_back(count32(indices.size())); + } + } +}; +} // namespace + +OccupancySolver::OccupancySolver(BackendKind backend, std::uint32_t device_index, float cutoff) + : cutoff_(cutoff) { + require(std::isfinite(cutoff) && cutoff > 0 && cutoff <= 1, + "epsilon cutoff must be finite and in (0, 1]"); + + switch (backend) { + case BackendKind::metal: +#ifdef CM_HAS_METAL + device_ = detail::make_metal_occupancy_device(device_index); + return; +#else + throw std::runtime_error("Metal occupancy backend was not built"); +#endif + case BackendKind::cuda: +#ifdef CM_HAS_CUDA + device_ = detail::make_cuda_occupancy_device(device_index); + return; +#else + throw std::runtime_error("CUDA occupancy backend was not built"); +#endif + default: + static_cast(device_index); + throw std::invalid_argument( + "native occupancy requires Metal or CUDA; use occupancy_reference for CPU"); + } +} + +OccupancySolver::~OccupancySolver() = default; + +namespace { +std::vector pack_occupancy_capsules(const std::vector& cells) { + std::vector packed_cells; + + for (const auto& cell : cells) { + for (float value : cell.center) { + finite(value, "capsule geometry must be finite"); + } + + for (float value : cell.direction) { + finite(value, "capsule geometry must be finite"); + } + + require(std::isfinite(cell.length) && cell.length >= 0 && std::isfinite(cell.radius) && + cell.radius > 0 && + std::hypot(double(cell.direction[0]), double(cell.direction[1]), + double(cell.direction[2])) > 0, + "capsule requires nonnegative length, positive radius and direction"); + packed_cells.insert(packed_cells.end(), cell.center.begin(), cell.center.end()); + packed_cells.push_back(cell.length); + packed_cells.insert(packed_cells.end(), cell.direction.begin(), cell.direction.end()); + packed_cells.push_back(cell.radius); + } + + return packed_cells; +} + +void validate_occupancy_step_parameters(float dt, std::uint32_t max_iterations, + float relative_tolerance) { + require(std::isfinite(dt) && dt >= 0, "dt must be finite and nonnegative"); + require(max_iterations > 0 && std::isfinite(relative_tolerance) && relative_tolerance > 0 && + relative_tolerance <= 1e-5F, + "invalid occupancy solver parameters"); +} + +void append_occupancy_faces(std::vector>& rows, + std::vector>& row_data, + const std::vector& volume, + const std::vector& faces) { + const auto n = volume.size(); + auto append = [&](std::uint32_t i, std::uint32_t j, float g, float q, float c) { + rows[i].push_back(j); + row_data[i].insert(row_data[i].end(), {g, q, c}); + }; + + for (const auto& face : faces) { + auto i = face.first, j = face.second; + require(i < n && j < n && i != j, "invalid transport face indices"); + require( + std::isfinite(face.conductance) && face.conductance >= 0 && std::isfinite(face.volume_flux), + "invalid transport coefficients"); + require((volume[i] > 0 && volume[j] > 0) || (face.conductance == 0 && face.volume_flux == 0), + "closed storage cannot have an open face"); + append(i, j, face.conductance, face.volume_flux, 0); + append(j, i, face.conductance, -face.volume_flux, 0); + } +} + +void append_occupancy_reservoirs(std::vector>& rows, + std::vector>& row_data, + const std::vector& volume, + const std::vector& reservoirs) { + const auto n = volume.size(); + auto append = [&](std::uint32_t i, std::uint32_t j, float g, float q, float c) { + rows[i].push_back(j); + row_data[i].insert(row_data[i].end(), {g, q, c}); + }; + + for (const auto& face : reservoirs) { + require(face.site < n && volume[face.site] > 0, "reservoir must connect accessible storage"); + require(std::isfinite(face.concentration) && face.concentration >= 0 && + std::isfinite(face.conductance) && face.conductance >= 0 && + std::isfinite(face.volume_flux), + "invalid reservoir coefficients"); + append(face.site, closed, face.conductance, face.volume_flux, face.concentration); + } +} + +} // namespace + +std::vector OccupancySolver::geometric_porosity( + const std::vector>& centers, std::array spacing, + const std::vector& cells, std::uint32_t subdivisions, + const std::vector& walls) { + // m^3 <= 2^24 keeps integer sample counts exactly representable in float32. + require(subdivisions >= 1 && subdivisions <= 256, "subdivisions must lie in [1, 256]"); + count32(centers.size() * 3); + count32(cells.size() * 8); + + for (float h : spacing) { + require(std::isfinite(h) && h > 0, "spacing must be finite and positive"); + } + + for (const auto& center : centers) { + for (float value : center) { + finite(value, "centers must be finite"); + } + } + + const auto packed_cells = pack_occupancy_capsules(cells); + + require(walls.empty() || walls.size() == centers.size(), "walls size mismatch"); + + for (auto wall : walls) { + require(wall <= 1, "walls must contain Booleans"); + } + + auto mask = walls.empty() ? std::vector(centers.size()) : walls; + OccupancyParameters p{.count = count32(centers.size()), + .auxiliary = count32(cells.size()), + .subdivisions = subdivisions, + .cutoff = cutoff_, + .hx = spacing[0], + .hy = spacing[1], + .hz = spacing[2]}; + auto out = device_->allocate(centers.size() * sizeof(float)); + device_->dispatch( + OccupancyKernel::geometry, p, + {device_->upload(centers), device_->upload(packed_cells), device_->upload(mask), out}); + + return checked_result(*device_, out, centers.size()); +} + +std::vector OccupancySolver::accessible_volumes(const std::vector& porosity, + float voxel_volume) { + vector_valid(porosity); + + for (float value : porosity) { + require(value <= 1, "porosity must lie in [0, 1]"); + } + + require(std::isfinite(voxel_volume) && voxel_volume > 0, + "voxel volume must be finite and positive"); + OccupancyParameters p{ + .count = count32(porosity.size()), .scalar = voxel_volume, .cutoff = cutoff_}; + auto out = device_->allocate(porosity.size() * sizeof(float)); + device_->dispatch(OccupancyKernel::volumes, p, {device_->upload(porosity), out}); + auto result = checked_result(*device_, out, porosity.size()); + + for (std::size_t i = 0; i < result.size(); ++i) { + require(porosity[i] < cutoff_ || result[i] > 0, "accessible volume underflow"); + } + + return result; +} + +std::vector OccupancySolver::concentration(const std::vector& amount, + const std::vector& volume) { + storage_valid(amount, volume); + OccupancyParameters p{.count = count32(amount.size())}; + auto out = device_->allocate(amount.size() * sizeof(float)); + device_->dispatch(OccupancyKernel::concentration, p, + {device_->upload(amount), device_->upload(volume), out}); + + return checked_result(*device_, out, amount.size()); +} + +OccupancyFace OccupancySolver::porosity_face(std::uint32_t first, std::uint32_t second, + float epsilon_first, float epsilon_second, + float diffusion, float area, float distance, + float velocity) { + require(std::isfinite(epsilon_first) && epsilon_first >= 0 && epsilon_first <= 1 && + std::isfinite(epsilon_second) && epsilon_second >= 0 && epsilon_second <= 1, + "face porosities must lie in [0, 1]"); + require(std::isfinite(diffusion) && diffusion >= 0 && std::isfinite(area) && area > 0 && + std::isfinite(distance) && distance > 0 && std::isfinite(velocity), + "invalid face data"); + const std::vector data{epsilon_first, epsilon_second, diffusion, area, distance, velocity}; + auto out = device_->allocate(2 * sizeof(float)); + device_->dispatch(OccupancyKernel::face, {.count = 1, .cutoff = cutoff_}, + {device_->upload(data), out}); + auto result = device_->download(out, 2); + + for (float value : result) { + finite(value, "face coefficients overflow"); + } + + return {first, second, result[0], result[1]}; +} + +std::vector OccupancySolver::exchange_weights(const std::vector& kernel, + const std::vector& volume) { + vector_valid(kernel); + vector_valid(volume); + require(kernel.size() == volume.size(), "exchange kernel and volume size mismatch"); + require(!kernel.empty(), "cell has no valid accessible exchange support"); + OccupancyParameters p{.count = count32(kernel.size())}; + auto weights = device_->allocate(kernel.size() * sizeof(float)); + auto total = device_->allocate(sizeof(float)); + auto out = device_->allocate(kernel.size() * sizeof(float)); + device_->dispatch(OccupancyKernel::product, p, + {device_->upload(kernel), device_->upload(volume), weights}); + device_->dispatch(OccupancyKernel::sum, p, {weights, total}); + float capacity = device_->download(total, 1)[0]; + require(std::isfinite(capacity) && capacity > 0, "cell has no valid accessible exchange support"); + device_->dispatch(OccupancyKernel::normalize, p, {weights, total, out}); + + return checked_result(*device_, out, kernel.size()); +} + +std::vector OccupancySolver::remap_amounts( + const std::vector& amount, const std::vector& old_volume, + const std::vector& new_volume, + const std::vector>& neighbors) { + storage_valid(amount, old_volume); + vector_valid(new_volume); + const auto n = count32(amount.size()); + require(new_volume.size() == n, "new volume size mismatch"); + std::vector> rows(n); + + for (auto [i, j] : neighbors) { + require(i < n && j < n && i != j, "invalid neighbor edge"); + rows[i].push_back(j); + rows[j].push_back(i); + } + + if (!n) { + return {}; + } + + const Graph graph(rows); + auto old = device_->upload(old_volume), next = device_->upload(new_volume); + auto offsets = device_->upload(graph.offsets), indices = device_->upload(graph.indices); + auto labels = device_->allocate(n * sizeof(std::uint32_t)); + auto updated = device_->allocate(n * sizeof(std::uint32_t)); + OccupancyParameters p{.count = n}; + device_->dispatch(OccupancyKernel::labels_init, p, {old, next, labels}); + auto previous = device_->download(labels, n); + + for (std::uint32_t iteration = 0; iteration < n; ++iteration) { + device_->dispatch(OccupancyKernel::labels_step, p, {offsets, indices, labels, updated}); + auto current = device_->download(updated, n); + std::swap(labels, updated); + + if (current == previous) { + previous = std::move(current); + break; + } + + previous = std::move(current); + + if (iteration == n - 1) { + throw std::runtime_error("occupancy component labels did not converge"); + } + } + + // Only topology is packed on the host. Device labels determine membership; + // all amount/capacity arithmetic below runs on the selected GPU in site order. + std::vector> components(n); + + for (std::uint32_t i = 0; i < n; ++i) { + if (previous[i] != closed) { + components[previous[i]].push_back(i); + } + } + + const Graph members(components); + const auto total_count = count32(std::size_t{2} * n); + auto totals = device_->allocate(total_count * sizeof(float)); + auto input = device_->upload(amount); + device_->dispatch( + OccupancyKernel::component_sums, p, + {device_->upload(members.offsets), device_->upload(members.indices), input, next, totals}); + const auto values = device_->download(totals, total_count); + + for (std::uint32_t i = 0; i < n; ++i) { + require(std::isfinite(values[2 * i]) && std::isfinite(values[2 * i + 1]) && + (values[2 * i] == 0 || values[2 * i + 1] > 0), + "closing component has solute but no accessible recipient (or volume overflow)"); + } + + auto out = device_->allocate(n * sizeof(float)); + device_->dispatch(OccupancyKernel::remap, p, {input, next, labels, totals, out}); + auto result = checked_result(*device_, out, n); + check_balance({.before = sum(amount), .after = sum(result)}); + + return result; +} + +namespace { +void validate_occupancy_sources(const std::vector& volume, const std::vector& sources, + const std::vector& losses) { + const auto n = volume.size(); + vector_valid(sources, false); + vector_valid(losses); + require(sources.size() == n && losses.size() == n, "source or loss size mismatch"); + + for (std::uint32_t i = 0; i < n; ++i) { + require(volume[i] > 0 || sources[i] == 0, "sources require accessible storage"); + } +} +} // namespace + +OccupancyStep OccupancySolver::backward_euler( + const std::vector& amount, const std::vector& volume, + const std::vector& faces, float dt, const std::vector& source, + const std::vector& loss, const std::vector& reservoirs, + std::uint32_t max_iterations, float relative_tolerance) { + storage_valid(amount, volume); + validate_occupancy_step_parameters(dt, max_iterations, relative_tolerance); + const auto n = count32(amount.size()); + const auto sources = source.empty() ? std::vector(n) : source; + const auto losses = loss.empty() ? std::vector(n) : loss; + validate_occupancy_sources(volume, sources, losses); + + std::vector> rows(n); + std::vector> row_data(n); + append_occupancy_faces(rows, row_data, volume, faces); + + append_occupancy_reservoirs(rows, row_data, volume, reservoirs); + + if (!n || dt == 0) { + return {amount, {.before = sum(amount), .after = sum(amount)}}; + } + + const Graph graph(rows); + std::vector coefficients; + + for (const auto& row : row_data) { + coefficients.insert(coefficients.end(), row.begin(), row.end()); + } + + count32(coefficients.size()); + const auto offsets = device_->upload(graph.offsets), indices = device_->upload(graph.indices); + const auto edges = device_->upload(coefficients), v = device_->upload(volume); + const auto a = device_->upload(amount), s = device_->upload(sources), k = device_->upload(losses); + auto diagonal = device_->allocate(n * sizeof(float)), rhs = device_->allocate(n * sizeof(float)); + auto current = device_->allocate(n * sizeof(float)), next = device_->allocate(n * sizeof(float)); + auto residual = device_->allocate(n * sizeof(float)), total = device_->allocate(sizeof(float)); + OccupancyParameters p{.count = n, .scalar = dt}; + device_->dispatch(OccupancyKernel::assemble, p, + {offsets, indices, edges, v, a, s, k, diagonal, rhs}); + // Overflow/underflow must not turn into a false convergence decision. + const auto diagonals = checked_result(*device_, diagonal, n); + + for (auto value : diagonals) { + require(value > 0, "occupancy matrix diagonal underflow"); + } + + const auto right_hand_side = device_->download(rhs, n); + vector_valid(right_hand_side, false); + double rhs_norm = 0; + + for (float value : right_hand_side) { + rhs_norm += std::abs(double(value)); + } + + device_->dispatch(OccupancyKernel::concentration, p, {a, v, current}); + OccupancyStep result; + + for (std::uint32_t iteration = 0;; ++iteration) { + // Evaluate in conservative flux form, avoiding cancellation between a + // large diffusive diagonal and nearly equal neighboring concentrations. + device_->dispatch(OccupancyKernel::residual, p, + {offsets, indices, edges, v, a, s, k, current, residual}); + + if (iteration % 8 == 0 || iteration == max_iterations) { + auto reduction = p; + reduction.absolute = 1; + device_->dispatch(OccupancyKernel::sum, reduction, {residual, total}); + const double error = device_->download(total, 1)[0]; + + if (!std::isfinite(error)) { + throw std::runtime_error("non-finite occupancy solver residual"); + } + + result.relative_residual = rhs_norm == 0 ? error : error / rhs_norm; + + if (error <= double(relative_tolerance) * rhs_norm) { + result.iterations = iteration; + break; + } + + if (iteration == max_iterations) { + std::ostringstream message; + message << "occupancy backward Euler did not converge; relative residual " + << result.relative_residual << " exceeds " << relative_tolerance + << "; candidate rejected"; + throw std::runtime_error(message.str()); + } + } + + device_->dispatch(OccupancyKernel::jacobi, p, {diagonal, current, residual, next}); + std::swap(current, next); + } + + auto out = device_->allocate(n * sizeof(float)); + auto reaction = device_->allocate(n * sizeof(float)), + boundary = device_->allocate(n * sizeof(float)); + device_->dispatch(OccupancyKernel::finish, p, + {offsets, indices, edges, v, k, current, out, reaction, boundary}); + result.amount = checked_result(*device_, out, n); + result.balance = {.before = sum(amount), + .after = sum(result.amount), + .source = double(dt) * sum(sources), + .reaction = sum(device_->download(reaction, n)), + .boundary = sum(device_->download(boundary, n))}; + check_balance(result.balance); + + return result; +} + +} // namespace cm diff --git a/cpp/core/occupancy_device.hpp b/cpp/core/occupancy_device.hpp new file mode 100644 index 0000000..888cfbd --- /dev/null +++ b/cpp/core/occupancy_device.hpp @@ -0,0 +1,79 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace cm::detail { + +// Host orchestration and graph packing are shared; each backend implements all +// numerical kernels with device-resident buffers. No CPU numerical fallback. +enum class OccupancyKernel { + geometry, + volumes, + concentration, + face, + product, + sum, + normalize, + labels_init, + labels_step, + component_sums, + remap, + assemble, + jacobi, + residual, + finish +}; +inline constexpr const char* occupancy_kernel_names[] = { + "occupancy_geometry", "occupancy_volumes", "occupancy_concentration", + "occupancy_face", "occupancy_product", "occupancy_sum", + "occupancy_normalize", "occupancy_labels_init", "occupancy_labels_step", + "occupancy_component_sums", "occupancy_remap", "occupancy_assemble", + "occupancy_jacobi", "occupancy_residual", "occupancy_finish"}; + +struct OccupancyParameters { + std::uint32_t count{0}, auxiliary{0}, subdivisions{0}, absolute{0}; + float scalar{0}, cutoff{0}, hx{0}, hy{0}, hz{0}; +}; + +static_assert(sizeof(OccupancyParameters) == 36); + +struct OccupancyBuffer { + virtual ~OccupancyBuffer() = default; +}; + +using OccupancyBufferPtr = std::shared_ptr; + +class OccupancyDevice { + public: + virtual ~OccupancyDevice() = default; + virtual OccupancyBufferPtr allocate(std::size_t bytes, const void* data = nullptr) = 0; + virtual void read(const OccupancyBufferPtr& buffer, void* data, std::size_t bytes) = 0; + // Parameters always occupy binding 15; buffers occupy consecutive bindings. + virtual void dispatch(OccupancyKernel kernel, const OccupancyParameters& parameters, + std::initializer_list buffers) = 0; + + template + OccupancyBufferPtr upload(const std::vector& values) { + return allocate(values.size() * sizeof(T), values.empty() ? nullptr : values.data()); + } + + template + std::vector download(const OccupancyBufferPtr& buffer, std::size_t count) { + std::vector result(count); + + if (count) { + read(buffer, result.data(), count * sizeof(T)); + } + + return result; + } +}; + +std::unique_ptr make_metal_occupancy_device(std::uint32_t device_index); +std::unique_ptr make_cuda_occupancy_device(std::uint32_t device_index); + +} // namespace cm::detail diff --git a/cpp/core/rate_operations.hpp b/cpp/core/rate_operations.hpp new file mode 100644 index 0000000..ebf9d63 --- /dev/null +++ b/cpp/core/rate_operations.hpp @@ -0,0 +1,56 @@ +#pragma once + +#include "cm/species.hpp" + +namespace cm::detail { + +inline bool is_cell_property_operation(RateOp operation) { + switch (operation) { + case RateOp::position_x: + case RateOp::position_y: + case RateOp::position_z: + case RateOp::cell_length: + case RateOp::cell_radius: + case RateOp::growth_rate: + case RateOp::cell_type: + case RateOp::cell_volume: + case RateOp::cell_volume_change_rate: + case RateOp::cell_surface_area: + return true; + default: + return false; + } +} + +inline bool is_unary_operation(RateOp operation) { + switch (operation) { + case RateOp::negate: + case RateOp::exponential: + case RateOp::logarithm: + return true; + default: + return false; + } +} + +inline bool is_binary_operation(RateOp operation) { + switch (operation) { + case RateOp::add: + case RateOp::subtract: + case RateOp::multiply: + case RateOp::divide: + case RateOp::power: + case RateOp::minimum: + case RateOp::maximum: + case RateOp::less: + case RateOp::less_equal: + case RateOp::greater: + case RateOp::greater_equal: + case RateOp::equal: + return true; + default: + return false; + } +} + +} // namespace cm::detail diff --git a/cpp/core/signals.cpp b/cpp/core/signals.cpp index cd81daf..32a72bf 100644 --- a/cpp/core/signals.cpp +++ b/cpp/core/signals.cpp @@ -15,6 +15,7 @@ std::size_t checked_multiply(std::size_t left, std::size_t right, const char* na if (right != 0 && left > std::numeric_limits::max() / right) { throw std::overflow_error(std::string("signal grid ") + name + " exceeds address space"); } + return left * right; } @@ -26,6 +27,7 @@ void validate_periodic_pair(const GridBoundary& lower, const GridBoundary& upper const char* axis) { const auto lower_periodic = lower.kind == GridBoundaryKind::periodic; const auto upper_periodic = upper.kind == GridBoundaryKind::periodic; + if (lower_periodic != upper_periodic) { throw std::invalid_argument(std::string("signal grid periodic ") + axis + " boundaries must be paired"); @@ -43,6 +45,7 @@ AxisWeights interpolation_axis(float position, float origin, float spacing, std: if (!std::isfinite(position)) { throw std::invalid_argument("signal sample position must be finite"); } + if (dimension == 1) { return {}; } @@ -50,6 +53,7 @@ AxisWeights interpolation_axis(float position, float origin, float spacing, std: auto coordinate = (static_cast(position) - static_cast(origin)) / static_cast(spacing); const auto upper_bound = static_cast(dimension - 1); + if (bound == GridSampleBound::clamped) { // Clamping happens in lattice coordinates, the same space the bound is // tested in, so a clamped position always lands inside the lattice. @@ -57,11 +61,15 @@ AxisWeights interpolation_axis(float position, float origin, float spacing, std: } else if (coordinate < 0.0 || coordinate > upper_bound) { throw std::out_of_range(std::string("signal sample is outside the ") + axis + " grid bound"); } + const auto lower = static_cast(std::floor(coordinate)); + if (lower == dimension - 1) { return {.indices = {lower, lower}, .weights = {1.0F, 0.0F}, .count = 1}; } + const auto fraction = static_cast(coordinate - static_cast(lower)); + return { .indices = {lower, lower + 1}, .weights = {1.0F - fraction, fraction}, @@ -100,6 +108,7 @@ struct FaceVelocities { FaceVelocities face_velocities(const SignalGridSpec& spec, std::size_t signal, std::uint32_t x, std::uint32_t y, std::uint32_t z) { FaceVelocities result{}; + if (spec.velocity_field.has_value()) { const auto& field = *spec.velocity_field; result.lower[0] = field.x_faces[x_face_index(spec.shape, x, y, z)]; @@ -108,14 +117,18 @@ FaceVelocities face_velocities(const SignalGridSpec& spec, std::size_t signal, s result.upper[1] = field.y_faces[y_face_index(spec.shape, x, y + 1, z)]; result.lower[2] = field.z_faces[z_face_index(spec.shape, x, y, z)]; result.upper[2] = field.z_faces[z_face_index(spec.shape, x, y, z + 1)]; + return result; } + const std::array velocity{spec.advection[signal].x, spec.advection[signal].y, spec.advection[signal].z}; + for (std::size_t axis = 0; axis < velocity.size(); ++axis) { result.lower[axis] = velocity[axis]; result.upper[axis] = velocity[axis]; } + return result; } @@ -129,6 +142,7 @@ float boundary_value(const GridBoundary& boundary, std::size_t signal, float cur case GridBoundaryKind::fixed: return boundary.values[signal]; } + throw std::logic_error("unknown signal grid boundary kind"); } @@ -150,9 +164,11 @@ FaceClosure face_closure(const SignalGridSpec& spec, std::uint32_t x, std::uint3 const std::array dimensions{spec.shape.x, spec.shape.y, spec.shape.z}; const std::array coordinates{x, y, z}; FaceClosure closure{}; + for (std::size_t axis = 0; axis < dimensions.size(); ++axis) { auto lower_neighbor = coordinates; auto upper_neighbor = coordinates; + if (coordinates[axis] == 0) { switch (lower_boundaries[axis]->kind) { case GridBoundaryKind::no_flux: @@ -170,6 +186,7 @@ FaceClosure face_closure(const SignalGridSpec& spec, std::uint32_t x, std::uint3 lower_neighbor[axis] = coordinates[axis] - 1; closure.lower[axis] = solid_at(spec, lower_neighbor[0], lower_neighbor[1], lower_neighbor[2]); } + if (coordinates[axis] + 1 == dimensions[axis]) { switch (upper_boundaries[axis]->kind) { case GridBoundaryKind::no_flux: @@ -188,6 +205,7 @@ FaceClosure face_closure(const SignalGridSpec& spec, std::uint32_t x, std::uint3 closure.upper[axis] = solid_at(spec, upper_neighbor[0], upper_neighbor[1], upper_neighbor[2]); } } + return closure; } @@ -196,35 +214,44 @@ float signal_operator_diagonal(const SignalGridSpec& spec, std::size_t signal, s if (solid_at(spec, x, y, z)) { return 0.0F; } + const std::array dimensions{spec.shape.x, spec.shape.y, spec.shape.z}; const std::array spacing{spec.spacing.x, spec.spacing.y, spec.spacing.z}; const auto faces = face_velocities(spec, signal, x, y, z); const auto closure = face_closure(spec, x, y, z); float diagonal = 0.0F; + for (std::size_t axis = 0; axis < dimensions.size(); ++axis) { if (dimensions[axis] == 1) { continue; } + const auto inverse_spacing = 1.0F / spacing[axis]; const auto diffusion_scale = spec.diffusion[signal] * inverse_spacing * inverse_spacing; diagonal -= 2.0F * diffusion_scale; + if (closure.lower[axis]) { diagonal += diffusion_scale; } + if (closure.upper[axis]) { diagonal += diffusion_scale; } + if (!closure.upper[axis] && faces.upper[axis] > 0.0F) { diagonal -= faces.upper[axis] * inverse_spacing; } + if (!closure.lower[axis] && faces.lower[axis] < 0.0F) { diagonal += faces.lower[axis] * inverse_spacing; } } + if (spec.reaction.has_value()) { const auto site = flat_site(spec.shape, x, y, z); diagonal -= spec.reaction->loss_rates[(signal * spec.site_count()) + site]; } + return diagonal; } @@ -232,12 +259,15 @@ std::vector signal_grid_operator_rates(const SignalGrid& grid, std::span levels) { auto rates = signal_grid_transport_rates(grid, levels); const auto& reaction = grid.spec().reaction; + if (!reaction.has_value()) { return rates; } + for (std::size_t index = 0; index < rates.size(); ++index) { rates[index] += reaction->source_rates[index] - (reaction->loss_rates[index] * levels[index]); } + return rates; } @@ -245,9 +275,11 @@ double max_reaction_loss(const SignalGridSpec& spec, std::size_t signal) { if (!spec.reaction.has_value()) { return 0.0; } + const auto sites = spec.site_count(); const auto begin = spec.reaction->loss_rates.begin() + static_cast(signal * sites); + return static_cast(*std::max_element(begin, begin + static_cast(sites))); } @@ -255,15 +287,88 @@ float rms(std::span values) { if (values.empty()) { return 0.0F; } + double sum = 0.0; + for (const auto value : values) { sum += static_cast(value) * static_cast(value); } + return static_cast(std::sqrt(sum / static_cast(values.size()))); } } // namespace +namespace { +std::size_t stencil_seed(const SignalGridSpec& spec, SignalGridStencil& result) { + // Only the face-connected fluid component of the strongest interpolation + // weight may exchange material with this cell. Corner contact is not flow. + std::size_t seed = result.count; + + for (std::size_t i = 0; i < result.count; ++i) { + if (spec.solid_site(result.sites[i])) { + result.weights[i] = 0.0F; + } + + if (result.weights[i] > 0.0F && + (seed == result.count || result.weights[i] > result.weights[seed])) { + seed = i; + } + } + + if (seed == result.count) { + result.entirely_solid = true; + + return seed; + } + + return seed; +} + +void normalize_connected_stencil(SignalGridStencil& result, + const std::array, 8>& coordinates, + std::size_t seed) { + std::array connected{}; + connected[seed] = true; + + for (std::size_t pass = 0; pass < result.count; ++pass) { + for (std::size_t i = 0; i < result.count; ++i) { + if (result.weights[i] <= 0.0F) { + continue; + } + + for (std::size_t j = 0; j < result.count; ++j) { + unsigned distance = 0; + + for (std::size_t axis = 0; axis < 3; ++axis) { + distance += static_cast(std::abs(static_cast(coordinates[i][axis]) - + static_cast(coordinates[j][axis]))); + } + + if (connected[j] && distance == 1) { + connected[i] = true; + } + } + } + } + + float total = 0.0F; + + for (std::size_t i = 0; i < result.count; ++i) { + if (!connected[i]) { + result.weights[i] = 0.0F; + } + + total += result.weights[i]; + } + + for (std::size_t i = 0; i < result.count; ++i) { + result.weights[i] /= total; + } +} + +} // namespace + SignalGridStencil signal_grid_stencil(const SignalGridSpec& spec, Vec3 position, GridSampleBound bound) { spec.validate_lattice(); @@ -275,6 +380,7 @@ SignalGridStencil signal_grid_stencil(const SignalGridSpec& spec, Vec3 position, interpolation_axis(position.z, spec.origin.z, spec.spacing.z, spec.shape.z, "z", bound); SignalGridStencil result; std::array, 8> coordinates{}; + for (std::size_t xi = 0; xi < x.count; ++xi) { for (std::size_t yi = 0; yi < y.count; ++yi) { for (std::size_t zi = 0; zi < z.count; ++zi) { @@ -286,42 +392,17 @@ SignalGridStencil signal_grid_stencil(const SignalGridSpec& spec, Vec3 position, } } } + if (spec.has_obstacles()) { - // Only the face-connected fluid component of the strongest interpolation - // weight may exchange material with this cell. Corner contact is not flow. - std::size_t seed = result.count; - for (std::size_t i = 0; i < result.count; ++i) { - if (spec.solid_site(result.sites[i])) result.weights[i] = 0.0F; - if (result.weights[i] > 0.0F && - (seed == result.count || result.weights[i] > result.weights[seed])) - seed = i; - } - if (seed == result.count) { - result.entirely_solid = true; + const auto seed = stencil_seed(spec, result); + + if (result.entirely_solid) { return result; } - std::array connected{}; - connected[seed] = true; - for (std::size_t pass = 0; pass < result.count; ++pass) { - for (std::size_t i = 0; i < result.count; ++i) { - if (result.weights[i] <= 0.0F) continue; - for (std::size_t j = 0; j < result.count; ++j) { - unsigned distance = 0; - for (std::size_t axis = 0; axis < 3; ++axis) { - distance += static_cast(std::abs(static_cast(coordinates[i][axis]) - - static_cast(coordinates[j][axis]))); - } - if (connected[j] && distance == 1) connected[i] = true; - } - } - } - float total = 0.0F; - for (std::size_t i = 0; i < result.count; ++i) { - if (!connected[i]) result.weights[i] = 0.0F; - total += result.weights[i]; - } - for (std::size_t i = 0; i < result.count; ++i) result.weights[i] /= total; + + normalize_connected_stencil(result, coordinates, seed); } + return result; } @@ -345,6 +426,7 @@ void GridBoundary::validate(std::size_t signal_count) const { } return; } + throw std::invalid_argument("unknown signal grid boundary kind"); } @@ -352,6 +434,7 @@ void SignalSolveParameters::validate() const { if (max_iterations == 0) { throw std::invalid_argument("signal solver iteration limit must be positive"); } + if (!std::isfinite(absolute_tolerance) || absolute_tolerance < 0.0F || !std::isfinite(relative_tolerance) || relative_tolerance < 0.0F || (absolute_tolerance == 0.0F && relative_tolerance == 0.0F)) { @@ -364,12 +447,14 @@ void SignalGridAffineReaction::validate(std::size_t level_count) const { throw std::invalid_argument( "signal grid affine reaction arrays must match the grid level count"); } + for (const auto value : source_rates) { if (!std::isfinite(value) || value < 0.0F) { throw std::invalid_argument( "signal grid affine source rates must be finite and non-negative"); } } + for (const auto value : loss_rates) { if (!std::isfinite(value) || value < 0.0F) { throw std::invalid_argument("signal grid affine loss rates must be finite and non-negative"); @@ -379,6 +464,7 @@ void SignalGridAffineReaction::validate(std::size_t level_count) const { std::size_t SignalGridSpec::site_count() const { const auto xy = checked_multiply(shape.x, shape.y, "site count"); + return checked_multiply(xy, shape.z, "site count"); } @@ -386,7 +472,9 @@ std::size_t SignalGridSpec::level_count() const { return checked_multiply(signal_count, site_count(), "level count"); } -float SignalGridSpec::voxel_volume() const noexcept { return spacing.x * spacing.y * spacing.z; } +float SignalGridSpec::voxel_volume() const noexcept { + return spacing.x * spacing.y * spacing.z; +} std::size_t SignalGridSpec::x_face_count() const { return checked_multiply(static_cast(shape.x) + 1, @@ -403,146 +491,225 @@ std::size_t SignalGridSpec::z_face_count() const { checked_multiply(shape.x, shape.y, "face plane"), "z face count"); } -bool SignalGridSpec::has_obstacles() const noexcept { return !obstacles.empty(); } +bool SignalGridSpec::has_obstacles() const noexcept { + return !obstacles.empty(); +} bool SignalGridSpec::solid_site(std::size_t site) const noexcept { return !obstacles.empty() && obstacles[site] != 0; } +namespace { +void validate_velocity_face_counts(const SignalGridSpec& spec) { + const auto& field = *spec.velocity_field; + + if (field.x_faces.size() != spec.x_face_count() || field.y_faces.size() != spec.y_face_count() || + field.z_faces.size() != spec.z_face_count()) { + throw std::invalid_argument("signal grid velocity field must cover every lattice face"); + } +} + +void validate_obstacle_reaction(const SignalGridSpec& spec) { + const auto& obstacles = spec.obstacles; + const auto& reaction = spec.reaction; + const auto signal_count = spec.signal_count; + + for (const auto value : obstacles) { + if (value > 1) { + throw std::invalid_argument("signal grid obstacle mask values must be 0 or 1"); + } + } + + if (reaction.has_value()) { + const auto sites = spec.site_count(); + + for (std::size_t signal = 0; signal < signal_count; ++signal) { + for (std::size_t site = 0; site < sites; ++site) { + if (obstacles[site] != 0 && (reaction->source_rates[(signal * sites) + site] != 0.0F || + reaction->loss_rates[(signal * sites) + site] != 0.0F)) { + throw std::invalid_argument("signal grid affine reaction must be zero at obstacle sites"); + } + } + } + } +} + +void validate_velocity_values(const SignalGridSpec& spec) { + const auto& field = *spec.velocity_field; + const auto& advection = spec.advection; + + for (const auto* faces : {&field.x_faces, &field.y_faces, &field.z_faces}) { + for (const auto value : *faces) { + if (!std::isfinite(value)) { + throw std::invalid_argument("signal grid velocity field values must be finite"); + } + } + } + + for (const auto velocity : advection) { + if (velocity.x != 0.0F || velocity.y != 0.0F || velocity.z != 0.0F) { + throw std::invalid_argument( + "signal grid velocity field requires zero constant advection vectors"); + } + } +} + +void validate_closed_face_velocities(const SignalGridSpec& spec, std::uint32_t x, std::uint32_t y, + std::uint32_t z) { + const auto& shape = spec.shape; + const auto closure = face_closure(spec, x, y, z); + const auto faces = face_velocities(spec, 0, x, y, z); + const auto solid_here = spec.solid_site(flat_site(shape, x, y, z)); + const std::array dimensions{shape.x, shape.y, shape.z}; + + for (std::size_t axis = 0; axis < dimensions.size(); ++axis) { + if (dimensions[axis] == 1) { + continue; + } + + if ((closure.lower[axis] || solid_here) && faces.lower[axis] != 0.0F) { + throw std::invalid_argument("signal grid velocity field must be zero on closed faces"); + } + + if ((closure.upper[axis] || solid_here) && faces.upper[axis] != 0.0F) { + throw std::invalid_argument("signal grid velocity field must be zero on closed faces"); + } + } +} + +void validate_periodic_velocity_x(const SignalGridSpec& spec) { + const auto& field = *spec.velocity_field; + const auto& shape = spec.shape; + const auto& x_lower = spec.x_lower; + + if (x_lower.kind == GridBoundaryKind::periodic && shape.x > 1) { + for (std::uint32_t y = 0; y < shape.y; ++y) { + for (std::uint32_t z = 0; z < shape.z; ++z) { + if (field.x_faces[x_face_index(shape, 0, y, z)] != + field.x_faces[x_face_index(shape, shape.x, y, z)]) { + throw std::invalid_argument( + "signal grid velocity field periodic faces must hold equal values"); + } + } + } + } +} + +void validate_periodic_velocity_y(const SignalGridSpec& spec) { + const auto& field = *spec.velocity_field; + const auto& shape = spec.shape; + const auto& y_lower = spec.y_lower; + + if (y_lower.kind == GridBoundaryKind::periodic && shape.y > 1) { + for (std::uint32_t x = 0; x < shape.x; ++x) { + for (std::uint32_t z = 0; z < shape.z; ++z) { + if (field.y_faces[y_face_index(shape, x, 0, z)] != + field.y_faces[y_face_index(shape, x, shape.y, z)]) { + throw std::invalid_argument( + "signal grid velocity field periodic faces must hold equal values"); + } + } + } + } +} + +void validate_periodic_velocity_z(const SignalGridSpec& spec) { + const auto& field = *spec.velocity_field; + const auto& shape = spec.shape; + const auto& z_lower = spec.z_lower; + + if (z_lower.kind == GridBoundaryKind::periodic && shape.z > 1) { + for (std::uint32_t x = 0; x < shape.x; ++x) { + for (std::uint32_t y = 0; y < shape.y; ++y) { + if (field.z_faces[z_face_index(shape, x, y, 0)] != + field.z_faces[z_face_index(shape, x, y, shape.z)]) { + throw std::invalid_argument( + "signal grid velocity field periodic faces must hold equal values"); + } + } + } + } +} + +} // namespace + void SignalGridSpec::validate_lattice() const { if (signal_count == 0) { throw std::invalid_argument("signal grid must contain at least one signal"); } + if (shape.x == 0 || shape.y == 0 || shape.z == 0) { throw std::invalid_argument("signal grid dimensions must be positive"); } + if (!finite(origin)) { throw std::invalid_argument("signal grid origin must be finite"); } + if (!finite(spacing) || spacing.x <= 0.0F || spacing.y <= 0.0F || spacing.z <= 0.0F) { throw std::invalid_argument("signal grid spacing must be finite and positive"); } + if (!obstacles.empty() && obstacles.size() != site_count()) { throw std::invalid_argument("signal grid obstacle mask must cover every site"); } + if (velocity_field.has_value()) { - const auto& field = *velocity_field; - if (field.x_faces.size() != x_face_count() || field.y_faces.size() != y_face_count() || - field.z_faces.size() != z_face_count()) { - throw std::invalid_argument("signal grid velocity field must cover every lattice face"); - } + validate_velocity_face_counts(*this); } } -void SignalGridSpec::validate() const { - validate_lattice(); - if (diffusion.size() != signal_count || advection.size() != signal_count) { +namespace { +void validate_signal_coefficients(const SignalGridSpec& spec) { + if (spec.diffusion.size() != spec.signal_count || spec.advection.size() != spec.signal_count) { throw std::invalid_argument("signal grid transport arrays must match signal count"); } - for (const auto value : diffusion) { + + for (const auto value : spec.diffusion) { if (!std::isfinite(value) || value < 0.0F) { throw std::invalid_argument("signal diffusion must be finite and non-negative"); } } - for (const auto velocity : advection) { + + for (const auto velocity : spec.advection) { if (!finite(velocity)) { throw std::invalid_argument("signal advection must be finite"); } } +} +} // namespace + +void SignalGridSpec::validate() const { + validate_lattice(); + + validate_signal_coefficients(*this); + if (reaction.has_value()) { reaction->validate(level_count()); } + if (!obstacles.empty()) { - for (const auto value : obstacles) { - if (value > 1) { - throw std::invalid_argument("signal grid obstacle mask values must be 0 or 1"); - } - } - if (reaction.has_value()) { - const auto sites = site_count(); - for (std::size_t signal = 0; signal < signal_count; ++signal) { - for (std::size_t site = 0; site < sites; ++site) { - if (obstacles[site] != 0 && (reaction->source_rates[(signal * sites) + site] != 0.0F || - reaction->loss_rates[(signal * sites) + site] != 0.0F)) { - throw std::invalid_argument( - "signal grid affine reaction must be zero at obstacle sites"); - } - } - } - } + validate_obstacle_reaction(*this); } + if (velocity_field.has_value()) { - const auto& field = *velocity_field; - for (const auto* faces : {&field.x_faces, &field.y_faces, &field.z_faces}) { - for (const auto value : *faces) { - if (!std::isfinite(value)) { - throw std::invalid_argument("signal grid velocity field values must be finite"); - } - } - } - for (const auto velocity : advection) { - if (velocity.x != 0.0F || velocity.y != 0.0F || velocity.z != 0.0F) { - throw std::invalid_argument( - "signal grid velocity field requires zero constant advection vectors"); - } - } + validate_velocity_values(*this); + for (std::uint32_t x = 0; x < shape.x; ++x) { for (std::uint32_t y = 0; y < shape.y; ++y) { for (std::uint32_t z = 0; z < shape.z; ++z) { - const auto closure = face_closure(*this, x, y, z); - const auto faces = face_velocities(*this, 0, x, y, z); - const auto solid_here = solid_site(flat_site(shape, x, y, z)); - const std::array dimensions{shape.x, shape.y, shape.z}; - for (std::size_t axis = 0; axis < dimensions.size(); ++axis) { - if (dimensions[axis] == 1) { - continue; - } - if ((closure.lower[axis] || solid_here) && faces.lower[axis] != 0.0F) { - throw std::invalid_argument( - "signal grid velocity field must be zero on closed faces"); - } - if ((closure.upper[axis] || solid_here) && faces.upper[axis] != 0.0F) { - throw std::invalid_argument( - "signal grid velocity field must be zero on closed faces"); - } - } - } - } - } - if (x_lower.kind == GridBoundaryKind::periodic && shape.x > 1) { - for (std::uint32_t y = 0; y < shape.y; ++y) { - for (std::uint32_t z = 0; z < shape.z; ++z) { - if (field.x_faces[x_face_index(shape, 0, y, z)] != - field.x_faces[x_face_index(shape, shape.x, y, z)]) { - throw std::invalid_argument( - "signal grid velocity field periodic faces must hold equal values"); - } - } - } - } - if (y_lower.kind == GridBoundaryKind::periodic && shape.y > 1) { - for (std::uint32_t x = 0; x < shape.x; ++x) { - for (std::uint32_t z = 0; z < shape.z; ++z) { - if (field.y_faces[y_face_index(shape, x, 0, z)] != - field.y_faces[y_face_index(shape, x, shape.y, z)]) { - throw std::invalid_argument( - "signal grid velocity field periodic faces must hold equal values"); - } - } - } - } - if (z_lower.kind == GridBoundaryKind::periodic && shape.z > 1) { - for (std::uint32_t x = 0; x < shape.x; ++x) { - for (std::uint32_t y = 0; y < shape.y; ++y) { - if (field.z_faces[z_face_index(shape, x, y, 0)] != - field.z_faces[z_face_index(shape, x, y, shape.z)]) { - throw std::invalid_argument( - "signal grid velocity field periodic faces must hold equal values"); - } + validate_closed_face_velocities(*this, x, y, z); } } } + + validate_periodic_velocity_x(*this); + + validate_periodic_velocity_y(*this); + + validate_periodic_velocity_z(*this); } + switch (integration) { case SignalIntegrationKind::forward_euler: case SignalIntegrationKind::crank_nicolson: @@ -551,14 +718,18 @@ void SignalGridSpec::validate() const { default: throw std::invalid_argument("unknown signal integration kind"); } + solver.validate(); + for (const auto* boundary : {&x_lower, &x_upper, &y_lower, &y_upper, &z_lower, &z_upper}) { boundary->validate(signal_count); } + validate_periodic_pair(x_lower, x_upper, "x"); validate_periodic_pair(y_lower, y_upper, "y"); validate_periodic_pair(z_lower, z_upper, "z"); const auto levels = level_count(); + if (levels > std::numeric_limits::max()) { throw std::overflow_error("signal grid level count exceeds the uint32 index space"); } @@ -566,16 +737,20 @@ void SignalGridSpec::validate() const { void SignalGridCheckpoint::validate() const { spec.validate(); + if (levels.size() != spec.level_count()) { throw std::invalid_argument("signal grid level count does not match its specification"); } + for (const auto level : levels) { if (!std::isfinite(level) || level < 0.0F) { throw std::invalid_argument("signal grid levels must be finite and non-negative"); } } + if (spec.has_obstacles()) { const auto sites = spec.site_count(); + for (std::size_t index = 0; index < levels.size(); ++index) { if (spec.solid_site(index % sites) && levels[index] != 0.0F) { throw std::invalid_argument("signal grid levels must be zero at obstacle sites"); @@ -587,9 +762,11 @@ void SignalGridCheckpoint::validate() const { SignalGrid::SignalGrid(const SignalGridSpec& spec, std::vector levels) : spec_(spec), levels_(std::move(levels)) { spec_.validate(); + if (levels_.empty()) { levels_.resize(spec_.level_count(), 0.0F); } + validate(); } @@ -598,22 +775,30 @@ SignalGrid::SignalGrid(const SignalGridCheckpoint& checkpoint) validate(); } -const SignalGridSpec& SignalGrid::spec() const noexcept { return spec_; } +const SignalGridSpec& SignalGrid::spec() const noexcept { + return spec_; +} -std::span SignalGrid::levels() const& noexcept { return levels_; } +std::span SignalGrid::levels() const& noexcept { + return levels_; +} std::vector SignalGrid::sample(Vec3 position) const { const auto stencil = signal_grid_stencil(spec_, position); + if (stencil.entirely_solid) { throw std::invalid_argument("signal sample position is inside a grid obstacle"); } + const auto sites = spec_.site_count(); std::vector result(spec_.signal_count, 0.0F); + for (std::size_t entry = 0; entry < stencil.count; ++entry) { for (std::size_t signal = 0; signal < spec_.signal_count; ++signal) { result[signal] += stencil.weights[entry] * levels_[(signal * sites) + stencil.sites[entry]]; } } + return result; } @@ -621,18 +806,22 @@ Vec3 SignalGrid::sample_velocity(Vec3 position, GridSampleBound bound) const { if (!spec_.velocity_field.has_value()) { throw std::logic_error("signal grid does not declare a velocity field"); } + const auto stencil = signal_grid_stencil(spec_, position, bound); // The field is zero on every face of a solid site, so a stencil with no // fluid in it samples zero: a cell that mechanics has pressed into a wall // does not drift. const auto& field = *spec_.velocity_field; Vec3 result{}; + for (std::size_t entry = 0; entry < stencil.count; ++entry) { const auto site = stencil.sites[entry]; const auto weight = stencil.weights[entry]; + if (weight == 0.0F) { continue; } + const auto z = site % spec_.shape.z; const auto y = (site / spec_.shape.z) % spec_.shape.y; const auto x = site / (static_cast(spec_.shape.y) * spec_.shape.z); @@ -649,11 +838,13 @@ Vec3 SignalGrid::sample_velocity(Vec3 position, GridSampleBound bound) const { (field.z_faces[z_face_index(spec_.shape, fx, fy, fz)] + field.z_faces[z_face_index(spec_.shape, fx, fy, fz + 1)]); } + return result; } SignalGridCheckpoint SignalGrid::checkpoint() const { validate(); + return {.spec = spec_, .levels = levels_}; } @@ -685,48 +876,59 @@ void SignalGrid::validate_step(float dt) const { if (!std::isfinite(dt) || dt < 0.0F) { throw std::invalid_argument("time step must be finite and non-negative"); } + if (spec_.integration != SignalIntegrationKind::forward_euler) { return; } + const std::array dimensions{spec_.shape.x, spec_.shape.y, spec_.shape.z}; const std::array spacing{spec_.spacing.x, spec_.spacing.y, spec_.spacing.z}; double maximum_outflow = 0.0; + if (spec_.velocity_field.has_value()) { for (std::uint32_t x = 0; x < spec_.shape.x; ++x) { for (std::uint32_t y = 0; y < spec_.shape.y; ++y) { for (std::uint32_t z = 0; z < spec_.shape.z; ++z) { const auto faces = face_velocities(spec_, 0, x, y, z); double outflow = 0.0; + for (std::size_t axis = 0; axis < dimensions.size(); ++axis) { if (dimensions[axis] == 1) { continue; } + const auto inverse_spacing = 1.0 / static_cast(spacing[axis]); outflow += (std::max(static_cast(faces.upper[axis]), 0.0) - std::min(static_cast(faces.lower[axis]), 0.0)) * inverse_spacing; } + maximum_outflow = std::max(maximum_outflow, outflow); } } } } + for (std::size_t signal = 0; signal < spec_.signal_count; ++signal) { const std::array velocity{spec_.advection[signal].x, spec_.advection[signal].y, spec_.advection[signal].z}; double inverse_square_sum = 0.0; double courant_sum = maximum_outflow; + for (std::size_t axis = 0; axis < dimensions.size(); ++axis) { if (dimensions[axis] == 1) { continue; } + const auto inverse_spacing = 1.0 / static_cast(spacing[axis]); inverse_square_sum += inverse_spacing * inverse_spacing; courant_sum += std::abs(static_cast(velocity[axis])) * inverse_spacing; } + const auto factor = static_cast(dt) * ((2.0 * static_cast(spec_.diffusion[signal]) * inverse_square_sum) + courant_sum + max_reaction_loss(spec_, signal)); + if (!std::isfinite(factor) || factor > 1.0) { throw std::invalid_argument("signal grid time step violates the explicit stability bound"); } @@ -740,33 +942,113 @@ void SignalGrid::validate() const { std::vector signal_grid_forward_euler_candidate(const SignalGrid& grid, float dt) { grid.validate(); grid.validate_step(dt); + if (dt == 0.0F) { return std::vector(grid.levels().begin(), grid.levels().end()); } + const auto levels = grid.levels(); const auto rates = signal_grid_operator_rates(grid, levels); std::vector updated(levels.begin(), levels.end()); + for (std::size_t index = 0; index < updated.size(); ++index) { const auto candidate = levels[index] + (dt * rates[index]); + if (!std::isfinite(candidate) || candidate < 0.0F) { throw std::runtime_error( "signal grid update produced a non-finite or negative concentration"); } + updated[index] = candidate; } + return updated; } +namespace { +float transport_stencil_rate(const SignalGridSpec& spec, float diffusion, + const std::array& spacing, float current, + const FaceVelocities& faces, const FaceClosure& closure, + std::array lower, std::array upper) { + float rate = 0.0F; + const std::array dimensions{spec.shape.x, spec.shape.y, spec.shape.z}; + + for (std::size_t axis = 0; axis < dimensions.size(); ++axis) { + if (dimensions[axis] == 1) { + continue; + } + + if (closure.lower[axis]) { + lower[axis] = current; + } + + if (closure.upper[axis]) { + upper[axis] = current; + } + + const auto inverse_spacing = 1.0F / spacing[axis]; + rate += diffusion * (lower[axis] - (2.0F * current) + upper[axis]) * inverse_spacing * + inverse_spacing; + auto lower_flux = + faces.lower[axis] >= 0.0F ? faces.lower[axis] * lower[axis] : faces.lower[axis] * current; + auto upper_flux = + faces.upper[axis] >= 0.0F ? faces.upper[axis] * current : faces.upper[axis] * upper[axis]; + + if (closure.lower[axis]) { + lower_flux = 0.0F; + } + + if (closure.upper[axis]) { + upper_flux = 0.0F; + } + + rate -= (upper_flux - lower_flux) * inverse_spacing; + } + + return rate; +} + +std::vector signal_jacobi_candidate(const SignalGridSpec& spec, + std::span current, + std::span rates, + std::span right_hand_side, float half_dt) { + const auto sites = spec.site_count(); + std::vector next(current.size()); + + for (std::size_t signal = 0; signal < spec.signal_count; ++signal) { + for (std::uint32_t x = 0; x < spec.shape.x; ++x) { + for (std::uint32_t y = 0; y < spec.shape.y; ++y) { + for (std::uint32_t z = 0; z < spec.shape.z; ++z) { + const auto index = (signal * sites) + flat_site(spec.shape, x, y, z); + const auto diagonal = signal_operator_diagonal(spec, signal, x, y, z); + const auto remainder = rates[index] - (diagonal * current[index]); + next[index] = + (right_hand_side[index] + (half_dt * remainder)) / (1.0F - (half_dt * diagonal)); + } + } + } + } + + return next; +} + +} // namespace + std::vector signal_grid_transport_rates(const SignalGrid& grid, std::span levels) { grid.validate(); const auto& spec = grid.spec(); + if (levels.size() != spec.level_count()) { throw std::invalid_argument("signal transport level count does not match the grid"); } - if (!std::ranges::all_of(levels, [](float value) { return std::isfinite(value); })) { + + if (!std::ranges::all_of(levels, [](float value) { + return std::isfinite(value); + })) { throw std::invalid_argument("signal transport levels must be finite"); } + const auto sites = spec.site_count(); std::vector rates(levels.size(), 0.0F); @@ -777,6 +1059,7 @@ std::vector signal_grid_transport_rates(const SignalGrid& grid, for (std::size_t signal = 0; signal < spec.signal_count; ++signal) { const auto diffusion = spec.diffusion[signal]; const std::array spacing{spec.spacing.x, spec.spacing.y, spec.spacing.z}; + for (std::uint32_t x = 0; x < spec.shape.x; ++x) { for (std::uint32_t y = 0; y < spec.shape.y; ++y) { for (std::uint32_t z = 0; z < spec.shape.z; ++z) { @@ -784,6 +1067,7 @@ std::vector signal_grid_transport_rates(const SignalGrid& grid, rates[(signal * sites) + flat_site(spec.shape, x, y, z)] = 0.0F; continue; } + const auto current = level(signal, x, y, z); const auto faces = face_velocities(spec, signal, x, y, z); const auto closure = face_closure(spec, x, y, z); @@ -808,55 +1092,47 @@ std::vector signal_grid_transport_rates(const SignalGrid& grid, ? boundary_value(spec.z_upper, signal, current, level(signal, x, y, 0)) : level(signal, x, y, z + 1); - float rate = 0.0F; - const std::array dimensions{spec.shape.x, spec.shape.y, spec.shape.z}; - for (std::size_t axis = 0; axis < dimensions.size(); ++axis) { - if (dimensions[axis] == 1) { - continue; - } - if (closure.lower[axis]) { - lower[axis] = current; - } - if (closure.upper[axis]) { - upper[axis] = current; - } - const auto inverse_spacing = 1.0F / spacing[axis]; - rate += diffusion * (lower[axis] - (2.0F * current) + upper[axis]) * inverse_spacing * - inverse_spacing; - auto lower_flux = faces.lower[axis] >= 0.0F ? faces.lower[axis] * lower[axis] - : faces.lower[axis] * current; - auto upper_flux = faces.upper[axis] >= 0.0F ? faces.upper[axis] * current - : faces.upper[axis] * upper[axis]; - if (closure.lower[axis]) { - lower_flux = 0.0F; - } - if (closure.upper[axis]) { - upper_flux = 0.0F; - } - rate -= (upper_flux - lower_flux) * inverse_spacing; - } + const float rate = transport_stencil_rate(spec, diffusion, spacing, current, faces, + closure, lower, upper); rates[(signal * sites) + flat_site(spec.shape, x, y, z)] = rate; } } } } + return rates; } -SignalSolveResult signal_grid_crank_nicolson_candidate(const SignalGrid& grid, float dt, - std::span source_rates) { - grid.validate(); +namespace { +void validate_implicit_signal_inputs(const SignalGrid& grid, float dt, + std::span source_rates) { if (!std::isfinite(dt) || dt < 0.0F) { throw std::invalid_argument("time step must be finite and non-negative"); } + const auto& spec = grid.spec(); + if (!source_rates.empty() && source_rates.size() != spec.level_count()) { throw std::invalid_argument("signal source rate count does not match the grid"); } - if (!std::ranges::all_of(source_rates, [](float value) { return std::isfinite(value); })) { + + if (!std::ranges::all_of(source_rates, [](float value) { + return std::isfinite(value); + })) { throw std::invalid_argument("signal source rates must be finite"); } +} +} // namespace + +SignalSolveResult signal_grid_crank_nicolson_candidate(const SignalGrid& grid, float dt, + std::span source_rates) { + grid.validate(); + + validate_implicit_signal_inputs(grid, dt, source_rates); + const auto& spec = grid.spec(); + const auto old = grid.levels(); + if (dt == 0.0F) { return {.levels = std::vector(old.begin(), old.end()), .report = {}}; } @@ -866,10 +1142,12 @@ SignalSolveResult signal_grid_crank_nicolson_candidate(const SignalGrid& grid, f const auto half_dt = (backward ? 1.0F : 0.5F) * dt; const auto explicit_dt = backward ? 0.0F : half_dt; std::vector right_hand_side(old.size()); + for (std::size_t index = 0; index < old.size(); ++index) { const auto source = source_rates.empty() ? 0.0F : source_rates[index]; right_hand_side[index] = old[index] + (explicit_dt * old_rates[index]) + (dt * source); } + // The relative term scales the residual the step starts with, not the field // it starts from. A field's own magnitude says nothing about how much of it // this step has to change, so scaling by the field lets a small source fall @@ -890,43 +1168,37 @@ SignalSolveResult signal_grid_crank_nicolson_candidate(const SignalGrid& grid, f for (; iterations <= spec.solver.max_iterations; ++iterations) { const auto rates = signal_grid_operator_rates(grid, current); + for (std::size_t index = 0; index < current.size(); ++index) { residual[index] = right_hand_side[index] - current[index] + (half_dt * rates[index]); } + residual_rms = rms(residual); + if (!std::isfinite(residual_rms)) { break; } + if (iterations == 0) { threshold += spec.solver.relative_tolerance * residual_rms; } + if (residual_rms <= threshold) { return { .levels = std::move(current), .report = {.converged = true, .iterations = iterations, .residual_rms = residual_rms}, }; } + if (iterations == spec.solver.max_iterations) { break; } - const auto sites = spec.site_count(); - std::vector next(current.size()); - for (std::size_t signal = 0; signal < spec.signal_count; ++signal) { - for (std::uint32_t x = 0; x < spec.shape.x; ++x) { - for (std::uint32_t y = 0; y < spec.shape.y; ++y) { - for (std::uint32_t z = 0; z < spec.shape.z; ++z) { - const auto index = (signal * sites) + flat_site(spec.shape, x, y, z); - const auto diagonal = signal_operator_diagonal(spec, signal, x, y, z); - const auto remainder = rates[index] - (diagonal * current[index]); - next[index] = - (right_hand_side[index] + (half_dt * remainder)) / (1.0F - (half_dt * diagonal)); - } - } - } - } + auto next = signal_jacobi_candidate(spec, current, rates, right_hand_side, half_dt); + current = std::move(next); } + return { .levels = std::move(current), .report = {.converged = false, .iterations = iterations, .residual_rms = residual_rms}, @@ -936,14 +1208,19 @@ SignalSolveResult signal_grid_crank_nicolson_candidate(const SignalGrid& grid, f SignalSolveReport advance_signal_grid_cpu(SignalGrid& grid, float dt) { if (grid.spec().integration == SignalIntegrationKind::forward_euler) { grid.replace_levels(signal_grid_forward_euler_candidate(grid, dt)); + return {}; } + auto result = signal_grid_crank_nicolson_candidate(grid, dt); + if (!result.report.converged) { throw std::runtime_error("Implicit signal solve did not converge after " + std::to_string(result.report.iterations) + " iterations"); } + grid.replace_levels(std::move(result.levels)); + return result.report; } diff --git a/cpp/core/simulation.cpp b/cpp/core/simulation.cpp index 59f0b1e..29be841 100644 --- a/cpp/core/simulation.cpp +++ b/cpp/core/simulation.cpp @@ -6,6 +6,8 @@ #include #include +#include "culture_simulation_internal.hpp" + namespace cm { namespace { @@ -26,11 +28,13 @@ std::unique_ptr make_backend(BackendKind kind, std::uint32_t dev throw std::runtime_error("CUDA backend is not implemented in this build"); #endif } + throw std::runtime_error("unknown compute backend"); } const SimulationCheckpoint& validated_checkpoint(const SimulationCheckpoint& checkpoint) { checkpoint.validate(); + return checkpoint; } @@ -40,6 +44,7 @@ std::size_t backend_device_count(BackendKind kind) noexcept { if (kind == BackendKind::cpu) { return 1; } + #if CM_HAS_METAL if (kind == BackendKind::metal) { return metal_backend_device_count(); @@ -50,6 +55,7 @@ std::size_t backend_device_count(BackendKind kind) noexcept { return cuda_backend_device_count(); } #endif + return 0; } @@ -74,48 +80,292 @@ Simulation::Simulation(BackendKind backend, const SimulationCheckpoint& checkpoi : std::nullopt), coupled_rate_plan_(checkpoint.coupled_rate_plan), time_(checkpoint.time) { + if (checkpoint.culture) { + culture_ = + std::make_unique(state_, *checkpoint.culture, backend_->info()); + } + validate(); } -BackendInfo Simulation::backend_info() const { return backend_->info(); } +Simulation::~Simulation() = default; +Simulation::Simulation(Simulation&&) noexcept = default; +Simulation& Simulation::operator=(Simulation&&) noexcept = default; + +void Simulation::restore_checkpoint(const SimulationCheckpoint& checkpoint) { + const auto info = backend_->info(); + Simulation candidate(info.kind, checkpoint, info.device_index); + *this = std::move(candidate); +} + +void Simulation::configure_culture(const CultureConfiguration& configuration, + const std::vector& concentrations, + const std::vector& biochemical_volumes) { + if (culture_ || signal_grid_ || coupled_rate_plan_ || !constraints_.empty() || time_ != 0) { + throw std::logic_error( + "configure fluid flow once, after seeding cells and before stepping, without legacy " + "signals or constraints"); + } + + auto candidate = std::make_unique( + state_, configuration, concentrations, biochemical_volumes, backend_->info()); + culture_ = std::move(candidate); +} + +bool Simulation::has_culture() const noexcept { + return bool(culture_); +} + +std::optional Simulation::culture_checkpoint() const { + return culture_ ? std::optional(culture_->checkpoint()) : std::nullopt; +} + +std::vector Simulation::fluid_fragments() const { + return culture_ ? culture_->geometry().fragments() : std::vector{}; +} + +void Simulation::set_cell_force(CellId id, std::array force, + std::array torque) { + if (!culture_) { + throw std::logic_error("simulation does not have fluid flow"); + } + + culture_->set_force(id, force, torque); +} + +std::vector Simulation::cell_surface_concentrations(CellId id) const { + if (!culture_) { + throw std::logic_error("simulation does not have fluid flow"); + } + + (void)state_.cell(id); + const auto& state = culture_->checkpoint(); + const auto& mesh = culture_->geometry(); + const auto ns = state.configuration.solutes.size(); + std::vector result(ns); + double area = 0; + + for (const auto& face : mesh.faces()) { + if (face.body_id == id) { + area += face.area; + + for (std::size_t s = 0; s < ns; ++s) { + result[s] += face.area * state.extracellular_amounts[face.first * ns + s] / + mesh.fragments()[face.first].volume; + } + } + } + + if (!(area > 0)) { + throw std::runtime_error("culture cell has no resolved extracellular surface"); + } + + for (double& c : result) { + c /= area; + } + + return result; +} + +BackendInfo Simulation::backend_info() const { + return backend_->info(); +} bool Simulation::supports(BackendFeature feature) const noexcept { return backend_->supports(feature); } -double Simulation::time() const noexcept { return time_; } +double Simulation::time() const noexcept { + return time_; +} -std::size_t Simulation::cell_count() const noexcept { return state_.size(); } +std::size_t Simulation::cell_count() const noexcept { + return state_.size(); +} -std::size_t Simulation::species_count() const noexcept { return state_.species_count(); } +std::size_t Simulation::species_count() const noexcept { + return state_.species_count(); +} std::size_t Simulation::signal_count() const noexcept { + if (culture_) { + return culture_->checkpoint().configuration.solutes.size(); + } + return signal_grid_.has_value() ? signal_grid_->spec().signal_count : 0; } -bool Simulation::has_signal_grid() const noexcept { return signal_grid_.has_value(); } +bool Simulation::has_signal_grid() const noexcept { + return signal_grid_.has_value(); +} std::optional Simulation::last_signal_solve_report() const noexcept { return last_signal_solve_report_; } -bool Simulation::has_coupled_rate_plan() const noexcept { return coupled_rate_plan_.has_value(); } +bool Simulation::has_coupled_rate_plan() const noexcept { + return coupled_rate_plan_.has_value(); +} + +CellId Simulation::add_cell(const CellInit& cell) { + if (culture_) { + throw std::logic_error("seed cells before configuring fluid flow"); + } + + return state_.add_cell(cell); +} + +void Simulation::remove_cell(CellId id) { + if (culture_) { + throw std::logic_error("instantaneous cell removal has no conservative culture-volume model"); + } -CellId Simulation::add_cell(const CellInit& cell) { return state_.add_cell(cell); } + state_.remove_cell(id); +} + +namespace { +struct DriftUpdate { + Slot slot; + Vec3 position; + Vec3 direction; + float length; +}; + +std::pair drift_derivative(const SignalGrid& grid, + const MechanicsIntegrationParameters& parameters, + const std::array& h, + const std::array& dims, float lambda, + Vec3 point, Vec3 axis) { + const auto sample = [&](Vec3 value) { + return grid.sample_velocity(value, GridSampleBound::clamped); + }; + // Central differences of the interpolated fluid velocity. This is an + // equivalent-spheroid Jeffery closure, not cell-resolved hydrodynamics. + std::array gradient{}; + + for (std::size_t k = 0; k < 3; ++k) { + if (dims[k] == 1) { + continue; + } + + Vec3 offset{}; + + if (k == 0) { + offset.x = h[k] * 0.5F; + } -void Simulation::remove_cell(CellId id) { state_.remove_cell(id); } + if (k == 1) { + offset.y = h[k] * 0.5F; + } + + if (k == 2) { + offset.z = h[k] * 0.5F; + } + + gradient[k] = (sample(point + offset) - sample(point - offset)) * (1 / h[k]); + } + + const Vec3 ap = gradient[0] * axis.x + gradient[1] * axis.y + gradient[2] * axis.z; + const Vec3 atp{dot(gradient[0], axis), dot(gradient[1], axis), dot(gradient[2], axis)}; + const Vec3 strain = (ap + atp) * 0.5F; + const Vec3 spin = (ap - atp) * 0.5F; + const auto orientation = parameters.max_rotation_radians == 0 + ? Vec3{} + : spin + (strain - axis * dot(axis, strain)) * lambda; + + return std::pair{sample(point), orientation}; +} + +DriftUpdate propose_drift_update(const SignalGrid& grid, const CellGeometryView& geometry, + std::size_t slot, float dt, + const MechanicsIntegrationParameters& parameters, + const std::array& h, + const std::array& dims, float spatial_step) { + Vec3 position{geometry.position_x[slot], geometry.position_y[slot], geometry.position_z[slot]}; + Vec3 direction{geometry.direction_x[slot], geometry.direction_y[slot], + geometry.direction_z[slot]}; + const float aspect = + (geometry.lengths[slot] + 2 * geometry.radii[slot]) / (2 * geometry.radii[slot]); + const float lambda = (aspect * aspect - 1) / (aspect * aspect + 1); + const auto derivative = [&](Vec3 point, Vec3 axis) { + return drift_derivative(grid, parameters, h, dims, lambda, point, axis); + }; + double remaining = dt; + std::uint32_t steps = 0; + + while (remaining > 0) { + if (++steps > 100000) { + throw std::runtime_error("flow drift needs too many substeps; reduce dt"); + } + + const auto [velocity, orientation] = derivative(position, direction); + float step = static_cast(remaining); + + if (norm(velocity) > 0) { + step = std::min(step, spatial_step / norm(velocity)); + } + + if (norm(orientation) > 0) { + step = std::min(step, parameters.max_rotation_radians / norm(orientation)); + } + + Vec3 mid_velocity, mid_orientation; + + while (true) { + const auto midpoint = position + velocity * (step * 0.5F); + const auto mid_direction = normalized(direction + orientation * (step * 0.5F)); + std::tie(mid_velocity, mid_orientation) = derivative(midpoint, mid_direction); + + if (step * norm(mid_velocity) <= spatial_step * 1.001F && + (parameters.max_rotation_radians == 0 || + step * norm(mid_orientation) <= parameters.max_rotation_radians * 1.001F)) { + break; + } + + step *= 0.5F; + + if (step <= 0) { + throw std::runtime_error("flow drift substep underflow"); + } + } + + if (!std::isfinite(step) || step <= 0) { + throw std::runtime_error("invalid flow drift substep"); + } + + position = position + mid_velocity * step; + direction = normalized(direction + mid_orientation * step); + remaining = std::max(0.0, remaining - step); + } + + if (!std::isfinite(position.x) || !std::isfinite(position.y) || !std::isfinite(position.z)) { + throw std::runtime_error("flow drift produced non-finite geometry"); + } + + return {static_cast(slot), position, direction, geometry.lengths[slot]}; +} + +} // namespace void Simulation::apply_flow_drift(float dt, const MechanicsIntegrationParameters& parameters) { + if (culture_) { + throw std::logic_error("fluid flow advances body poses during Simulation.step"); + } + if (!std::isfinite(dt) || dt < 0.0F) { throw std::invalid_argument("time step must be finite and non-negative"); } + validate_mechanics_integration_parameters(parameters); + if (!signal_grid_.has_value() || !signal_grid_->spec().velocity_field.has_value()) { throw std::logic_error("flow drift requires a signal grid with a velocity field"); } + if (dt == 0.0F || state_.empty()) { return; } + const auto geometry = state_.geometry_state(); const auto attributes = state_.cell_attributes(); const auto spacing = signal_grid_->spec().spacing; @@ -123,141 +373,131 @@ void Simulation::apply_flow_drift(float dt, const MechanicsIntegrationParameters const std::array dims{ signal_grid_->spec().shape.x, signal_grid_->spec().shape.y, signal_grid_->spec().shape.z}; const float spatial_step = 0.25F * *std::min_element(h.begin(), h.end()); - struct DriftUpdate { - Slot slot; - Vec3 position; - Vec3 direction; - float length; - }; + std::vector updates; updates.reserve(geometry.size()); + for (std::size_t slot = 0; slot < geometry.size(); ++slot) { - if (attributes.fixed[slot] != 0) continue; - Vec3 position{geometry.position_x[slot], geometry.position_y[slot], geometry.position_z[slot]}; - Vec3 direction{geometry.direction_x[slot], geometry.direction_y[slot], - geometry.direction_z[slot]}; - const float aspect = - (geometry.lengths[slot] + 2 * geometry.radii[slot]) / (2 * geometry.radii[slot]); - const float lambda = (aspect * aspect - 1) / (aspect * aspect + 1); - const auto sample = [&](Vec3 point) { - return signal_grid_->sample_velocity(point, GridSampleBound::clamped); - }; - const auto derivative = [&](Vec3 point, Vec3 axis) { - // Central differences of the interpolated fluid velocity. This is an - // equivalent-spheroid Jeffery closure, not cell-resolved hydrodynamics. - std::array gradient{}; - for (std::size_t k = 0; k < 3; ++k) { - if (dims[k] == 1) continue; - Vec3 offset{}; - if (k == 0) offset.x = h[k] * 0.5F; - if (k == 1) offset.y = h[k] * 0.5F; - if (k == 2) offset.z = h[k] * 0.5F; - gradient[k] = (sample(point + offset) - sample(point - offset)) * (1 / h[k]); - } - const Vec3 ap = gradient[0] * axis.x + gradient[1] * axis.y + gradient[2] * axis.z; - const Vec3 atp{dot(gradient[0], axis), dot(gradient[1], axis), dot(gradient[2], axis)}; - const Vec3 strain = (ap + atp) * 0.5F; - const Vec3 spin = (ap - atp) * 0.5F; - const auto orientation = parameters.max_rotation_radians == 0 - ? Vec3{} - : spin + (strain - axis * dot(axis, strain)) * lambda; - return std::pair{sample(point), orientation}; - }; - double remaining = dt; - std::uint32_t steps = 0; - while (remaining > 0) { - if (++steps > 100000) - throw std::runtime_error("flow drift needs too many substeps; reduce dt"); - const auto [velocity, orientation] = derivative(position, direction); - float step = static_cast(remaining); - if (norm(velocity) > 0) step = std::min(step, spatial_step / norm(velocity)); - if (norm(orientation) > 0) - step = std::min(step, parameters.max_rotation_radians / norm(orientation)); - Vec3 mid_velocity, mid_orientation; - while (true) { - const auto midpoint = position + velocity * (step * 0.5F); - const auto mid_direction = normalized(direction + orientation * (step * 0.5F)); - std::tie(mid_velocity, mid_orientation) = derivative(midpoint, mid_direction); - if (step * norm(mid_velocity) <= spatial_step * 1.001F && - (parameters.max_rotation_radians == 0 || - step * norm(mid_orientation) <= parameters.max_rotation_radians * 1.001F)) - break; - step *= 0.5F; - if (step <= 0) throw std::runtime_error("flow drift substep underflow"); - } - if (!std::isfinite(step) || step <= 0) throw std::runtime_error("invalid flow drift substep"); - position = position + mid_velocity * step; - direction = normalized(direction + mid_orientation * step); - remaining = std::max(0.0, remaining - step); + if (attributes.fixed[slot] != 0) { + continue; } - if (!std::isfinite(position.x) || !std::isfinite(position.y) || !std::isfinite(position.z)) - throw std::runtime_error("flow drift produced non-finite geometry"); - updates.push_back({static_cast(slot), position, direction, geometry.lengths[slot]}); + + updates.push_back( + propose_drift_update(*signal_grid_, geometry, slot, dt, parameters, h, dims, spatial_step)); } + for (const auto& update : updates) { state_.set_cell_geometry(update.slot, update.position, update.direction, update.length); } } ConstraintId Simulation::add_plane_constraint(const PlaneConstraintInit& plane) { + if (culture_) { + throw std::logic_error("culture walls belong to FluidGridSpec"); + } + return constraints_.add_plane(plane); } ConstraintId Simulation::add_sphere_constraint(const SphereConstraintInit& sphere) { + if (culture_) { + throw std::logic_error("culture walls belong to FluidGridSpec"); + } + return constraints_.add_sphere(sphere); } ConstraintId Simulation::add_box_constraint(const BoxConstraintInit& box) { + if (culture_) { + throw std::logic_error("culture walls belong to FluidGridSpec"); + } + return constraints_.add_box(box); } ConstraintId Simulation::add_cylinder_constraint(const CylinderConstraintInit& cylinder) { + if (culture_) { + throw std::logic_error("culture walls belong to FluidGridSpec"); + } + return constraints_.add_cylinder(cylinder); } void Simulation::set_cell_geometry(CellId id, Vec3 position, Vec3 direction, float length) { + if (culture_) { + throw std::logic_error( + "culture poses change through physical stepping or conservative division"); + } + state_.set_cell_geometry(id, position, direction, length); } void Simulation::set_cell_attributes(CellId id, float growth_rate, std::int32_t cell_type) { + if (culture_ && !culture_->checkpoint().configuration.growth.empty() && growth_rate != 0) { + throw std::invalid_argument("kinetic culture owns growth; prescribed elongation must be zero"); + } + state_.set_cell_attributes(id, growth_rate, cell_type); } -void Simulation::set_cell_fixed(CellId id, bool fixed) { state_.set_cell_fixed(id, fixed); } +void Simulation::set_cell_fixed(CellId id, bool fixed) { + state_.set_cell_fixed(id, fixed); +} void Simulation::set_species(CellId id, std::span levels) { + if (culture_) { + culture_->set_species(id, levels); + } + state_.set_species(id, levels); } void Simulation::set_species_rate_plan(const SpeciesRatePlan& plan) { plan.validate(); + if (plan.species_count() != state_.species_count()) { throw std::invalid_argument("species rate plan and simulation species counts disagree"); } + species_rate_plan_ = plan; } void Simulation::set_coupled_rate_plan(const CoupledRatePlan& plan) { + if (culture_) { + throw std::logic_error("culture exchange uses explicit surface transfer bindings"); + } + plan.validate(); + if (!signal_grid_.has_value()) { throw std::logic_error("coupled rate plan requires a signal grid"); } + if (plan.species_count() != state_.species_count() || plan.signal_count() != signal_grid_->spec().signal_count) { throw std::invalid_argument("coupled rate plan counts disagree with the simulation"); } + coupled_rate_plan_ = plan; } -void Simulation::clear_coupled_rate_plan() noexcept { coupled_rate_plan_.reset(); } +void Simulation::clear_coupled_rate_plan() noexcept { + coupled_rate_plan_.reset(); +} void Simulation::configure_signal_grid(const SignalGridSpec& spec, std::vector levels) { + if (culture_) { + throw std::logic_error("culture solutes belong to CultureConfiguration"); + } + if (!state_.empty()) { throw std::logic_error("signal grid geometry must be configured before cells are added"); } + if (signal_grid_.has_value()) { throw std::logic_error("signal grid geometry is already configured"); } + signal_grid_.emplace(spec, std::move(levels)); } @@ -265,6 +505,7 @@ void Simulation::set_signal_levels(std::span levels) { if (!signal_grid_.has_value()) { throw std::logic_error("simulation does not have a signal grid"); } + signal_grid_->set_levels(levels); } @@ -272,6 +513,7 @@ void Simulation::set_velocity_field(std::optional field if (!signal_grid_.has_value()) { throw std::logic_error("simulation does not have a signal grid"); } + signal_grid_->set_velocity_field(std::move(field)); } @@ -279,45 +521,77 @@ void Simulation::set_signal_reaction(std::optional rea if (!signal_grid_.has_value()) { throw std::logic_error("simulation does not have a signal grid"); } + signal_grid_->set_reaction(std::move(reaction)); } std::pair Simulation::divide(CellId parent_id, float first_fraction) { + if (culture_) { + return culture_->divide(state_, parent_id, first_fraction); + } + return state_.divide(parent_id, first_fraction); } std::pair Simulation::divide_equal(CellId parent_id) { + if (culture_) { + return culture_->divide(state_, parent_id, 0.5); + } + return state_.divide_equal(parent_id); } -void Simulation::step(float dt) { - if (!std::isfinite(dt) || dt < 0.0F) { - throw std::invalid_argument("time step must be finite and non-negative"); - } - const auto geometry = state_.geometry_state(); - const std::vector previous_lengths(geometry.lengths.begin(), geometry.lengths.end()); +void Simulation::validate_step_backend(std::span previous_lengths, float dt) const { if (coupled_rate_plan_.has_value()) { if (!backend_->supports(BackendFeature::coupled_rates)) { throw std::runtime_error("selected backend does not implement coupled rates"); } + validate_coupled_step(state_, *signal_grid_, *coupled_rate_plan_, previous_lengths, dt); } else { if (state_.species_count() != 0 && !backend_->supports(BackendFeature::species)) { throw std::runtime_error("selected backend does not implement species integration"); } } + if (signal_grid_.has_value() && !coupled_rate_plan_.has_value()) { if (!backend_->supports(BackendFeature::signals)) { throw std::runtime_error("selected backend does not implement signal grid integration"); } + signal_grid_->validate_step(dt); } +} + +void Simulation::step(float dt) { + if (!std::isfinite(dt) || dt < 0.0F) { + throw std::invalid_argument("time step must be finite and non-negative"); + } + + if (culture_) { + if (!std::isfinite(time_ + dt)) { + throw std::overflow_error("simulation time overflow"); + } + + culture_->step(state_, *backend_, species_rate_plan_, dt); + time_ += dt; + + return; + } + + const auto geometry = state_.geometry_state(); + const std::vector previous_lengths(geometry.lengths.begin(), geometry.lengths.end()); + + validate_step_backend(previous_lengths, dt); + auto saved_state = state_; const auto saved_levels = signal_grid_.has_value() ? signal_levels() : std::vector{}; const auto saved_report = last_signal_solve_report_; + try { backend_->advance_growth(state_, dt); last_signal_solve_report_.reset(); + if (coupled_rate_plan_.has_value()) { last_signal_solve_report_ = backend_->advance_coupled( state_, *signal_grid_, *coupled_rate_plan_, previous_lengths, dt); @@ -325,16 +599,22 @@ void Simulation::step(float dt) { if (state_.species_count() != 0) { backend_->advance_species(state_, species_rate_plan_, previous_lengths, dt); } + if (signal_grid_.has_value()) { last_signal_solve_report_ = backend_->advance_signal_grid(*signal_grid_, dt); } } } catch (...) { state_ = std::move(saved_state); - if (signal_grid_.has_value()) signal_grid_->set_levels(saved_levels); + + if (signal_grid_.has_value()) { + signal_grid_->set_levels(saved_levels); + } + last_signal_solve_report_ = saved_report; throw; } + time_ += static_cast(dt); } @@ -347,6 +627,7 @@ ExternalContactGraph Simulation::find_external_contacts( if (!backend_->supports(BackendFeature::external_constraints)) { throw std::runtime_error("selected backend does not implement external constraints"); } + return backend_->find_external_contacts(state_, constraints_, parameters); } @@ -356,16 +637,21 @@ MechanicsSolveResult Simulation::solve_cell_mechanics( if (!backend_->supports(BackendFeature::cell_mechanics)) { throw std::runtime_error("selected backend does not implement cell mechanics"); } + validate_constraint_contact_parameters(constraint_parameters); + if (!constraints_.empty() && !backend_->supports(BackendFeature::external_constraints)) { throw std::runtime_error("selected backend does not implement external constraints"); } + const auto contacts = backend_->find_cell_contacts(state_, contact_parameters); ExternalContactGraph external_contacts(state_.size(), {}); + if (!constraints_.empty()) { external_contacts = backend_->find_external_contacts(state_, constraints_, constraint_parameters); } + return backend_->solve_cell_mechanics(state_, contacts, external_contacts, mechanics_parameters); } @@ -373,9 +659,14 @@ MechanicsSolveResult Simulation::relax_cell_mechanics( const MechanicsParameters& mechanics_parameters, const ContactParameters& contact_parameters, const MechanicsIntegrationParameters& integration_parameters, const ConstraintContactParameters& constraint_parameters) { + if (culture_) { + throw std::logic_error("fluid flow includes hydrodynamic motion and contact constraints"); + } + auto result = solve_cell_mechanics(mechanics_parameters, contact_parameters, constraint_parameters); integrate_mechanics_result(state_, result, integration_parameters); + return result; } @@ -385,6 +676,7 @@ DepthAveragedFlowResult Simulation::solve_depth_averaged_flow( if (!backend_->supports(BackendFeature::depth_averaged_flow)) { throw std::runtime_error("selected backend does not implement depth-averaged flow"); } + return backend_->solve_depth_averaged_flow(spec, mobility, parameters); } @@ -394,12 +686,17 @@ ResolvedFlowResult Simulation::solve_resolved_flow(const SignalGridSpec& spec, if (!backend_->supports(BackendFeature::resolved_flow)) { throw std::runtime_error("selected backend does not implement resolved flow"); } + return backend_->solve_resolved_flow(spec, drag, parameters); } -CellSnapshot Simulation::cell(CellId id) const { return state_.cell(id); } +CellSnapshot Simulation::cell(CellId id) const { + return state_.cell(id); +} -std::vector Simulation::cells() const { return state_.cells(); } +std::vector Simulation::cells() const { + return state_.cells(); +} std::optional Simulation::lineage_parent(CellId id) const noexcept { return state_.lineage_parent(id); @@ -409,6 +706,7 @@ std::vector Simulation::signal_levels() const { if (!signal_grid_.has_value()) { throw std::logic_error("simulation does not have a signal grid"); } + return std::vector(signal_grid_->levels().begin(), signal_grid_->levels().end()); } @@ -416,6 +714,7 @@ std::vector Simulation::sample_signals(Vec3 position) const { if (!signal_grid_.has_value()) { throw std::logic_error("simulation does not have a signal grid"); } + return signal_grid_->sample(position); } @@ -431,29 +730,40 @@ SimulationCheckpoint Simulation::checkpoint() const { ? std::optional(signal_grid_->checkpoint()) : std::nullopt, .coupled_rate_plan = coupled_rate_plan_, + .culture = culture_ ? std::optional(culture_->checkpoint()) : std::nullopt, }; result.validate(); + return result; } void Simulation::validate() const { + if (culture_) { + culture_->checkpoint().validate(state_.checkpoint()); + } + state_.validate(); constraints_.validate(); species_rate_plan_.validate(); + if (signal_grid_.has_value()) { signal_grid_->validate(); } + if (coupled_rate_plan_.has_value()) { coupled_rate_plan_->validate(); + if (!signal_grid_.has_value() || coupled_rate_plan_->species_count() != state_.species_count() || coupled_rate_plan_->signal_count() != signal_grid_->spec().signal_count) { throw std::logic_error("simulation coupled rate plan counts disagree with state"); } } + if (!std::isfinite(time_) || time_ < 0.0) { throw std::logic_error("simulation time must be finite and non-negative"); } + if (species_rate_plan_.species_count() != state_.species_count()) { throw std::logic_error("simulation rate plan and world species counts disagree"); } diff --git a/cpp/core/solute_transport.cpp b/cpp/core/solute_transport.cpp new file mode 100644 index 0000000..91a5740 --- /dev/null +++ b/cpp/core/solute_transport.cpp @@ -0,0 +1,339 @@ +#include "cm/solute_transport.hpp" + +#include +#include +#include + +#include "numerics_device.hpp" +#include "transport_geometry_internal.hpp" + +namespace cm { +namespace { +void require(bool ok, const char* message) { + if (!ok) { + throw std::invalid_argument(message); + } +} +} // namespace + +struct SoluteTransportSolver::Impl { + std::unique_ptr device; + + void validate_chemical_input(const std::vector& amounts, + const std::vector& solutes, std::size_t old_count) { + const auto ns = solutes.size(); + require(amounts.size() == old_count * ns, "chemical amount dimensions mismatch"); + + for (double a : amounts) { + require(std::isfinite(a) && a >= 0, "chemical amounts must be finite and nonnegative"); + } + + std::set names; + + for (const auto& s : solutes) { + require(!s.name.empty() && names.insert(s.name).second && std::isfinite(s.diffusion) && + s.diffusion >= 0, + "invalid solute definition"); + } + } + + void map_reservoirs(const std::vector& ports, + const std::vector& reservoirs, std::size_t ns, + std::vector>& reservoir, + std::vector& boundary_kind, + std::vector& backflow) { + std::set names; + + for (const auto& r : reservoirs) { + const auto found = std::find_if(ports.begin(), ports.end(), [&](const auto& p) { + return p.name == r.port; + }); + require(found != ports.end() && names.insert(r.port).second && r.concentrations.size() == ns, + "invalid culture reservoir"); + + for (double c : r.concentrations) { + require(std::isfinite(c) && c >= 0, "invalid reservoir concentration"); + } + + const auto index = std::size_t(found - ports.begin()); + require(r.kind == ChemicalBoundaryKind::reservoir_contact || + r.kind == ChemicalBoundaryKind::advective || + r.kind == ChemicalBoundaryKind::outflow, + "invalid chemical boundary kind"); + reservoir[index] = r.concentrations; + boundary_kind[index] = r.kind; + backflow[index] = r.allow_backflow; + } + + for (const auto& r : reservoir) { + require(r.size() == ns, "missing culture reservoir concentrations"); + } + } + + void validate_surface_exchange(const std::vector& exchange, + const std::map& body_area, std::size_t ns) { + for (const auto& e : exchange) { + require(body_area.contains(e.body_id) && e.solute < ns && std::isfinite(e.uptake_velocity) && + e.uptake_velocity >= 0 && std::isfinite(e.secretion_rate) && + e.secretion_rate >= 0, + "invalid surface transfer"); + } + } + + detail::NumericsLinearResult solve_species(const TransportGeometry::Impl& g, + const std::vector& solutes, + const std::vector>& reservoir, + const std::vector& boundary_kind, + const std::vector& exchange, + const std::vector& initial, std::size_t s, + double amount_scale, + const LinearSolveParameters& parameters) { + const auto n = g.group_count, ns = solutes.size(); + const double dt = g.dt, h = g.grid.spacing, vh = h * h * h; + const auto& v1 = g.v1; + const auto& edges = g.edges; + const auto& membranes = g.membranes; + const auto& body_area = g.body_area; + detail::NumericsLinearRows system(n); + std::vector source(n); + + for (std::uint32_t i = 0; i < n; ++i) { + system[i].emplace_back(i, v1[i] / vh); + source[i] = initial[i * ns + s] / amount_scale; + } + + for (const auto& e : edges) { + const auto a = e.first, b = e.second; + const double diffusion = + (b == fluid_boundary && boundary_kind[e.port] != ChemicalBoundaryKind::reservoir_contact) + ? 0 + : solutes[s].diffusion * e.area / (h * (b == fluid_boundary ? 0.5 : 1)); + const double out = dt * (diffusion + std::max(e.q, 0.0)) / vh; + const double in = dt * (diffusion + std::max(-e.q, 0.0)) / vh; + system[a].emplace_back(a, out); + + if (b == fluid_boundary) { + source[a] += in * reservoir[e.port][s] * vh / amount_scale; + } else { + system[a].emplace_back(b, -in); + system[b].emplace_back(a, -out); + system[b].emplace_back(b, in); + } + } + + for (const auto& membrane : membranes) { + for (const auto& e : exchange) { + if (e.solute == s && e.body_id == membrane.body) { + system[membrane.group].emplace_back(membrane.group, + dt * e.uptake_velocity * membrane.area / vh); + source[membrane.group] += + dt * e.secretion_rate * membrane.area / body_area.at(e.body_id) / amount_scale; + } + } + } + + auto chemical_parameters = parameters; + // Preserve relative accuracy even when a dilute/slow feed makes the + // equilibrated right-hand side much smaller than the reservoir scale. + chemical_parameters.absolute_tolerance = 0; + const auto solved = + detail::solve_numerics_linear(device.get(), std::move(system), source, chemical_parameters); + std::vector concentration(n); + + for (std::size_t i = 0; i < n; ++i) { + concentration[i] = solved.solution[i] * amount_scale / vh; + + if (concentration[i] < 0) { + throw std::runtime_error( + "solute transport produced a negative concentration; candidate rejected"); + } + } + + auto result = solved; + result.solution = std::move(concentration); + + return result; + } + + void record_species_ledger(const TransportGeometry::Impl& g, const std::vector& solutes, + const std::vector>& reservoir, + const std::vector& boundary_kind, + const std::vector& exchange, + const std::map& cell_transfer, + const std::vector& concentration, std::size_t s, + double total_initial, double amount_scale, + const LinearSolveParameters& parameters, + SoluteTransportResult& result) { + const auto old_count = g.old_count, new_count = g.new_count, ns = solutes.size(); + const double dt = g.dt, h = g.grid.spacing; + const auto& group = g.group; + const auto& edges = g.edges; + const auto& membranes = g.membranes; + const auto& body_area = g.body_area; + double total_final = 0, injected = 0, uptake = 0; + + for (std::size_t i = 0; i < new_count; ++i) { + const double c = concentration[group[old_count + i]], m = c * g.final_volumes[i]; + result.amounts[i * ns + s] = m; + result.concentrations[i * ns + s] = c; + total_final += m; + } + + for (const auto& e : edges) { + if (e.second == fluid_boundary) { + const double diffusion = boundary_kind[e.port] == ChemicalBoundaryKind::reservoir_contact + ? solutes[s].diffusion * e.area / (h * 0.5) + : 0; + const double transfer = dt * ((diffusion + std::max(-e.q, 0.0)) * reservoir[e.port][s] - + (diffusion + std::max(e.q, 0.0)) * concentration[e.first]); + result.reservoirs[e.port].amounts[s] += transfer; + injected += transfer; + } + } + + for (const auto& membrane : membranes) { + for (const auto& e : exchange) { + if (e.solute == s && e.body_id == membrane.body) { + const double transfer = dt * membrane.area * + (e.uptake_velocity * concentration[membrane.group] - + e.secretion_rate / body_area.at(e.body_id)); + result.cells[cell_transfer.at(e.body_id)].amounts[s] += transfer; + uptake += transfer; + } + } + } + + for (const auto& membrane : membranes) { + result.surfaces[cell_transfer.at(membrane.body)].concentrations[s] += + membrane.area * concentration[membrane.group] / body_area.at(membrane.body); + } + + const double error = total_final - total_initial - injected + uptake; + result.report.mass_balance_error[s] = error; + const double budget = total_initial + std::abs(injected) + std::abs(uptake) + total_final; + + if (std::abs(error) > 10 * (parameters.relative_tolerance * budget + + parameters.absolute_tolerance * amount_scale)) { + throw std::runtime_error("solute transport mass ledger failed"); + } + } + + void validate_outlet_backflow(const TransportGeometry::Impl& g, + const std::vector& boundary_kind, + const std::vector& backflow) { + const auto& edges = g.edges; + const double h = g.grid.spacing, vh = h * h * h, dt = g.dt; + + for (const auto& e : edges) { + if (e.second == fluid_boundary && boundary_kind[e.port] == ChemicalBoundaryKind::outflow && + !backflow[e.port] && e.q < -1e-10 * vh / dt) { + throw std::runtime_error("chemical outlet backflow requires an external composition"); + } + } + } + + Impl(BackendKind kind, std::uint32_t index) : device(detail::make_numerics_device(kind, index)) {} +}; + +SoluteTransportSolver::SoluteTransportSolver(BackendKind kind, std::uint32_t index) + : impl_(std::make_unique(kind, index)) {} + +SoluteTransportSolver::~SoluteTransportSolver() = default; + +SoluteTransportResult SoluteTransportSolver::step( + const FluidGeometry& before, const FluidGeometry& after, const MacVelocityField& velocity, + const std::vector& ports, const std::vector& solutes, + const std::vector& reservoirs, const std::vector& amounts, double dt, + const std::vector& exchange, const LinearSolveParameters& parameters) { + const auto geometry = TransportGeometry::prepare(before, after, velocity, ports, dt, parameters, + impl_->device.get()); + + return propose(geometry, solutes, reservoirs, amounts, exchange, parameters); +} + +SoluteTransportResult SoluteTransportSolver::propose( + const TransportGeometry& prepared, const std::vector& solutes, + const std::vector& reservoirs, const std::vector& amounts, + const std::vector& exchange, const LinearSolveParameters& parameters) { + parameters.validate(); + const auto& g = *prepared.impl_; + const auto& grid = g.grid; + const auto& ports = g.ports; + const double dt = g.dt, h = grid.spacing, vh = h * h * h; + const auto old_count = g.old_count, new_count = g.new_count, n = g.group_count, + ns = solutes.size(); + const auto& group = g.group; + const auto& body_area = g.body_area; + impl_->validate_chemical_input(amounts, solutes, old_count); + + std::vector> reservoir(ports.size()); + std::vector boundary_kind(ports.size()); + std::vector backflow(ports.size(), true); + impl_->map_reservoirs(ports, reservoirs, ns, reservoir, boundary_kind, backflow); + + impl_->validate_surface_exchange(exchange, body_area, ns); + + std::vector initial(n * ns); + + for (std::size_t i = 0; i < old_count; ++i) { + for (std::size_t s = 0; s < ns; ++s) { + initial[group[i] * ns + s] += amounts[i * ns + s]; + } + } + + SoluteTransportResult result; + result.report.projection_iterations = g.report.projection_iterations; + result.report.maximum_volume_residual = g.report.maximum_volume_residual; + + impl_->validate_outlet_backflow(g, boundary_kind, backflow); + + result.amounts.resize(new_count * ns); + result.concentrations.resize(new_count * ns); + result.report.mass_balance_error.resize(ns); + + for (const auto& p : ports) { + result.reservoirs.push_back({p.name, 0, std::vector(ns)}); + } + + std::map cell_transfer; + + for (const auto& [body, area] : body_area) { + cell_transfer[body] = result.cells.size(); + result.cells.push_back({"", body, std::vector(ns)}); + result.surfaces.push_back({body, area, std::vector(ns)}); + } + + for (std::size_t s = 0; s < ns; ++s) { + double amount_scale = 0, total_initial = 0; + + for (std::size_t i = 0; i < n; ++i) { + amount_scale = std::max(amount_scale, initial[i * ns + s]); + total_initial += initial[i * ns + s]; + } + + for (const auto& r : reservoir) { + amount_scale = std::max(amount_scale, r[s] * vh); + } + + for (const auto& e : exchange) { + if (e.solute == s) { + amount_scale = std::max(amount_scale, e.secretion_rate * dt); + } + } + + if (amount_scale == 0) { + continue; + } + + const auto solved = impl_->solve_species(g, solutes, reservoir, boundary_kind, exchange, + initial, s, amount_scale, parameters); + result.report.transport_iterations += solved.iterations; + const auto& concentration = solved.solution; + + impl_->record_species_ledger(g, solutes, reservoir, boundary_kind, exchange, cell_transfer, + concentration, s, total_initial, amount_scale, parameters, result); + } + + return result; +} +} // namespace cm diff --git a/cpp/core/species.cpp b/cpp/core/species.cpp index 3be757c..6fd7077 100644 --- a/cpp/core/species.cpp +++ b/cpp/core/species.cpp @@ -7,13 +7,34 @@ #include #include +#include "rate_operations.hpp" + namespace cm { +void BiochemicalVolumeView::validate(std::size_t count) const { + if (previous.empty() && current.empty()) { + return; + } + + if (previous.size() != count || current.size() != count) { + throw std::invalid_argument("biochemical volume count disagrees with cells"); + } + + for (auto values : {previous, current}) { + for (float v : values) { + if (!std::isfinite(v) || v <= 0) { + throw std::invalid_argument("biochemical volumes must be finite and positive"); + } + } + } +} + namespace { std::uint32_t checked_index(std::size_t index) { if (index > std::numeric_limits::max()) { throw std::overflow_error("species rate plan exceeds the uint32 index space"); } + return static_cast(index); } @@ -24,6 +45,48 @@ void validate_input(std::uint32_t input, std::size_t instruction_index, const ch } } +void validate_instruction(const RateInstruction& instruction, std::size_t index, + std::size_t species_count) { + if (detail::is_cell_property_operation(instruction.operation)) { + return; + } + + if (detail::is_unary_operation(instruction.operation)) { + validate_input(instruction.first, index, "unary"); + + return; + } + + if (detail::is_binary_operation(instruction.operation)) { + validate_input(instruction.first, index, "first"); + validate_input(instruction.second, index, "second"); + + return; + } + + switch (instruction.operation) { + case RateOp::constant: + if (!std::isfinite(instruction.value)) { + throw std::invalid_argument("species rate constants must be finite"); + } + break; + case RateOp::species: + if (instruction.first >= species_count) { + throw std::invalid_argument("species rate instruction uses an unknown species"); + } + break; + case RateOp::signal: + throw std::invalid_argument("standalone species rate plans cannot sample signals"); + case RateOp::select: + validate_input(instruction.first, index, "condition"); + validate_input(instruction.second, index, "true"); + validate_input(instruction.third, index, "false"); + break; + default: + throw std::invalid_argument("species rate plan uses an unknown operation"); + } +} + } // namespace SpeciesRatePlan::SpeciesRatePlan(std::size_t species_count, @@ -39,83 +102,41 @@ SpeciesRatePlan SpeciesRatePlan::zero(std::size_t species_count) { if (species_count == 0) { return {}; } + std::vector instructions{{.operation = RateOp::constant}}; + return SpeciesRatePlan(species_count, std::move(instructions), std::vector(species_count, 0)); } -std::size_t SpeciesRatePlan::species_count() const noexcept { return species_count_; } +std::size_t SpeciesRatePlan::species_count() const noexcept { + return species_count_; +} std::span SpeciesRatePlan::instructions() const& noexcept { return instructions_; } -std::span SpeciesRatePlan::outputs() const& noexcept { return outputs_; } +std::span SpeciesRatePlan::outputs() const& noexcept { + return outputs_; +} void SpeciesRatePlan::validate() const { checked_index(species_count_); checked_index(instructions_.size()); + if (outputs_.size() != species_count_) { throw std::invalid_argument("species rate output count must match the species count"); } + if (species_count_ != 0 && instructions_.empty()) { throw std::invalid_argument("a nonempty species rate plan needs instructions"); } for (std::size_t index = 0; index < instructions_.size(); ++index) { const auto& instruction = instructions_[index]; - switch (instruction.operation) { - case RateOp::constant: - if (!std::isfinite(instruction.value)) { - throw std::invalid_argument("species rate constants must be finite"); - } - break; - case RateOp::species: - if (instruction.first >= species_count_) { - throw std::invalid_argument("species rate instruction uses an unknown species"); - } - break; - case RateOp::signal: - throw std::invalid_argument("standalone species rate plans cannot sample signals"); - case RateOp::position_x: - case RateOp::position_y: - case RateOp::position_z: - case RateOp::cell_length: - case RateOp::cell_radius: - case RateOp::growth_rate: - case RateOp::cell_type: - case RateOp::cell_volume: - case RateOp::cell_volume_change_rate: - case RateOp::cell_surface_area: - break; - case RateOp::negate: - case RateOp::exponential: - case RateOp::logarithm: - validate_input(instruction.first, index, "unary"); - break; - case RateOp::add: - case RateOp::subtract: - case RateOp::multiply: - case RateOp::divide: - case RateOp::power: - case RateOp::minimum: - case RateOp::maximum: - case RateOp::less: - case RateOp::less_equal: - case RateOp::greater: - case RateOp::greater_equal: - case RateOp::equal: - validate_input(instruction.first, index, "first"); - validate_input(instruction.second, index, "second"); - break; - case RateOp::select: - validate_input(instruction.first, index, "condition"); - validate_input(instruction.second, index, "true"); - validate_input(instruction.third, index, "false"); - break; - default: - throw std::invalid_argument("species rate plan uses an unknown operation"); - } + + validate_instruction(instruction, index, species_count_); } for (const auto output : outputs_) { @@ -129,11 +150,13 @@ float effective_cell_volume(float length, float radius) noexcept { // Conserved biomass volume for the endpoint-preserving division rule. // This is a biochemical measure, not the geometric capsule volume. constexpr float pi = 3.14159265358979323846F; + return pi * radius * radius * (length + 2.0F * radius); } float effective_cell_surface_area(float length, float radius) noexcept { constexpr float pi = 3.14159265358979323846F; + return 2.0F * pi * radius * (length + 2.0F * radius); } diff --git a/cpp/core/stokes_flow.cpp b/cpp/core/stokes_flow.cpp new file mode 100644 index 0000000..1a2aff3 --- /dev/null +++ b/cpp/core/stokes_flow.cpp @@ -0,0 +1,1348 @@ +#include "cm/stokes_flow.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "capsule_bodies.hpp" +#include "capsule_contacts.hpp" +#include "flexible_gmres.hpp" +#include "flow_system.hpp" +#include "fluid_multigrid.hpp" +#include "numerics_device.hpp" +#include "numerics_linear.hpp" + +namespace cm { +namespace { +constexpr std::uint32_t absent = std::numeric_limits::max(); +using Coordinates = std::array; +using detail::norm2; +using detail::solve_cpu; +using detail::solve_gpu; +using detail::SparseMatrix; +using Row = std::vector>; + +void require(bool condition, const char* message) { + if (!condition) { + throw std::invalid_argument(message); + } +} + +bool positive(double x) { + return std::isfinite(x) && x > 0; +} + +std::uint32_t index32(std::size_t n) { + if (n >= absent) { + throw std::length_error("fluid flow exceeds uint32 indexing"); + } + + return static_cast(n); +} + +struct PortedSystem { + FluidGridSpec lattice; + detail::FlowGridLayout layout; + std::vector velocity, pressure, component, face_port, port_pressure; + std::vector> port_faces; + std::vector reference_pressure; + std::vector volume_source_m3_s; + std::vector body_volume_rate_m3_s; + std::vector rows; + std::vector rhs; + double h_m, speed_scale, pressure_scale; + std::uint64_t estimated_bytes; + std::size_t velocity_count{0}; + std::vector markers; + std::vector body_velocity; + std::uint32_t marker_start{0}; + std::uint32_t contact_start{0}; + + PortedSystem(const FluidGridSpec& grid, const FluidProperties& fluid, + const std::vector& ports, const LinearSolveParameters& parameters, + const std::vector& bodies, + const std::vector& contacts) + : lattice(grid), + layout(lattice, FlowAxis::y), + h_m(double(grid.spacing) * grid.length_unit_m), + speed_scale(h_m / grid.time_unit_s), + pressure_scale(0), + estimated_bytes(0) { + const auto n = layout.site_count(), nf = layout.total_face_count(); + // Includes peak host assembly and 2*120 Arnoldi vectors in CPU binary64. + // Checked before allocating topology, not after a failed device allocation. + estimated_bytes = (n + nf + ports.size() + contacts.size()) * + (bodies.empty() ? std::uint64_t{4096} : std::uint64_t{12288}); + + validate_bodies(fluid, parameters, bodies); + initialize_topology(ports.size()); + const auto nc = identify_components(); + const auto [component_source, component_source_scale] = distribute_growth_sources(bodies, nc); + const auto anchored = + initialize_ports(ports, fluid, nc, component_source, component_source_scale); + + allocate_velocity_dofs(); + allocate_constraint_dofs(ports, bodies, contacts.size(), anchored); + + assemble_normal_strain(); + assemble_shear_strain(); + assemble_port_tractions(ports); + assemble_body_coupling(bodies); + assemble_body_forces(fluid, bodies, contacts); + } + + private: + void validate_bodies(const FluidProperties& fluid, const LinearSolveParameters& parameters, + const std::vector& bodies) { + const auto& grid = lattice; + const auto n = layout.site_count(); + std::set body_ids; + + for (const auto& body : bodies) { + body.validate(); + require(body_ids.insert(body.id).second, "duplicate fluid body id"); + require(body.radius >= 2 * grid.spacing, + "fluid body radius requires at least two grid spacings"); + const double markers_estimate = 32 + 4 * std::numbers::pi * body.radius * + (body.length + 2 * body.radius) / + (double(grid.spacing) * grid.spacing); + require(markers_estimate * 32768 <= double(parameters.memory_limit_bytes), + "fluid body marker memory estimate exceeds memory_limit_bytes"); + const auto body_bytes = static_cast(markers_estimate * 32768); + require(body_bytes <= parameters.memory_limit_bytes && + estimated_bytes <= parameters.memory_limit_bytes - body_bytes, + "fluid body memory estimate exceeds memory_limit_bytes"); + estimated_bytes += body_bytes; + const auto direction = detail::body_rotate(body.orientation, {1, 0, 0}); + std::array start{}, end{}; + const std::array origin{grid.origin.x, grid.origin.y, grid.origin.z}; + + for (std::size_t a = 0; a < 3; ++a) { + start[a] = body.position[a] - 0.5 * body.length * direction[a]; + end[a] = body.position[a] + 0.5 * body.length * direction[a]; + require(std::min(start[a], end[a]) - body.radius >= origin[a] - 0.5 * grid.spacing && + std::max(start[a], end[a]) + body.radius <= + origin[a] + (double(layout.dimensions()[a]) - 0.5) * grid.spacing, + "fluid body intersects the domain boundary"); + } + + speed_scale = + std::max(speed_scale, std::abs(body.length_rate) * grid.length_unit_m / grid.time_unit_s); + + for (std::size_t s = 0; s < n; ++s) { + if (lattice.solid_site(s)) { + const auto x = layout.site_coordinates(s); + std::array lo{}, hi{}; + + for (std::size_t a = 0; a < 3; ++a) { + lo[a] = origin[a] + (double(x[a]) - 0.5) * grid.spacing; + hi[a] = lo[a] + grid.spacing; + } + + require(detail::body_segment_box_distance_squared(start, end, lo, hi) >= + body.radius * body.radius, + "fluid body penetrates a wall voxel"); + } + } + + for (std::size_t a = 0; a < 3; ++a) { + speed_scale = + std::max(speed_scale, std::abs(body.force_n[a]) / (fluid.viscosity_pa_s * h_m)); + speed_scale = + std::max(speed_scale, std::abs(body.torque_nm[a]) / (fluid.viscosity_pa_s * h_m * h_m)); + } + } + + require(estimated_bytes <= parameters.memory_limit_bytes, + "fluid memory estimate exceeds memory_limit_bytes"); + + for (std::size_t a = 0; a < bodies.size(); ++a) { + for (std::size_t b = a + 1; b < bodies.size(); ++b) { + const auto [a0, a1] = detail::body_endpoints(bodies[a]); + const auto [b0, b1] = detail::body_endpoints(bodies[b]); + const auto [pa, pb] = detail::body_closest_segments(a0, a1, b0, b1); + double distance = 0; + + for (std::size_t d = 0; d < 3; ++d) { + distance += std::pow(pa[d] - pb[d], 2); + } + + require(std::sqrt(distance) >= bodies[a].radius + bodies[b].radius, "fluid bodies overlap"); + } + } + } + + void initialize_topology(std::size_t port_count) { + const auto n = layout.site_count(), nf = layout.total_face_count(); + index32(n + nf + port_count); + velocity.assign(nf, absent); + pressure.assign(n, absent); + component.assign(n, absent); + face_port.assign(nf, absent); + port_pressure.assign(port_count, absent); + port_faces.resize(port_count); + } + + std::uint32_t identify_components() { + const auto n = layout.site_count(); + // Components are defined by face connectivity; sealed pockets have their + // own pressure gauge and cannot exchange mass through a corner. + std::uint32_t nc = 0; + + for (std::size_t seed = 0; seed < n; ++seed) { + if (lattice.solid_site(seed) || component[seed] != absent) { + continue; + } + + std::vector pending{seed}; + component[seed] = nc; + + for (std::size_t q = 0; q < pending.size(); ++q) { + for (std::size_t a = 0; a < 3; ++a) { + for (int sign : {-1, 1}) { + auto neighbor = layout.neighbor_site(pending[q], a, sign); + + if (neighbor && !lattice.solid_site(*neighbor) && component[*neighbor] == absent) { + component[*neighbor] = nc; + pending.push_back(*neighbor); + } + } + } + } + + ++nc; + } + + return nc; + } + + std::pair, std::vector> distribute_growth_sources( + const std::vector& bodies, std::uint32_t nc) { + const auto& grid = lattice; + const auto n = layout.site_count(); + reference_pressure.assign(nc, 0); + volume_source_m3_s.resize(n, 0); + std::vector component_source(nc, 0), component_source_scale(nc, 0); + + for (const auto& body : bodies) { + const double rate = std::numbers::pi * body.radius * body.radius * body.length_rate * + std::pow(grid.length_unit_m, 3) / grid.time_unit_s; + require(std::isfinite(rate), "fluid body volume rate overflows physical units"); + body_volume_rate_m3_s.push_back(rate); + + if (rate == 0) { + continue; + } + + const auto axis = detail::body_rotate(body.orientation, {1, 0, 0}); + const std::array origin{grid.origin.x, grid.origin.y, grid.origin.z}; + std::vector inside; + + for (std::size_t s = 0; s < n; ++s) { + const auto x = layout.site_coordinates(s); + std::array r{}; + double axial = 0; + + for (std::size_t a = 0; a < 3; ++a) { + r[a] = origin[a] + double(x[a]) * grid.spacing - body.position[a]; + axial += r[a] * axis[a]; + } + + axial = std::clamp(axial, -body.length / 2, body.length / 2); + double distance = 0; + + for (std::size_t a = 0; a < 3; ++a) { + distance += std::pow(r[a] - axial * axis[a], 2); + } + + if (distance < body.radius * body.radius) { + inside.push_back(s); + } + } + + require(!inside.empty(), "growing fluid body has no interior source cells"); + const auto c = component[inside[0]]; + require(c != absent, "growing fluid body intersects a wall"); + + for (auto s : inside) { + require(component[s] == c, "growing fluid body spans disconnected fluid components"); + volume_source_m3_s[s] += rate / double(inside.size()); + } + + component_source[c] += rate; + component_source_scale[c] += std::abs(rate); + } + + return {std::move(component_source), std::move(component_source_scale)}; + } + + std::vector resolve_port_sites(const FlowPort& port, std::size_t axis) const { + const auto n = layout.site_count(); + auto sites = port.sites; + + if (sites.empty()) { + for (std::size_t s = 0; s < n; ++s) { + if (!lattice.solid_site(s) && + layout.site_coordinates(s)[axis] == (port.upper ? layout.dimensions()[axis] - 1 : 0)) { + sites.push_back(index32(s)); + } + } + } + + require(!sites.empty(), "flow port is entirely blocked"); + + return sites; + } + + std::uint32_t register_port_faces(const FlowPort& port, std::size_t k, std::size_t axis, + const std::vector& sites) { + const auto n = layout.site_count(); + std::uint32_t c = absent; + + for (auto site : sites) { + require(site < n && !lattice.solid_site(site), "flow port site is not fluid"); + auto coordinates = layout.site_coordinates(site); + require(coordinates[axis] == (port.upper ? layout.dimensions()[axis] - 1 : 0), + "flow port site does not touch its declared boundary"); + + if (c == absent) { + c = component[site]; + } + + require(c == component[site], "a flow port must belong to one connected fluid component"); + + if (port.upper) { + ++coordinates[axis]; + } + + const auto face = layout.face_index(axis, coordinates[0], coordinates[1], coordinates[2]); + require(face_port[face] == absent, "flow ports contain duplicate or overlapping faces"); + face_port[face] = index32(k); + port_faces[k].push_back(face); + } + + return c; + } + + std::vector initialize_ports(const std::vector& ports, + const FluidProperties& fluid, std::uint32_t nc, + const std::vector& component_source, + const std::vector& component_source_scale) { + std::vector anchored(nc, false); + std::vector net_rate(nc, 0), rate_scale(nc, 0); + std::set names; + + for (std::size_t k = 0; k < ports.size(); ++k) { + const auto& port = ports[k]; + const auto axis = static_cast(port.axis); + require(axis < 3, "invalid flow port axis"); + require(!port.name.empty() && names.insert(port.name).second, + "flow port names must be nonempty and unique"); + require(std::isfinite(port.value), "flow port value must be finite"); + require(port.kind == FlowPortKind::pressure || port.kind == FlowPortKind::flow_rate, + "invalid flow port kind"); + const auto sites = resolve_port_sites(port, axis); + + const auto c = register_port_faces(port, k, axis, sites); + + if (port.kind == FlowPortKind::pressure) { + if (!anchored[c]) { + reference_pressure[c] = port.value; + } + + anchored[c] = true; + } else { + net_rate[c] += port.value; + rate_scale[c] += std::abs(port.value); + speed_scale = std::max( + speed_scale, std::abs(port.value) / (h_m * h_m * static_cast(sites.size()))); + } + } + + for (std::size_t c = 0; c < nc; ++c) { + require(anchored[c] || std::abs(net_rate[c] - component_source[c]) <= + 1e-10 * (rate_scale[c] + component_source_scale[c]), + "prescribed flow rates are incompatible in an unanchored fluid component"); + } + + for (std::size_t k = 0; k < ports.size(); ++k) { + auto face = port_faces[k][0]; + auto [axis, x] = layout.face_coordinates(face); + auto site = layout.adjacent_site(axis, x, ports[k].upper ? -1 : 1); + + if (ports[k].kind == FlowPortKind::pressure) { + speed_scale = + std::max(speed_scale, std::abs(ports[k].value - reference_pressure[component[*site]]) * + h_m / fluid.viscosity_pa_s); + } + } + + pressure_scale = fluid.viscosity_pa_s * speed_scale / h_m; + require(positive(pressure_scale) && positive(speed_scale), "hydraulic unit scales overflow"); + + return anchored; + } + + void allocate_velocity_dofs() { + const auto nf = layout.total_face_count(); + + for (std::size_t f = 0; f < nf; ++f) { + auto [axis, x] = layout.face_coordinates(f); + auto lo = layout.adjacent_site(axis, x, -1), hi = layout.adjacent_site(axis, x, 1); + + if ((lo && hi && !lattice.solid_site(*lo) && !lattice.solid_site(*hi)) || + face_port[f] != absent) { + velocity[f] = index32(velocity_count++); + } + } + } + + void allocate_constraint_dofs(const std::vector& ports, + const std::vector& bodies, std::size_t contact_count, + const std::vector& anchored) { + const auto& grid = lattice; + const auto n = layout.site_count(); + std::size_t size = velocity_count; + std::vector gauged = anchored; + + for (std::size_t s = 0; s < n; ++s) { + if (component[s] == absent) { + continue; + } + + if (!gauged[component[s]]) { + gauged[component[s]] = true; + } else { + pressure[s] = index32(size++); + } + } + + for (std::size_t k = 0; k < ports.size(); ++k) { + if (ports[k].kind == FlowPortKind::flow_rate) { + port_pressure[k] = index32(size++); + } + } + + markers = detail::capsule_surface_markers(bodies, 2 * grid.spacing); + marker_start = index32(size); + size += 3 * markers.size(); + body_velocity.resize(bodies.size(), absent); + + for (std::size_t b = 0; b < bodies.size(); ++b) { + if (!bodies[b].fixed) { + body_velocity[b] = index32(size); + size += 6; + } + } + + contact_start = index32(size); + size += contact_count; + index32(size); + rows.resize(size); + rhs.resize(size); + } + + void add(std::uint32_t i, std::uint32_t j, double v) { + if (i != absent && j != absent && v != 0) { + rows[i].emplace_back(j, v); + } + } + + void energy(const Row& gradient, double weight) { + for (auto [i, a] : gradient) { + for (auto [j, b] : gradient) { + add(i, j, weight * a * b); + } + } + } + + std::uint32_t face_dof(std::size_t a, Coordinates x) { + return velocity[layout.face_index(a, x[0], x[1], x[2])]; + } + + void assemble_normal_strain() { + const auto n = layout.site_count(); + + // 2 mu e:e. Normal strains live at cell centers and shear strains on + // edges. This symmetric energy form gives pressure TRACTION at ports, + // including the normal viscous stress, instead of rescaling a velocity. + for (std::size_t s = 0; s < n; ++s) { + if (lattice.solid_site(s)) { + continue; + } + + if (pressure[s] != absent) { + rhs[pressure[s]] = -volume_source_m3_s[s] / (h_m * h_m * speed_scale); + } + + auto x = layout.site_coordinates(s); + + for (std::size_t a = 0; a < 3; ++a) { + auto y = x; + ++y[a]; + auto lo = face_dof(a, x), hi = face_dof(a, y); + energy({{lo, -1}, {hi, 1}}, 2); + add(pressure[s], lo, 1); + add(lo, pressure[s], 1); + add(pressure[s], hi, -1); + add(hi, pressure[s], -1); + } + } + } + + unsigned edge_fluid_sites(std::size_t a, std::size_t b, Coordinates edge, + bool (&fluid)[2][2]) const { + unsigned count = 0; + + for (unsigned i = 0; i < 2; ++i) { + for (unsigned j = 0; j < 2; ++j) { + if ((i == 0 && edge[a] == 0) || (j == 0 && edge[b] == 0)) { + continue; + } + + auto q = edge; + q[a] -= 1 - i; + q[b] -= 1 - j; + + if (q[a] >= layout.dimensions()[a] || q[b] >= layout.dimensions()[b]) { + continue; + } + + fluid[i][j] = !lattice.solid_site(layout.site_index(q[0], q[1], q[2])); + count += fluid[i][j] ? 1U : 0U; + } + } + + return count; + } + + bool assemble_wall_strain(std::size_t a, std::size_t b, Coordinates edge, + const bool (&fluid)[2][2], unsigned count, Row& strain) { + bool flat = false; + + // A half control volume at a no-slip wall uses a reflected + // tangential ghost. At an open port, tangential traction is zero. + if (count == 2) { + for (unsigned axis = 0; axis < 2; ++axis) { + for (unsigned side = 0; side < 2; ++side) { + bool pair = + axis == 0 ? fluid[side][0] && fluid[side][1] : fluid[0][side] && fluid[1][side]; + + if (!pair) { + continue; + } + + flat = true; + const auto normal = axis == 0 ? a : b, tangent = axis == 0 ? b : a; + auto q0 = edge, q1 = edge; + --q0[tangent]; + const auto f0 = layout.face_index(normal, q0[0], q0[1], q0[2]); + const auto f1 = layout.face_index(normal, q1[0], q1[1], q1[2]); + + if (face_port[f0] != absent && face_port[f1] != absent) { + continue; + } + + auto q = edge; + q[normal] -= 1 - side; + strain.emplace_back(face_dof(tangent, q), side == 1 ? 2 : -2); + strain.emplace_back(velocity[f0], -1); + strain.emplace_back(velocity[f1], 1); + energy(strain, 0.5); + } + } + } + + return flat; + } + + void assemble_edge_strain(std::size_t a, std::size_t b, Coordinates edge) { + bool fluid[2][2]{}; + const unsigned count = edge_fluid_sites(a, b, edge, fluid); + + if (count <= 1) { + return; + } + + Row strain; + const bool flat = assemble_wall_strain(a, b, edge, fluid, count, strain); + + if (flat || count == 2) { + return; + } + + for (auto [normal, tangent] : {std::pair{a, b}, std::pair{b, a}}) { + if (edge[normal] > 0) { + auto q = edge; + --q[normal]; + strain.emplace_back(face_dof(tangent, q), -1); + } + + if (edge[normal] < layout.dimensions()[normal]) { + strain.emplace_back(face_dof(tangent, edge), 1); + } + } + + energy(strain, double(count) / 4); + } + + void assemble_shear_strain() { + for (std::size_t a = 0; a < 3; ++a) { + for (std::size_t b = a + 1; b < 3; ++b) { + auto dims = layout.dimensions(); + ++dims[a]; + ++dims[b]; + + for (std::uint32_t x = 0; x < dims[0]; ++x) { + for (std::uint32_t y = 0; y < dims[1]; ++y) { + for (std::uint32_t z = 0; z < dims[2]; ++z) { + assemble_edge_strain(a, b, {x, y, z}); + } + } + } + } + } + } + + void assemble_port_tractions(const std::vector& ports) { + for (std::size_t k = 0; k < ports.size(); ++k) { + const auto& port = ports[k]; + const double sign = port.upper ? 1 : -1; + + for (auto f : port_faces[k]) { + if (port.kind == FlowPortKind::pressure) { + auto [a, q] = layout.face_coordinates(f); + auto site = layout.adjacent_site(a, q, port.upper ? -1 : 1); + rhs[velocity[f]] = + -sign * (port.value - reference_pressure[component[*site]]) / pressure_scale; + } else { + add(velocity[f], port_pressure[k], sign); + add(port_pressure[k], velocity[f], sign); + } + } + + if (port.kind == FlowPortKind::flow_rate) { + rhs[port_pressure[k]] = port.value / (h_m * h_m * speed_scale); + } + } + } + + void couple_marker_to_fluid(std::size_t a, std::uint32_t row, const std::array& base, + const std::array, 3>& weights) { + for (unsigned i = 0; i < 6; ++i) { + for (unsigned j = 0; j < 6; ++j) { + for (unsigned k = 0; k < 6; ++k) { + std::array q{base[0] + 3 - int(i), base[1] + 3 - int(j), base[2] + 3 - int(k)}; + const double weight = weights[0][i] * weights[1][j] * weights[2][k]; + + if (std::abs(weight) < 1e-18) { + continue; + } + + // No-slip walls have zero extension. This is a bounded wall + // regularization; unresolved lubrication is not added to the model. + bool inside = true; + + for (std::size_t d = 0; d < 3; ++d) { + inside = inside && q[d] >= 0 && q[d] < int(layout.dimensions()[d] + (a == d ? 1U : 0U)); + } + + if (!inside) { + continue; + } + + const auto face = + layout.face_index(a, std::uint32_t(q[0]), std::uint32_t(q[1]), std::uint32_t(q[2])); + require(face_port[face] == absent, "fluid body kernel intersects an open port"); + add(row, velocity[face], weight); + add(velocity[face], row, weight); + } + } + } + } + + void assemble_body_coupling(const std::vector& bodies) { + const auto& grid = lattice; + // Symmetric constrained-IB system: A u + G p + J^T lambda = f, + // J u - K U = 0, -K^T lambda = F_external. Lambda is force ON the body. + const std::array origin{grid.origin.x, grid.origin.y, grid.origin.z}; + + for (std::size_t m = 0; m < markers.size(); ++m) { + const auto& marker = markers[m]; + const auto& body = bodies[marker.body]; + const auto body_axis = detail::body_rotate(body.orientation, {1, 0, 0}); + + for (std::size_t a = 0; a < 3; ++a) { + const auto row = index32(marker_start + 3 * m + a); + rhs[row] = body_axis[a] * marker.axial_fraction * body.length_rate * grid.length_unit_m / + (grid.time_unit_s * speed_scale); + std::array base{}; + std::array, 3> weights{}; + + for (std::size_t d = 0; d < 3; ++d) { + const double p = (body.position[d] + marker.offset[d] - origin[d]) / grid.spacing; + require(p > -0.5 && p < double(layout.dimensions()[d]) - 0.5, + "fluid body intersects the domain boundary"); + const double t = p + (a == d ? 0.5 : 0); + base[d] = static_cast(std::floor(t)); + weights[d] = detail::immersed_delta_weights(t - base[d]); + } + + couple_marker_to_fluid(a, row, base, weights); + + if (body_velocity[marker.body] != absent) { + const auto u = body_velocity[marker.body]; + add(row, u + std::uint32_t(a), -1); + add(u + std::uint32_t(a), row, -1); + + for (std::size_t d = 0; d < 3; ++d) { + std::array unit{}; + unit[d] = 1; + const auto& r = marker.offset; + const std::array cross{unit[1] * r[2] - unit[2] * r[1], + unit[2] * r[0] - unit[0] * r[2], + unit[0] * r[1] - unit[1] * r[0]}; + const double v = -cross[a] / grid.spacing; + add(row, u + 3 + std::uint32_t(d), v); + add(u + 3 + std::uint32_t(d), row, v); + } + } + } + } + } + + void assemble_body_forces(const FluidProperties& fluid, const std::vector& bodies, + const std::vector& contacts) { + const auto& grid = lattice; + const double force_scale = fluid.viscosity_pa_s * speed_scale * h_m; + require(positive(force_scale) && positive(force_scale * h_m), + "fluid force units overflow or underflow"); + + for (std::size_t b = 0; b < bodies.size(); ++b) { + if (body_velocity[b] != absent) { + for (std::size_t a = 0; a < 3; ++a) { + rhs[body_velocity[b] + a] = bodies[b].force_n[a] / force_scale; + rhs[body_velocity[b] + 3 + a] = bodies[b].torque_nm[a] / (force_scale * h_m); + } + } + } + + for (std::size_t c = 0; c < contacts.size(); ++c) { + const auto row = contact_start + index32(c); + bool movable = false; + + for (bool second : {false, true}) { + const auto b = second ? contacts[c].second : contacts[c].first; + + if (b == detail::fluid_wall_body || body_velocity[b] == absent) { + continue; + } + + movable = true; + const auto gradient = detail::contact_velocity_row(contacts[c], second, grid.spacing); + + for (std::uint32_t a = 0; a < 6; ++a) { + add(row, body_velocity[b] + a, -gradient[a]); + add(body_velocity[b] + a, row, -gradient[a]); + } + } + + require(movable, "fluid contact cannot be satisfied by fixed bodies"); + rhs[row] = + -contacts[c].required_speed * grid.length_unit_m / (grid.time_unit_s * speed_scale); + } + } +}; + +} // namespace + +std::size_t FluidGridSpec::site_count() const { + const auto xy = std::uint64_t(shape.x) * shape.y; + + if (shape.z && xy >= absent / shape.z) { + throw std::length_error("fluid grid exceeds uint32 indexing"); + } + + return std::size_t(xy * shape.z); +} + +void FluidGridSpec::validate() const { + require(shape.x >= 2 && shape.y >= 2 && shape.z >= 2, + "resolved fluid flow requires at least two sites along every axis"); + (void)site_count(); + require(positive(spacing) && positive(length_unit_m) && positive(time_unit_s), + "fluid spacing and unit scales must be finite and positive"); + require(std::isfinite(origin.x) && std::isfinite(origin.y) && std::isfinite(origin.z), + "fluid origin must be finite"); + const double h = double(spacing) * length_unit_m; + require(positive(h * h * h) && positive(h * std::max({shape.x, shape.y, shape.z})), + "physical fluid grid dimensions overflow or underflow"); + require(obstacles.empty() || obstacles.size() == site_count(), "fluid obstacle size mismatch"); + + for (auto v : obstacles) { + require(v <= 1, "fluid obstacles must be Boolean"); + } +} + +void FluidProperties::validate() const { + require(positive(viscosity_pa_s) && positive(density_kg_m3), + "fluid viscosity and density must be finite and positive"); +} + +void CapsuleBody::validate() const { + require(id != 0, "fluid body id must be nonzero"); + require(std::isfinite(length) && length >= 0 && positive(radius), + "invalid fluid body dimensions"); + require(std::isfinite(length_rate), "nonfinite fluid body length rate"); + + for (double x : position) { + require(std::isfinite(x), "nonfinite fluid body position"); + } + + for (double x : force_n) { + require(std::isfinite(x), "nonfinite fluid body force"); + } + + for (double x : torque_nm) { + require(std::isfinite(x), "nonfinite fluid body torque"); + } + + double q = 0; + + for (double x : orientation) { + require(std::isfinite(x), "nonfinite fluid orientation"); + q += x * x; + } + + require(std::abs(q - 1) < 1e-10, "fluid orientation must be a unit quaternion"); +} + +double CapsuleBody::geometric_volume() const { + return std::numbers::pi * radius * radius * (length + 4 * radius / 3); +} + +struct StokesFlowSolver::Impl { + BackendKind backend; + std::unique_ptr device; + + explicit Impl(BackendKind kind, std::uint32_t index) + : backend(kind), device(detail::make_numerics_device(kind, index)) {} +}; + +StokesFlowSolver::StokesFlowSolver(BackendKind backend, std::uint32_t device_index) + : impl_(std::make_unique(backend, device_index)) {} + +StokesFlowSolver::~StokesFlowSolver() = default; + +FluidFlowResult StokesFlowSolver::solve(const FluidGridSpec& grid, const FluidProperties& fluid, + const std::vector& ports, + const LinearSolveParameters& parameters) { + return solve_bodies(grid, fluid, ports, {}, parameters); +} + +static std::vector velocity_hierarchy(const PortedSystem& system, + const SparseMatrix& matrix) { + detail::CsrMatrix::Rows velocity_rows(system.velocity_count); + + for (std::size_t i = 0; i < system.velocity_count; ++i) { + for (auto j = matrix.offsets[i]; j < matrix.offsets[i + 1]; ++j) { + if (matrix.columns[j] < system.velocity_count) { + velocity_rows[i][matrix.columns[j]] = matrix.values[j]; + } + } + } + + std::vector coordinates(system.velocity_count); + + for (std::size_t f = 0; f < system.velocity.size(); ++f) { + if (system.velocity[f] != absent) { + auto [axis, x] = system.layout.face_coordinates(f); + coordinates[system.velocity[f]] = {static_cast(axis), x[0], x[1], x[2]}; + } + } + + return detail::build_velocity_hierarchy(detail::CsrMatrix(velocity_rows), coordinates); +} + +static double reconstruct_body_results(const PortedSystem& system, const FluidGridSpec& grid, + const FluidProperties& fluid, + const std::vector& bodies, + const std::vector& solution, + FluidFlowResult& result) { + result.bodies.resize(bodies.size()); + const double force_scale = fluid.viscosity_pa_s * system.speed_scale * system.h_m; + + for (std::size_t b = 0; b < bodies.size(); ++b) { + auto& body = result.bodies[b]; + body.id = bodies[b].id; + body.volume_change_rate_m3_s = system.body_volume_rate_m3_s[b]; + + if (system.body_velocity[b] != absent) { + for (std::size_t a = 0; a < 3; ++a) { + body.velocity[a] = solution[system.body_velocity[b] + a] * system.speed_scale * + grid.time_unit_s / grid.length_unit_m; + body.angular_velocity[a] = solution[system.body_velocity[b] + 3 + a] * system.speed_scale * + grid.time_unit_s / system.h_m; + } + } + } + + for (std::size_t m = 0; m < system.markers.size(); ++m) { + const auto& marker = system.markers[m]; + auto& body = result.bodies[marker.body]; + ++body.marker_count; + std::array force{}; + + for (std::size_t a = 0; a < 3; ++a) { + const auto row = system.marker_start + 3 * m + a; + force[a] = solution[row] * force_scale; + body.hydrodynamic_force_n[a] += force[a]; + double error = -system.rhs[row]; + + for (auto [j, v] : system.rows[row]) { + error += v * solution[j]; + } + + body.no_slip_rms_m_s += error * error * system.speed_scale * system.speed_scale; + } + + const auto& r = marker.offset; + body.hydrodynamic_torque_nm[0] += (r[1] * force[2] - r[2] * force[1]) * grid.length_unit_m; + body.hydrodynamic_torque_nm[1] += (r[2] * force[0] - r[0] * force[2]) * grid.length_unit_m; + body.hydrodynamic_torque_nm[2] += (r[0] * force[1] - r[1] * force[0]) * grid.length_unit_m; + } + + for (auto& body : result.bodies) { + body.no_slip_rms_m_s = std::sqrt(body.no_slip_rms_m_s / (3 * body.marker_count)); + } + + return force_scale; +} + +static void reconstruct_port_results(const PortedSystem& system, const std::vector& ports, + const std::vector& solution, + const std::vector& velocity, FluidFlowResult& result) { + auto& report = result.report; + + for (std::size_t k = 0; k < ports.size(); ++k) { + double q = 0; + + for (auto f : system.port_faces[k]) { + q += velocity[f] * (ports[k].upper ? 1 : -1); + } + + auto [a, x] = system.layout.face_coordinates(system.port_faces[k][0]); + auto s = system.layout.adjacent_site(a, x, ports[k].upper ? -1 : 1); + const double p = ports[k].kind == FlowPortKind::pressure + ? ports[k].value + : solution[system.port_pressure[k]] * system.pressure_scale + + system.reference_pressure[system.component[*s]]; + result.ports.push_back( + {ports[k].name, p, q * system.h_m * system.h_m, + static_cast(system.port_faces[k].size()) * system.h_m * system.h_m}); + report.net_flow_rate_m3_s += result.ports.back().flow_rate_m3_s; + } +} + +static void reconstruct_fluid_results(const PortedSystem& system, const FluidGridSpec& grid, + const FluidProperties& fluid, + const std::vector& ports, + const std::vector& solution, + FluidFlowResult& result) { + auto& report = result.report; + std::vector velocity(system.layout.total_face_count()); + + for (std::size_t f = 0; f < velocity.size(); ++f) { + if (system.velocity[f] != absent) { + velocity[f] = solution[system.velocity[f]] * system.speed_scale; + } + } + + result.pressure_pa.resize(system.layout.site_count(), 0); + double divergence_square = 0, continuity_square = 0; + std::size_t fluid_count = 0; + + for (std::size_t s = 0; s < result.pressure_pa.size(); ++s) { + if (system.component[s] == absent) { + continue; + } + + ++fluid_count; + result.pressure_pa[s] = + system.reference_pressure[system.component[s]] + + (system.pressure[s] == absent ? 0 : solution[system.pressure[s]] * system.pressure_scale); + auto x = system.layout.site_coordinates(s); + double divergence = 0; + + for (std::size_t a = 0; a < 3; ++a) { + auto y = x; + ++y[a]; + divergence += (velocity[system.layout.face_index(a, y[0], y[1], y[2])] - + velocity[system.layout.face_index(a, x[0], x[1], x[2])]) / + system.h_m; + } + + divergence_square += divergence * divergence; + const double source = system.volume_source_m3_s[s] / std::pow(system.h_m, 3); + continuity_square += (divergence - source) * (divergence - source); + report.source_volume_rate_m3_s += system.volume_source_m3_s[s]; + } + + reconstruct_port_results(system, ports, solution, velocity, result); + + report.divergence_rms_per_s = + fluid_count ? std::sqrt(divergence_square / double(fluid_count)) : 0; + report.continuity_rms_per_s = + fluid_count ? std::sqrt(continuity_square / double(fluid_count)) : 0; + std::array*, 3> fields{&result.field.x_faces, &result.field.y_faces, + &result.field.z_faces}; + + for (std::size_t a = 0; a < 3; ++a) { + fields[a]->resize(system.layout.face_counts()[a]); + + for (std::size_t j = 0; j < fields[a]->size(); ++j) { + const double u = velocity[system.layout.face_offsets()[a] + j]; + report.max_speed_m_s = std::max(report.max_speed_m_s, std::abs(u)); + const float v = float(u * grid.time_unit_s / grid.length_unit_m); + + if (!std::isfinite(v)) { + throw std::overflow_error("fluid model velocity overflow"); + } + + (*fields[a])[j] = v; + } + } + + const double length = system.h_m * std::max({grid.shape.x, grid.shape.y, grid.shape.z}); + report.reynolds_number = + fluid.density_kg_m3 * report.max_speed_m_s * length / fluid.viscosity_pa_s; + report.viscous_relaxation_time_s = fluid.density_kg_m3 * length * length / fluid.viscosity_pa_s; +} + +static FluidFlowResult solve_stokes_system(detail::NumericsDevice* device, + const FluidGridSpec& grid, const FluidProperties& fluid, + const std::vector& ports, + const std::vector& bodies, + const LinearSolveParameters& parameters, + const std::vector& contacts, + std::vector* contact_forces) { + grid.validate(); + fluid.validate(); + parameters.validate(); + + if (device) { + require(parameters.relative_tolerance >= std::numeric_limits::epsilon(), + "GPU fluid relative tolerance must be at least float32 epsilon"); + } + + PortedSystem system(grid, fluid, ports, parameters, bodies, contacts); + SparseMatrix matrix(system.rows); + const auto hierarchy = velocity_hierarchy(system, matrix); + + auto rhs = system.rhs; + + for (std::size_t i = 0; i < rhs.size(); ++i) { + rhs[i] *= matrix.scale[i]; + } + + const auto solved = device + ? solve_gpu(*device, matrix, rhs, parameters, hierarchy, !bodies.empty()) + : solve_cpu(matrix, rhs, parameters, hierarchy, !bodies.empty()); + auto residual = rhs; + matrix.apply(solved.solution, residual); + + for (std::size_t i = 0; i < rhs.size(); ++i) { + residual[i] -= rhs[i]; + } + + const double absolute = norm2(residual), rhs_norm = norm2(rhs); + + if (!std::isfinite(absolute) || + absolute > 1.05 * std::max(parameters.absolute_tolerance, + parameters.relative_tolerance * rhs_norm)) { + throw std::runtime_error("fluid true residual failed; candidate rejected"); + } + + auto solution = solved.solution; + + for (std::size_t i = 0; i < solution.size(); ++i) { + solution[i] *= matrix.scale[i]; + + if (!std::isfinite(solution[i])) { + throw std::runtime_error("nonfinite fluid solution"); + } + } + + FluidFlowResult result; + auto& report = result.report; + report.iterations = solved.iterations; + report.relative_residual = rhs_norm == 0 ? 0 : absolute / rhs_norm; + report.absolute_residual = absolute; + report.estimated_memory_bytes = system.estimated_bytes; + reconstruct_fluid_results(system, grid, fluid, ports, solution, result); + + const double force_scale = + reconstruct_body_results(system, grid, fluid, bodies, solution, result); + + if (contact_forces) { + contact_forces->resize(contacts.size()); + + for (std::size_t c = 0; c < contacts.size(); ++c) { + (*contact_forces)[c] = solution[system.contact_start + c] * force_scale; + } + } + + return result; +} + +FluidFlowResult StokesFlowSolver::solve_bodies(const FluidGridSpec& grid, + const FluidProperties& fluid, + const std::vector& ports, + const std::vector& bodies, + const LinearSolveParameters& parameters) { + return solve_stokes_system(impl_->device.get(), grid, fluid, ports, bodies, parameters, {}, + nullptr); +} + +void FluidBodyStepParameters::validate() const { + require(std::isfinite(minimum_gap_m) && minimum_gap_m >= 0, "invalid fluid minimum contact gap"); + require(positive(maximum_displacement_fraction) && maximum_displacement_fraction <= 0.25, + "body displacement fraction must be in (0,0.25]"); + require(max_halvings <= 64 && max_contact_iterations > 0, "invalid fluid body step limits"); +} + +static bool remove_attractive_contact(std::vector& active, + const std::vector& forces, double force_tolerance) { + std::size_t remove = active.size(); + double most_negative = -force_tolerance; + + for (std::size_t c = 0; c < forces.size(); ++c) { + if (forces[c] < most_negative) { + most_negative = forces[c]; + remove = c; + } + } + + if (remove < active.size()) { + active.erase(active.begin() + static_cast(remove)); + + return true; + } + + return false; +} + +static bool add_violated_contact(std::vector& active, + const std::vector& candidates, + const FluidFlowResult& flow, const FluidGridSpec& grid, + double dt) { + double worst = -1e-7 * grid.spacing / dt; + std::size_t add = candidates.size(); + + for (std::size_t c = 0; c < candidates.size(); ++c) { + if (std::find(active.begin(), active.end(), c) != active.end()) { + continue; + } + + const double slack = + detail::contact_separation_speed(candidates[c], flow) - candidates[c].required_speed; + + if (slack < worst) { + worst = slack; + add = c; + } + } + + if (add < candidates.size()) { + active.push_back(add); + + return true; + } + + return false; +} + +static bool advance_body_positions(std::vector& bodies, const FluidFlowResult& flow, + double dt, const FluidGridSpec& grid, + const FluidBodyStepParameters& step_parameters) { + bool bounded = true; + + for (std::size_t b = 0; b < bodies.size(); ++b) { + const auto& motion = flow.bodies[b]; + auto& body = bodies[b]; + const double speed = std::sqrt(detail::body_dot(motion.velocity, motion.velocity)); + const double spin = + std::sqrt(detail::body_dot(motion.angular_velocity, motion.angular_velocity)); + const double displacement = + dt * (speed + (body.length / 2 + body.radius) * spin + std::abs(body.length_rate) / 2); + + if (displacement > step_parameters.maximum_displacement_fraction * grid.spacing || + body.length + dt * body.length_rate < 0) { + bounded = false; + break; + } + + for (std::size_t d = 0; d < 3; ++d) { + body.position[d] += dt * motion.velocity[d]; + } + + body.length += dt * body.length_rate; + + if (spin > 0) { + const double angle = dt * spin; + const double s = std::sin(angle / 2) / spin; + const std::array dq{std::cos(angle / 2), s * motion.angular_velocity[0], + s * motion.angular_velocity[1], + s * motion.angular_velocity[2]}; + const auto& q = body.orientation; + std::array next{dq[0] * q[0] - dq[1] * q[1] - dq[2] * q[2] - dq[3] * q[3], + dq[0] * q[1] + dq[1] * q[0] + dq[2] * q[3] - dq[3] * q[2], + dq[0] * q[2] - dq[1] * q[3] + dq[2] * q[0] + dq[3] * q[1], + dq[0] * q[3] + dq[1] * q[2] - dq[2] * q[1] + dq[3] * q[0]}; + double norm = 0; + + for (double x : next) { + norm += x * x; + } + + for (double& x : next) { + x /= std::sqrt(norm); + } + + body.orientation = next; + } + } + + return bounded; +} + +static void record_active_contacts(const FluidGridSpec& grid, + const std::vector& bodies, + const std::vector& candidates, + const std::vector& active, + const std::vector& forces, FluidBodyStep& candidate) { + for (std::size_t c = 0; c < active.size(); ++c) { + const auto& contact = candidates[active[c]]; + auto point = bodies[contact.first].position; + + for (std::size_t a = 0; a < 3; ++a) { + point[a] += contact.first_offset[a]; + } + + candidate.contacts.push_back( + {bodies[contact.first].id, + contact.second == detail::fluid_wall_body ? 0 : bodies[contact.second].id, contact.normal, + point, contact.gap * grid.length_unit_m, forces[c]}); + } +} + +FluidBodyStep StokesFlowSolver::propose_body_step( + const FluidGridSpec& grid, const FluidProperties& fluid, const std::vector& ports, + const std::vector& bodies, double maximum_dt, + const LinearSolveParameters& solve_parameters, const FluidBodyStepParameters& step_parameters) { + step_parameters.validate(); + require(positive(maximum_dt) && positive(maximum_dt * grid.time_unit_s), + "body step time must be finite and positive"); + const auto unconstrained = solve_bodies(grid, fluid, ports, bodies, solve_parameters); + const double gap = step_parameters.minimum_gap_m / grid.length_unit_m; + require(std::isfinite(gap), "fluid contact gap overflows model units"); + const double guarded_gap = gap + 1e-5 * grid.spacing; + double dt = maximum_dt; + std::string failure = "body displacement exceeds the geometric step limit"; + + for (std::uint32_t halving = 0; halving <= step_parameters.max_halvings; ++halving, dt *= 0.5) { + if (!positive(dt)) { + break; + } + + const auto candidates = + detail::capsule_contacts(grid, bodies, guarded_gap, dt, 0.5 * grid.spacing); + std::vector active; + std::vector forces; + auto flow = unconstrained; + bool converged = false; + std::uint32_t iteration = 0; + + for (; iteration < step_parameters.max_contact_iterations; ++iteration) { + std::vector constraints; + + for (auto c : active) { + constraints.push_back(candidates[c]); + } + + if (!active.empty()) { + flow = solve_stokes_system(impl_->device.get(), grid, fluid, ports, bodies, + solve_parameters, constraints, &forces); + } else { + flow = unconstrained; + forces.clear(); + } + + const double force_tolerance = + std::max(1e-30, solve_parameters.relative_tolerance * fluid.viscosity_pa_s * + flow.report.max_speed_m_s * grid.spacing * grid.length_unit_m); + + if (remove_attractive_contact(active, forces, force_tolerance)) { + continue; + } + + if (add_violated_contact(active, candidates, flow, grid, dt)) { + continue; + } + + converged = true; + break; + } + + if (!converged) { + throw std::runtime_error( + "fluid contact complementarity did not converge; candidate rejected"); + } + + FluidBodyStep candidate; + candidate.bodies = bodies; + candidate.accepted_dt = dt; + candidate.halvings = halving; + candidate.contact_iterations = iteration + 1; + const bool bounded = advance_body_positions(candidate.bodies, flow, dt, grid, step_parameters); + + if (!bounded) { + continue; + } + + try { + const auto after = detail::capsule_contacts(grid, candidate.bodies, gap, dt, 0); + bool separated = true; + + for (const auto& c : after) { + separated = separated && c.gap >= gap; + } + + if (!separated) { + failure = "nonlinear contact gap failed after the proposed motion"; + continue; + } + + // Check domain, ports, walls, resolution, and budgets before returning a + // candidate. No second fluid solution is implied by this geometry check. + (void)PortedSystem(grid, fluid, ports, solve_parameters, candidate.bodies, {}); + } catch (const std::invalid_argument& error) { + failure = error.what(); + continue; + } + + record_active_contacts(grid, bodies, candidates, active, forces, candidate); + + candidate.flow = std::move(flow); + + return candidate; + } + + throw std::runtime_error("fluid body step exhausted substep halvings: " + failure); +} +} // namespace cm diff --git a/cpp/core/transport_geometry.cpp b/cpp/core/transport_geometry.cpp new file mode 100644 index 0000000..69e47fc --- /dev/null +++ b/cpp/core/transport_geometry.cpp @@ -0,0 +1,481 @@ +#include +#include +#include + +#include "flow_system.hpp" +#include "numerics_device.hpp" +#include "transport_geometry_internal.hpp" + +namespace cm { +namespace { +void require(bool ok, const char* message) { + if (!ok) { + throw std::invalid_argument(message); + } +} + +struct UnionFind { + std::vector root; + + explicit UnionFind(std::size_t n) : root(n) { + std::iota(root.begin(), root.end(), 0); + } + + std::uint32_t find(std::uint32_t i) { + while (root[i] != i) { + root[i] = root[root[i]]; + i = root[i]; + } + + return i; + } + + void join(std::uint32_t i, std::uint32_t j) { + root[find(i)] = find(j); + } +}; + +std::vector validated_face_velocities(const MacVelocityField& velocity) { + std::vector face_velocity; + + for (const auto* values : {&velocity.x_faces, &velocity.y_faces, &velocity.z_faces}) { + for (float u : *values) { + require(std::isfinite(u), "nonfinite culture velocity"); + face_velocity.push_back(u); + } + } + + return face_velocity; +} + +std::vector resolve_transport_port(const FluidGridSpec& grid, + const detail::FlowGridLayout& layout, + const FlowPort& port) { + const auto& lattice = grid; + const auto axis = static_cast(port.axis); + auto sites = port.sites; + + if (sites.empty()) { + for (std::uint32_t s = 0; s < grid.site_count(); ++s) { + const auto c = layout.site_coordinates(s); + + if (!lattice.solid_site(s) && c[axis] == (port.upper ? layout.dimensions()[axis] - 1 : 0)) { + sites.push_back(s); + } + } + } + + return sites; +} + +std::vector transport_port_faces(const FluidGridSpec& grid, + const detail::FlowGridLayout& layout, + const std::vector& ports) { + const auto& lattice = grid; + std::set names; + std::vector port_face(layout.total_face_count(), fluid_boundary); + + for (std::uint32_t p = 0; p < ports.size(); ++p) { + const auto& port = ports[p]; + const auto axis = static_cast(port.axis); + require(axis < 3 && !port.name.empty() && names.insert(port.name).second && + std::isfinite(port.value) && + (port.kind == FlowPortKind::pressure || port.kind == FlowPortKind::flow_rate), + "invalid culture port"); + const auto sites = resolve_transport_port(grid, layout, port); + + require(!sites.empty(), "culture port is empty"); + + for (auto s : sites) { + require(s < grid.site_count() && !lattice.solid_site(s), + "culture port selects an invalid site"); + auto c = layout.site_coordinates(s); + require(c[axis] == (port.upper ? layout.dimensions()[axis] - 1 : 0), + "culture port site is not on its boundary"); + + if (port.upper) { + ++c[axis]; + } + + const auto f = layout.face_index(axis, c[0], c[1], c[2]); + require(port_face[f] == fluid_boundary, "overlapping culture ports"); + port_face[f] = p; + } + } + + return port_face; +} + +std::tuple, std::vector, std::vector> temporal_volumes( + const FluidGeometry& before, const FluidGeometry& after, + const std::vector& overlaps, const LinearSolveParameters& parameters) { + const auto old_count = before.fragments().size(), new_count = after.fragments().size(); + // Space-time control volumes are connected unions of old and new fragments + // within ONE voxel. Only a positive geometric overlap joins the time levels. + // Closing and opening fragments remain in the solve with zero final/initial + // volume; no component-wide amount redistribution is performed. + UnionFind temporal(old_count + new_count); + + for (const auto& o : overlaps) { + temporal.join(o.first, static_cast(old_count) + o.second); + } + + std::map group_ids; + std::vector group(old_count + new_count); + + for (std::uint32_t i = 0; i < group.size(); ++i) { + const auto [it, inserted] = + group_ids.try_emplace(temporal.find(i), static_cast(group_ids.size())); + group[i] = it->second; + } + + const auto n = group_ids.size(); + require(n * std::uint64_t{12288} <= parameters.memory_limit_bytes, + "solute transport memory limit exceeded"); + std::vector v0(n), v1(n); + + for (std::size_t i = 0; i < old_count; ++i) { + v0[group[i]] += before.fragments()[i].volume; + } + + for (std::size_t i = 0; i < new_count; ++i) { + v1[group[old_count + i]] += after.fragments()[i].volume; + } + + return {std::move(group), std::move(v0), std::move(v1)}; +} + +void append_transport_face( + const FluidFace& face, std::size_t offset, const std::vector& group, + const std::vector& port_face, const std::vector& face_velocity, + std::map, TransportEdge>& edge_map, + std::map, double>& membrane_map) { + auto a = group[offset + face.first]; + + if (face.body_id) { + membrane_map[{a, face.body_id}] += 0.5 * face.area; + + return; + } + + if (face.grid_face == fluid_boundary) { + return; + } + + auto b = face.second == fluid_boundary ? fluid_boundary : group[offset + face.second]; + const auto p = b == fluid_boundary ? port_face[face.grid_face] : fluid_boundary; + + if (b == fluid_boundary && p == fluid_boundary) { + return; + } + + if (a == b) { + return; + } + + double q = 0.5 * face.area * face.normal[static_cast(face.axis)] * + face_velocity[face.grid_face]; + + if (b != fluid_boundary && a > b) { + std::swap(a, b); + q = -q; + } + + auto [it, inserted] = edge_map.try_emplace({a, b, p}, TransportEdge{a, b, p}); + auto& e = it->second; + e.area += 0.5 * face.area; + e.q += q; +} + +std::tuple, std::vector, std::map> +transport_faces(const FluidGeometry& before, const FluidGeometry& after, + const std::vector& group, + const std::vector& port_face, + const std::vector& face_velocity) { + const auto old_count = before.fragments().size(); + std::map, TransportEdge> edge_map; + std::map, double> membrane_map; + + for (bool newer : {false, true}) { + const auto& geometry = newer ? after : before; + const auto offset = newer ? old_count : 0; + + for (const auto& face : geometry.faces()) { + append_transport_face(face, offset, group, port_face, face_velocity, edge_map, membrane_map); + } + } + + std::vector edges; + + for (const auto& [key, e] : edge_map) { + edges.push_back(e); + } + + std::vector membranes; + std::map body_area; + + for (const auto& [key, area] : membrane_map) { + membranes.push_back({key.first, key.second, area}); + body_area[key.second] += area; + } + + return {std::move(edges), std::move(membranes), std::move(body_area)}; +} + +void fix_projection_gauge(detail::NumericsLinearRows& rows, std::vector& rhs, + UnionFind& components, const std::vector& pressure_node, + const std::vector& v0, const std::vector& v1, double vh) { + const auto np = rhs.size(), n = v0.size(); + std::map anchored; + std::map balance, budget; + + for (std::uint32_t i = 0; i < np; ++i) { + const auto root = components.find(i); + anchored[root] = anchored[root] || pressure_node[i]; + balance[root] += rhs[i]; + budget[root] += std::abs(rhs[i]); + + if (i < n) { + budget[root] += (v0[i] + v1[i]) / vh; + } + } + + std::set gauge; + + for (const auto& [root, has_pressure] : anchored) { + if (!has_pressure) { + require(std::abs(balance[root]) <= 1e-10 * std::max(1.0, budget[root]), + "incompatible culture volume change and prescribed port rates"); + gauge.insert(root); + } + } + + for (std::uint32_t i = 0; i < np; ++i) { + if (gauge.contains(i)) { + rows[i] = {{i, 1}}; + rhs[i] = 0; + } else { + std::erase_if(rows[i], [&](const auto& a) { + return gauge.contains(a.first); + }); + } + } +} + +detail::NumericsLinearResult solve_volume_projection(detail::NumericsDevice* device, + const detail::NumericsLinearRows& rows, + const std::vector& rhs, + const LinearSolveParameters& parameters, + std::size_t n) { + auto projection = detail::solve_numerics_linear(device, rows, rhs, parameters); + + // A global Krylov norm does not control every cut-cell volume equation. + // Refine with residuals of the original operator. Corrections are solved on + // the selected backend; no host solve or tolerance relaxation is involved. + for (unsigned refinement = 0; refinement < 4; ++refinement) { + auto residual = rhs; + double scale = 0; + + for (std::size_t i = 0; i < rows.size(); ++i) { + for (const auto& [j, a] : rows[i]) { + residual[i] -= a * projection.solution[j]; + } + + scale = std::max(scale, std::abs(residual[i])); + } + + if (scale < 0.1 * std::max(parameters.relative_tolerance, parameters.absolute_tolerance) / + std::max(1.0, double(n))) { + break; + } + + for (double& r : residual) { + r /= scale; + } + + const auto correction = detail::solve_numerics_linear(device, rows, residual, parameters); + projection.iterations += correction.iterations; + + for (std::size_t i = 0; i < rows.size(); ++i) { + projection.solution[i] += scale * correction.solution[i]; + } + } + + return projection; +} + +GeometricFluxReport apply_volume_projection(std::vector& edges, + const std::vector& rate_node, + const detail::NumericsLinearResult& projection, + const std::vector& v0, + const std::vector& v1, double vh, double dt, + const LinearSolveParameters& parameters) { + const auto n = v0.size(); + std::vector volume_residual(n); + + for (std::size_t i = 0; i < n; ++i) { + volume_residual[i] = v1[i] - v0[i]; + } + + for (auto& e : edges) { + const auto b = e.second == fluid_boundary ? rate_node[e.port] : e.second; + e.q += e.weight * + (projection.solution[e.first] - (b == fluid_boundary ? 0 : projection.solution[b])) * + vh / dt; + volume_residual[e.first] += dt * e.q; + + if (e.second != fluid_boundary) { + volume_residual[e.second] -= dt * e.q; + } + } + + GeometricFluxReport report; + report.projection_iterations = projection.iterations; + + for (double r : volume_residual) { + report.maximum_volume_residual = std::max(report.maximum_volume_residual, std::abs(r)); + } + + if (report.maximum_volume_residual > + 10 * vh * std::max(parameters.relative_tolerance, parameters.absolute_tolerance)) { + throw std::runtime_error("culture geometric conservation residual failed"); + } + + return report; +} + +GeometricFluxReport project_transport_flux( + const FluidGridSpec& grid, const std::vector& ports, double dt, + const std::vector& v0, const std::vector& v1, std::vector& edges, + const LinearSolveParameters& parameters, detail::NumericsDevice* device) { + const auto n = v0.size(); + const double h = grid.spacing, vh = h * h * h; + std::vector rate_node(ports.size(), fluid_boundary); + std::size_t np = n; + + for (std::size_t p = 0; p < ports.size(); ++p) { + if (ports[p].kind == FlowPortKind::flow_rate) { + rate_node[p] = static_cast(np++); + } + } + + detail::NumericsLinearRows rows(np); + std::vector rhs(np), raw_port(ports.size()); + std::vector pressure_node(np, false); + UnionFind components(np); + + for (std::size_t i = 0; i < n; ++i) { + rhs[i] = (v0[i] - v1[i]) / vh; + } + + for (auto& e : edges) { + const auto a = e.first, b = e.second == fluid_boundary ? rate_node[e.port] : e.second; + e.weight = e.area / (h * h) * (e.second == fluid_boundary ? 2 : 1); + rows[a].emplace_back(a, e.weight); + rhs[a] -= e.q * dt / vh; + + if (b != fluid_boundary) { + rows[a].emplace_back(b, -e.weight); + rows[b].emplace_back(a, -e.weight); + rows[b].emplace_back(b, e.weight); + rhs[b] += e.q * dt / vh; + components.join(a, b); + } else { + pressure_node[a] = true; + } + + if (e.second == fluid_boundary) { + raw_port[e.port] += e.q; + } + } + + const double rate_to_model = grid.time_unit_s / std::pow(grid.length_unit_m, 3); + + for (std::size_t p = 0; p < ports.size(); ++p) { + if (rate_node[p] != fluid_boundary) { + rhs[rate_node[p]] -= ports[p].value * rate_to_model * dt / vh; + } + } + + fix_projection_gauge(rows, rhs, components, pressure_node, v0, v1, vh); + + const auto projection = solve_volume_projection(device, rows, rhs, parameters, n); + + const auto report = + apply_volume_projection(edges, rate_node, projection, v0, v1, vh, dt, parameters); + + return report; +} + +} // namespace + +TransportGeometry::TransportGeometry(std::shared_ptr value) : impl_(std::move(value)) {} + +TransportGeometry::TransportGeometry(const FluidGeometry& before, const FluidGeometry& after, + const MacVelocityField& velocity, + const std::vector& ports, double dt, + BackendKind backend, std::uint32_t device_index, + const LinearSolveParameters& parameters) + : TransportGeometry(prepare(before, after, velocity, ports, dt, parameters, + detail::make_numerics_device(backend, device_index).get())) {} + +const GeometricFluxReport& TransportGeometry::report() const { + return impl_->report; +} + +TransportGeometry TransportGeometry::prepare(const FluidGeometry& before, + const FluidGeometry& after, + const MacVelocityField& velocity, + const std::vector& ports, double dt, + const LinearSolveParameters& parameters, + detail::NumericsDevice* device) { + parameters.validate(); + require(std::isfinite(dt) && dt > 0, "solute transport dt must be finite and positive"); + const auto& grid = before.grid(); + require(grid.length_unit_m == after.grid().length_unit_m && + grid.time_unit_s == after.grid().time_unit_s, + "solute transport units changed during a step"); + const auto overlaps = before.overlaps(after); // also validates the immutable device grid + const auto old_count = before.fragments().size(), new_count = after.fragments().size(); + require(old_count + new_count < fluid_boundary, "too many solute transport fragments"); + const auto& lattice = grid; + const detail::FlowGridLayout layout(lattice, FlowAxis::x); + require(velocity.x_faces.size() == layout.face_count(0) && + velocity.y_faces.size() == layout.face_count(1) && + velocity.z_faces.size() == layout.face_count(2), + "solute transport velocity dimensions mismatch"); + const auto face_velocity = validated_face_velocities(velocity); + + const auto port_face = transport_port_faces(grid, layout, ports); + + auto [group, v0, v1] = temporal_volumes(before, after, overlaps, parameters); + const auto n = v0.size(); + + auto [edges, membranes, body_area] = + transport_faces(before, after, group, port_face, face_velocity); + + const auto report = project_transport_flux(grid, ports, dt, v0, v1, edges, parameters, device); + + auto value = std::make_shared(); + value->grid = grid; + value->ports = ports; + value->dt = dt; + value->old_count = old_count; + value->new_count = new_count; + value->group_count = n; + value->group = std::move(group); + value->v0 = std::move(v0); + value->v1 = std::move(v1); + + for (const auto& f : after.fragments()) { + value->final_volumes.push_back(f.volume); + } + + value->edges = std::move(edges); + value->membranes = std::move(membranes); + value->body_area = std::move(body_area); + value->report = report; + + return TransportGeometry(std::move(value)); +} +} // namespace cm diff --git a/cpp/core/transport_geometry_internal.hpp b/cpp/core/transport_geometry_internal.hpp new file mode 100644 index 0000000..b3e3ae1 --- /dev/null +++ b/cpp/core/transport_geometry_internal.hpp @@ -0,0 +1,30 @@ +#pragma once +#include + +#include "cm/transport_geometry.hpp" + +namespace cm { +struct TransportEdge { + std::uint32_t first, second, port; + double area{0}, q{0}, weight{0}; +}; + +struct TransportMembrane { + std::uint32_t group; + std::uint64_t body; + double area; +}; + +struct TransportGeometry::Impl { + FluidGridSpec grid; + std::vector ports; + double dt; + std::size_t old_count, new_count, group_count; + std::vector group; + std::vector v0, v1, final_volumes; + std::vector edges; + std::vector membranes; + std::map body_area; + GeometricFluxReport report; +}; +} // namespace cm diff --git a/cpp/core/world_state.cpp b/cpp/core/world_state.cpp index a3a2ae1..bf8effe 100644 --- a/cpp/core/world_state.cpp +++ b/cpp/core/world_state.cpp @@ -16,20 +16,30 @@ void validate_cell(const CellInit& cell, std::size_t species_count) { cell.position.x, cell.position.y, cell.position.z, cell.direction.x, cell.direction.y, cell.direction.z, cell.length, cell.radius, cell.growth_rate, }; - if (!std::ranges::all_of(values, [](float value) { return std::isfinite(value); })) { + + if (!std::ranges::all_of(values, [](float value) { + return std::isfinite(value); + })) { throw std::invalid_argument("cell fields must be finite"); } + if (cell.length < 0.0F) { throw std::invalid_argument("cell length must be non-negative"); } + if (cell.radius <= 0.0F) { throw std::invalid_argument("cell radius must be positive"); } + static_cast(normalized(cell.direction)); + if (!cell.species.empty() && cell.species.size() != species_count) { throw std::invalid_argument("cell species count does not match the world state"); } - if (!std::ranges::all_of(cell.species, [](float value) { return std::isfinite(value); })) { + + if (!std::ranges::all_of(cell.species, [](float value) { + return std::isfinite(value); + })) { throw std::invalid_argument("cell species levels must be finite"); } } @@ -42,6 +52,7 @@ WorldState::WorldState(std::size_t reserved_capacity, std::size_t species_count) reserved_capacity > std::numeric_limits::max() / species_count) { throw std::overflow_error("reserved species storage size overflow"); } + ids_.reserve(reserved_capacity); position_x_.reserve(reserved_capacity); position_y_.reserve(reserved_capacity); @@ -59,13 +70,33 @@ WorldState::WorldState(std::size_t reserved_capacity, std::size_t species_count) lineage_.reserve(reserved_capacity); } +namespace { +void validate_checkpoint_lineage(const std::vector& lineage, CellId next_id) { + std::unordered_set lineage_children; + lineage_children.reserve(lineage.size()); + + for (const auto& entry : lineage) { + if (entry.child == invalid_cell_id || entry.parent == invalid_cell_id || + entry.parent >= entry.child || entry.child >= next_id) { + throw std::invalid_argument("checkpoint lineage violates monotonic cell identity"); + } + + if (!lineage_children.insert(entry.child).second) { + throw std::invalid_argument("checkpoint contains a duplicate lineage child"); + } + } +} +} // namespace + void WorldStateCheckpoint::validate() const { if (next_id == invalid_cell_id) { throw std::invalid_argument("checkpoint next cell identifier is invalid"); } + if (cells.size() > static_cast(invalid_slot)) { throw std::overflow_error("checkpoint exceeds the cell slot space"); } + if (species_count != 0 && cells.size() > std::numeric_limits::max() / species_count) { throw std::overflow_error("checkpoint species storage size overflow"); @@ -73,20 +104,26 @@ void WorldStateCheckpoint::validate() const { std::unordered_set active_ids; active_ids.reserve(cells.size()); + for (std::size_t index = 0; index < cells.size(); ++index) { const auto& cell = cells[index]; + if (cell.slot != static_cast(index)) { throw std::invalid_argument("checkpoint cell slots are not compact and ordered"); } + if (cell.id == invalid_cell_id || cell.id >= next_id) { throw std::invalid_argument("checkpoint cell identifier is outside the allocated range"); } + if (!active_ids.insert(cell.id).second) { throw std::invalid_argument("checkpoint contains a duplicate active cell identifier"); } + if (cell.species.size() != species_count) { throw std::invalid_argument("checkpoint cell species count does not match the world"); } + validate_cell( { .position = cell.position, @@ -99,28 +136,20 @@ void WorldStateCheckpoint::validate() const { .species = cell.species, }, species_count); + if (std::abs(norm(cell.direction) - 1.0F) > 1.0e-5F) { throw std::invalid_argument("checkpoint cell direction is not normalized"); } } - std::unordered_set lineage_children; - lineage_children.reserve(lineage.size()); - for (const auto& entry : lineage) { - if (entry.child == invalid_cell_id || entry.parent == invalid_cell_id || - entry.parent >= entry.child || entry.child >= next_id) { - throw std::invalid_argument("checkpoint lineage violates monotonic cell identity"); - } - if (!lineage_children.insert(entry.child).second) { - throw std::invalid_argument("checkpoint contains a duplicate lineage child"); - } - } + validate_checkpoint_lineage(lineage, next_id); } WorldState::WorldState(const WorldStateCheckpoint& checkpoint) : WorldState(checkpoint.cells.size(), checkpoint.species_count) { checkpoint.validate(); next_id_ = checkpoint.next_id; + for (const auto& cell : checkpoint.cells) { ids_.push_back(cell.id); position_x_.push_back(cell.position.x); @@ -137,40 +166,55 @@ WorldState::WorldState(const WorldStateCheckpoint& checkpoint) species_.insert(species_.end(), cell.species.begin(), cell.species.end()); id_to_slot_.emplace(cell.id, cell.slot); } + for (const auto& entry : checkpoint.lineage) { lineage_.emplace(entry.child, entry.parent); } + validate(); } -std::size_t WorldState::size() const noexcept { return ids_.size(); } +std::size_t WorldState::size() const noexcept { + return ids_.size(); +} -bool WorldState::empty() const noexcept { return ids_.empty(); } +bool WorldState::empty() const noexcept { + return ids_.empty(); +} -bool WorldState::contains(CellId id) const noexcept { return id_to_slot_.contains(id); } +bool WorldState::contains(CellId id) const noexcept { + return id_to_slot_.contains(id); +} -std::size_t WorldState::species_count() const noexcept { return species_count_; } +std::size_t WorldState::species_count() const noexcept { + return species_count_; +} CellId WorldState::allocate_id() { if (next_id_ == invalid_cell_id || next_id_ == std::numeric_limits::max()) { throw std::overflow_error("cell identifier space exhausted"); } + return next_id_++; } Slot WorldState::slot_for(CellId id) const { const auto found = id_to_slot_.find(id); + if (found == id_to_slot_.end()) { throw std::out_of_range("unknown cell id " + std::to_string(id)); } + return found->second; } void WorldState::append(CellId id, const CellInit& cell) { validate_cell(cell, species_count_); + if (ids_.size() >= static_cast(invalid_slot)) { throw std::overflow_error("cell slot space exhausted"); } + const auto direction = normalized(cell.direction); const auto slot = static_cast(ids_.size()); ids_.push_back(id); @@ -185,20 +229,24 @@ void WorldState::append(CellId id, const CellInit& cell) { growth_rate_.push_back(cell.growth_rate); cell_type_.push_back(cell.cell_type); fixed_.push_back(static_cast(cell.fixed)); + if (cell.species.empty()) { species_.insert(species_.end(), species_count_, 0.0F); } else { species_.insert(species_.end(), cell.species.begin(), cell.species.end()); } + id_to_slot_.emplace(id, slot); } void WorldState::replace(Slot slot, CellId id, const CellInit& cell) { validate_cell(cell, species_count_); const auto index = static_cast(slot); + if (index >= size()) { throw std::out_of_range("cell slot is out of range"); } + const auto direction = normalized(cell.direction); ids_[index] = id; position_x_[index] = cell.position.x; @@ -213,17 +261,20 @@ void WorldState::replace(Slot slot, CellId id, const CellInit& cell) { cell_type_[index] = cell.cell_type; fixed_[index] = static_cast(cell.fixed); const auto species_begin = species_.begin() + static_cast(index * species_count_); + if (cell.species.empty()) { std::fill_n(species_begin, species_count_, 0.0F); } else { std::copy(cell.species.begin(), cell.species.end(), species_begin); } + id_to_slot_[id] = slot; } CellId WorldState::add_cell(const CellInit& cell) { const auto id = allocate_id(); append(id, cell); + return id; } @@ -231,8 +282,10 @@ std::pair WorldState::divide(CellId parent_id, float first_fract if (!std::isfinite(first_fraction) || first_fraction <= 0.0F || first_fraction >= 1.0F) { throw std::invalid_argument("first daughter fraction must be finite and between zero and one"); } + const auto parent = cell(parent_id); const auto available_length = parent.length - (2.0F * parent.radius); + if (!(available_length >= 0.0F)) { throw std::domain_error("parent is too short to divide into valid daughters"); } @@ -264,6 +317,7 @@ std::pair WorldState::divide(CellId parent_id, float first_fract append(second_id, second_daughter); lineage_[first_id] = parent_id; lineage_[second_id] = parent_id; + return {first_id, second_id}; } @@ -274,6 +328,7 @@ std::pair WorldState::divide_equal(CellId parent_id) { void WorldState::remove_cell(CellId id) { const auto slot = slot_for(id); const auto last = ids_.size() - 1; + if (slot != last) { ids_[slot] = ids_[last]; position_x_[slot] = position_x_[last]; @@ -287,11 +342,14 @@ void WorldState::remove_cell(CellId id) { growth_rate_[slot] = growth_rate_[last]; cell_type_[slot] = cell_type_[last]; fixed_[slot] = fixed_[last]; + for (std::size_t index = 0; index < species_count_; ++index) { species_[(slot * species_count_) + index] = species_[(last * species_count_) + index]; } + id_to_slot_[ids_[slot]] = static_cast(slot); } + ids_.pop_back(); position_x_.pop_back(); position_y_.pop_back(); @@ -312,6 +370,7 @@ void WorldState::advance_growth(float dt) { if (!std::isfinite(dt) || dt < 0.0F) { throw std::invalid_argument("time step must be finite and non-negative"); } + for (std::size_t index = 0; index < size(); ++index) { length_[index] += growth_rate_[index] * length_[index] * dt; } @@ -319,9 +378,11 @@ void WorldState::advance_growth(float dt) { void WorldState::set_cell_geometry(Slot slot, Vec3 position, Vec3 direction, float length) { const auto index = static_cast(slot); + if (index >= size()) { throw std::out_of_range("cell geometry slot is out of range"); } + const CellInit candidate{ .position = position, .direction = direction, @@ -351,6 +412,7 @@ void WorldState::set_cell_attributes(CellId id, float growth_rate, std::int32_t if (!std::isfinite(growth_rate)) { throw std::invalid_argument("cell growth rate must be finite"); } + const auto index = static_cast(slot_for(id)); growth_rate_[index] = growth_rate; cell_type_[index] = cell_type; @@ -364,9 +426,13 @@ void WorldState::set_species(CellId id, std::span levels) { if (levels.size() != species_count_) { throw std::invalid_argument("cell species count does not match the world state"); } - if (!std::ranges::all_of(levels, [](float value) { return std::isfinite(value); })) { + + if (!std::ranges::all_of(levels, [](float value) { + return std::isfinite(value); + })) { throw std::invalid_argument("cell species levels must be finite"); } + const auto offset = static_cast(slot_for(id)) * species_count_; std::copy(levels.begin(), levels.end(), species_.begin() + static_cast(offset)); } @@ -420,6 +486,7 @@ CellSnapshot WorldState::cell(CellId id) const { const auto slot = slot_for(id); const auto index = static_cast(slot); const auto species_offset = index * species_count_; + return { .id = ids_[index], .slot = slot, @@ -439,17 +506,21 @@ CellSnapshot WorldState::cell(CellId id) const { std::vector WorldState::cells() const { std::vector result; result.reserve(size()); + for (const auto id : ids_) { result.push_back(cell(id)); } + return result; } std::optional WorldState::lineage_parent(CellId id) const noexcept { const auto found = lineage_.find(id); + if (found == lineage_.end()) { return std::nullopt; } + return found->second; } @@ -462,14 +533,26 @@ WorldStateCheckpoint WorldState::checkpoint() const { .lineage = {}, }; result.lineage.reserve(lineage_.size()); + for (const auto& [child, parent] : lineage_) { result.lineage.push_back({.child = child, .parent = parent}); } + std::ranges::sort(result.lineage, {}, &LineageEntry::child); result.validate(); + return result; } +void WorldState::validate_lineage() const { + for (const auto& [child, parent] : lineage_) { + if (child == invalid_cell_id || parent == invalid_cell_id || parent >= child || + child >= next_id_) { + throw std::logic_error("world lineage violates monotonic cell identity"); + } + } +} + void WorldState::validate() const { const auto expected = ids_.size(); const std::array sizes{ @@ -477,36 +560,52 @@ void WorldState::validate() const { direction_y_.size(), direction_z_.size(), length_.size(), radius_.size(), growth_rate_.size(), cell_type_.size(), fixed_.size(), }; - if (!std::ranges::all_of(sizes, [expected](std::size_t size) { return size == expected; })) { + + if (!std::ranges::all_of(sizes, [expected](std::size_t size) { + return size == expected; + })) { throw std::logic_error("world state arrays have inconsistent lengths"); } + if (id_to_slot_.size() != expected) { throw std::logic_error("cell id index has the wrong size"); } + if (species_count_ != 0 && expected > std::numeric_limits::max() / species_count_) { throw std::logic_error("world species storage size overflow"); } + if (species_.size() != expected * species_count_) { throw std::logic_error("world species storage has the wrong size"); } - if (!std::ranges::all_of(species_, [](float value) { return std::isfinite(value); })) { + + if (!std::ranges::all_of(species_, [](float value) { + return std::isfinite(value); + })) { throw std::logic_error("world species levels must be finite"); } + if (next_id_ == invalid_cell_id) { throw std::logic_error("world next cell identifier is invalid"); } + for (std::size_t index = 0; index < expected; ++index) { const auto id = ids_[index]; + if (id == invalid_cell_id) { throw std::logic_error("active cell has an invalid identifier"); } + if (id >= next_id_) { throw std::logic_error("active cell identifier is outside the allocated range"); } + const auto found = id_to_slot_.find(id); + if (found == id_to_slot_.end() || found->second != static_cast(index)) { throw std::logic_error("cell id and slot index disagree"); } + const CellInit value{ .position = {position_x_[index], position_y_[index], position_z_[index]}, .direction = {direction_x_[index], direction_y_[index], direction_z_[index]}, @@ -518,19 +617,17 @@ void WorldState::validate() const { .species = {}, }; validate_cell(value, species_count_); + if (std::abs(norm(value.direction) - 1.0F) > 1.0e-5F) { throw std::logic_error("cell direction is not normalized"); } + if (fixed_[index] > 1) { throw std::logic_error("cell fixed flag is invalid"); } } - for (const auto& [child, parent] : lineage_) { - if (child == invalid_cell_id || parent == invalid_cell_id || parent >= child || - child >= next_id_) { - throw std::logic_error("world lineage violates monotonic cell identity"); - } - } + + validate_lineage(); } } // namespace cm diff --git a/cpp/cpu/cpu_backend.cpp b/cpp/cpu/cpu_backend.cpp index 58daca1..55e8ce3 100644 --- a/cpp/cpu/cpu_backend.cpp +++ b/cpp/cpu/cpu_backend.cpp @@ -24,14 +24,17 @@ class CpuBackend final : public ComputeBackend { feature == BackendFeature::external_constraints || feature == BackendFeature::signals || feature == BackendFeature::coupled_rates || feature == BackendFeature::depth_averaged_flow || - feature == BackendFeature::resolved_flow; + feature == BackendFeature::resolved_flow || feature == BackendFeature::culture; } - void advance_growth(WorldState& state, float dt) override { state.advance_growth(dt); } + void advance_growth(WorldState& state, float dt) override { + state.advance_growth(dt); + } void advance_species(WorldState& state, const SpeciesRatePlan& plan, - std::span previous_lengths, float dt) override { - advance_species_cpu(state, plan, previous_lengths, dt); + std::span previous_lengths, float dt, + BiochemicalVolumeView volumes = {}) override { + advance_species_cpu(state, plan, previous_lengths, dt, volumes); } SignalSolveReport advance_signal_grid(SignalGrid& grid, float dt) override { @@ -81,6 +84,7 @@ std::unique_ptr make_cpu_backend(std::uint32_t device_index) { if (device_index != 0) { throw std::out_of_range("CPU backend exposes only device index 0"); } + return std::make_unique(); } diff --git a/cpp/cpu/cpu_constraints.cpp b/cpp/cpu/cpu_constraints.cpp index 8d8a6ba..61d7919 100644 --- a/cpp/cpu/cpu_constraints.cpp +++ b/cpp/cpu/cpu_constraints.cpp @@ -25,6 +25,7 @@ std::array endpoints(const CellGeometryView& geometry, std: const Vec3 axis{geometry.direction_x[slot], geometry.direction_y[slot], geometry.direction_z[slot]}; const auto half_length = geometry.lengths[slot] * 0.5F; + return { EndpointGeometry{RodContactLocation::negative, center - axis * half_length}, EndpointGeometry{RodContactLocation::positive, center + axis * half_length}, @@ -49,24 +50,31 @@ bool segment_intersects_bounds(Vec3 start, Vec3 end, Vec3 lower, Vec3 upper) { const std::array uppers{upper.x, upper.y, upper.z}; auto entry = 0.0F; auto exit = 1.0F; + for (std::size_t axis = 0; axis < starts.size(); ++axis) { if (deltas[axis] == 0.0F) { if (starts[axis] < lowers[axis] || starts[axis] > uppers[axis]) { return false; } + continue; } + auto first = (lowers[axis] - starts[axis]) / deltas[axis]; auto second = (uppers[axis] - starts[axis]) / deltas[axis]; + if (first > second) { std::swap(first, second); } + entry = std::max(entry, first); exit = std::min(exit, second); + if (entry > exit) { return false; } } + return true; } @@ -75,11 +83,13 @@ CenterlineMinimum minimize_surface_on_segment(Vec3 start, Vec3 end, const Surfac const auto delta = end - start; auto lower = 0.0F; auto upper = 1.0F; + for (std::size_t iteration = 0; iteration < segment_minimization_iterations; ++iteration) { const auto first_parameter = lower + (upper - lower) / 3.0F; const auto second_parameter = upper - (upper - lower) / 3.0F; const auto first = surface_at(start + delta * first_parameter); const auto second = surface_at(start + delta * second_parameter); + if (first.signed_distance < second.signed_distance) { upper = second_parameter; } else if (second.signed_distance < first.signed_distance) { @@ -92,13 +102,16 @@ CenterlineMinimum minimize_surface_on_segment(Vec3 start, Vec3 end, const Surfac CenterlineMinimum result{start, surface_at(start)}; const std::array candidates{1.0F, 0.5F, lower, (lower + upper) * 0.5F, upper}; + for (const auto parameter : candidates) { const auto point = start + delta * parameter; const auto surface = surface_at(point); + if (surface.signed_distance < result.surface.signed_distance) { result = {point, surface}; } } + return result; } @@ -112,6 +125,7 @@ void append_outside_minimum_contacts(std::vector& contacts, const ConstraintContactParameters& parameters) { const auto radius = geometry.radii[slot]; const auto minimum_separation = minimum.surface.signed_distance - radius; + if (minimum_separation >= parameters.activation_margin) { return; } @@ -121,10 +135,13 @@ void append_outside_minimum_contacts(std::vector& contacts, Vec3 centerline_point; SurfacePoint surface; }; + std::array candidates{}; std::size_t count = 0; + for (std::size_t index = 0; index < cell_endpoints.size(); ++index) { const auto endpoint_separation = endpoint_surfaces[index].signed_distance - radius; + if (endpoint_separation < parameters.activation_margin && std::abs(endpoint_surfaces[index].signed_distance - minimum.surface.signed_distance) <= parameters.degeneracy_epsilon) { @@ -135,11 +152,13 @@ void append_outside_minimum_contacts(std::vector& contacts, }; } } + if (count == 0) { candidates[count++] = {RodContactLocation::interior, minimum.point, minimum.surface}; } const auto weight = coefficient * (count == 2 ? inverse_sqrt_two : 1.0F); + for (std::size_t index = 0; index < count; ++index) { const auto normal = candidates[index].surface.outward * -1.0F; contacts.push_back({ @@ -162,19 +181,23 @@ void append_plane_contacts(std::vector& contacts, const CellGeo const auto cell_endpoints = endpoints(geometry, slot); std::array separations{}; std::array active{}; + for (std::size_t index = 0; index < cell_endpoints.size(); ++index) { separations[index] = dot(cell_endpoints[index].centerline_point - plane.point, plane.inward_normal) - geometry.radii[slot]; active[index] = separations[index] < parameters.activation_margin; } + const auto active_count = static_cast(active[0]) + static_cast(active[1]); const auto weight = plane.coefficient * (active_count == 2 ? inverse_sqrt_two : 1.0F); const auto normal = plane.inward_normal * -1.0F; + for (std::size_t index = 0; index < cell_endpoints.size(); ++index) { if (!active[index]) { continue; } + contacts.push_back({ .cell_id = geometry.ids[slot], .cell_slot = static_cast(slot), @@ -195,6 +218,7 @@ void append_sphere_contacts(std::vector& contacts, const ConstraintContactParameters& parameters) { const auto cell_endpoints = endpoints(geometry, slot); std::array surfaces{}; + for (std::size_t index = 0; index < cell_endpoints.size(); ++index) { const auto center_delta = cell_endpoints[index].centerline_point - sphere.center; const auto distance = norm(center_delta); @@ -202,6 +226,7 @@ void append_sphere_contacts(std::vector& contacts, : Vec3{1.0F, 0.0F, 0.0F}; surfaces[index] = {distance - sphere.radius, radial}; } + if (sphere.allowed_region == SphereRegion::outside) { const auto start = cell_endpoints[0].centerline_point; const auto end = cell_endpoints[1].centerline_point; @@ -219,23 +244,28 @@ void append_sphere_contacts(std::vector& contacts, append_outside_minimum_contacts( contacts, geometry, slot, sphere.id, ExternalConstraintKind::sphere, sphere.coefficient, cell_endpoints, surfaces, {point, {distance - sphere.radius, radial}}, parameters); + return; } std::array separations{}; std::array normals{}; std::array active{}; + for (std::size_t index = 0; index < cell_endpoints.size(); ++index) { separations[index] = -surfaces[index].signed_distance - geometry.radii[slot]; normals[index] = surfaces[index].outward; active[index] = separations[index] < parameters.activation_margin; } + const auto active_count = static_cast(active[0]) + static_cast(active[1]); const auto weight = sphere.coefficient * (active_count == 2 ? inverse_sqrt_two : 1.0F); + for (std::size_t index = 0; index < cell_endpoints.size(); ++index) { if (!active[index]) { continue; } + contacts.push_back({ .cell_id = geometry.ids[slot], .cell_slot = static_cast(slot), @@ -260,26 +290,31 @@ SurfacePoint box_surface(const Vec3& point, const BoxConstraint& box, float dege }; const auto outside_vector = delta - clamped; const auto outside_distance = norm(outside_vector); + if (outside_distance > degeneracy_epsilon) { return {outside_distance, outside_vector * (1.0F / outside_distance)}; } + const std::array clearances{ box.half_extents.x - std::abs(delta.x), box.half_extents.y - std::abs(delta.y), box.half_extents.z - std::abs(delta.z), }; std::size_t nearest_axis = 0; + for (std::size_t axis = 1; axis < clearances.size(); ++axis) { if (clearances[axis] < clearances[nearest_axis]) { nearest_axis = axis; } } + const std::array offsets{delta.x, delta.y, delta.z}; const auto sign = std::abs(offsets[nearest_axis]) <= degeneracy_epsilon || offsets[nearest_axis] >= 0.0F ? 1.0F : -1.0F; Vec3 outward{}; + if (nearest_axis == 0) { outward = {sign, 0.0F, 0.0F}; } else if (nearest_axis == 1) { @@ -287,6 +322,7 @@ SurfacePoint box_surface(const Vec3& point, const BoxConstraint& box, float dege } else { outward = {0.0F, 0.0F, sign}; } + return {-clearances[nearest_axis], outward}; } @@ -295,10 +331,12 @@ void append_box_contacts(std::vector& contacts, const CellGeome const ConstraintContactParameters& parameters) { const auto cell_endpoints = endpoints(geometry, slot); std::array surfaces{}; + for (std::size_t index = 0; index < cell_endpoints.size(); ++index) { surfaces[index] = box_surface(cell_endpoints[index].centerline_point, box, parameters.degeneracy_epsilon); } + if (box.allowed_region == ConstraintRegion::outside) { const auto reach = geometry.radii[slot] + parameters.activation_margin; const Vec3 lower{ @@ -311,10 +349,12 @@ void append_box_contacts(std::vector& contacts, const CellGeome box.center.y + box.half_extents.y + reach, box.center.z + box.half_extents.z + reach, }; + if (!segment_intersects_bounds(cell_endpoints[0].centerline_point, cell_endpoints[1].centerline_point, lower, upper)) { return; } + const auto minimum = minimize_surface_on_segment( cell_endpoints[0].centerline_point, cell_endpoints[1].centerline_point, [&box, ¶meters](const Vec3& point) { @@ -322,23 +362,28 @@ void append_box_contacts(std::vector& contacts, const CellGeome }); append_outside_minimum_contacts(contacts, geometry, slot, box.id, ExternalConstraintKind::box, box.coefficient, cell_endpoints, surfaces, minimum, parameters); + return; } std::array separations{}; std::array normals{}; std::array active{}; + for (std::size_t index = 0; index < cell_endpoints.size(); ++index) { separations[index] = -surfaces[index].signed_distance - geometry.radii[slot]; normals[index] = surfaces[index].outward; active[index] = separations[index] < parameters.activation_margin; } + const auto active_count = static_cast(active[0]) + static_cast(active[1]); const auto weight = box.coefficient * (active_count == 2 ? inverse_sqrt_two : 1.0F); + for (std::size_t index = 0; index < cell_endpoints.size(); ++index) { if (!active[index]) { continue; } + contacts.push_back({ .cell_id = geometry.ids[slot], .cell_slot = static_cast(slot), @@ -365,20 +410,26 @@ SurfacePoint cylinder_surface(const Vec3& point, const CylinderConstraint& cylin const Vec3 axial{0.0F, 0.0F, z_sign}; const auto radial_excess = radial_distance - cylinder.radius; const auto axial_excess = std::abs(z_offset) - cylinder.half_height; + if (radial_excess > 0.0F && axial_excess > 0.0F) { const auto distance = std::sqrt((radial_excess * radial_excess) + (axial_excess * axial_excess)); + return {distance, (radial * radial_excess + axial * axial_excess) * (1.0F / distance)}; } + if (radial_excess > 0.0F) { return {radial_excess, radial}; } + if (axial_excess > 0.0F) { return {axial_excess, axial}; } + if (-radial_excess <= -axial_excess) { return {radial_excess, radial}; } + return {axial_excess, axial}; } @@ -393,6 +444,7 @@ CenterlineMinimum minimize_cylinder_surface_on_segment( const auto consider = [&result, &start, &delta, &surface_at](float parameter) { const auto point = start + delta * std::clamp(parameter, 0.0F, 1.0F); const auto surface = surface_at(point); + if (surface.signed_distance <= result.surface.signed_distance) { result = {point, surface}; } @@ -401,11 +453,14 @@ CenterlineMinimum minimize_cylinder_surface_on_segment( if (std::abs(delta.z) > parameters.degeneracy_epsilon) { consider((cylinder.center.z - start.z) / delta.z); } + const auto radial_length_squared = delta.x * delta.x + delta.y * delta.y; + if (radial_length_squared > parameters.degeneracy_epsilon * parameters.degeneracy_epsilon) { consider(-((start.x - cylinder.center.x) * delta.x + (start.y - cylinder.center.y) * delta.y) / radial_length_squared); } + return result; } @@ -415,10 +470,12 @@ void append_cylinder_contacts(std::vector& contacts, const ConstraintContactParameters& parameters) { const auto cell_endpoints = endpoints(geometry, slot); std::array surfaces{}; + for (std::size_t index = 0; index < cell_endpoints.size(); ++index) { surfaces[index] = cylinder_surface(cell_endpoints[index].centerline_point, cylinder, parameters.degeneracy_epsilon); } + if (cylinder.allowed_region == ConstraintRegion::outside) { const auto reach = geometry.radii[slot] + parameters.activation_margin; const Vec3 lower{ @@ -431,33 +488,40 @@ void append_cylinder_contacts(std::vector& contacts, cylinder.center.y + cylinder.radius + reach, cylinder.center.z + cylinder.half_height + reach, }; + if (!segment_intersects_bounds(cell_endpoints[0].centerline_point, cell_endpoints[1].centerline_point, lower, upper)) { return; } + const auto minimum = minimize_cylinder_surface_on_segment(cell_endpoints[0].centerline_point, cell_endpoints[1].centerline_point, cylinder, parameters); append_outside_minimum_contacts(contacts, geometry, slot, cylinder.id, ExternalConstraintKind::cylinder, cylinder.coefficient, cell_endpoints, surfaces, minimum, parameters); + return; } std::array separations{}; std::array normals{}; std::array active{}; + for (std::size_t index = 0; index < cell_endpoints.size(); ++index) { separations[index] = -surfaces[index].signed_distance - geometry.radii[slot]; normals[index] = surfaces[index].outward; active[index] = separations[index] < parameters.activation_margin; } + const auto active_count = static_cast(active[0]) + static_cast(active[1]); const auto weight = cylinder.coefficient * (active_count == 2 ? inverse_sqrt_two : 1.0F); + for (std::size_t index = 0; index < cell_endpoints.size(); ++index) { if (!active[index]) { continue; } + contacts.push_back({ .cell_id = geometry.ids[slot], .cell_slot = static_cast(slot), @@ -482,23 +546,29 @@ ExternalContactGraph find_external_contacts_cpu(const WorldState& state, state.validate(); const auto geometry = state.geometry_state(); std::vector contacts; + for (std::size_t slot = 0; slot < geometry.size(); ++slot) { for (const auto& plane : constraints.planes()) { append_plane_contacts(contacts, geometry, slot, plane, parameters); } + for (const auto& sphere : constraints.spheres()) { append_sphere_contacts(contacts, geometry, slot, sphere, parameters); } + for (const auto& box : constraints.boxes()) { append_box_contacts(contacts, geometry, slot, box, parameters); } + for (const auto& cylinder : constraints.cylinders()) { append_cylinder_contacts(contacts, geometry, slot, cylinder, parameters); } } + std::ranges::sort(contacts, {}, [](const ExternalContact& contact) { return std::tuple{contact.cell_id, contact.constraint_id, contact.location}; }); + return ExternalContactGraph(geometry.size(), std::move(contacts)); } diff --git a/cpp/cpu/cpu_contacts.cpp b/cpp/cpu/cpu_contacts.cpp index a4ce142..23e0534 100644 --- a/cpp/cpu/cpu_contacts.cpp +++ b/cpp/cpu/cpu_contacts.cpp @@ -56,13 +56,16 @@ PointPair closest_points(const Capsule& first, const Capsule& second, float epsi float first_parameter = 0.0F; float second_parameter = 0.0F; + if (first_length_squared <= epsilon * epsilon && second_length_squared <= epsilon * epsilon) { return {.first = first_start, .second = second_start}; } + if (first_length_squared <= epsilon * epsilon) { second_parameter = std::clamp(second_projection / second_length_squared, 0.0F, 1.0F); } else { const auto first_projection = dot(first_delta, between_starts); + if (second_length_squared <= epsilon * epsilon) { first_parameter = std::clamp(-first_projection / first_length_squared, 0.0F, 1.0F); } else { @@ -71,14 +74,17 @@ PointPair closest_points(const Capsule& first, const Capsule& second, float epsi (first_length_squared * second_length_squared) - (cross_projection * cross_projection); const auto parallel_tolerance = std::numeric_limits::epsilon() * first_length_squared * second_length_squared; + if (denominator > parallel_tolerance) { first_parameter = std::clamp( ((cross_projection * second_projection) - (first_projection * second_length_squared)) / denominator, 0.0F, 1.0F); } + second_parameter = (cross_projection * first_parameter + second_projection) / second_length_squared; + if (second_parameter < 0.0F) { second_parameter = 0.0F; first_parameter = std::clamp(-first_projection / first_length_squared, 0.0F, 1.0F); @@ -100,6 +106,7 @@ std::vector contact_points(const Capsule& first, const Capsule& secon const ContactParameters& parameters) { const auto axis_dot = std::clamp(dot(first.axis, second.axis), -1.0F, 1.0F); const auto sine = std::sqrt(std::max(0.0F, 1.0F - (axis_dot * axis_dot))); + if (sine > parameters.parallel_sine_threshold || first.length <= parameters.degeneracy_epsilon || second.length <= parameters.degeneracy_epsilon) { return {closest_points(first, second, parameters.degeneracy_epsilon)}; @@ -111,12 +118,14 @@ std::vector contact_points(const Capsule& first, const Capsule& secon const auto projected_second_half = second_half * std::abs(axis_dot); const auto overlap_begin = std::max(-first_half, center_coordinate - projected_second_half); const auto overlap_end = std::min(first_half, center_coordinate + projected_second_half); + if (overlap_end - overlap_begin <= parameters.degeneracy_epsilon) { return {closest_points(first, second, parameters.degeneracy_epsilon)}; } std::vector result; result.reserve(2); + for (const auto first_parameter : {overlap_begin, overlap_end}) { const auto point_on_first = first.center + (first.axis * first_parameter); const auto second_parameter = @@ -126,23 +135,27 @@ std::vector contact_points(const Capsule& first, const Capsule& secon .second = second.center + (second.axis * second_parameter), }); } + return result; } Vec3 deterministic_normal(const Capsule& first, const Capsule& second, const PointPair& points, float epsilon) { const auto point_delta = points.second - points.first; + if (norm(point_delta) > epsilon) { return normalized(point_delta); } const auto axes_cross = cross(first.axis, second.axis); + if (norm(axes_cross) > epsilon) { return normalized(axes_cross); } const auto center_delta = second.center - first.center; const auto transverse_center_delta = center_delta - (first.axis * dot(center_delta, first.axis)); + if (norm(transverse_center_delta) > epsilon) { return normalized(transverse_center_delta); } @@ -152,6 +165,7 @@ Vec3 deterministic_normal(const Capsule& first, const Capsule& second, const Poi std::ranges::min_element(basis, [&first](const Vec3& left, const Vec3& right) { return std::abs(dot(first.axis, left)) < std::abs(dot(first.axis, right)); }); + return normalized(cross(first.axis, *least_aligned)); } @@ -166,12 +180,15 @@ ContactGraph contacts_for_candidates(const WorldState& state, const ContactParam const auto points = contact_points(first, second, parameters); const auto weight = points.size() == 2 ? inverse_sqrt_two : 1.0F; + for (std::size_t ordinal = 0; ordinal < points.size(); ++ordinal) { const auto point_delta = points[ordinal].second - points[ordinal].first; const auto separation = norm(point_delta) - (first.radius + second.radius); + if (separation >= parameters.activation_margin) { continue; } + const auto normal = deterministic_normal(first, second, points[ordinal], parameters.degeneracy_epsilon); contacts.push_back({ @@ -191,6 +208,7 @@ ContactGraph contacts_for_candidates(const WorldState& state, const ContactParam std::ranges::sort(contacts, {}, [](const CellContact& contact) { return std::tuple{contact.first_id, contact.second_id, contact.ordinal}; }); + return ContactGraph(geometry.size(), std::move(contacts)); } @@ -198,6 +216,7 @@ ContactGraph contacts_for_candidates(const WorldState& state, const ContactParam ContactGraph find_cell_contacts_cpu(const WorldState& state, const ContactParameters& parameters) { const auto candidates = find_cell_contact_candidates(state, parameters); + return contacts_for_candidates(state, parameters, candidates); } @@ -206,13 +225,16 @@ ContactGraph find_cell_contacts_cpu_exhaustive(const WorldState& state, validate_contact_parameters(parameters); const auto geometry = state.geometry_state(); std::vector candidates; + if (geometry.size() > 1 && geometry.size() - 1 > std::numeric_limits::max() / geometry.size()) { throw std::overflow_error("exhaustive contact candidate count overflow"); } + const auto pair_count = geometry.size() < 2 ? std::size_t{0} : geometry.size() * (geometry.size() - 1) / 2; candidates.reserve(pair_count); + for (std::size_t first = 0; first < geometry.size(); ++first) { for (std::size_t second = first + 1; second < geometry.size(); ++second) { candidates.push_back( @@ -221,6 +243,7 @@ ContactGraph find_cell_contacts_cpu_exhaustive(const WorldState& state, : ContactCandidate{static_cast(second), static_cast(first)}); } } + return contacts_for_candidates(state, parameters, candidates); } diff --git a/cpp/cpu/cpu_coupled.cpp b/cpp/cpu/cpu_coupled.cpp index f22c179..9dc26a2 100644 --- a/cpp/cpu/cpu_coupled.cpp +++ b/cpp/cpu/cpu_coupled.cpp @@ -13,6 +13,53 @@ namespace cm { namespace { +float evaluate_predicate(const RateInstruction& instruction, std::span workspace) { + switch (instruction.operation) { + case RateOp::less: + return workspace[instruction.first] < workspace[instruction.second] ? 1.0F : 0.0F; + case RateOp::less_equal: + return workspace[instruction.first] <= workspace[instruction.second] ? 1.0F : 0.0F; + case RateOp::greater: + return workspace[instruction.first] > workspace[instruction.second] ? 1.0F : 0.0F; + case RateOp::greater_equal: + return workspace[instruction.first] >= workspace[instruction.second] ? 1.0F : 0.0F; + case RateOp::equal: + return workspace[instruction.first] == workspace[instruction.second] ? 1.0F : 0.0F; + case RateOp::select: + return workspace[instruction.first] != 0.0F ? workspace[instruction.second] + : workspace[instruction.third]; + default: + throw std::logic_error("unknown coupled rate operation"); + } +} + +float evaluate_arithmetic(const RateInstruction& instruction, std::span workspace) { + switch (instruction.operation) { + case RateOp::add: + return workspace[instruction.first] + workspace[instruction.second]; + case RateOp::subtract: + return workspace[instruction.first] - workspace[instruction.second]; + case RateOp::multiply: + return workspace[instruction.first] * workspace[instruction.second]; + case RateOp::divide: + return workspace[instruction.first] / workspace[instruction.second]; + case RateOp::power: + return std::pow(workspace[instruction.first], workspace[instruction.second]); + case RateOp::minimum: + return std::min(workspace[instruction.first], workspace[instruction.second]); + case RateOp::maximum: + return std::max(workspace[instruction.first], workspace[instruction.second]); + case RateOp::negate: + return -workspace[instruction.first]; + case RateOp::exponential: + return std::exp(workspace[instruction.first]); + case RateOp::logarithm: + return std::log(workspace[instruction.first]); + default: + return evaluate_predicate(instruction, workspace); + } +} + float evaluate_instruction(const RateInstruction& instruction, std::span workspace, std::span species, std::span signals, const CellGeometryView& geometry, const CellAttributeView& attributes, @@ -44,41 +91,26 @@ float evaluate_instruction(const RateInstruction& instruction, std::span workspace[instruction.second] ? 1.0F : 0.0F; - case RateOp::greater_equal: - return workspace[instruction.first] >= workspace[instruction.second] ? 1.0F : 0.0F; - case RateOp::equal: - return workspace[instruction.first] == workspace[instruction.second] ? 1.0F : 0.0F; - case RateOp::select: - return workspace[instruction.first] != 0.0F ? workspace[instruction.second] - : workspace[instruction.third]; + default: + return evaluate_arithmetic(instruction, workspace); + } +} + +void dilute_coupled_species(const WorldState& state, std::span previous_lengths, + std::vector& next_species) { + const auto geometry = state.geometry_state(); + + for (std::size_t cell = 0; cell < state.size(); ++cell) { + const auto previous_volume = + effective_cell_volume(previous_lengths[cell], geometry.radii[cell]); + const auto current_volume = effective_cell_volume(geometry.lengths[cell], geometry.radii[cell]); + const auto dilution = previous_volume / current_volume; + const auto offset = cell * state.species_count(); + + for (std::size_t species = 0; species < state.species_count(); ++species) { + next_species[offset + species] *= dilution; + } } - throw std::logic_error("unknown coupled rate operation"); } } // namespace @@ -89,28 +121,37 @@ void validate_coupled_step(const WorldState& state, const SignalGrid& grid, if (!std::isfinite(dt) || dt < 0.0F) { throw std::invalid_argument("coupled time step must be finite and non-negative"); } + state.validate(); grid.validate(); grid.validate_step(dt); plan.validate(); + if (plan.species_count() != state.species_count()) { throw std::invalid_argument("coupled rate plan and world state species counts disagree"); } + if (plan.signal_count() != grid.spec().signal_count) { throw std::invalid_argument("coupled rate plan and signal grid counts disagree"); } + if (previous_lengths.size() != state.size()) { throw std::invalid_argument("previous cell lengths and world state cell counts disagree"); } - if (!std::ranges::all_of(previous_lengths, - [](float value) { return std::isfinite(value) && value >= 0.0F; })) { + + if (!std::ranges::all_of(previous_lengths, [](float value) { + return std::isfinite(value) && value >= 0.0F; + })) { throw std::invalid_argument("previous cell lengths must be finite and non-negative"); } + const auto geometry = state.geometry_state(); + for (std::size_t cell = 0; cell < state.size(); ++cell) { const auto stencil = signal_grid_stencil( grid.spec(), {geometry.position_x[cell], geometry.position_y[cell], geometry.position_z[cell]}); + if (stencil.entirely_solid) { throw std::invalid_argument("signal sample position is inside a grid obstacle"); } @@ -132,11 +173,13 @@ SignalSolveReport advance_coupled_cpu(WorldState& state, SignalGrid& grid, std::vector stencils; stencils.reserve(state.size()); std::vector sampled(state.size() * plan.signal_count(), 0.0F); + for (std::size_t cell = 0; cell < state.size(); ++cell) { const auto stencil = signal_grid_stencil( grid_spec, {geometry.position_x[cell], geometry.position_y[cell], geometry.position_z[cell]}); stencils.push_back(stencil); + for (std::size_t entry = 0; entry < stencil.count; ++entry) { for (std::size_t signal = 0; signal < plan.signal_count(); ++signal) { sampled[(cell * plan.signal_count()) + signal] += @@ -146,25 +189,19 @@ SignalSolveReport advance_coupled_cpu(WorldState& state, SignalGrid& grid, } std::vector next_species(species_state.levels.begin(), species_state.levels.end()); - for (std::size_t cell = 0; cell < state.size(); ++cell) { - const auto previous_volume = - effective_cell_volume(previous_lengths[cell], geometry.radii[cell]); - const auto current_volume = effective_cell_volume(geometry.lengths[cell], geometry.radii[cell]); - const auto dilution = previous_volume / current_volume; - const auto offset = cell * state.species_count(); - for (std::size_t species = 0; species < state.species_count(); ++species) { - next_species[offset + species] *= dilution; - } - } + + dilute_coupled_species(state, previous_lengths, next_species); std::vector signal_sources(grid_spec.level_count(), 0.0F); std::vector workspace(plan.instructions().size()); + for (std::size_t cell = 0; cell < state.size(); ++cell) { const auto species_offset = cell * state.species_count(); const auto cell_species = std::span(next_species).subspan(species_offset, state.species_count()); const auto cell_signals = std::span(sampled).subspan(cell * plan.signal_count(), plan.signal_count()); + for (std::size_t index = 0; index < plan.instructions().size(); ++index) { workspace[index] = evaluate_instruction( plan.instructions()[index], workspace, cell_species, cell_signals, geometry, attributes, @@ -173,21 +210,27 @@ SignalSolveReport advance_coupled_cpu(WorldState& state, SignalGrid& grid, : (effective_cell_volume(geometry.lengths[cell], geometry.radii[cell]) - effective_cell_volume(previous_lengths[cell], geometry.radii[cell])) / dt); + if (!std::isfinite(workspace[index])) { throw std::domain_error("coupled rate instruction " + std::to_string(index) + " produced a non-finite value"); } } + for (std::size_t species = 0; species < state.species_count(); ++species) { next_species[species_offset + species] += dt * workspace[plan.species_outputs()[species]]; + if (!std::isfinite(next_species[species_offset + species])) { throw std::domain_error("coupled species update produced a non-finite level"); } } + const auto& stencil = stencils[cell]; + for (std::size_t signal = 0; signal < plan.signal_count(); ++signal) { const auto concentration_rate = workspace[plan.signal_outputs()[signal]] / grid_spec.voxel_volume(); + for (std::size_t entry = 0; entry < stencil.count; ++entry) { signal_sources[(signal * site_count) + stencil.sites[entry]] += stencil.weights[entry] * concentration_rate; @@ -197,23 +240,29 @@ SignalSolveReport advance_coupled_cpu(WorldState& state, SignalGrid& grid, SignalSolveReport signal_report; std::vector next_grid; + if (grid_spec.integration != SignalIntegrationKind::forward_euler) { auto result = signal_grid_crank_nicolson_candidate(grid, dt, signal_sources); signal_report = result.report; + if (!signal_report.converged) { throw std::runtime_error("Implicit coupled signal solve did not converge after " + std::to_string(signal_report.iterations) + " iterations"); } + next_grid = std::move(result.levels); } else { next_grid = signal_grid_forward_euler_candidate(grid, dt); + for (std::size_t index = 0; index < next_grid.size(); ++index) { next_grid[index] += dt * signal_sources[index]; } } + SignalGridCheckpoint{.spec = grid_spec, .levels = next_grid}.validate(); std::ranges::copy(next_species, species_state.levels.begin()); grid.replace_levels(std::move(next_grid)); + return signal_report; } diff --git a/cpp/cpu/cpu_flow.cpp b/cpp/cpu/cpu_flow.cpp index 4009df8..db85f09 100644 --- a/cpp/cpu/cpu_flow.cpp +++ b/cpp/cpu/cpu_flow.cpp @@ -27,13 +27,26 @@ struct ConjugateGradientResult { if (left.size() != right.size()) { throw std::logic_error("conjugate-gradient vectors have inconsistent sizes"); } + double result = 0.0; + for (std::size_t index = 0; index < left.size(); ++index) { result += left[index] * right[index]; } + return result; } +void apply_diagonal_preconditioner(std::span residual, + std::span diagonal, + std::vector& preconditioned) { + for (std::size_t index = 0; index < residual.size(); ++index) { + if (diagonal[index] > 0.0F) { + preconditioned[index] = residual[index] / diagonal[index]; + } + } +} + template [[nodiscard]] ConjugateGradientResult conjugate_gradient( Apply&& apply, std::span right_hand_side, std::span diagonal, @@ -41,58 +54,73 @@ template if (right_hand_side.size() != diagonal.size()) { throw std::logic_error(std::string(label) + " arrays have inconsistent sizes"); } + std::vector solution(right_hand_side.size(), 0.0); std::vector residual(right_hand_side.begin(), right_hand_side.end()); const auto rhs_norm_squared = dot_product(residual, residual); + if (rhs_norm_squared == 0.0) { return {.solution = std::move(solution)}; } + const auto rhs_norm = std::sqrt(rhs_norm_squared); std::vector preconditioned(residual.size(), 0.0); - for (std::size_t index = 0; index < residual.size(); ++index) { - if (diagonal[index] > 0.0F) { - preconditioned[index] = residual[index] / diagonal[index]; - } - } + + apply_diagonal_preconditioner(residual, diagonal, preconditioned); + auto direction = preconditioned; auto rho = dot_product(residual, preconditioned); auto relative = 1.0; + for (std::uint32_t iteration = 1; iteration <= max_iterations; ++iteration) { std::vector transformed; apply(direction, transformed); const auto curvature = dot_product(direction, transformed); + if (!std::isfinite(curvature) || curvature <= 0.0) { throw std::runtime_error(std::string(label) + " conjugate gradient encountered non-positive curvature"); } + const auto alpha = rho / curvature; + for (std::size_t index = 0; index < solution.size(); ++index) { solution[index] += alpha * direction[index]; residual[index] -= alpha * transformed[index]; } + relative = std::sqrt(dot_product(residual, residual)) / rhs_norm; + if (!std::isfinite(relative)) { throw std::runtime_error(std::string(label) + " conjugate gradient produced a non-finite residual"); } + if (relative <= tolerance) { return { .solution = std::move(solution), .iterations = iteration, .relative_residual = relative}; } + for (std::size_t index = 0; index < residual.size(); ++index) { preconditioned[index] = diagonal[index] > 0.0F ? residual[index] / diagonal[index] : 0.0; } + const auto next_rho = dot_product(residual, preconditioned); + if (!std::isfinite(next_rho) || rho == 0.0) { throw std::runtime_error(std::string(label) + " conjugate gradient encountered a preconditioner breakdown"); } + const auto beta = next_rho / rho; + for (std::size_t index = 0; index < direction.size(); ++index) { direction[index] = preconditioned[index] + beta * direction[index]; } + rho = next_rho; } + throw std::runtime_error(std::string(label) + " conjugate gradient did not converge: relative " + std::to_string(relative)); } @@ -104,6 +132,7 @@ template {&spec.y_lower, &spec.y_upper}, {&spec.z_lower, &spec.z_upper}, }}; + return boundaries[axis]; } @@ -120,16 +149,20 @@ void DepthAveragedFlowParameters::validate() const { if (!std::isfinite(mean_inlet_speed) || mean_inlet_speed == 0.0F) { throw std::invalid_argument("depth-averaged mean inlet speed must be finite and nonzero"); } + validate_relative_tolerance(relative_tolerance, "depth-averaged relative tolerance"); + if (max_iterations == 0) { throw std::invalid_argument("depth-averaged iteration limit must be positive"); } + switch (axis) { case FlowAxis::x: case FlowAxis::y: case FlowAxis::z: return; } + throw std::invalid_argument("unknown depth-averaged flow axis"); } @@ -137,68 +170,120 @@ void ResolvedFlowParameters::validate() const { if (!std::isfinite(mean_inlet_speed) || mean_inlet_speed == 0.0F) { throw std::invalid_argument("resolved-flow mean inlet speed must be finite and nonzero"); } + validate_relative_tolerance(relative_tolerance, "resolved-flow outer relative tolerance"); validate_relative_tolerance(inner_relative_tolerance, "resolved-flow inner relative tolerance"); + if (max_outer_iterations == 0 || max_inner_iterations == 0) { throw std::invalid_argument("resolved-flow iteration limits must be positive"); } + switch (axis) { case FlowAxis::x: case FlowAxis::y: case FlowAxis::z: return; } + throw std::invalid_argument("unknown resolved-flow axis"); } -void validate_flow_grid(const SignalGridSpec& spec, FlowAxis axis) { - spec.validate(); - const auto axis_index = static_cast(axis); - if (axis_index >= 3) { - throw std::invalid_argument("unknown flow axis"); - } +namespace { +void validate_flow_boundaries(const SignalGridSpec& spec, std::size_t axis_index) { for (std::size_t candidate = 0; candidate < 3; ++candidate) { const auto boundaries = axis_boundaries(spec, candidate); + if (boundaries[0]->kind == GridBoundaryKind::periodic || boundaries[1]->kind == GridBoundaryKind::periodic) { throw std::invalid_argument("native flow solvers do not support periodic boundaries"); } } + for (const auto* boundary : axis_boundaries(spec, axis_index)) { if (boundary->kind != GridBoundaryKind::fixed) { throw std::invalid_argument( "native flow-axis boundaries must be fixed to identify inlet and outlet"); } } +} + +std::vector solve_resolved_momentum(const detail::ResolvedFlowSystem& system, + const ResolvedFlowParameters& parameters, + std::span right_hand_side, + std::uint64_t& inner_iterations) { + std::vector rhs(right_hand_side.size()); + std::transform(right_hand_side.begin(), right_hand_side.end(), rhs.begin(), [](double value) { + return static_cast(value); + }); + const auto result = conjugate_gradient( + [&](std::span input, std::vector& output) { + system.apply_momentum(input, output); + }, + rhs, system.diagonal(), parameters.inner_relative_tolerance, parameters.max_inner_iterations, + "resolved-flow momentum"); + + if (inner_iterations > std::numeric_limits::max() - result.iterations) { + throw std::overflow_error("resolved-flow inner iteration count overflow"); + } + + inner_iterations += result.iterations; + + return result.solution; +} + +} // namespace + +void validate_flow_grid(const SignalGridSpec& spec, FlowAxis axis) { + spec.validate(); + const auto axis_index = static_cast(axis); + + if (axis_index >= 3) { + throw std::invalid_argument("unknown flow axis"); + } + + validate_flow_boundaries(spec, axis_index); + const detail::FlowGridLayout layout(spec, axis); std::vector visited(layout.site_count(), 0); std::vector pending; + for (std::size_t i = 0; i < layout.site_count(); ++i) { if (!spec.solid_site(i) && layout.site_coordinates(i)[axis_index] == 0) { visited[i] = 1; pending.push_back(i); } } - if (pending.empty()) throw std::invalid_argument("the flow inlet boundary is entirely blocked"); + + if (pending.empty()) { + throw std::invalid_argument("the flow inlet boundary is entirely blocked"); + } + bool reachable = false; + while (!pending.empty()) { const auto site = pending.back(); pending.pop_back(); + if (layout.site_coordinates(site)[axis_index] + 1 == layout.dimensions()[axis_index]) { reachable = true; break; } - for (std::size_t component = 0; component < 3; ++component) + + for (std::size_t component = 0; component < 3; ++component) { for (const auto offset : {-1, 1}) { const auto neighbor = layout.neighbor_site(site, component, offset); + if (neighbor && !visited[*neighbor] && !spec.solid_site(*neighbor)) { visited[*neighbor] = 1; pending.push_back(*neighbor); } } + } } - if (!reachable) + + if (!reachable) { throw std::runtime_error("the device carries no through-flow: the outlet is unreachable"); + } } DepthAveragedFlowResult solve_depth_averaged_flow_cpu( @@ -208,15 +293,17 @@ DepthAveragedFlowResult solve_depth_averaged_flow_cpu( const detail::ShallowFlowReduction reduction(spec, mobility, parameters.axis); const detail::DepthAveragedFlowSystem system(reduction.grid(), reduction.conductance(), parameters.axis); - const auto solve = - conjugate_gradient([&](std::span input, - std::vector& output) { system.apply(input, output); }, - system.right_hand_side(), system.diagonal(), parameters.relative_tolerance, - parameters.max_iterations, "depth-averaged flow"); + const auto solve = conjugate_gradient( + [&](std::span input, std::vector& output) { + system.apply(input, output); + }, + system.right_hand_side(), system.diagonal(), parameters.relative_tolerance, + parameters.max_iterations, "depth-averaged flow"); const auto unscaled = system.velocity(solve.solution); const auto scaled = detail::scale_velocity(spec, reduction.original_layout(), reduction.lift(unscaled), reduction.open_inlet_faces(), parameters.mean_inlet_speed); + return { .field = scaled.field, .report = {.iterations = solve.iterations, @@ -232,20 +319,7 @@ ResolvedFlowResult solve_resolved_flow_cpu(const SignalGridSpec& spec, std::span const detail::ResolvedFlowSystem system(spec, drag, parameters.axis); std::uint64_t inner_iterations = 0; const auto solve_momentum = [&](std::span right_hand_side) { - std::vector rhs(right_hand_side.size()); - std::transform(right_hand_side.begin(), right_hand_side.end(), rhs.begin(), - [](double value) { return static_cast(value); }); - const auto result = conjugate_gradient( - [&](std::span input, std::vector& output) { - system.apply_momentum(input, output); - }, - rhs, system.diagonal(), parameters.inner_relative_tolerance, - parameters.max_inner_iterations, "resolved-flow momentum"); - if (inner_iterations > std::numeric_limits::max() - result.iterations) { - throw std::overflow_error("resolved-flow inner iteration count overflow"); - } - inner_iterations += result.iterations; - return result.solution; + return solve_resolved_momentum(system, parameters, right_hand_side, inner_iterations); }; const double continuity_scale = @@ -255,12 +329,20 @@ ResolvedFlowResult solve_resolved_flow_cpu(const SignalGridSpec& spec, std::span const auto pressure_diagonal = system.pressure_diagonal(); using Vector = std::vector; detail::FlexibleKrylovOperations ops; - ops.make_zero = [&] { return Vector(nu + np, 0.0); }; - ops.copy = [](const Vector& source, Vector& target) { target = source; }; + ops.make_zero = [&] { + return Vector(nu + np, 0.0); + }; + ops.copy = [](const Vector& source, Vector& target) { + target = source; + }; ops.axpy = [](Vector& target, double alpha, const Vector& source) { - for (std::size_t i = 0; i < target.size(); ++i) target[i] += alpha * source[i]; + for (std::size_t i = 0; i < target.size(); ++i) { + target[i] += alpha * source[i]; + } + }; + ops.dot = [](const Vector& a, const Vector& b) { + return dot_product(a, b); }; - ops.dot = [](const Vector& a, const Vector& b) { return dot_product(a, b); }; ops.apply = [&](const Vector& input, Vector& output) { const auto u = std::span(input).first(nu); const auto p = std::span(input).subspan(nu); @@ -268,15 +350,23 @@ ResolvedFlowResult solve_resolved_flow_cpu(const SignalGridSpec& spec, std::span system.apply_momentum(u, momentum); const auto gradient = system.gradient(p); const auto divergence = system.divergence(u); - for (std::size_t i = 0; i < nu; ++i) output[i] = momentum[i] + gradient[i]; - for (std::size_t i = 0; i < np; ++i) output[nu + i] = continuity_scale * divergence[i]; + + for (std::size_t i = 0; i < nu; ++i) { + output[i] = momentum[i] + gradient[i]; + } + + for (std::size_t i = 0; i < np; ++i) { + output[nu + i] = continuity_scale * divergence[i]; + } }; ops.precondition = [&](const Vector& input, Vector& output) { const auto u = solve_momentum(std::span(input).first(nu)); std::copy(u.begin(), u.end(), output.begin()); - for (std::size_t i = 0; i < np; ++i) + + for (std::size_t i = 0; i < np; ++i) { output[nu + i] = pressure_diagonal[i] > 0 ? -input[nu + i] / (continuity_scale * pressure_diagonal[i]) : 0; + } }; auto rhs = ops.make_zero(); std::copy(system.force().begin(), system.force().end(), rhs.begin()); @@ -287,26 +377,32 @@ ResolvedFlowResult solve_resolved_flow_cpu(const SignalGridSpec& spec, std::span Vector residual = ops.make_zero(); ops.apply(solution.solution, residual); double momentum_square = 0.0, force_square = 0.0; + for (std::size_t i = 0; i < nu; ++i) { momentum_square += (residual[i] - rhs[i]) * (residual[i] - rhs[i]); force_square += rhs[i] * rhs[i]; } + const auto divergence = system.divergence(velocity); double divergence_square_sum = 0.0; std::size_t fluid_count = 0; + for (std::size_t site = 0; site < divergence.size(); ++site) { if (system.fluid()[site] != 0) { divergence_square_sum += divergence[site] * divergence[site]; ++fluid_count; } } + const auto divergence_rms = fluid_count == 0 ? 0.0 : std::sqrt(divergence_square_sum / static_cast(fluid_count)); std::vector unscaled(velocity.size()); - std::transform(velocity.begin(), velocity.end(), unscaled.begin(), - [](double value) { return static_cast(value); }); + std::transform(velocity.begin(), velocity.end(), unscaled.begin(), [](double value) { + return static_cast(value); + }); const auto scaled = detail::scale_velocity( spec, system.layout(), unscaled, system.open_inlet_faces(), parameters.mean_inlet_speed); + return { .field = scaled.field, .report = {.outer_iterations = solution.iterations, diff --git a/cpp/cpu/cpu_mechanics.cpp b/cpp/cpu/cpu_mechanics.cpp index 3adab9e..1310350 100644 --- a/cpp/cpu/cpu_mechanics.cpp +++ b/cpp/cpu/cpu_mechanics.cpp @@ -30,22 +30,27 @@ struct ContactRow { return false; } } + return true; } [[nodiscard]] float dof_dot(const Dofs& left, const Dofs& right) { float result = 0.0F; + for (std::size_t index = 0; index < degrees_of_freedom; ++index) { result += left[index] * right[index]; } + return result; } [[nodiscard]] float vector_dot(const DofVector& left, const DofVector& right) { float result = 0.0F; + for (std::size_t index = 0; index < left.size(); ++index) { result += dof_dot(left[index], right[index]); } + return result; } @@ -65,6 +70,7 @@ void add_scaled(DofVector& destination, const DofVector& source, float scale) { if (residual.empty()) { return 0.0F; } + return std::sqrt(vector_dot(residual, residual) / static_cast(residual.size())); } @@ -72,6 +78,7 @@ void add_scaled(DofVector& destination, const DofVector& source, float scale) { float total_length, float weight) { const auto angular = cross(arm, normal); const auto length = dot(axis, arm) * dot(axis, normal) / total_length; + return { weight * normal.x, weight * normal.y, weight * normal.z, weight * angular.x, weight * angular.y, weight * angular.z, weight * length, @@ -97,22 +104,28 @@ void add_scaled(DofVector& destination, const DofVector& source, float scale) { [[nodiscard]] DofVector flatten(std::span values) { DofVector result; result.reserve(values.size()); + for (const auto& value : values) { const auto flattened = flatten(value); + if (!finite(flattened)) { throw std::invalid_argument("mechanics input correction must be finite"); } + result.push_back(flattened); } + return result; } [[nodiscard]] std::vector unflatten(const DofVector& values) { std::vector result; result.reserve(values.size()); + for (const auto& value : values) { result.push_back(unflatten(value)); } + return result; } @@ -125,20 +138,25 @@ class CpuMechanicsSystem { fixed_ = state.cell_attributes().fixed; state.validate(); validate_mechanics_parameters(parameters_); + if (contacts.cell_count() != geometry_.size()) { throw std::invalid_argument("contact graph and world state cell counts disagree"); } + if (external_contacts.cell_count() != geometry_.size()) { throw std::invalid_argument("external contact graph and world state cell counts disagree"); } + if (external_contacts.size() > std::numeric_limits::max() - contacts.size()) { throw std::overflow_error("mechanics contact row count overflow"); } rows_.reserve(contacts.size() + external_contacts.size()); + for (const auto& contact : contacts.contacts()) { const auto first = static_cast(contact.first_slot); const auto second = static_cast(contact.second_slot); + if (geometry_.ids[first] != contact.first_id || geometry_.ids[second] != contact.second_id) { throw std::invalid_argument("contact graph identifiers do not match current state slots"); } @@ -167,6 +185,7 @@ class CpuMechanicsSystem { for (const auto& contact : external_contacts.contacts()) { const auto cell = static_cast(contact.cell_slot); + if (geometry_.ids[cell] != contact.cell_id) { throw std::invalid_argument( "external contact graph identifiers do not match current state slots"); @@ -186,34 +205,44 @@ class CpuMechanicsSystem { } } - [[nodiscard]] std::size_t cell_count() const noexcept { return geometry_.size(); } + [[nodiscard]] std::size_t cell_count() const noexcept { + return geometry_.size(); + } void apply(const DofVector& input, DofVector& output) const { auto projected = input; + for (std::size_t index = 0; index < cell_count(); ++index) { if (fixed_[index] != 0) { projected[index] = {}; } } + output.assign(cell_count(), Dofs{}); + for (const auto& row : rows_) { const auto first = static_cast(row.first_slot); auto row_value = dof_dot(row.first, projected[first]); + if (row.second_slot != invalid_slot) { row_value -= dof_dot(row.second, projected[static_cast(row.second_slot)]); } + add_scaled(output[first], row.first, row_value); + if (row.second_slot != invalid_slot) { add_scaled(output[static_cast(row.second_slot)], row.second, -row_value); } } const auto regularization = 1.0F / parameters_.gamma; + for (std::size_t index = 0; index < cell_count(); ++index) { if (fixed_[index] != 0) { output[index] = input[index]; continue; } + const Vec3 axis{geometry_.direction_x[index], geometry_.direction_y[index], geometry_.direction_z[index]}; const Vec3 rotation{input[index][3], input[index][4], input[index][5]}; @@ -239,19 +268,23 @@ class CpuMechanicsSystem { [[nodiscard]] DofVector right_hand_side() const { DofVector result(cell_count()); + for (const auto& row : rows_) { const auto first = static_cast(row.first_slot); add_scaled(result[first], row.first, row.right_hand_side); + if (row.second_slot != invalid_slot) { add_scaled(result[static_cast(row.second_slot)], row.second, -row.right_hand_side); } } + for (std::size_t index = 0; index < cell_count(); ++index) { if (fixed_[index] != 0) { result[index] = {}; } } + return result; } @@ -269,6 +302,7 @@ class CpuMechanicsSystem { system.apply(solution, applied); auto residual = right_hand_side; add_scaled(residual, applied, -1.0F); + return residual; } @@ -277,9 +311,11 @@ class CpuMechanicsSystem { if (parameters.max_iterations != 0) { return parameters.max_iterations; } + if (cell_count > std::numeric_limits::max() / degrees_of_freedom) { throw std::overflow_error("default mechanics iteration limit exceeds uint32"); } + return static_cast(cell_count * degrees_of_freedom); } @@ -289,9 +325,11 @@ void validate_mechanics_parameters(const MechanicsParameters& parameters) { if (!std::isfinite(parameters.mu_a) || parameters.mu_a <= 0.0F) { throw std::invalid_argument("mechanics mu_a must be finite and positive"); } + if (!std::isfinite(parameters.gamma) || parameters.gamma <= 0.0F) { throw std::invalid_argument("mechanics gamma must be finite and positive"); } + if (!std::isfinite(parameters.residual_rms_tolerance) || parameters.residual_rms_tolerance < 0.0F) { throw std::invalid_argument("mechanics residual tolerance must be finite and non-negative"); @@ -303,12 +341,15 @@ std::vector apply_mechanics_operator_cpu( const ExternalContactGraph& external_contacts, std::span input, const MechanicsParameters& parameters) { const CpuMechanicsSystem system(state, contacts, external_contacts, parameters); + if (input.size() != system.cell_count()) { throw std::invalid_argument("mechanics input size does not match the world state"); } + const auto flat_input = flatten(input); DofVector output; system.apply(flat_input, output); + return unflatten(output); } @@ -325,6 +366,7 @@ std::vector build_mechanics_rhs_cpu(const WorldState& state, const ExternalContactGraph& external_contacts, const MechanicsParameters& parameters) { const CpuMechanicsSystem system(state, contacts, external_contacts, parameters); + return unflatten(system.right_hand_side()); } @@ -348,11 +390,14 @@ MechanicsSolveResult solve_cell_mechanics_cpu(const WorldState& state, const Con result.corrections.resize(system.cell_count()); result.report.initial_residual_rms = residual_rms(residual); result.report.final_residual_rms = result.report.initial_residual_rms; + if (!std::isfinite(result.report.initial_residual_rms)) { result.report.status = SolverStatus::breakdown; result.report.breakdown = SolverBreakdown::non_finite_residual; + return result; } + if (result.report.initial_residual_rms <= parameters.residual_rms_tolerance) { return result; } @@ -361,14 +406,17 @@ MechanicsSolveResult solve_cell_mechanics_cpu(const WorldState& state, const Con auto residual_squared = vector_dot(residual, residual); const auto maximum_iterations = iteration_limit(parameters, system.cell_count()); DofVector applied; + for (std::uint32_t iteration = 0; iteration < maximum_iterations; ++iteration) { system.apply(search_direction, applied); const auto curvature = vector_dot(search_direction, applied); + if (!std::isfinite(curvature)) { result.report.status = SolverStatus::breakdown; result.report.breakdown = SolverBreakdown::non_finite_curvature; break; } + if (curvature <= 0.0F) { result.report.status = SolverStatus::breakdown; result.report.breakdown = SolverBreakdown::non_positive_curvature; @@ -383,6 +431,7 @@ MechanicsSolveResult solve_cell_mechanics_cpu(const WorldState& state, const Con const auto next_residual_squared = vector_dot(residual, residual); const auto recurrence_rms = std::sqrt(next_residual_squared / static_cast(system.cell_count())); + if (!std::isfinite(recurrence_rms)) { result.report.status = SolverStatus::breakdown; result.report.breakdown = SolverBreakdown::non_finite_residual; @@ -392,38 +441,46 @@ MechanicsSolveResult solve_cell_mechanics_cpu(const WorldState& state, const Con if (recurrence_rms <= parameters.residual_rms_tolerance) { residual = exact_residual(system, right_hand_side, solution); const auto recomputed_rms = residual_rms(residual); + if (!std::isfinite(recomputed_rms)) { result.report.status = SolverStatus::breakdown; result.report.breakdown = SolverBreakdown::non_finite_residual; break; } + if (recomputed_rms <= parameters.residual_rms_tolerance) { result.report.status = SolverStatus::converged; break; } + search_direction = residual; residual_squared = vector_dot(residual, residual); continue; } const auto beta = next_residual_squared / residual_squared; + for (std::size_t index = 0; index < search_direction.size(); ++index) { for (std::size_t component = 0; component < degrees_of_freedom; ++component) { search_direction[index][component] = residual[index][component] + beta * search_direction[index][component]; } } + residual_squared = next_residual_squared; } residual = exact_residual(system, right_hand_side, solution); result.report.final_residual_rms = residual_rms(residual); + if (!std::isfinite(result.report.final_residual_rms) && result.report.status != SolverStatus::breakdown) { result.report.status = SolverStatus::breakdown; result.report.breakdown = SolverBreakdown::non_finite_residual; } + result.corrections = unflatten(solution); + return result; } diff --git a/cpp/cpu/cpu_species.cpp b/cpp/cpu/cpu_species.cpp index 89d7490..5951a31 100644 --- a/cpp/cpu/cpu_species.cpp +++ b/cpp/cpu/cpu_species.cpp @@ -11,10 +11,57 @@ namespace cm { namespace { +float evaluate_predicate(const RateInstruction& instruction, std::span workspace) { + switch (instruction.operation) { + case RateOp::less: + return workspace[instruction.first] < workspace[instruction.second] ? 1.0F : 0.0F; + case RateOp::less_equal: + return workspace[instruction.first] <= workspace[instruction.second] ? 1.0F : 0.0F; + case RateOp::greater: + return workspace[instruction.first] > workspace[instruction.second] ? 1.0F : 0.0F; + case RateOp::greater_equal: + return workspace[instruction.first] >= workspace[instruction.second] ? 1.0F : 0.0F; + case RateOp::equal: + return workspace[instruction.first] == workspace[instruction.second] ? 1.0F : 0.0F; + case RateOp::select: + return workspace[instruction.first] != 0.0F ? workspace[instruction.second] + : workspace[instruction.third]; + default: + throw std::logic_error("unknown species rate operation"); + } +} + +float evaluate_arithmetic(const RateInstruction& instruction, std::span workspace) { + switch (instruction.operation) { + case RateOp::add: + return workspace[instruction.first] + workspace[instruction.second]; + case RateOp::subtract: + return workspace[instruction.first] - workspace[instruction.second]; + case RateOp::multiply: + return workspace[instruction.first] * workspace[instruction.second]; + case RateOp::divide: + return workspace[instruction.first] / workspace[instruction.second]; + case RateOp::power: + return std::pow(workspace[instruction.first], workspace[instruction.second]); + case RateOp::minimum: + return std::min(workspace[instruction.first], workspace[instruction.second]); + case RateOp::maximum: + return std::max(workspace[instruction.first], workspace[instruction.second]); + case RateOp::negate: + return -workspace[instruction.first]; + case RateOp::exponential: + return std::exp(workspace[instruction.first]); + case RateOp::logarithm: + return std::log(workspace[instruction.first]); + default: + return evaluate_predicate(instruction, workspace); + } +} + float evaluate_instruction(const RateInstruction& instruction, std::span workspace, std::span species, const CellGeometryView& geometry, const CellAttributeView& attributes, std::size_t cell, - float volume_change_rate) { + float volume_change_rate, float current_volume) { switch (instruction.operation) { case RateOp::constant: return instruction.value; @@ -39,61 +86,60 @@ float evaluate_instruction(const RateInstruction& instruction, std::span workspace[instruction.second] ? 1.0F : 0.0F; - case RateOp::greater_equal: - return workspace[instruction.first] >= workspace[instruction.second] ? 1.0F : 0.0F; - case RateOp::equal: - return workspace[instruction.first] == workspace[instruction.second] ? 1.0F : 0.0F; - case RateOp::select: - return workspace[instruction.first] != 0.0F ? workspace[instruction.second] - : workspace[instruction.third]; + default: + return evaluate_arithmetic(instruction, workspace); + } +} + +void dilute_species_levels(const WorldState& state, std::span previous_lengths, + BiochemicalVolumeView volumes, std::vector& next_levels) { + const auto geometry = state.geometry_state(); + + for (std::size_t cell = 0; cell < state.size(); ++cell) { + if (!std::isfinite(previous_lengths[cell]) || previous_lengths[cell] < 0.0F) { + throw std::invalid_argument("previous cell lengths must be finite and non-negative"); + } + + const auto radius = geometry.radii[cell]; + const auto previous_volume = volumes.previous.empty() + ? effective_cell_volume(previous_lengths[cell], radius) + : volumes.previous[cell]; + const auto current_volume = volumes.current.empty() + ? effective_cell_volume(geometry.lengths[cell], radius) + : volumes.current[cell]; + const auto dilution = previous_volume / current_volume; + const auto offset = cell * state.species_count(); + + for (std::size_t species = 0; species < state.species_count(); ++species) { + next_levels[offset + species] *= dilution; + } } - throw std::logic_error("unknown species rate operation"); } } // namespace void advance_species_cpu(WorldState& state, const SpeciesRatePlan& plan, - std::span previous_lengths, float dt) { + std::span previous_lengths, float dt, + BiochemicalVolumeView volumes) { if (!std::isfinite(dt) || dt < 0.0F) { throw std::invalid_argument("species time step must be finite and non-negative"); } + state.validate(); + volumes.validate(state.size()); plan.validate(); + if (plan.species_count() != state.species_count()) { throw std::invalid_argument("species rate plan and world state species counts disagree"); } + if (previous_lengths.size() != state.size()) { throw std::invalid_argument("previous cell lengths and world state cell counts disagree"); } + if (state.empty() || state.species_count() == 0) { return; } @@ -102,45 +148,45 @@ void advance_species_cpu(WorldState& state, const SpeciesRatePlan& plan, const auto attributes = state.cell_attributes(); auto species_state = state.species_state(); std::vector next_levels(species_state.levels.begin(), species_state.levels.end()); - for (std::size_t cell = 0; cell < state.size(); ++cell) { - if (!std::isfinite(previous_lengths[cell]) || previous_lengths[cell] < 0.0F) { - throw std::invalid_argument("previous cell lengths must be finite and non-negative"); - } - const auto radius = geometry.radii[cell]; - const auto previous_volume = effective_cell_volume(previous_lengths[cell], radius); - const auto current_volume = effective_cell_volume(geometry.lengths[cell], radius); - const auto dilution = previous_volume / current_volume; - const auto offset = cell * state.species_count(); - for (std::size_t species = 0; species < state.species_count(); ++species) { - next_levels[offset + species] *= dilution; - } - } + + dilute_species_levels(state, previous_lengths, volumes, next_levels); std::vector workspace(plan.instructions().size()); + for (std::size_t cell = 0; cell < state.size(); ++cell) { const auto offset = cell * state.species_count(); + const auto previous_volume = + volumes.previous.empty() + ? effective_cell_volume(previous_lengths[cell], geometry.radii[cell]) + : volumes.previous[cell]; + const auto current_volume = + volumes.current.empty() + ? effective_cell_volume(geometry.lengths[cell], geometry.radii[cell]) + : volumes.current[cell]; const auto cell_species = std::span(next_levels).subspan(offset, state.species_count()); + for (std::size_t index = 0; index < plan.instructions().size(); ++index) { workspace[index] = evaluate_instruction( plan.instructions()[index], workspace, cell_species, geometry, attributes, cell, - dt == 0.0F ? 0.0F - : (effective_cell_volume(geometry.lengths[cell], geometry.radii[cell]) - - effective_cell_volume(previous_lengths[cell], geometry.radii[cell])) / - dt); + dt == 0.0F ? 0.0F : (current_volume - previous_volume) / dt, current_volume); + if (!std::isfinite(workspace[index])) { throw std::domain_error("species rate instruction " + std::to_string(index) + " produced a non-finite value"); } } + for (std::size_t species = 0; species < state.species_count(); ++species) { const auto rate = workspace[plan.outputs()[species]]; next_levels[offset + species] += dt * rate; + if (!std::isfinite(next_levels[offset + species])) { throw std::domain_error("species Euler update produced a non-finite level"); } } } + std::ranges::copy(next_levels, species_state.levels.begin()); } diff --git a/cpp/cuda/cuda_backend.cu b/cpp/cuda/cuda_backend.cu index f6ef544..7af0503 100644 --- a/cpp/cuda/cuda_backend.cu +++ b/cpp/cuda/cuda_backend.cu @@ -50,7 +50,9 @@ class CudaBuffer { if (count <= capacity_) { return; } + const auto new_capacity = std::bit_ceil(count); + if (new_capacity > std::numeric_limits::max() / sizeof(T)) { throw std::overflow_error(std::string("CUDA buffer size overflow for ") + description); } @@ -60,20 +62,28 @@ class CudaBuffer { const auto allocation_operation = std::string("failed to allocate CUDA ") + description; check_cuda(cudaMalloc(reinterpret_cast(&replacement), byte_count), allocation_operation.c_str()); + if (data_ != nullptr) { const auto release_result = cudaFree(data_); + if (release_result != cudaSuccess) { cudaFree(replacement); const auto release_operation = std::string("failed to release old CUDA ") + description; check_cuda(release_result, release_operation.c_str()); } } + data_ = replacement; capacity_ = new_capacity; } - [[nodiscard]] T* data() noexcept { return data_; } - [[nodiscard]] const T* data() const noexcept { return data_; } + [[nodiscard]] T* data() noexcept { + return data_; + } + + [[nodiscard]] const T* data() const noexcept { + return data_; + } private: T* data_{nullptr}; @@ -86,11 +96,14 @@ class CudaBackend final : public ComputeBackend { if (device_index > static_cast(std::numeric_limits::max())) { throw std::out_of_range("CUDA device index exceeds the runtime index space"); } + int device_count = 0; check_cuda(cudaGetDeviceCount(&device_count), "failed to enumerate CUDA devices"); + if (device_index >= static_cast(device_count)) { throw std::out_of_range("CUDA device index is unavailable"); } + device_index_ = static_cast(device_index); check_cuda(cudaSetDevice(device_index_), "failed to select the CUDA device"); check_cuda(cudaGetDeviceProperties(&device_properties_, device_index_), @@ -101,6 +114,7 @@ class CudaBackend final : public ComputeBackend { ~CudaBackend() override { static_cast(cudaSetDevice(device_index_)); + if (stream_ != nullptr) { cudaStreamDestroy(stream_); } @@ -123,18 +137,21 @@ class CudaBackend final : public ComputeBackend { feature == BackendFeature::cell_mechanics || feature == BackendFeature::signals || feature == BackendFeature::coupled_rates || feature == BackendFeature::depth_averaged_flow || - feature == BackendFeature::resolved_flow; + feature == BackendFeature::resolved_flow || feature == BackendFeature::culture; } void advance_growth(WorldState& state, float dt) override { activate_device(); auto view = state.growth_state(); + if (view.lengths.empty()) { return; } + if (view.lengths.size() > std::numeric_limits::max()) { throw std::overflow_error("CUDA growth launch exceeds the uint32 index space"); } + lengths_.reserve(view.lengths.size(), "growth lengths"); growth_rates_.reserve(view.growth_rates.size(), "growth rates"); @@ -156,63 +173,49 @@ class CudaBackend final : public ComputeBackend { } void advance_species(WorldState& state, const SpeciesRatePlan& plan, - std::span previous_lengths, float dt) override { + std::span previous_lengths, float dt, + BiochemicalVolumeView volumes = {}) override { activate_device(); + if (!std::isfinite(dt) || dt < 0.0F) { throw std::invalid_argument("species time step must be finite and non-negative"); } + state.validate(); + volumes.validate(state.size()); plan.validate(); + if (plan.species_count() != state.species_count()) { throw std::invalid_argument("species rate plan and world state species counts disagree"); } + if (previous_lengths.size() != state.size()) { throw std::invalid_argument("previous cell lengths and world state cell counts disagree"); } + if (state.empty() || state.species_count() == 0) { return; } - if (state.size() > std::numeric_limits::max() || - state.species_count() > std::numeric_limits::max() || - plan.instructions().size() > std::numeric_limits::max()) { - throw std::overflow_error("CUDA species launch exceeds the uint32 index space"); - } - if (!std::ranges::all_of(previous_lengths, - [](float value) { return std::isfinite(value) && value >= 0.0F; })) { - throw std::invalid_argument("previous cell lengths must be finite and non-negative"); - } - if (state.size() > std::numeric_limits::max() / state.species_count() || - state.size() > std::numeric_limits::max() / plan.instructions().size()) { - throw std::overflow_error("CUDA species buffer size overflow"); - } - const auto level_count = state.size() * state.species_count(); - const auto workspace_count = state.size() * plan.instructions().size(); - if (level_count > std::numeric_limits::max() || - workspace_count > std::numeric_limits::max()) { - throw std::overflow_error("CUDA flattened species storage exceeds the uint32 index space"); - } - if (level_count > std::numeric_limits::max() / sizeof(float) || - workspace_count > std::numeric_limits::max() / sizeof(float) || - plan.instructions().size() > - std::numeric_limits::max() / sizeof(cuda::RateInstructionGpu)) { - throw std::overflow_error("CUDA species allocation size overflow"); - } - ensure_species_capacity(state.size(), level_count, plan.instructions().size(), - state.species_count(), workspace_count); + prepare_species_storage(state, plan, previous_lengths); const auto geometry = state.geometry_state(); const auto attributes = state.cell_attributes(); auto species_state = state.species_state(); std::vector centers(state.size()); std::vector shapes(state.size()); + for (std::size_t index = 0; index < state.size(); ++index) { centers[index] = make_float4(geometry.position_x[index], geometry.position_y[index], geometry.position_z[index], 0.0F); - shapes[index] = make_float4(geometry.lengths[index], geometry.radii[index], 0.0F, 0.0F); + shapes[index] = make_float4(geometry.lengths[index], geometry.radii[index], + volumes.current.empty() ? 0.0F : volumes.current[index], + volumes.previous.empty() ? 0.0F : volumes.previous[index]); } + std::vector instructions; instructions.reserve(plan.instructions().size()); + for (const auto& instruction : plan.instructions()) { instructions.push_back({ .operation = static_cast(instruction.operation), @@ -222,6 +225,7 @@ class CudaBackend final : public ComputeBackend { .value = instruction.value, }); } + const std::vector level_values(species_state.levels.begin(), species_state.levels.end()); const std::vector previous_values(previous_lengths.begin(), previous_lengths.end()); const std::vector growth_values(attributes.growth_rates.begin(), @@ -259,9 +263,11 @@ class CudaBackend final : public ComputeBackend { stream_), "failed to download the CUDA species error flag"); check_cuda(cudaStreamSynchronize(stream_), "CUDA species execution failed"); + if (error != 0) { throw std::domain_error("CUDA species kernel produced a non-finite value"); } + check_cuda(cudaMemcpyAsync(species_state.levels.data(), species_levels_.data(), species_state.levels.size_bytes(), cudaMemcpyDeviceToHost, stream_), "failed to download CUDA species levels"); @@ -272,74 +278,19 @@ class CudaBackend final : public ComputeBackend { activate_device(); grid.validate(); grid.validate_step(dt); + if (dt == 0.0F) { return {}; } + const auto& spec = grid.spec(); const auto level_view = grid.levels(); const std::vector levels(level_view.begin(), level_view.end()); const auto signal_count = spec.signal_count; const auto level_count = static_cast(levels.size()); - signal_levels_.reserve(levels.size(), "signal-grid levels"); - signal_output_.reserve(levels.size(), "signal-grid output"); - signal_diffusion_.reserve(signal_count, "signal-grid diffusion"); - signal_advection_.reserve(signal_count, "signal-grid advection"); - signal_fixed_values_.reserve(static_cast(6) * signal_count, - "signal-grid boundary values"); - signal_reaction_source_.reserve(levels.size(), "signal-grid affine sources"); - signal_reaction_loss_.reserve(levels.size(), "signal-grid affine losses"); - signal_obstacles_.reserve(spec.site_count(), "signal-grid obstacles"); - signal_x_faces_.reserve(std::max(spec.x_face_count(), 1), "signal x faces"); - signal_y_faces_.reserve(std::max(spec.y_face_count(), 1), "signal y faces"); - signal_z_faces_.reserve(std::max(spec.z_face_count(), 1), "signal z faces"); - signal_error_.reserve(1, "signal-grid error flag"); - - std::vector advection; - advection.reserve(signal_count); - for (const auto velocity : spec.advection) { - advection.push_back(make_float4(velocity.x, velocity.y, velocity.z, 0.0F)); - } - const std::array boundary_records{ - &spec.x_lower, &spec.x_upper, &spec.y_lower, &spec.y_upper, &spec.z_lower, &spec.z_upper, - }; - std::vector fixed_values(static_cast(6) * signal_count, 0.0F); - for (std::size_t face = 0; face < boundary_records.size(); ++face) { - if (boundary_records[face]->kind == GridBoundaryKind::fixed) { - std::copy(boundary_records[face]->values.begin(), boundary_records[face]->values.end(), - fixed_values.begin() + static_cast(face * signal_count)); - } - } - std::vector reaction_source(levels.size(), 0.0F); - std::vector reaction_loss(levels.size(), 0.0F); - if (spec.reaction.has_value()) { - reaction_source = spec.reaction->source_rates; - reaction_loss = spec.reaction->loss_rates; - } - std::vector obstacles(spec.site_count(), 0); - if (spec.has_obstacles()) { - obstacles = spec.obstacles; - } + upload_signal_inputs(spec, levels); - copy_to_device(signal_levels_, levels, "failed to upload CUDA signal-grid levels"); - copy_to_device(signal_diffusion_, spec.diffusion, - "failed to upload CUDA signal-grid diffusion"); - copy_to_device(signal_advection_, advection, "failed to upload CUDA signal-grid advection"); - copy_to_device(signal_fixed_values_, fixed_values, - "failed to upload CUDA signal-grid boundary values"); - copy_to_device(signal_reaction_source_, reaction_source, - "failed to upload CUDA signal-grid affine sources"); - copy_to_device(signal_reaction_loss_, reaction_loss, - "failed to upload CUDA signal-grid affine losses"); - copy_to_device(signal_obstacles_, obstacles, "failed to upload CUDA signal-grid obstacles"); - if (spec.velocity_field.has_value()) { - copy_to_device(signal_x_faces_, spec.velocity_field->x_faces, - "failed to upload CUDA signal x faces"); - copy_to_device(signal_y_faces_, spec.velocity_field->y_faces, - "failed to upload CUDA signal y faces"); - copy_to_device(signal_z_faces_, spec.velocity_field->z_faces, - "failed to upload CUDA signal z faces"); - } const auto has_velocity_field = static_cast(spec.velocity_field.has_value()); check_cuda(cudaMemsetAsync(signal_error_.data(), 0, sizeof(std::uint32_t), stream_), "failed to clear the CUDA signal-grid error flag"); @@ -373,12 +324,15 @@ class CudaBackend final : public ComputeBackend { stream_), "failed to download the CUDA signal-grid error flag"); check_cuda(cudaStreamSynchronize(stream_), "CUDA signal-grid execution failed"); + if (error != 0) { throw std::domain_error( "CUDA signal-grid kernel produced a non-finite or negative concentration"); } + const float* result_device = signal_output_.data(); SignalSolveReport report; + if (crank_nicolson) { const auto solve = solve_signal_crank_nicolson( signal_levels_.data(), signal_output_.data(), signal_diffusion_.data(), @@ -389,17 +343,20 @@ class CudaBackend final : public ComputeBackend { (crank_nicolson == 2 ? dt : 0.5F * dt), signal_count, level_count, spec.solver); result_device = solve.first; report = solve.second; + if (!report.converged) { throw std::runtime_error("CUDA Implicit signal solve did not converge after " + std::to_string(report.iterations) + " iterations"); } } + std::vector output(levels.size()); check_cuda(cudaMemcpyAsync(output.data(), result_device, output.size() * sizeof(float), cudaMemcpyDeviceToHost, stream_), "failed to download CUDA signal-grid levels"); check_cuda(cudaStreamSynchronize(stream_), "CUDA signal-grid download failed"); grid.replace_levels(std::move(output)); + return report; } @@ -412,6 +369,7 @@ class CudaBackend final : public ComputeBackend { if (right != 0 && left > std::numeric_limits::max() / right) { throw std::overflow_error(std::string("CUDA coupled ") + name + " size overflow"); } + return left * right; }; const auto cell_count_size = state.size(); @@ -426,6 +384,7 @@ class CudaBackend final : public ComputeBackend { checked_product(cell_count_size, signal_count_size, "cell signal"); const auto grid_level_count = grid.levels().size(); const auto& spec = grid.spec(); + for (const auto count : {cell_count_size, species_count_size, signal_count_size, instruction_count_size, species_level_count, workspace_count, cell_signal_count, grid_level_count}) { @@ -434,131 +393,13 @@ class CudaBackend final : public ComputeBackend { } } - coupled_species_levels_.reserve(species_level_count, "coupled species levels"); - coupled_previous_lengths_.reserve(cell_count_size, "coupled previous lengths"); - coupled_centers_.reserve(cell_count_size, "coupled cell centers"); - coupled_geometry_.reserve(cell_count_size, "coupled cell geometry"); - coupled_growth_rates_.reserve(cell_count_size, "coupled growth rates"); - coupled_cell_types_.reserve(cell_count_size, "coupled cell types"); - coupled_instructions_.reserve(instruction_count_size, "coupled rate instructions"); - coupled_species_outputs_.reserve(species_count_size, "coupled species outputs"); - coupled_signal_outputs_.reserve(signal_count_size, "coupled signal outputs"); - coupled_workspace_.reserve(workspace_count, "coupled rate workspace"); - coupled_cell_signal_rates_.reserve(cell_signal_count, "coupled cell signal rates"); - coupled_grid_levels_.reserve(grid_level_count, "coupled grid levels"); - coupled_grid_output_.reserve(grid_level_count, "coupled grid output"); - coupled_diffusion_.reserve(signal_count_size, "coupled diffusion"); - coupled_advection_.reserve(signal_count_size, "coupled advection"); - coupled_fixed_values_.reserve(6 * signal_count_size, "coupled boundary values"); - coupled_reaction_source_.reserve(grid_level_count, "coupled affine sources"); - coupled_reaction_loss_.reserve(grid_level_count, "coupled affine losses"); - coupled_obstacles_.reserve(spec.site_count(), "coupled grid obstacles"); - coupled_x_faces_.reserve(std::max(spec.x_face_count(), 1), "coupled x faces"); - coupled_y_faces_.reserve(std::max(spec.y_face_count(), 1), "coupled y faces"); - coupled_z_faces_.reserve(std::max(spec.z_face_count(), 1), "coupled z faces"); - coupled_error_.reserve(1, "coupled error flag"); + reserve_coupled_buffers(spec, cell_count_size, species_level_count, instruction_count_size, + species_count_size, signal_count_size, workspace_count, + cell_signal_count, grid_level_count); - const auto geometry = state.geometry_state(); - const auto attributes = state.cell_attributes(); + upload_coupled_inputs(state, grid, plan, previous_lengths); auto species_state = state.species_state(); - std::vector centers(cell_count_size); - std::vector shapes(cell_count_size); - for (std::size_t index = 0; index < cell_count_size; ++index) { - centers[index] = make_float4(geometry.position_x[index], geometry.position_y[index], - geometry.position_z[index], 0.0F); - shapes[index] = make_float4(geometry.lengths[index], geometry.radii[index], 0.0F, 0.0F); - } - std::vector instructions; - instructions.reserve(instruction_count_size); - for (const auto& instruction : plan.instructions()) { - instructions.push_back({ - .operation = static_cast(instruction.operation), - .first = instruction.first, - .second = instruction.second, - .third = instruction.third, - .value = instruction.value, - }); - } - std::vector advection; - advection.reserve(signal_count_size); - for (const auto velocity : spec.advection) { - advection.push_back(make_float4(velocity.x, velocity.y, velocity.z, 0.0F)); - } - const std::array boundary_records{ - &spec.x_lower, &spec.x_upper, &spec.y_lower, &spec.y_upper, &spec.z_lower, &spec.z_upper, - }; - std::vector fixed_values(6 * signal_count_size, 0.0F); - for (std::size_t face = 0; face < boundary_records.size(); ++face) { - if (boundary_records[face]->kind == GridBoundaryKind::fixed) { - std::copy(boundary_records[face]->values.begin(), boundary_records[face]->values.end(), - fixed_values.begin() + static_cast(face * signal_count_size)); - } - } - std::vector reaction_source(grid_level_count, 0.0F); - std::vector reaction_loss(grid_level_count, 0.0F); - if (spec.reaction.has_value()) { - reaction_source = spec.reaction->source_rates; - reaction_loss = spec.reaction->loss_rates; - } - const std::vector species_levels(species_state.levels.begin(), - species_state.levels.end()); - const std::vector previous_values(previous_lengths.begin(), previous_lengths.end()); - const std::vector growth_values(attributes.growth_rates.begin(), - attributes.growth_rates.end()); - const std::vector cell_type_values(attributes.cell_types.begin(), - attributes.cell_types.end()); - const std::vector species_outputs(plan.species_outputs().begin(), - plan.species_outputs().end()); - const std::vector signal_outputs(plan.signal_outputs().begin(), - plan.signal_outputs().end()); - const std::vector grid_levels(grid.levels().begin(), grid.levels().end()); - if (!species_levels.empty()) { - copy_to_device(coupled_species_levels_, species_levels, - "failed to upload CUDA coupled species levels"); - } - if (!previous_values.empty()) { - copy_to_device(coupled_previous_lengths_, previous_values, - "failed to upload CUDA coupled previous lengths"); - copy_to_device(coupled_centers_, centers, "failed to upload CUDA coupled cell centers"); - copy_to_device(coupled_geometry_, shapes, "failed to upload CUDA coupled cell geometry"); - copy_to_device(coupled_growth_rates_, growth_values, - "failed to upload CUDA coupled growth rates"); - copy_to_device(coupled_cell_types_, cell_type_values, - "failed to upload CUDA coupled cell types"); - } - copy_to_device(coupled_instructions_, instructions, - "failed to upload CUDA coupled instructions"); - if (!species_outputs.empty()) { - copy_to_device(coupled_species_outputs_, species_outputs, - "failed to upload CUDA coupled species outputs"); - } - copy_to_device(coupled_signal_outputs_, signal_outputs, - "failed to upload CUDA coupled signal outputs"); - copy_to_device(coupled_grid_levels_, grid_levels, "failed to upload CUDA coupled grid levels"); - copy_to_device(coupled_diffusion_, spec.diffusion, "failed to upload CUDA coupled diffusion"); - copy_to_device(coupled_advection_, advection, "failed to upload CUDA coupled advection"); - copy_to_device(coupled_fixed_values_, fixed_values, - "failed to upload CUDA coupled boundary values"); - copy_to_device(coupled_reaction_source_, reaction_source, - "failed to upload CUDA coupled affine sources"); - copy_to_device(coupled_reaction_loss_, reaction_loss, - "failed to upload CUDA coupled affine losses"); - { - std::vector obstacles(spec.site_count(), 0); - if (spec.has_obstacles()) { - obstacles = spec.obstacles; - } - copy_to_device(coupled_obstacles_, obstacles, "failed to upload CUDA coupled obstacles"); - } - if (spec.velocity_field.has_value()) { - copy_to_device(coupled_x_faces_, spec.velocity_field->x_faces, - "failed to upload CUDA coupled x faces"); - copy_to_device(coupled_y_faces_, spec.velocity_field->y_faces, - "failed to upload CUDA coupled y faces"); - copy_to_device(coupled_z_faces_, spec.velocity_field->z_faces, - "failed to upload CUDA coupled z faces"); - } const auto has_velocity_field = static_cast(spec.velocity_field.has_value()); check_cuda(cudaMemsetAsync(coupled_error_.data(), 0, sizeof(std::uint32_t), stream_), "failed to clear the CUDA coupled error flag"); @@ -578,38 +419,13 @@ class CudaBackend final : public ComputeBackend { .sites = static_cast(spec.site_count()), }; const auto crank_nicolson = static_cast(spec.integration); - check_cuda( - cuda::launch_advance_coupled( - coupled_species_levels_.data(), coupled_previous_lengths_.data(), - coupled_centers_.data(), coupled_geometry_.data(), coupled_growth_rates_.data(), - coupled_cell_types_.data(), coupled_instructions_.data(), - coupled_species_outputs_.data(), coupled_signal_outputs_.data(), - coupled_workspace_.data(), coupled_grid_levels_.data(), coupled_grid_output_.data(), - coupled_diffusion_.data(), coupled_advection_.data(), coupled_fixed_values_.data(), - coupled_reaction_source_.data(), coupled_reaction_loss_.data(), - coupled_obstacles_.data(), coupled_x_faces_.data(), coupled_y_faces_.data(), - coupled_z_faces_.data(), has_velocity_field, coupled_cell_signal_rates_.data(), - coupled_error_.data(), boundaries, shape, - make_float4(spec.origin.x, spec.origin.y, spec.origin.z, 0.0F), - make_float4(spec.spacing.x, spec.spacing.y, spec.spacing.z, 0.0F), dt, - static_cast(species_count_size), - static_cast(signal_count_size), - static_cast(instruction_count_size), - static_cast(cell_count_size), - static_cast(grid_level_count), crank_nicolson, stream_), - "failed to launch the CUDA coupled kernels"); - - std::uint32_t error = 0; - check_cuda(cudaMemcpyAsync(&error, coupled_error_.data(), sizeof(error), cudaMemcpyDeviceToHost, - stream_), - "failed to download the CUDA coupled error flag"); - check_cuda(cudaStreamSynchronize(stream_), "CUDA coupled execution failed"); - if (error != 0) { - throw std::domain_error("CUDA coupled kernels produced an invalid value"); - } + dispatch_coupled(boundaries, shape, spec, dt, species_count_size, signal_count_size, + instruction_count_size, cell_count_size, grid_level_count, crank_nicolson, + has_velocity_field); const float* result_device = coupled_grid_output_.data(); SignalSolveReport report; + if (crank_nicolson) { const auto solve = solve_signal_crank_nicolson( coupled_grid_levels_.data(), coupled_grid_output_.data(), coupled_diffusion_.data(), @@ -622,19 +438,21 @@ class CudaBackend final : public ComputeBackend { static_cast(grid_level_count), spec.solver); result_device = solve.first; report = solve.second; + if (!report.converged) { - throw std::runtime_error( - "CUDA Implicit coupled signal solve did not converge after " + - std::to_string(report.iterations) + " iterations"); + throw std::runtime_error("CUDA Implicit coupled signal solve did not converge after " + + std::to_string(report.iterations) + " iterations"); } } std::vector next_species(species_level_count); std::vector next_grid(grid_level_count); + if (!next_species.empty()) { copy_to_host(next_species, coupled_species_levels_, "failed to download CUDA coupled species levels"); } + check_cuda(cudaMemcpyAsync(next_grid.data(), result_device, next_grid.size() * sizeof(float), cudaMemcpyDeviceToHost, stream_), "failed to download CUDA coupled grid levels"); @@ -642,6 +460,7 @@ class CudaBackend final : public ComputeBackend { SignalGridCheckpoint{.spec = spec, .levels = next_grid}.validate(); std::ranges::copy(next_species, species_state.levels.begin()); grid.replace_levels(std::move(next_grid)); + return report; } @@ -650,16 +469,21 @@ class CudaBackend final : public ComputeBackend { activate_device(); validate_contact_parameters(parameters); const auto geometry = state.geometry_state(); + if (geometry.size() == 0) { return ContactGraph{}; } + if (geometry.size() > std::numeric_limits::max()) { throw std::overflow_error("CUDA contact launch exceeds the uint32 cell index space"); } + const auto candidates = find_cell_contact_candidates(state, parameters); + if (candidates.empty()) { return ContactGraph(geometry.size(), {}); } + if (candidates.size() > std::numeric_limits::max() / 2) { throw std::overflow_error("CUDA contact candidates exceed the uint32 scan space"); } @@ -671,12 +495,14 @@ class CudaBackend final : public ComputeBackend { upload_contact_candidates(candidates); const auto candidate_count = static_cast(candidates.size()); const auto contact_count = count_contacts(candidate_count, parameters); + if (contact_count == 0) { return ContactGraph(geometry.size(), {}); } ensure_contact_output_capacity(contact_count); fill_contacts(candidate_count, parameters); + return download_contacts(geometry.size(), contact_count); } @@ -687,17 +513,22 @@ class CudaBackend final : public ComputeBackend { validate_constraint_contact_parameters(parameters); state.validate(); const auto geometry = state.geometry_state(); + if (geometry.size() == 0 || constraints.empty()) { return ExternalContactGraph(geometry.size(), {}); } + if (geometry.size() > std::numeric_limits::max() || constraints.size() > std::numeric_limits::max()) { throw std::overflow_error("CUDA external-contact launch exceeds the uint32 index space"); } + if (geometry.size() > std::numeric_limits::max() / constraints.size()) { throw std::overflow_error("CUDA external-contact pair count overflow"); } + const auto pair_count = geometry.size() * constraints.size(); + if (pair_count > std::numeric_limits::max() / 2) { throw std::overflow_error("CUDA external-contact staging exceeds the uint32 scan space"); } @@ -710,142 +541,481 @@ class CudaBackend final : public ComputeBackend { const auto contact_count = count_external_contacts( static_cast(geometry.size()), static_cast(constraints.size()), static_cast(pair_count), parameters); + if (contact_count == 0) { return ExternalContactGraph(geometry.size(), {}); } - ensure_contact_output_capacity(contact_count); - fill_external_contacts(static_cast(geometry.size()), - static_cast(constraints.size()), parameters); - return download_external_contacts(geometry.size(), contact_count); + ensure_contact_output_capacity(contact_count); + fill_external_contacts(static_cast(geometry.size()), + static_cast(constraints.size()), parameters); + + return download_external_contacts(geometry.size(), contact_count); + } + + [[nodiscard]] MechanicsSolveResult solve_cell_mechanics( + const WorldState& state, const ContactGraph& contacts, + const ExternalContactGraph& external_contacts, + const MechanicsParameters& parameters) override { + activate_device(); + validate_mechanics_parameters(parameters); + state.validate(); + const auto geometry = state.geometry_state(); + + if (contacts.cell_count() != geometry.size()) { + throw std::invalid_argument("contact graph and world state cell counts disagree"); + } + + if (external_contacts.cell_count() != geometry.size()) { + throw std::invalid_argument("external contact graph and world state cell counts disagree"); + } + + if (external_contacts.size() > std::numeric_limits::max() - contacts.size()) { + throw std::overflow_error("CUDA mechanics row count overflow"); + } + + const auto row_count = contacts.size() + external_contacts.size(); + + if (geometry.size() > std::numeric_limits::max() || + row_count > std::numeric_limits::max() / 2) { + throw std::overflow_error("CUDA mechanics exceeds the uint32 index space"); + } + + MechanicsSolveResult result; + result.corrections.resize(geometry.size()); + + if (geometry.size() == 0 || row_count == 0) { + return result; + } + + validate_mechanics_contacts(geometry, contacts); + validate_external_mechanics_contacts(geometry, external_contacts); + ensure_contact_cell_capacity(geometry.size()); + ensure_contact_output_capacity(row_count); + ensure_mechanics_capacity(geometry.size(), row_count); + upload_contact_cells(geometry); + upload_mechanics_fixed(state.cell_attributes().fixed); + upload_mechanics_contacts(contacts, external_contacts); + upload_mechanics_incidence(contacts, external_contacts); + + const auto cell_count = static_cast(geometry.size()); + const auto contact_count = static_cast(row_count); + auto residual_squared = initialize_mechanics(cell_count, contact_count); + result.report.initial_residual_rms = + std::sqrt(residual_squared / static_cast(cell_count)); + result.report.final_residual_rms = result.report.initial_residual_rms; + + if (!std::isfinite(result.report.initial_residual_rms)) { + result.report.status = SolverStatus::breakdown; + result.report.breakdown = SolverBreakdown::non_finite_residual; + + return result; + } + + if (result.report.initial_residual_rms <= parameters.residual_rms_tolerance) { + return result; + } + + result.report.status = SolverStatus::iteration_limit; + const auto maximum_iterations = mechanics_iteration_limit(parameters, geometry.size()); + + iterate_mechanics(cell_count, contact_count, maximum_iterations, parameters, residual_squared, + result.report); + + residual_squared = recompute_residual(cell_count, contact_count, parameters); + result.report.final_residual_rms = std::sqrt(residual_squared / static_cast(cell_count)); + + if (!std::isfinite(result.report.final_residual_rms) && + result.report.status != SolverStatus::breakdown) { + result.report.status = SolverStatus::breakdown; + result.report.breakdown = SolverBreakdown::non_finite_residual; + } + + result.corrections = download_mechanics_solution(geometry.size()); + + return result; + } + + [[nodiscard]] DepthAveragedFlowResult solve_depth_averaged_flow( + const SignalGridSpec& spec, std::span mobility, + const DepthAveragedFlowParameters& parameters) override { + activate_device(); + + return cuda::solve_depth_averaged_flow(spec, mobility, parameters, stream_); + } + + [[nodiscard]] ResolvedFlowResult solve_resolved_flow( + const SignalGridSpec& spec, std::span drag, + const ResolvedFlowParameters& parameters) override { + activate_device(); + + return cuda::solve_resolved_flow(spec, drag, parameters, stream_); + } + + private: + void dispatch_coupled(cuda::SignalGridBoundariesGpu boundaries, cuda::SignalGridShapeGpu shape, + const SignalGridSpec& spec, float dt, std::size_t species_count_size, + std::size_t signal_count_size, std::size_t instruction_count_size, + std::size_t cell_count_size, std::size_t grid_level_count, + std::uint32_t crank_nicolson, std::uint32_t has_velocity_field) { + check_cuda( + cuda::launch_advance_coupled( + coupled_species_levels_.data(), coupled_previous_lengths_.data(), + coupled_centers_.data(), coupled_geometry_.data(), coupled_growth_rates_.data(), + coupled_cell_types_.data(), coupled_instructions_.data(), + coupled_species_outputs_.data(), coupled_signal_outputs_.data(), + coupled_workspace_.data(), coupled_grid_levels_.data(), coupled_grid_output_.data(), + coupled_diffusion_.data(), coupled_advection_.data(), coupled_fixed_values_.data(), + coupled_reaction_source_.data(), coupled_reaction_loss_.data(), + coupled_obstacles_.data(), coupled_x_faces_.data(), coupled_y_faces_.data(), + coupled_z_faces_.data(), has_velocity_field, coupled_cell_signal_rates_.data(), + coupled_error_.data(), boundaries, shape, + make_float4(spec.origin.x, spec.origin.y, spec.origin.z, 0.0F), + make_float4(spec.spacing.x, spec.spacing.y, spec.spacing.z, 0.0F), dt, + static_cast(species_count_size), + static_cast(signal_count_size), + static_cast(instruction_count_size), + static_cast(cell_count_size), + static_cast(grid_level_count), crank_nicolson, stream_), + "failed to launch the CUDA coupled kernels"); + + std::uint32_t error = 0; + check_cuda(cudaMemcpyAsync(&error, coupled_error_.data(), sizeof(error), cudaMemcpyDeviceToHost, + stream_), + "failed to download the CUDA coupled error flag"); + check_cuda(cudaStreamSynchronize(stream_), "CUDA coupled execution failed"); + + if (error != 0) { + throw std::domain_error("CUDA coupled kernels produced an invalid value"); + } + } + + void prepare_species_storage(const WorldState& state, const SpeciesRatePlan& plan, + std::span previous_lengths) { + if (state.size() > std::numeric_limits::max() || + state.species_count() > std::numeric_limits::max() || + plan.instructions().size() > std::numeric_limits::max()) { + throw std::overflow_error("CUDA species launch exceeds the uint32 index space"); + } + + if (!std::ranges::all_of(previous_lengths, [](float value) { + return std::isfinite(value) && value >= 0.0F; + })) { + throw std::invalid_argument("previous cell lengths must be finite and non-negative"); + } + + if (state.size() > std::numeric_limits::max() / state.species_count() || + state.size() > std::numeric_limits::max() / plan.instructions().size()) { + throw std::overflow_error("CUDA species buffer size overflow"); + } + + const auto level_count = state.size() * state.species_count(); + const auto workspace_count = state.size() * plan.instructions().size(); + + if (level_count > std::numeric_limits::max() || + workspace_count > std::numeric_limits::max()) { + throw std::overflow_error("CUDA flattened species storage exceeds the uint32 index space"); + } + + if (level_count > std::numeric_limits::max() / sizeof(float) || + workspace_count > std::numeric_limits::max() / sizeof(float) || + plan.instructions().size() > + std::numeric_limits::max() / sizeof(cuda::RateInstructionGpu)) { + throw std::overflow_error("CUDA species allocation size overflow"); + } + + ensure_species_capacity(state.size(), level_count, plan.instructions().size(), + state.species_count(), workspace_count); + } + + void iterate_mechanics(std::uint32_t cell_count, std::uint32_t contact_count, + std::uint32_t maximum_iterations, const MechanicsParameters& parameters, + float& residual_squared, SolverReport& report) { + for (std::uint32_t iteration = 0; iteration < maximum_iterations; ++iteration) { + const auto curvature = apply_search_direction(cell_count, contact_count, parameters); + + if (!std::isfinite(curvature)) { + report.status = SolverStatus::breakdown; + report.breakdown = SolverBreakdown::non_finite_curvature; + break; + } + + if (curvature <= 0.0F) { + report.status = SolverStatus::breakdown; + report.breakdown = SolverBreakdown::non_positive_curvature; + break; + } + + const auto alpha = residual_squared / curvature; + const auto next_residual_squared = update_solution_residual(cell_count, alpha); + report.iterations = iteration + 1; + const auto recurrence_rms = std::sqrt(next_residual_squared / static_cast(cell_count)); + + if (!std::isfinite(recurrence_rms)) { + report.status = SolverStatus::breakdown; + report.breakdown = SolverBreakdown::non_finite_residual; + break; + } + + if (recurrence_rms <= parameters.residual_rms_tolerance) { + residual_squared = recompute_residual(cell_count, contact_count, parameters); + const auto recomputed_rms = std::sqrt(residual_squared / static_cast(cell_count)); + + if (!std::isfinite(recomputed_rms)) { + report.status = SolverStatus::breakdown; + report.breakdown = SolverBreakdown::non_finite_residual; + break; + } + + if (recomputed_rms <= parameters.residual_rms_tolerance) { + report.status = SolverStatus::converged; + break; + } + + update_search_direction(cell_count, 0.0F); + continue; + } + + const auto beta = next_residual_squared / residual_squared; + update_search_direction(cell_count, beta); + residual_squared = next_residual_squared; + } + } + + void upload_signal_inputs(const SignalGridSpec& spec, const std::vector& levels) { + const auto signal_count = spec.signal_count; + signal_levels_.reserve(levels.size(), "signal-grid levels"); + signal_output_.reserve(levels.size(), "signal-grid output"); + signal_diffusion_.reserve(signal_count, "signal-grid diffusion"); + signal_advection_.reserve(signal_count, "signal-grid advection"); + signal_fixed_values_.reserve(static_cast(6) * signal_count, + "signal-grid boundary values"); + signal_reaction_source_.reserve(levels.size(), "signal-grid affine sources"); + signal_reaction_loss_.reserve(levels.size(), "signal-grid affine losses"); + signal_obstacles_.reserve(spec.site_count(), "signal-grid obstacles"); + signal_x_faces_.reserve(std::max(spec.x_face_count(), 1), "signal x faces"); + signal_y_faces_.reserve(std::max(spec.y_face_count(), 1), "signal y faces"); + signal_z_faces_.reserve(std::max(spec.z_face_count(), 1), "signal z faces"); + signal_error_.reserve(1, "signal-grid error flag"); + + std::vector advection; + advection.reserve(signal_count); + + for (const auto velocity : spec.advection) { + advection.push_back(make_float4(velocity.x, velocity.y, velocity.z, 0.0F)); + } + + const std::array boundary_records{ + &spec.x_lower, &spec.x_upper, &spec.y_lower, &spec.y_upper, &spec.z_lower, &spec.z_upper, + }; + std::vector fixed_values(static_cast(6) * signal_count, 0.0F); + + for (std::size_t face = 0; face < boundary_records.size(); ++face) { + if (boundary_records[face]->kind == GridBoundaryKind::fixed) { + std::copy(boundary_records[face]->values.begin(), boundary_records[face]->values.end(), + fixed_values.begin() + static_cast(face * signal_count)); + } + } + + std::vector reaction_source(levels.size(), 0.0F); + std::vector reaction_loss(levels.size(), 0.0F); + + if (spec.reaction.has_value()) { + reaction_source = spec.reaction->source_rates; + reaction_loss = spec.reaction->loss_rates; + } + + std::vector obstacles(spec.site_count(), 0); + + if (spec.has_obstacles()) { + obstacles = spec.obstacles; + } + + copy_to_device(signal_levels_, levels, "failed to upload CUDA signal-grid levels"); + copy_to_device(signal_diffusion_, spec.diffusion, + "failed to upload CUDA signal-grid diffusion"); + copy_to_device(signal_advection_, advection, "failed to upload CUDA signal-grid advection"); + copy_to_device(signal_fixed_values_, fixed_values, + "failed to upload CUDA signal-grid boundary values"); + copy_to_device(signal_reaction_source_, reaction_source, + "failed to upload CUDA signal-grid affine sources"); + copy_to_device(signal_reaction_loss_, reaction_loss, + "failed to upload CUDA signal-grid affine losses"); + copy_to_device(signal_obstacles_, obstacles, "failed to upload CUDA signal-grid obstacles"); + + if (spec.velocity_field.has_value()) { + copy_to_device(signal_x_faces_, spec.velocity_field->x_faces, + "failed to upload CUDA signal x faces"); + copy_to_device(signal_y_faces_, spec.velocity_field->y_faces, + "failed to upload CUDA signal y faces"); + copy_to_device(signal_z_faces_, spec.velocity_field->z_faces, + "failed to upload CUDA signal z faces"); + } + } + + void reserve_coupled_buffers(const SignalGridSpec& spec, std::size_t cell_count_size, + std::size_t species_level_count, std::size_t instruction_count_size, + std::size_t species_count_size, std::size_t signal_count_size, + std::size_t workspace_count, std::size_t cell_signal_count, + std::size_t grid_level_count) { + coupled_species_levels_.reserve(species_level_count, "coupled species levels"); + coupled_previous_lengths_.reserve(cell_count_size, "coupled previous lengths"); + coupled_centers_.reserve(cell_count_size, "coupled cell centers"); + coupled_geometry_.reserve(cell_count_size, "coupled cell geometry"); + coupled_growth_rates_.reserve(cell_count_size, "coupled growth rates"); + coupled_cell_types_.reserve(cell_count_size, "coupled cell types"); + coupled_instructions_.reserve(instruction_count_size, "coupled rate instructions"); + coupled_species_outputs_.reserve(species_count_size, "coupled species outputs"); + coupled_signal_outputs_.reserve(signal_count_size, "coupled signal outputs"); + coupled_workspace_.reserve(workspace_count, "coupled rate workspace"); + coupled_cell_signal_rates_.reserve(cell_signal_count, "coupled cell signal rates"); + coupled_grid_levels_.reserve(grid_level_count, "coupled grid levels"); + coupled_grid_output_.reserve(grid_level_count, "coupled grid output"); + coupled_diffusion_.reserve(signal_count_size, "coupled diffusion"); + coupled_advection_.reserve(signal_count_size, "coupled advection"); + coupled_fixed_values_.reserve(6 * signal_count_size, "coupled boundary values"); + coupled_reaction_source_.reserve(grid_level_count, "coupled affine sources"); + coupled_reaction_loss_.reserve(grid_level_count, "coupled affine losses"); + coupled_obstacles_.reserve(spec.site_count(), "coupled grid obstacles"); + coupled_x_faces_.reserve(std::max(spec.x_face_count(), 1), "coupled x faces"); + coupled_y_faces_.reserve(std::max(spec.y_face_count(), 1), "coupled y faces"); + coupled_z_faces_.reserve(std::max(spec.z_face_count(), 1), "coupled z faces"); + coupled_error_.reserve(1, "coupled error flag"); + } + + void upload_coupled_grid(const SignalGridSpec& spec, const std::vector& grid_levels, + const std::vector& advection, + const std::vector& fixed_values, + const std::vector& reaction_source, + const std::vector& reaction_loss) { + copy_to_device(coupled_grid_levels_, grid_levels, "failed to upload CUDA coupled grid levels"); + copy_to_device(coupled_diffusion_, spec.diffusion, "failed to upload CUDA coupled diffusion"); + copy_to_device(coupled_advection_, advection, "failed to upload CUDA coupled advection"); + copy_to_device(coupled_fixed_values_, fixed_values, + "failed to upload CUDA coupled boundary values"); + copy_to_device(coupled_reaction_source_, reaction_source, + "failed to upload CUDA coupled affine sources"); + copy_to_device(coupled_reaction_loss_, reaction_loss, + "failed to upload CUDA coupled affine losses"); + { + std::vector obstacles(spec.site_count(), 0); + + if (spec.has_obstacles()) { + obstacles = spec.obstacles; + } + + copy_to_device(coupled_obstacles_, obstacles, "failed to upload CUDA coupled obstacles"); + } + + if (spec.velocity_field.has_value()) { + copy_to_device(coupled_x_faces_, spec.velocity_field->x_faces, + "failed to upload CUDA coupled x faces"); + copy_to_device(coupled_y_faces_, spec.velocity_field->y_faces, + "failed to upload CUDA coupled y faces"); + copy_to_device(coupled_z_faces_, spec.velocity_field->z_faces, + "failed to upload CUDA coupled z faces"); + } } - [[nodiscard]] MechanicsSolveResult solve_cell_mechanics( - const WorldState& state, const ContactGraph& contacts, - const ExternalContactGraph& external_contacts, - const MechanicsParameters& parameters) override { - activate_device(); - validate_mechanics_parameters(parameters); - state.validate(); + void upload_coupled_inputs(WorldState& state, const SignalGrid& grid, const CoupledRatePlan& plan, + std::span previous_lengths) { + const auto& spec = grid.spec(); + const auto cell_count_size = state.size(), instruction_count_size = plan.instructions().size(); + const auto signal_count_size = plan.signal_count(), grid_level_count = grid.levels().size(); const auto geometry = state.geometry_state(); - if (contacts.cell_count() != geometry.size()) { - throw std::invalid_argument("contact graph and world state cell counts disagree"); - } - if (external_contacts.cell_count() != geometry.size()) { - throw std::invalid_argument("external contact graph and world state cell counts disagree"); - } - if (external_contacts.size() > std::numeric_limits::max() - contacts.size()) { - throw std::overflow_error("CUDA mechanics row count overflow"); - } - const auto row_count = contacts.size() + external_contacts.size(); - if (geometry.size() > std::numeric_limits::max() || - row_count > std::numeric_limits::max() / 2) { - throw std::overflow_error("CUDA mechanics exceeds the uint32 index space"); - } + const auto attributes = state.cell_attributes(); + auto species_state = state.species_state(); + std::vector centers(cell_count_size); + std::vector shapes(cell_count_size); - MechanicsSolveResult result; - result.corrections.resize(geometry.size()); - if (geometry.size() == 0 || row_count == 0) { - return result; + for (std::size_t index = 0; index < cell_count_size; ++index) { + centers[index] = make_float4(geometry.position_x[index], geometry.position_y[index], + geometry.position_z[index], 0.0F); + shapes[index] = make_float4(geometry.lengths[index], geometry.radii[index], 0.0F, 0.0F); } - validate_mechanics_contacts(geometry, contacts); - validate_external_mechanics_contacts(geometry, external_contacts); - ensure_contact_cell_capacity(geometry.size()); - ensure_contact_output_capacity(row_count); - ensure_mechanics_capacity(geometry.size(), row_count); - upload_contact_cells(geometry); - upload_mechanics_fixed(state.cell_attributes().fixed); - upload_mechanics_contacts(contacts, external_contacts); - upload_mechanics_incidence(contacts, external_contacts); + std::vector instructions; + instructions.reserve(instruction_count_size); - const auto cell_count = static_cast(geometry.size()); - const auto contact_count = static_cast(row_count); - auto residual_squared = initialize_mechanics(cell_count, contact_count); - result.report.initial_residual_rms = - std::sqrt(residual_squared / static_cast(cell_count)); - result.report.final_residual_rms = result.report.initial_residual_rms; - if (!std::isfinite(result.report.initial_residual_rms)) { - result.report.status = SolverStatus::breakdown; - result.report.breakdown = SolverBreakdown::non_finite_residual; - return result; + for (const auto& instruction : plan.instructions()) { + instructions.push_back({ + .operation = static_cast(instruction.operation), + .first = instruction.first, + .second = instruction.second, + .third = instruction.third, + .value = instruction.value, + }); } - if (result.report.initial_residual_rms <= parameters.residual_rms_tolerance) { - return result; + + std::vector advection; + advection.reserve(signal_count_size); + + for (const auto velocity : spec.advection) { + advection.push_back(make_float4(velocity.x, velocity.y, velocity.z, 0.0F)); } - result.report.status = SolverStatus::iteration_limit; - const auto maximum_iterations = mechanics_iteration_limit(parameters, geometry.size()); - for (std::uint32_t iteration = 0; iteration < maximum_iterations; ++iteration) { - const auto curvature = apply_search_direction(cell_count, contact_count, parameters); - if (!std::isfinite(curvature)) { - result.report.status = SolverStatus::breakdown; - result.report.breakdown = SolverBreakdown::non_finite_curvature; - break; - } - if (curvature <= 0.0F) { - result.report.status = SolverStatus::breakdown; - result.report.breakdown = SolverBreakdown::non_positive_curvature; - break; - } + const std::array boundary_records{ + &spec.x_lower, &spec.x_upper, &spec.y_lower, &spec.y_upper, &spec.z_lower, &spec.z_upper, + }; + std::vector fixed_values(6 * signal_count_size, 0.0F); - const auto alpha = residual_squared / curvature; - const auto next_residual_squared = update_solution_residual(cell_count, alpha); - result.report.iterations = iteration + 1; - const auto recurrence_rms = std::sqrt(next_residual_squared / static_cast(cell_count)); - if (!std::isfinite(recurrence_rms)) { - result.report.status = SolverStatus::breakdown; - result.report.breakdown = SolverBreakdown::non_finite_residual; - break; + for (std::size_t face = 0; face < boundary_records.size(); ++face) { + if (boundary_records[face]->kind == GridBoundaryKind::fixed) { + std::copy(boundary_records[face]->values.begin(), boundary_records[face]->values.end(), + fixed_values.begin() + static_cast(face * signal_count_size)); } + } - if (recurrence_rms <= parameters.residual_rms_tolerance) { - residual_squared = recompute_residual(cell_count, contact_count, parameters); - const auto recomputed_rms = std::sqrt(residual_squared / static_cast(cell_count)); - if (!std::isfinite(recomputed_rms)) { - result.report.status = SolverStatus::breakdown; - result.report.breakdown = SolverBreakdown::non_finite_residual; - break; - } - if (recomputed_rms <= parameters.residual_rms_tolerance) { - result.report.status = SolverStatus::converged; - break; - } - update_search_direction(cell_count, 0.0F); - continue; - } + std::vector reaction_source(grid_level_count, 0.0F); + std::vector reaction_loss(grid_level_count, 0.0F); - const auto beta = next_residual_squared / residual_squared; - update_search_direction(cell_count, beta); - residual_squared = next_residual_squared; + if (spec.reaction.has_value()) { + reaction_source = spec.reaction->source_rates; + reaction_loss = spec.reaction->loss_rates; } - residual_squared = recompute_residual(cell_count, contact_count, parameters); - result.report.final_residual_rms = std::sqrt(residual_squared / static_cast(cell_count)); - if (!std::isfinite(result.report.final_residual_rms) && - result.report.status != SolverStatus::breakdown) { - result.report.status = SolverStatus::breakdown; - result.report.breakdown = SolverBreakdown::non_finite_residual; + const std::vector species_levels(species_state.levels.begin(), + species_state.levels.end()); + const std::vector previous_values(previous_lengths.begin(), previous_lengths.end()); + const std::vector growth_values(attributes.growth_rates.begin(), + attributes.growth_rates.end()); + const std::vector cell_type_values(attributes.cell_types.begin(), + attributes.cell_types.end()); + const std::vector species_outputs(plan.species_outputs().begin(), + plan.species_outputs().end()); + const std::vector signal_outputs(plan.signal_outputs().begin(), + plan.signal_outputs().end()); + const std::vector grid_levels(grid.levels().begin(), grid.levels().end()); + + if (!species_levels.empty()) { + copy_to_device(coupled_species_levels_, species_levels, + "failed to upload CUDA coupled species levels"); } - result.corrections = download_mechanics_solution(geometry.size()); - return result; - } - [[nodiscard]] DepthAveragedFlowResult solve_depth_averaged_flow( - const SignalGridSpec& spec, std::span mobility, - const DepthAveragedFlowParameters& parameters) override { - activate_device(); - return cuda::solve_depth_averaged_flow(spec, mobility, parameters, stream_); - } + if (!previous_values.empty()) { + copy_to_device(coupled_previous_lengths_, previous_values, + "failed to upload CUDA coupled previous lengths"); + copy_to_device(coupled_centers_, centers, "failed to upload CUDA coupled cell centers"); + copy_to_device(coupled_geometry_, shapes, "failed to upload CUDA coupled cell geometry"); + copy_to_device(coupled_growth_rates_, growth_values, + "failed to upload CUDA coupled growth rates"); + copy_to_device(coupled_cell_types_, cell_type_values, + "failed to upload CUDA coupled cell types"); + } - [[nodiscard]] ResolvedFlowResult solve_resolved_flow( - const SignalGridSpec& spec, std::span drag, - const ResolvedFlowParameters& parameters) override { - activate_device(); - return cuda::solve_resolved_flow(spec, drag, parameters, stream_); + copy_to_device(coupled_instructions_, instructions, + "failed to upload CUDA coupled instructions"); + + if (!species_outputs.empty()) { + copy_to_device(coupled_species_outputs_, species_outputs, + "failed to upload CUDA coupled species outputs"); + } + + copy_to_device(coupled_signal_outputs_, signal_outputs, + "failed to upload CUDA coupled signal outputs"); + upload_coupled_grid(spec, grid_levels, advection, fixed_values, reaction_source, reaction_loss); } - private: void activate_device() { check_cuda(cudaSetDevice(device_index_), "failed to activate the CUDA device"); } @@ -902,6 +1072,7 @@ class CudaBackend final : public ComputeBackend { std::vector centers(geometry.size()); std::vector axes(geometry.size()); std::vector shapes(geometry.size()); + for (std::size_t index = 0; index < geometry.size(); ++index) { centers[index] = make_float4(geometry.position_x[index], geometry.position_y[index], geometry.position_z[index], 0.0F); @@ -927,9 +1098,11 @@ class CudaBackend final : public ComputeBackend { void upload_contact_candidates(std::span candidates) { std::vector values; values.reserve(candidates.size()); + for (const auto& candidate : candidates) { values.push_back(make_uint2(candidate.first_slot, candidate.second_slot)); } + check_cuda(cudaMemcpy(contact_candidates_.data(), values.data(), values.size() * sizeof(uint2), cudaMemcpyHostToDevice), "failed to upload CUDA contact candidates"); @@ -938,6 +1111,7 @@ class CudaBackend final : public ComputeBackend { void upload_external_constraints(const ConstraintSet& constraints) { std::vector values; values.reserve(constraints.size()); + for (const auto& plane : constraints.planes()) { values.push_back({ .id = plane.id, @@ -948,6 +1122,7 @@ class CudaBackend final : public ComputeBackend { plane.inward_normal.z, plane.coefficient), }); } + for (const auto& sphere : constraints.spheres()) { values.push_back({ .id = sphere.id, @@ -957,6 +1132,7 @@ class CudaBackend final : public ComputeBackend { .parameters = make_float4(0.0F, 0.0F, 0.0F, sphere.coefficient), }); } + for (const auto& box : constraints.boxes()) { values.push_back({ .id = box.id, @@ -967,6 +1143,7 @@ class CudaBackend final : public ComputeBackend { box.coefficient), }); } + for (const auto& cylinder : constraints.cylinders()) { values.push_back({ .id = cylinder.id, @@ -977,6 +1154,7 @@ class CudaBackend final : public ComputeBackend { .parameters = make_float4(cylinder.half_height, 0.0F, 0.0F, cylinder.coefficient), }); } + std::ranges::sort(values, {}, &cuda::ExternalConstraintGpu::id); copy_to_device(external_constraints_, values, "failed to upload CUDA external constraints"); } @@ -985,17 +1163,21 @@ class CudaBackend final : public ComputeBackend { const std::uint32_t* scan_input = contact_counts_.data(); std::uint32_t* scan_output = contact_scan_a_.data(); std::uint32_t offset = 1; + while (offset < element_count) { cuda::launch_inclusive_scan_step(scan_input, scan_output, offset, element_count, stream_); check_cuda(cudaGetLastError(), "failed to launch the CUDA contact-scan kernel"); scan_input = scan_output; scan_output = scan_output == contact_scan_a_.data() ? contact_scan_b_.data() : contact_scan_a_.data(); + if (offset > element_count / 2) { break; } + offset *= 2; } + contact_inclusive_counts_ = scan_input; } @@ -1006,6 +1188,7 @@ class CudaBackend final : public ComputeBackend { sizeof(contact_count), cudaMemcpyDeviceToHost, stream_), "failed to download the CUDA contact count"); check_cuda(cudaStreamSynchronize(stream_), operation); + return contact_count; } @@ -1021,6 +1204,7 @@ class CudaBackend final : public ComputeBackend { contact_counts_.data(), gpu_parameters, candidate_count, stream_); check_cuda(cudaGetLastError(), "failed to launch the CUDA contact-count kernel"); scan_contact_counts(candidate_count); + return download_contact_count(candidate_count, "CUDA contact count or scan failed"); } @@ -1066,10 +1250,12 @@ class CudaBackend final : public ComputeBackend { std::vector contacts; contacts.reserve(contact_count); + for (std::uint32_t index = 0; index < contact_count; ++index) { if (ordinals[index] > 1) { throw std::runtime_error("CUDA contact kernel produced an invalid ordinal"); } + contacts.push_back({ .first_id = first_ids[index], .second_id = second_ids[index], @@ -1082,9 +1268,11 @@ class CudaBackend final : public ComputeBackend { .weight = weights[index], }); } + std::ranges::sort(contacts, {}, [](const CellContact& contact) { return std::tuple{contact.first_id, contact.second_id, contact.ordinal}; }); + return ContactGraph(cell_count, std::move(contacts)); } @@ -1101,6 +1289,7 @@ class CudaBackend final : public ComputeBackend { gpu_parameters, cell_count, constraint_count, stream_); check_cuda(cudaGetLastError(), "failed to launch the CUDA external-contact-count kernel"); scan_contact_counts(pair_count); + return download_contact_count(pair_count, "CUDA external contact count or scan failed"); } @@ -1148,11 +1337,13 @@ class CudaBackend final : public ComputeBackend { std::vector contacts; contacts.reserve(contact_count); + for (std::uint32_t index = 0; index < contact_count; ++index) { if (constraint_kinds[index] > static_cast(ExternalConstraintKind::cylinder) || locations[index] > static_cast(RodContactLocation::interior)) { throw std::runtime_error("CUDA external-contact kernel produced an invalid tag"); } + contacts.push_back({ .cell_id = cell_ids[index], .cell_slot = cell_slots[index], @@ -1165,9 +1356,11 @@ class CudaBackend final : public ComputeBackend { .weight = weights[index], }); } + std::ranges::sort(contacts, {}, [](const ExternalContact& contact) { return std::tuple{contact.cell_id, contact.constraint_id, contact.location}; }); + return ExternalContactGraph(cell_count, std::move(contacts)); } @@ -1176,6 +1369,7 @@ class CudaBackend final : public ComputeBackend { for (const auto& contact : contacts.contacts()) { const auto first = static_cast(contact.first_slot); const auto second = static_cast(contact.second_slot); + if (geometry.ids[first] != contact.first_id || geometry.ids[second] != contact.second_id) { throw std::invalid_argument("contact graph identifiers do not match current state slots"); } @@ -1186,6 +1380,7 @@ class CudaBackend final : public ComputeBackend { const ExternalContactGraph& contacts) { for (const auto& contact : contacts.contacts()) { const auto cell = static_cast(contact.cell_slot); + if (geometry.ids[cell] != contact.cell_id) { throw std::invalid_argument( "external contact graph identifiers do not match current state slots"); @@ -1198,10 +1393,13 @@ class CudaBackend final : public ComputeBackend { if (parameters.max_iterations != 0) { return parameters.max_iterations; } + constexpr std::size_t degrees_of_freedom = 7; + if (cell_count > std::numeric_limits::max() / degrees_of_freedom) { throw std::overflow_error("default mechanics iteration limit exceeds uint32"); } + return static_cast(cell_count * degrees_of_freedom); } @@ -1238,6 +1436,7 @@ class CudaBackend final : public ComputeBackend { std::vector normals(row_count); std::vector separations(row_count); std::vector weights(row_count); + for (std::size_t index = 0; index < contacts.size(); ++index) { const auto& contact = contacts.contacts()[index]; first_slots[index] = contact.first_slot; @@ -1248,6 +1447,7 @@ class CudaBackend final : public ComputeBackend { separations[index] = contact.signed_separation; weights[index] = contact.weight; } + for (std::size_t index = 0; index < external_contacts.size(); ++index) { const auto output_index = contacts.size() + index; const auto& contact = external_contacts.contacts()[index]; @@ -1260,6 +1460,7 @@ class CudaBackend final : public ComputeBackend { separations[output_index] = contact.signed_separation; weights[output_index] = contact.weight; } + copy_to_device(contact_first_slots_, first_slots, "failed to upload CUDA mechanics first slots"); copy_to_device(contact_second_slots_, second_slots, @@ -1276,20 +1477,26 @@ class CudaBackend final : public ComputeBackend { std::vector offsets(contacts.cell_count() + 1); std::vector indices(contacts.size() * 2 + external_contacts.size()); std::uint32_t cursor = 0; + for (std::size_t slot = 0; slot < contacts.cell_count(); ++slot) { offsets[slot] = cursor; + for (const auto contact_index : contacts.incident_contact_indices(static_cast(slot))) { indices[cursor++] = static_cast(contact_index); } + for (const auto contact_index : external_contacts.incident_contact_indices(static_cast(slot))) { indices[cursor++] = static_cast(contacts.size() + contact_index); } } + offsets[contacts.cell_count()] = cursor; + if (cursor != contacts.size() * 2 + external_contacts.size()) { throw std::logic_error("contact incidence size is inconsistent"); } + copy_to_device(mechanics_incidence_offsets_, offsets, "failed to upload CUDA mechanics incidence offsets"); copy_to_device(mechanics_incidence_indices_, indices, @@ -1327,6 +1534,7 @@ class CudaBackend final : public ComputeBackend { const float* input = signal_cn_terms_.data(); float* output = signal_cn_reduce_a_.data(); auto element_count = level_count; + while (element_count > 1) { cuda::launch_reduce_sum_pairs(input, output, element_count, stream_); check_cuda(cudaGetLastError(), "failed to launch the CUDA signal-reduction kernel"); @@ -1335,10 +1543,12 @@ class CudaBackend final : public ComputeBackend { : signal_cn_reduce_a_.data(); element_count = (element_count + 1) / 2; } + float result = 0.0F; check_cuda(cudaMemcpyAsync(&result, input, sizeof(result), cudaMemcpyDeviceToHost, stream_), "failed to download a CUDA signal reduction"); check_cuda(cudaStreamSynchronize(stream_), operation); + return result; } @@ -1354,6 +1564,7 @@ class CudaBackend final : public ComputeBackend { reaction_source, reaction_loss, obstacles, x_faces, y_faces, z_faces, has_velocity_field, boundaries, shape, spacing, half_dt, signal_count, level_count, stream_); check_cuda(cudaGetLastError(), "failed to launch the CUDA signal-residual kernel"); + return std::sqrt(reduce_signal_terms(level_count, "CUDA signal residual failed") / static_cast(level_count)); } @@ -1362,6 +1573,7 @@ class CudaBackend final : public ComputeBackend { cuda::launch_signal_square_terms(right_hand_side, signal_cn_terms_.data(), level_count, stream_); check_cuda(cudaGetLastError(), "failed to launch the CUDA signal-norm kernel"); + return std::sqrt(reduce_signal_terms(level_count, "CUDA signal norm failed") / static_cast(level_count)); } @@ -1393,17 +1605,21 @@ class CudaBackend final : public ComputeBackend { std::numeric_limits::epsilon() * signal_rhs_rms(right_hand_side, level_count); const auto threshold = std::max(parameters.absolute_tolerance, floor) + (parameters.relative_tolerance * report.residual_rms); + if (std::isfinite(report.residual_rms) && report.residual_rms <= threshold) { return {initial, report}; } + if (!std::isfinite(report.residual_rms) || !std::isfinite(threshold)) { report.converged = false; + return {initial, report}; } check_cuda(cudaMemsetAsync(error, 0, sizeof(std::uint32_t), stream_), "failed to clear the CUDA signal solver error flag"); const float* current = initial; + for (std::uint32_t iteration = 1; iteration <= parameters.max_iterations; ++iteration) { float* output = current == signal_cn_a_.data() ? signal_cn_b_.data() : signal_cn_a_.data(); cuda::launch_signal_crank_nicolson_jacobi( @@ -1423,16 +1639,22 @@ class CudaBackend final : public ComputeBackend { stream_), "failed to download the CUDA signal solver error flag"); check_cuda(cudaStreamSynchronize(stream_), "CUDA signal solver error check failed"); + if (error_value != 0 || !std::isfinite(report.residual_rms)) { report.converged = false; + return {current, report}; } + if (report.residual_rms <= threshold) { report.converged = true; + return {current, report}; } } + report.converged = false; + return {current, report}; } @@ -1444,6 +1666,7 @@ class CudaBackend final : public ComputeBackend { const float* input = mechanics_dot_terms_.data(); float* output = mechanics_reduce_a_.data(); auto element_count = cell_count; + while (element_count > 1) { cuda::launch_reduce_sum_pairs(input, output, element_count, stream_); check_cuda(cudaGetLastError(), "failed to launch the CUDA mechanics-reduction kernel"); @@ -1452,10 +1675,12 @@ class CudaBackend final : public ComputeBackend { : mechanics_reduce_a_.data(); element_count = (element_count + 1) / 2; } + float result = 0.0F; check_cuda(cudaMemcpyAsync(&result, input, sizeof(result), cudaMemcpyDeviceToHost, stream_), "failed to download a CUDA mechanics reduction"); check_cuda(cudaStreamSynchronize(stream_), operation); + return result; } @@ -1476,6 +1701,7 @@ class CudaBackend final : public ComputeBackend { mechanics_residual_.data(), mechanics_search_.data(), mechanics_fixed_.data(), cell_count, stream_); check_cuda(cudaGetLastError(), "failed to launch the CUDA mechanics-initialize kernel"); + return reduce_mechanics_dot(mechanics_residual_.data(), mechanics_residual_.data(), cell_count, "CUDA mechanics initialization failed"); } @@ -1484,6 +1710,7 @@ class CudaBackend final : public ComputeBackend { const MechanicsParameters& parameters) { apply_mechanics_operator(mechanics_search_.data(), mechanics_applied_.data(), cell_count, contact_count, parameters); + return reduce_mechanics_dot(mechanics_search_.data(), mechanics_applied_.data(), cell_count, "CUDA mechanics operator application failed"); } @@ -1493,6 +1720,7 @@ class CudaBackend final : public ComputeBackend { mechanics_solution_.data(), mechanics_residual_.data(), mechanics_search_.data(), mechanics_applied_.data(), alpha, cell_count, stream_); check_cuda(cudaGetLastError(), "failed to launch the CUDA mechanics-update kernel"); + return reduce_mechanics_dot(mechanics_residual_.data(), mechanics_residual_.data(), cell_count, "CUDA mechanics update failed"); } @@ -1511,6 +1739,7 @@ class CudaBackend final : public ComputeBackend { cuda::launch_subtract_mechanics_vectors(mechanics_rhs_.data(), mechanics_applied_.data(), mechanics_residual_.data(), cell_count, stream_); check_cuda(cudaGetLastError(), "failed to launch the CUDA mechanics-residual kernel"); + return reduce_mechanics_dot(mechanics_residual_.data(), mechanics_residual_.data(), cell_count, "CUDA mechanics residual recomputation failed"); } @@ -1521,6 +1750,7 @@ class CudaBackend final : public ComputeBackend { check_cuda(cudaStreamSynchronize(stream_), "CUDA mechanics solution download failed"); std::vector result; result.reserve(cell_count); + for (const auto& value : values) { result.push_back({ .translation = {value.linear_length.x, value.linear_length.y, value.linear_length.z}, @@ -1528,6 +1758,7 @@ class CudaBackend final : public ComputeBackend { .length = value.linear_length.w, }); } + return result; } @@ -1655,10 +1886,13 @@ std::unique_ptr make_cuda_backend(std::uint32_t device_index) { std::size_t cuda_backend_device_count() noexcept { int device_count = 0; const auto result = cudaGetDeviceCount(&device_count); + if (result != cudaSuccess) { static_cast(cudaGetLastError()); + return 0; } + return device_count > 0 ? static_cast(device_count) : 0; } diff --git a/cpp/cuda/cuda_flow.cu b/cpp/cuda/cuda_flow.cu index b03531c..193e759 100644 --- a/cpp/cuda/cuda_flow.cu +++ b/cpp/cuda/cuda_flow.cu @@ -26,13 +26,16 @@ void check_cuda(cudaError_t result, const char* operation) { } } -void check_launch(const char* operation) { check_cuda(cudaGetLastError(), operation); } +void check_launch(const char* operation) { + check_cuda(cudaGetLastError(), operation); +} std::uint32_t checked_count(std::size_t count, const char* description) { if (count == 0 || count > std::numeric_limits::max()) { throw std::overflow_error(std::string("CUDA flow ") + description + " must fit the nonzero uint32 index space"); } + return static_cast(count); } @@ -41,14 +44,18 @@ FlowGridParameters make_grid_parameters(const detail::FlowGridLayout& layout) { const auto face_count = checked_count(layout.total_face_count(), "face count"); const auto offsets = layout.face_offsets(); const auto counts = layout.face_counts(); + for (const auto value : offsets) { static_cast(checked_count(value == 0 ? 1 : value, "face offset")); } + for (const auto value : counts) { static_cast(checked_count(value, "component face count")); } + const auto spacing = layout.spacing(); FlowGridParameters result{}; + for (std::size_t axis = 0; axis < 3; ++axis) { result.dimensions[axis] = layout.dimensions()[axis]; result.spacing[axis] = spacing[axis]; @@ -56,11 +63,13 @@ FlowGridParameters make_grid_parameters(const detail::FlowGridLayout& layout) { result.face_offsets[axis] = static_cast(offsets[axis]); result.face_counts[axis] = static_cast(counts[axis]); } + result.face_offsets[3] = face_count; result.face_counts[3] = face_count; result.flow_axis = static_cast(layout.flow_axis()); result.site_count = site_count; result.total_face_count = face_count; + return result; } @@ -71,6 +80,7 @@ class DeviceBuffer { if (count == 0 || count > std::numeric_limits::max() / sizeof(T)) { throw std::overflow_error(std::string("invalid CUDA flow buffer size for ") + description); } + check_cuda(cudaMalloc(reinterpret_cast(&data_), count * sizeof(T)), description); } @@ -83,9 +93,17 @@ class DeviceBuffer { } } - [[nodiscard]] T* data() noexcept { return data_; } - [[nodiscard]] const T* data() const noexcept { return data_; } - [[nodiscard]] std::size_t count() const noexcept { return count_; } + [[nodiscard]] T* data() noexcept { + return data_; + } + + [[nodiscard]] const T* data() const noexcept { + return data_; + } + + [[nodiscard]] std::size_t count() const noexcept { + return count_; + } private: T* data_{nullptr}; @@ -98,6 +116,7 @@ void upload(DeviceBuffer& destination, std::span source, cudaStream_ if (destination.count() != source.size()) { throw std::logic_error(std::string(operation) + ": buffer size mismatch"); } + check_cuda(cudaMemcpyAsync(destination.data(), source.data(), source.size_bytes(), cudaMemcpyHostToDevice, stream), operation); @@ -110,6 +129,7 @@ std::vector download(const DeviceBuffer& source, cudaStream_t stream, cons cudaMemcpyDeviceToHost, stream), operation); check_cuda(cudaStreamSynchronize(stream), operation); + return result; } @@ -146,9 +166,11 @@ double dot(const float* left, const float* right, std::uint32_t count, PcgWorksp "failed to download CUDA flow reduction"); check_cuda(cudaStreamSynchronize(stream), "CUDA flow reduction failed"); double result = 0.0; + for (const auto value : workspace.host_partials) { result += value; } + return result; } @@ -162,27 +184,34 @@ PcgReport solve_pcg(const float* right_hand_side, const float* diagonal, float* stream); check_launch("failed to launch CUDA flow PCG initialization"); const auto rhs_norm_squared = dot(right_hand_side, right_hand_side, count, workspace, stream); + if (rhs_norm_squared == 0.0) { return {}; } + const auto rhs_norm = std::sqrt(rhs_norm_squared); auto rho = dot(workspace.residual.data(), workspace.preconditioned.data(), count, workspace, stream); auto relative = 1.0; + for (std::uint32_t iteration = 1; iteration <= max_iterations; ++iteration) { apply(workspace.direction.data(), workspace.transformed.data()); const auto curvature = dot(workspace.direction.data(), workspace.transformed.data(), count, workspace, stream); + if (!std::isfinite(curvature) || curvature <= 0.0) { throw std::runtime_error(std::string(label) + " conjugate gradient encountered non-positive curvature"); } + const auto alpha_double = rho / curvature; + if (!std::isfinite(alpha_double) || std::abs(alpha_double) > std::numeric_limits::max()) { throw std::runtime_error(std::string(label) + " conjugate gradient produced a non-finite step"); } + const auto alpha = static_cast(alpha_double); launch_flow_pcg_update(solution, workspace.residual.data(), workspace.direction.data(), workspace.transformed.data(), alpha, count, stream); @@ -190,32 +219,40 @@ PcgReport solve_pcg(const float* right_hand_side, const float* diagonal, float* const auto residual_squared = dot(workspace.residual.data(), workspace.residual.data(), count, workspace, stream); relative = std::sqrt(std::max(0.0, residual_squared)) / rhs_norm; + if (!std::isfinite(relative)) { throw std::runtime_error(std::string(label) + " conjugate gradient produced a non-finite residual"); } + if (relative <= tolerance) { return {.iterations = iteration, .relative_residual = static_cast(relative)}; } + launch_flow_pcg_precondition(workspace.residual.data(), diagonal, workspace.preconditioned.data(), count, stream); check_launch("failed to launch CUDA flow PCG preconditioner"); const auto next_rho = dot(workspace.residual.data(), workspace.preconditioned.data(), count, workspace, stream); + if (!std::isfinite(next_rho) || rho == 0.0) { throw std::runtime_error(std::string(label) + " conjugate gradient encountered a preconditioner breakdown"); } + const auto beta_double = next_rho / rho; + if (!std::isfinite(beta_double) || std::abs(beta_double) > std::numeric_limits::max()) { throw std::runtime_error(std::string(label) + " conjugate gradient produced a non-finite direction"); } + launch_flow_pcg_direction(workspace.preconditioned.data(), workspace.direction.data(), static_cast(beta_double), count, stream); check_launch("failed to launch CUDA flow PCG direction update"); rho = next_rho; } + throw std::runtime_error(std::string(label) + " conjugate gradient did not converge: relative " + std::to_string(relative)); } @@ -259,6 +296,7 @@ DepthAveragedFlowResult solve_depth_averaged_flow(const SignalGridSpec& spec, const auto scaled = detail::scale_velocity(spec, reduction.original_layout(), reduction.lift(velocity), reduction.open_inlet_faces(), parameters.mean_inlet_speed); + return { .field = scaled.field, .report = {.iterations = report.iterations, @@ -268,55 +306,84 @@ DepthAveragedFlowResult solve_depth_averaged_flow(const SignalGridSpec& spec, }; } +namespace { +struct ResolvedFlowBuffers { + DeviceBuffer fluid_buffer; + DeviceBuffer active_buffer; + DeviceBuffer exists_buffer; + DeviceBuffer face_drag_buffer; + DeviceBuffer face_diagonal_buffer; + DeviceBuffer force_buffer; + std::vector pressure_diagonal; + DeviceBuffer pressure_diagonal_buffer; + + ResolvedFlowBuffers(const detail::ResolvedFlowSystem& system, const FlowGridParameters& grid, + cudaStream_t stream) + : fluid_buffer(grid.site_count, "failed to allocate CUDA fluid mask"), + active_buffer(grid.total_face_count, "failed to allocate CUDA active face mask"), + exists_buffer(grid.total_face_count, "failed to allocate CUDA face existence mask"), + face_drag_buffer(grid.total_face_count, "failed to allocate CUDA face drag"), + face_diagonal_buffer(grid.total_face_count, "failed to allocate CUDA momentum diagonal"), + force_buffer(grid.total_face_count, "failed to allocate CUDA momentum force"), + pressure_diagonal(system.pressure_diagonal()), + pressure_diagonal_buffer(grid.site_count, "failed to allocate CUDA pressure diagonal") { + upload(fluid_buffer, std::span(system.fluid()), stream, + "failed to upload CUDA fluid mask"); + upload(active_buffer, std::span(system.active()), stream, + "failed to upload CUDA active face mask"); + upload(exists_buffer, std::span(system.exists()), stream, + "failed to upload CUDA face existence mask"); + upload(face_drag_buffer, std::span(system.face_drag()), stream, + "failed to upload CUDA face drag"); + upload(face_diagonal_buffer, std::span(system.diagonal()), stream, + "failed to upload CUDA momentum diagonal"); + upload(force_buffer, std::span(system.force()), stream, + "failed to upload CUDA momentum force"); + upload(pressure_diagonal_buffer, std::span(pressure_diagonal), stream, + "failed to upload CUDA pressure diagonal"); + } +}; + +void combine_krylov_vectors(const float* source, float* target, double alpha, float beta, + std::uint32_t count, cudaStream_t stream) { + if (!std::isfinite(alpha) || std::abs(alpha) > std::numeric_limits::max()) { + throw std::runtime_error("non-finite CUDA Krylov coefficient"); + } + + launch_flow_vector_combine(source, target, static_cast(alpha), beta, count, stream); + check_launch("CUDA Krylov vector update"); +} + +} // namespace + +namespace { +struct ResolvedKrylovBlock { + DeviceBuffer u, p; + + ResolvedKrylovBlock(std::size_t nu, std::size_t np) + : u(nu, "CUDA Krylov velocity"), p(np, "CUDA Krylov pressure") {} +}; + +} // namespace + ResolvedFlowResult solve_resolved_flow(const SignalGridSpec& spec, std::span drag, const ResolvedFlowParameters& parameters, cudaStream_t stream) { parameters.validate(); const detail::ResolvedFlowSystem system(spec, drag, parameters.axis); const auto grid = make_grid_parameters(system.layout()); - DeviceBuffer fluid_buffer(grid.site_count, "failed to allocate CUDA fluid mask"); - DeviceBuffer active_buffer(grid.total_face_count, - "failed to allocate CUDA active face mask"); - DeviceBuffer exists_buffer(grid.total_face_count, - "failed to allocate CUDA face existence mask"); - DeviceBuffer face_drag_buffer(grid.total_face_count, "failed to allocate CUDA face drag"); - DeviceBuffer face_diagonal_buffer(grid.total_face_count, - "failed to allocate CUDA momentum diagonal"); - DeviceBuffer force_buffer(grid.total_face_count, "failed to allocate CUDA momentum force"); - const auto pressure_diagonal = system.pressure_diagonal(); - DeviceBuffer pressure_diagonal_buffer(grid.site_count, - "failed to allocate CUDA pressure diagonal"); - upload(fluid_buffer, std::span(system.fluid()), stream, - "failed to upload CUDA fluid mask"); - upload(active_buffer, std::span(system.active()), stream, - "failed to upload CUDA active face mask"); - upload(exists_buffer, std::span(system.exists()), stream, - "failed to upload CUDA face existence mask"); - upload(face_drag_buffer, std::span(system.face_drag()), stream, - "failed to upload CUDA face drag"); - upload(face_diagonal_buffer, std::span(system.diagonal()), stream, - "failed to upload CUDA momentum diagonal"); - upload(force_buffer, std::span(system.force()), stream, - "failed to upload CUDA momentum force"); - upload(pressure_diagonal_buffer, std::span(pressure_diagonal), stream, - "failed to upload CUDA pressure diagonal"); + ResolvedFlowBuffers buffers(system, grid, stream); DeviceBuffer gradient(grid.total_face_count, "CUDA block gradient"); PcgWorkspace inner_workspace(grid.total_face_count), outer_workspace(grid.site_count); - struct Block { - DeviceBuffer u, p; - Block(std::size_t nu, std::size_t np) - : u(nu, "CUDA Krylov velocity"), p(np, "CUDA Krylov pressure") {} - }; + + using Block = ResolvedKrylovBlock; using Vector = std::shared_ptr; const double continuity_scale = 1.0 / *std::min_element(system.layout().spacing().begin(), system.layout().spacing().end()); const auto combine = [&](const float* source, float* target, double alpha, float beta, std::uint32_t count) { - if (!std::isfinite(alpha) || std::abs(alpha) > std::numeric_limits::max()) - throw std::runtime_error("non-finite CUDA Krylov coefficient"); - launch_flow_vector_combine(source, target, static_cast(alpha), beta, count, stream); - check_launch("CUDA Krylov vector update"); + combine_krylov_vectors(source, target, alpha, beta, count, stream); }; detail::FlexibleKrylovOperations ops; ops.make_zero = [&] { @@ -325,6 +392,7 @@ ResolvedFlowResult solve_resolved_flow(const SignalGridSpec& spec, std::spanp.data(), 0, grid.site_count * sizeof(float), stream), "zero CUDA Krylov pressure"); + return value; }; ops.copy = [&](const Vector& source, Vector& target) { @@ -340,32 +408,34 @@ ResolvedFlowResult solve_resolved_flow(const SignalGridSpec& spec, std::spanp.data(), b->p.data(), grid.site_count, outer_workspace, stream); }; ops.apply = [&](const Vector& input, Vector& output) { - launch_resolved_flow_momentum(input->u.data(), active_buffer.data(), exists_buffer.data(), - face_drag_buffer.data(), output->u.data(), grid, stream); - launch_resolved_flow_gradient(input->p.data(), fluid_buffer.data(), active_buffer.data(), - gradient.data(), grid, stream); + launch_resolved_flow_momentum(input->u.data(), buffers.active_buffer.data(), + buffers.exists_buffer.data(), buffers.face_drag_buffer.data(), + output->u.data(), grid, stream); + launch_resolved_flow_gradient(input->p.data(), buffers.fluid_buffer.data(), + buffers.active_buffer.data(), gradient.data(), grid, stream); combine(gradient.data(), output->u.data(), 1, 1, grid.total_face_count); - launch_resolved_flow_divergence(input->u.data(), fluid_buffer.data(), output->p.data(), grid, - stream); + launch_resolved_flow_divergence(input->u.data(), buffers.fluid_buffer.data(), output->p.data(), + grid, stream); combine(output->p.data(), output->p.data(), continuity_scale, 0, grid.site_count); }; std::uint64_t inner_iterations = 0; ops.precondition = [&](const Vector& input, Vector& output) { const auto report = solve_pcg( - input->u.data(), face_diagonal_buffer.data(), output->u.data(), inner_workspace, + input->u.data(), buffers.face_diagonal_buffer.data(), output->u.data(), inner_workspace, grid.total_face_count, parameters.inner_relative_tolerance, parameters.max_inner_iterations, "CUDA momentum preconditioner", stream, [&](const float* x, float* y) { - launch_resolved_flow_momentum(x, active_buffer.data(), exists_buffer.data(), - face_drag_buffer.data(), y, grid, stream); + launch_resolved_flow_momentum(x, buffers.active_buffer.data(), + buffers.exists_buffer.data(), + buffers.face_drag_buffer.data(), y, grid, stream); check_launch("CUDA preconditioner momentum"); }); inner_iterations += report.iterations; - launch_flow_pcg_precondition(input->p.data(), pressure_diagonal_buffer.data(), output->p.data(), - grid.site_count, stream); + launch_flow_pcg_precondition(input->p.data(), buffers.pressure_diagonal_buffer.data(), + output->p.data(), grid.site_count, stream); combine(output->p.data(), output->p.data(), -1 / continuity_scale, 0, grid.site_count); }; auto rhs = ops.make_zero(); - combine(force_buffer.data(), rhs->u.data(), 1, 0, grid.total_face_count); + combine(buffers.force_buffer.data(), rhs->u.data(), 1, 0, grid.total_face_count); const auto solution = detail::flexible_gmres(ops, rhs, parameters.relative_tolerance, parameters.max_outer_iterations); auto residual = ops.make_zero(); @@ -386,6 +456,7 @@ ResolvedFlowResult solve_resolved_flow(const SignalGridSpec& spec, std::spanu, stream, "download CUDA velocity"); const auto scaled = detail::scale_velocity( spec, system.layout(), velocity, system.open_inlet_faces(), parameters.mean_inlet_speed); + return { .field = scaled.field, .report = {.outer_iterations = solution.iterations, diff --git a/cpp/cuda/cuda_numerics.cu b/cpp/cuda/cuda_numerics.cu new file mode 100644 index 0000000..dedd7d1 --- /dev/null +++ b/cpp/cuda/cuda_numerics.cu @@ -0,0 +1,327 @@ +#include + +#include +#include +#include +#include +#include + +#include "core/numerics_device.hpp" + +namespace cm::detail { +namespace { + +__global__ void conservative_apply(const unsigned* offsets, const unsigned* columns, + const float* values, const float* x, float* y, + const float* row_sums, NumericsParameters p) { + const unsigned i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i >= p.count) { + return; + } + + float total = 0, correction = 0; + + for (unsigned j = offsets[i]; j < offsets[i + 1]; ++j) { + const float term = values[j] * (x[columns[j]] - x[i]) - correction; + const float next = total + term; + correction = (next - total) - term; + total = next; + } + + y[i] = total + row_sums[i] * x[i]; +} + +__global__ void nutrient_growth(const unsigned* offsets, const float* cells, + const float* requirements, const float* concentration, + const float* uptake, float* alpha, float* output, + NumericsParameters p) { + const unsigned i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i >= p.count) { + return; + } + + const unsigned begin = offsets[i], end = offsets[i + 1]; + const float mass = cells[6 * i], mu = cells[6 * i + 1], area = cells[6 * i + 2]; + float limitation = 1.0f, extent = INFINITY, largest = 0.0f; + + for (unsigned j = begin; j < end; ++j) { + const float c = concentration[j], k = requirements[2 * j], y = requirements[2 * j + 1]; + limitation = fminf(limitation, c / (k + c)); + extent = fminf(extent, uptake[j] * y); + largest = fmaxf(largest, uptake[j] * y); + } + + for (unsigned j = begin; j < end; ++j) { + const float c = concentration[j], k = requirements[2 * j], y = requirements[2 * j + 1]; + alpha[j] = end - begin == 1 ? mu * mass / (y * area * (k + c)) + : (c > 0 ? mu * mass * limitation / (y * area * c) : 0.0f); + } + + output[5 * i] = extent; + output[5 * i + 1] = extent / cells[6 * i + 3]; + output[5 * i + 2] = output[5 * i + 1] / cells[6 * i + 4]; + output[5 * i + 3] = extent / (mass * cells[6 * i + 5]); + output[5 * i + 4] = largest > 0 ? (largest - extent) / largest : 0.0f; +} + +__global__ void numerics_ilu(const unsigned* offsets, const unsigned* columns, const float* values, + const unsigned* diagonal, const float* rhs, float* x, + NumericsParameters p) { + if (blockIdx.x || threadIdx.x) { + return; + } + + for (unsigned i = 0; i < p.count; ++i) { + float value = rhs[i]; + + for (unsigned j = offsets[i]; j < diagonal[i]; ++j) { + value -= values[j] * x[columns[j]]; + } + + x[i] = value; + } + + for (unsigned i = p.count; i-- > 0;) { + float value = x[i]; + + for (unsigned j = diagonal[i] + 1; j < offsets[i + 1]; ++j) { + value -= values[j] * x[columns[j]]; + } + + x[i] = value / values[diagonal[i]]; + } +} + +__global__ void numerics_fill(float* y, NumericsParameters p) { + const auto i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i < p.count) { + y[i] = p.scalar; + } +} + +__global__ void numerics_copy(const float* x, float* y, NumericsParameters p) { + const auto i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i < p.count) { + y[i] = x[i]; + } +} + +__global__ void numerics_axpy(const float* x, float* y, NumericsParameters p) { + const auto i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i < p.count) { + y[i] += p.scalar * x[i]; + } +} + +__global__ void numerics_jacobi(const float* rhs, const float* ax, const float* inverse_diagonal, + float* x, NumericsParameters p) { + const auto i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i < p.count) { + x[i] += p.scalar * inverse_diagonal[i] * (rhs[i] - ax[i]); + } +} + +__global__ void numerics_dot(const float* x, const float* y, float* out, NumericsParameters p) { + __shared__ float values[128]; + const auto local = threadIdx.x, i = blockIdx.x * blockDim.x + local; + values[local] = i < p.count ? x[i] * y[i] : 0; + __syncthreads(); + + for (unsigned s = 64; s; s /= 2) { + if (local < s) { + values[local] += values[local + s]; + } + + __syncthreads(); + } + + if (!local) { + out[blockIdx.x] = values[0]; + } +} + +__global__ void numerics_apply(const unsigned* offsets, const unsigned* columns, + const float* values, const float* x, float* y, + NumericsParameters p) { + const auto i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i >= p.count) { + return; + } + + float total = 0, correction = 0; + + for (auto j = offsets[i]; j < offsets[i + 1]; ++j) { + const float term = values[j] * x[columns[j]] - correction; + const float next = total + term; + correction = (next - total) - term; + total = next; + } + + y[i] = total; +} + +void launch_numerics(NumericsKernel kernel, NumericsParameters p, void** b, cudaStream_t stream) { + const auto groups = (p.count + 127U) / 128U; + + switch (kernel) { + case NumericsKernel::conservative_apply: + conservative_apply<<>>((unsigned*)b[0], (unsigned*)b[1], (float*)b[2], + (float*)b[3], (float*)b[4], (float*)b[5], p); + break; + case NumericsKernel::growth: + nutrient_growth<<>>((unsigned*)b[0], (float*)b[1], (float*)b[2], + (float*)b[3], (float*)b[4], (float*)b[5], + (float*)b[6], p); + break; + case NumericsKernel::ilu: + numerics_ilu<<<1, 1, 0, stream>>>((unsigned*)b[0], (unsigned*)b[1], (float*)b[2], + (unsigned*)b[3], (float*)b[4], (float*)b[5], p); + break; + case NumericsKernel::fill: + numerics_fill<<>>((float*)b[0], p); + break; + case NumericsKernel::copy: + numerics_copy<<>>((float*)b[0], (float*)b[1], p); + break; + case NumericsKernel::axpy: + numerics_axpy<<>>((float*)b[0], (float*)b[1], p); + break; + case NumericsKernel::dot: + numerics_dot<<>>((float*)b[0], (float*)b[1], (float*)b[2], p); + break; + case NumericsKernel::apply: + numerics_apply<<>>((unsigned*)b[0], (unsigned*)b[1], (float*)b[2], + (float*)b[3], (float*)b[4], p); + break; + case NumericsKernel::jacobi: + numerics_jacobi<<>>((float*)b[0], (float*)b[1], (float*)b[2], + (float*)b[3], p); + break; + } +} + +void check(cudaError_t status, const char* operation) { + if (status != cudaSuccess) { + throw std::runtime_error(std::string(operation) + ": " + cudaGetErrorString(status)); + } +} + +class DeviceScope { + public: + explicit DeviceScope(int index) { + check(cudaGetDevice(&previous_), "get CUDA numerics device"); + check(cudaSetDevice(index), "select CUDA numerics device"); + } + + ~DeviceScope() { + cudaSetDevice(previous_); + } + + private: + int previous_; +}; + +struct CudaNumericsBuffer final : NumericsBuffer { + void* value{nullptr}; + int index; + + explicit CudaNumericsBuffer(int device) : index(device) {} + + ~CudaNumericsBuffer() override { + int previous = 0; + + if (cudaGetDevice(&previous) == cudaSuccess) { + if (cudaSetDevice(index) == cudaSuccess) { + cudaFree(value); + } + + cudaSetDevice(previous); + } + } +}; + +class CudaNumericsDevice final : public NumericsDevice { + public: + explicit CudaNumericsDevice(std::uint32_t index) { + int count = 0; + check(cudaGetDeviceCount(&count), "enumerate CUDA numerics devices"); + + if (index >= static_cast(count) || index > std::numeric_limits::max()) { + throw std::out_of_range("CUDA numerics device index is unavailable"); + } + + index_ = static_cast(index); + DeviceScope selected(index_); + check(cudaStreamCreate(&stream_), "create CUDA numerics stream"); + } + + ~CudaNumericsDevice() override { + int previous = 0; + + if (cudaGetDevice(&previous) == cudaSuccess) { + if (cudaSetDevice(index_) == cudaSuccess) { + cudaStreamDestroy(stream_); + } + + cudaSetDevice(previous); + } + } + + NumericsBufferPtr allocate(std::size_t bytes, const void* data) override { + DeviceScope selected(index_); + auto result = std::make_shared(index_); + check(cudaMalloc(&result->value, std::max(bytes, std::size_t{4})), + "allocate CUDA numerics buffer"); + + if (data && bytes) { + check(cudaMemcpyAsync(result->value, data, bytes, cudaMemcpyHostToDevice, stream_), + "upload CUDA numerics buffer"); + check(cudaStreamSynchronize(stream_), "finish CUDA numerics upload"); + } + + return result; + } + + void read(const NumericsBufferPtr& buffer, void* data, std::size_t bytes) override { + DeviceScope selected(index_); + check(cudaMemcpyAsync(data, static_cast(*buffer).value, bytes, + cudaMemcpyDeviceToHost, stream_), + "read CUDA numerics buffer"); + check(cudaStreamSynchronize(stream_), "finish CUDA numerics read"); + } + + void dispatch(NumericsKernel kernel, const NumericsParameters& p, + std::initializer_list buffers) override { + if (!p.count) { + return; + } + + DeviceScope selected(index_); + std::array pointers{}; + std::size_t i = 0; + + for (const auto& buffer : buffers) { + pointers[i++] = static_cast(*buffer).value; + } + + launch_numerics(kernel, p, pointers.data(), stream_); + check(cudaGetLastError(), "launch CUDA numerics kernel"); + } + + private: + int index_; + cudaStream_t stream_{}; +}; +} // namespace + +std::unique_ptr make_cuda_numerics_device(std::uint32_t index) { + return std::make_unique(index); +} +} // namespace cm::detail diff --git a/cpp/cuda/cuda_occupancy.cu b/cpp/cuda/cuda_occupancy.cu new file mode 100644 index 0000000..f5e4187 --- /dev/null +++ b/cpp/cuda/cuda_occupancy.cu @@ -0,0 +1,130 @@ +#include +#include +#include +#include +#include + +#include "core/occupancy_device.hpp" +#include "kernels/occupancy.cuh" + +namespace cm::detail { +namespace { +void check(cudaError_t status, const char* operation) { + if (status != cudaSuccess) { + throw std::runtime_error(std::string(operation) + ": " + cudaGetErrorString(status)); + } +} + +class DeviceScope { + public: + explicit DeviceScope(int index) { + check(cudaGetDevice(&previous_), "get CUDA occupancy device"); + check(cudaSetDevice(index), "select CUDA occupancy device"); + } + + ~DeviceScope() { + cudaSetDevice(previous_); + } + + private: + int previous_; +}; + +struct CudaOccupancyBuffer final : OccupancyBuffer { + void* value{nullptr}; + int index; + + explicit CudaOccupancyBuffer(int device) : index(device) {} + + ~CudaOccupancyBuffer() override { + int previous = 0; + + if (cudaGetDevice(&previous) == cudaSuccess) { + if (cudaSetDevice(index) == cudaSuccess) { + cudaFree(value); + } + + cudaSetDevice(previous); + } + } +}; + +class CudaOccupancyDevice final : public OccupancyDevice { + public: + explicit CudaOccupancyDevice(std::uint32_t index) { + int count = 0; + check(cudaGetDeviceCount(&count), "enumerate CUDA occupancy devices"); + + if (index >= static_cast(count) || index > std::numeric_limits::max()) { + throw std::out_of_range("CUDA occupancy device index is unavailable"); + } + + index_ = static_cast(index); + DeviceScope selected(index_); + check(cudaStreamCreate(&stream_), "create CUDA occupancy stream"); + } + + ~CudaOccupancyDevice() override { + int previous = 0; + + if (cudaGetDevice(&previous) == cudaSuccess) { + if (cudaSetDevice(index_) == cudaSuccess) { + cudaStreamDestroy(stream_); + } + + cudaSetDevice(previous); + } + } + + OccupancyBufferPtr allocate(std::size_t bytes, const void* data) override { + DeviceScope selected(index_); + auto result = std::make_shared(index_); + check(cudaMalloc(&result->value, std::max(bytes, std::size_t{4})), + "allocate CUDA occupancy buffer"); + + if (data && bytes) { + check(cudaMemcpyAsync(result->value, data, bytes, cudaMemcpyHostToDevice, stream_), + "upload CUDA occupancy buffer"); + check(cudaStreamSynchronize(stream_), "finish CUDA occupancy upload"); + } + + return result; + } + + void read(const OccupancyBufferPtr& buffer, void* data, std::size_t bytes) override { + DeviceScope selected(index_); + check(cudaMemcpyAsync(data, static_cast(*buffer).value, bytes, + cudaMemcpyDeviceToHost, stream_), + "read CUDA occupancy buffer"); + check(cudaStreamSynchronize(stream_), "finish CUDA occupancy read"); + } + + void dispatch(OccupancyKernel kernel, const OccupancyParameters& p, + std::initializer_list buffers) override { + if (!p.count) { + return; + } + + DeviceScope selected(index_); + std::array pointers{}; + std::size_t i = 0; + + for (const auto& buffer : buffers) { + pointers[i++] = static_cast(*buffer).value; + } + + cuda::launch_occupancy(kernel, p, pointers.data(), stream_); + check(cudaGetLastError(), "launch CUDA occupancy kernel"); + check(cudaStreamSynchronize(stream_), "execute CUDA occupancy kernel"); + } + + private: + int index_; + cudaStream_t stream_{}; +}; +} // namespace + +std::unique_ptr make_cuda_occupancy_device(std::uint32_t index) { + return std::make_unique(index); +} +} // namespace cm::detail diff --git a/cpp/cuda/kernels/contacts.cu b/cpp/cuda/kernels/contacts.cu index 40a6b9a..0105835 100644 --- a/cpp/cuda/kernels/contacts.cu +++ b/cpp/cuda/kernels/contacts.cu @@ -68,7 +68,9 @@ __device__ float3 cross_product(float3 left, float3 right) { left.x * right.y - left.y * right.x); } -__device__ float magnitude(float3 value) { return sqrtf(dot_product(value, value)); } +__device__ float magnitude(float3 value) { + return sqrtf(dot_product(value, value)); +} __device__ float3 normalized_vector(float3 value) { return multiply(value, 1.0F / magnitude(value)); @@ -82,6 +84,7 @@ __device__ Capsule load_capsule(const std::uint64_t* ids, const float4* centers, const float4* geometry, std::uint32_t slot) { const auto center = centers[slot]; const auto axis = axes[slot]; + return { ids[slot], slot, @@ -117,13 +120,16 @@ __device__ PointPair closest_points(const Capsule& first, const Capsule& second, float first_parameter = 0.0F; float second_parameter = 0.0F; + if (first_length_squared <= epsilon_squared && second_length_squared <= epsilon_squared) { return {first_start, second_start}; } + if (first_length_squared <= epsilon_squared) { second_parameter = clamp_value(second_projection / second_length_squared, 0.0F, 1.0F); } else { const auto first_projection = dot_product(first_delta, between_starts); + if (second_length_squared <= epsilon_squared) { first_parameter = clamp_value(-first_projection / first_length_squared, 0.0F, 1.0F); } else { @@ -131,14 +137,17 @@ __device__ PointPair closest_points(const Capsule& first, const Capsule& second, const auto denominator = first_length_squared * second_length_squared - cross_projection * cross_projection; const auto parallel_tolerance = float_epsilon * first_length_squared * second_length_squared; + if (denominator > parallel_tolerance) { first_parameter = clamp_value( (cross_projection * second_projection - first_projection * second_length_squared) / denominator, 0.0F, 1.0F); } + second_parameter = (cross_projection * first_parameter + second_projection) / second_length_squared; + if (second_parameter < 0.0F) { second_parameter = 0.0F; first_parameter = clamp_value(-first_projection / first_length_squared, 0.0F, 1.0F); @@ -161,10 +170,12 @@ __device__ PairPoints contact_points(const Capsule& first, const Capsule& second const auto axis_dot = clamp_value(dot_product(first.axis, second.axis), -1.0F, 1.0F); const auto sine = sqrtf(fmaxf(0.0F, 1.0F - axis_dot * axis_dot)); PairPoints result{}; + if (sine > parameters.parallel_sine_threshold || first.length <= parameters.degeneracy_epsilon || second.length <= parameters.degeneracy_epsilon) { result.values[0] = closest_points(first, second, parameters.degeneracy_epsilon); result.count = 1; + return result; } @@ -174,13 +185,16 @@ __device__ PairPoints contact_points(const Capsule& first, const Capsule& second const auto projected_second_half = second_half * fabsf(axis_dot); const auto overlap_begin = fmaxf(-first_half, center_coordinate - projected_second_half); const auto overlap_end = fminf(first_half, center_coordinate + projected_second_half); + if (overlap_end - overlap_begin <= parameters.degeneracy_epsilon) { result.values[0] = closest_points(first, second, parameters.degeneracy_epsilon); result.count = 1; + return result; } const float first_parameters[2] = {overlap_begin, overlap_end}; + for (std::uint32_t index = 0; index < 2; ++index) { const auto point_on_first = add(first.center, multiply(first.axis, first_parameters[index])); const auto second_parameter = @@ -191,18 +205,22 @@ __device__ PairPoints contact_points(const Capsule& first, const Capsule& second add(second.center, multiply(second.axis, second_parameter)), }; } + result.count = 2; + return result; } __device__ float3 deterministic_normal(const Capsule& first, const Capsule& second, const PointPair& points, float epsilon) { const auto point_delta = subtract(points.second, points.first); + if (magnitude(point_delta) > epsilon) { return normalized_vector(point_delta); } const auto axes_cross = cross_product(first.axis, second.axis); + if (magnitude(axes_cross) > epsilon) { return normalized_vector(axes_cross); } @@ -210,6 +228,7 @@ __device__ float3 deterministic_normal(const Capsule& first, const Capsule& seco const auto center_delta = subtract(second.center, first.center); const auto transverse_center_delta = subtract(center_delta, multiply(first.axis, dot_product(center_delta, first.axis))); + if (magnitude(transverse_center_delta) > epsilon) { return normalized_vector(transverse_center_delta); } @@ -217,6 +236,7 @@ __device__ float3 deterministic_normal(const Capsule& first, const Capsule& seco const auto absolute_axis = make_float3(fabsf(first.axis.x), fabsf(first.axis.y), fabsf(first.axis.z)); float3 basis{}; + if (absolute_axis.x <= absolute_axis.y && absolute_axis.x <= absolute_axis.z) { basis = make_float3(1.0F, 0.0F, 0.0F); } else if (absolute_axis.y <= absolute_axis.z) { @@ -224,9 +244,48 @@ __device__ float3 deterministic_normal(const Capsule& first, const Capsule& seco } else { basis = make_float3(0.0F, 0.0F, 1.0F); } + return normalized_vector(cross_product(first.axis, basis)); } +__device__ SurfacePoint box_surface(float3 point, const ExternalConstraintGpu& constraint, + float degeneracy_epsilon) { + const auto center = + make_float3(constraint.geometry.x, constraint.geometry.y, constraint.geometry.z); + const auto half_extents = + make_float3(constraint.parameters.x, constraint.parameters.y, constraint.parameters.z); + const auto delta = subtract(point, center); + const auto outside_vector = + make_float3(delta.x - clamp_value(delta.x, -half_extents.x, half_extents.x), + delta.y - clamp_value(delta.y, -half_extents.y, half_extents.y), + delta.z - clamp_value(delta.z, -half_extents.z, half_extents.z)); + const auto outside_distance = magnitude(outside_vector); + + if (outside_distance > degeneracy_epsilon) { + return {outside_distance, multiply(outside_vector, 1.0F / outside_distance)}; + } + + const auto clearances = + make_float3(half_extents.x - fabsf(delta.x), half_extents.y - fabsf(delta.y), + half_extents.z - fabsf(delta.z)); + + if (clearances.x <= clearances.y && clearances.x <= clearances.z) { + const auto sign = fabsf(delta.x) <= degeneracy_epsilon || delta.x >= 0.0F ? 1.0F : -1.0F; + + return {-clearances.x, make_float3(sign, 0.0F, 0.0F)}; + } + + if (clearances.y <= clearances.z) { + const auto sign = fabsf(delta.y) <= degeneracy_epsilon || delta.y >= 0.0F ? 1.0F : -1.0F; + + return {-clearances.y, make_float3(0.0F, sign, 0.0F)}; + } + + const auto sign = fabsf(delta.z) <= degeneracy_epsilon || delta.z >= 0.0F ? 1.0F : -1.0F; + + return {-clearances.z, make_float3(0.0F, 0.0F, sign)}; +} + __device__ SurfacePoint external_surface(float3 point, const ExternalConstraintGpu& constraint, float degeneracy_epsilon) { if (constraint.kind == 0) { @@ -234,8 +293,10 @@ __device__ SurfacePoint external_surface(float3 point, const ExternalConstraintG make_float3(constraint.parameters.x, constraint.parameters.y, constraint.parameters.z); const auto plane_point = make_float3(constraint.geometry.x, constraint.geometry.y, constraint.geometry.z); + return {dot_product(subtract(point, plane_point), inward_normal), inward_normal}; } + if (constraint.kind == 1) { const auto center = make_float3(constraint.geometry.x, constraint.geometry.y, constraint.geometry.z); @@ -243,35 +304,12 @@ __device__ SurfacePoint external_surface(float3 point, const ExternalConstraintG const auto distance = magnitude(delta); const auto outward = distance > degeneracy_epsilon ? multiply(delta, 1.0F / distance) : make_float3(1.0F, 0.0F, 0.0F); + return {distance - constraint.geometry.w, outward}; } + if (constraint.kind == 2) { - const auto center = - make_float3(constraint.geometry.x, constraint.geometry.y, constraint.geometry.z); - const auto half_extents = - make_float3(constraint.parameters.x, constraint.parameters.y, constraint.parameters.z); - const auto delta = subtract(point, center); - const auto outside_vector = - make_float3(delta.x - clamp_value(delta.x, -half_extents.x, half_extents.x), - delta.y - clamp_value(delta.y, -half_extents.y, half_extents.y), - delta.z - clamp_value(delta.z, -half_extents.z, half_extents.z)); - const auto outside_distance = magnitude(outside_vector); - if (outside_distance > degeneracy_epsilon) { - return {outside_distance, multiply(outside_vector, 1.0F / outside_distance)}; - } - const auto clearances = - make_float3(half_extents.x - fabsf(delta.x), half_extents.y - fabsf(delta.y), - half_extents.z - fabsf(delta.z)); - if (clearances.x <= clearances.y && clearances.x <= clearances.z) { - const auto sign = fabsf(delta.x) <= degeneracy_epsilon || delta.x >= 0.0F ? 1.0F : -1.0F; - return {-clearances.x, make_float3(sign, 0.0F, 0.0F)}; - } - if (clearances.y <= clearances.z) { - const auto sign = fabsf(delta.y) <= degeneracy_epsilon || delta.y >= 0.0F ? 1.0F : -1.0F; - return {-clearances.y, make_float3(0.0F, sign, 0.0F)}; - } - const auto sign = fabsf(delta.z) <= degeneracy_epsilon || delta.z >= 0.0F ? 1.0F : -1.0F; - return {-clearances.z, make_float3(0.0F, 0.0F, sign)}; + return box_surface(point, constraint, degeneracy_epsilon); } const auto delta = @@ -283,17 +321,22 @@ __device__ SurfacePoint external_surface(float3 point, const ExternalConstraintG const auto axial = make_float3(0.0F, 0.0F, z_offset >= 0.0F ? 1.0F : -1.0F); const auto radial_excess = radial_distance - constraint.geometry.w; const auto axial_excess = fabsf(z_offset) - constraint.parameters.x; + if (radial_excess > 0.0F && axial_excess > 0.0F) { const auto distance = sqrtf(radial_excess * radial_excess + axial_excess * axial_excess); + return {distance, multiply(add(multiply(radial, radial_excess), multiply(axial, axial_excess)), 1.0F / distance)}; } + if (radial_excess > 0.0F) { return {radial_excess, radial}; } + if (axial_excess > 0.0F) { return {axial_excess, axial}; } + return -radial_excess <= -axial_excess ? SurfacePoint{radial_excess, radial} : SurfacePoint{axial_excess, axial}; } @@ -306,26 +349,33 @@ __device__ bool segment_intersects_bounds(float3 start, float3 end, float3 lower const float uppers[3] = {upper.x, upper.y, upper.z}; auto entry = 0.0F; auto exit = 1.0F; + for (std::uint32_t axis = 0; axis < 3; ++axis) { if (deltas[axis] == 0.0F) { if (starts[axis] < lowers[axis] || starts[axis] > uppers[axis]) { return false; } + continue; } + auto first = (lowers[axis] - starts[axis]) / deltas[axis]; auto second = (uppers[axis] - starts[axis]) / deltas[axis]; + if (first > second) { const auto temporary = first; first = second; second = temporary; } + entry = fmaxf(entry, first); exit = fminf(exit, second); + if (entry > exit) { return false; } } + return true; } @@ -335,6 +385,7 @@ __device__ CenterlineMinimum minimize_surface_on_segment(float3 start, float3 en const auto delta = subtract(end, start); auto lower = 0.0F; auto upper = 1.0F; + for (std::uint32_t iteration = 0; iteration < segment_minimization_iterations; ++iteration) { const auto first_parameter = lower + (upper - lower) / 3.0F; const auto second_parameter = upper - (upper - lower) / 3.0F; @@ -342,6 +393,7 @@ __device__ CenterlineMinimum minimize_surface_on_segment(float3 start, float3 en external_surface(add(start, multiply(delta, first_parameter)), constraint, epsilon); const auto second = external_surface(add(start, multiply(delta, second_parameter)), constraint, epsilon); + if (first.signed_distance < second.signed_distance) { upper = second_parameter; } else if (second.signed_distance < first.signed_distance) { @@ -354,23 +406,29 @@ __device__ CenterlineMinimum minimize_surface_on_segment(float3 start, float3 en CenterlineMinimum result{start, external_surface(start, constraint, epsilon)}; const float candidates[5] = {1.0F, 0.5F, lower, (lower + upper) * 0.5F, upper}; + for (std::uint32_t index = 0; index < 5; ++index) { const auto point = add(start, multiply(delta, candidates[index])); const auto surface = external_surface(point, constraint, epsilon); + if (surface.signed_distance < result.surface.signed_distance) { result = {point, surface}; } } + if (constraint.kind == 3) { if (fabsf(delta.z) > epsilon) { const auto parameter = clamp_value((constraint.geometry.z - start.z) / delta.z, 0.0F, 1.0F); const auto point = add(start, multiply(delta, parameter)); const auto surface = external_surface(point, constraint, epsilon); + if (surface.signed_distance <= result.surface.signed_distance) { result = {point, surface}; } } + const auto radial_length_squared = delta.x * delta.x + delta.y * delta.y; + if (radial_length_squared > epsilon * epsilon) { const auto parameter = clamp_value(-((start.x - constraint.geometry.x) * delta.x + (start.y - constraint.geometry.y) * delta.y) / @@ -378,11 +436,13 @@ __device__ CenterlineMinimum minimize_surface_on_segment(float3 start, float3 en 0.0F, 1.0F); const auto point = add(start, multiply(delta, parameter)); const auto surface = external_surface(point, constraint, epsilon); + if (surface.signed_distance <= result.surface.signed_distance) { result = {point, surface}; } } } + return result; } @@ -398,6 +458,7 @@ __device__ CenterlineMinimum sphere_minimum(float3 start, float3 end, ? clamp_value(-dot_product(subtract(start, center), delta) / length_squared, 0.0F, 1.0F) : 0.0F; const auto point = add(start, multiply(delta, parameter)); + return {point, external_surface(point, constraint, epsilon)}; } @@ -427,11 +488,13 @@ evaluate_external_constraint(const Capsule& cell, const ExternalConstraintGpu& c }; const auto finite_outside = constraint.kind != 0 && constraint.allowed_region == 0; + if (finite_outside) { if (constraint.kind >= 2) { const auto reach = cell.radius + contact_parameters.activation_margin; float3 lower{}; float3 upper{}; + if (constraint.kind == 2) { lower = make_float3(constraint.geometry.x - constraint.parameters.x - reach, constraint.geometry.y - constraint.parameters.y - reach, @@ -447,6 +510,7 @@ evaluate_external_constraint(const Capsule& cell, const ExternalConstraintGpu& c constraint.geometry.y + constraint.geometry.w + reach, constraint.geometry.z + constraint.parameters.x + reach); } + if (!segment_intersects_bounds(endpoints[0], endpoints[1], lower, upper)) { return result; } @@ -457,11 +521,14 @@ evaluate_external_constraint(const Capsule& cell, const ExternalConstraintGpu& c contact_parameters.degeneracy_epsilon) : minimize_surface_on_segment(endpoints[0], endpoints[1], constraint, contact_parameters.degeneracy_epsilon); + if (minimum.surface.signed_distance - cell.radius >= contact_parameters.activation_margin) { return result; } + for (std::uint32_t endpoint = 0; endpoint < 2; ++endpoint) { const auto separation = endpoint_surfaces[endpoint].signed_distance - cell.radius; + if (separation < contact_parameters.activation_margin && fabsf(endpoint_surfaces[endpoint].signed_distance - minimum.surface.signed_distance) <= contact_parameters.degeneracy_epsilon) { @@ -469,23 +536,28 @@ evaluate_external_constraint(const Capsule& cell, const ExternalConstraintGpu& c cell.radius, true); } } + if (result.active_count == 0) { add_external_contact(result, interior_location, minimum.point, minimum.surface, cell.radius, true); } + return result; } const auto outside = constraint.allowed_region == 0; + for (std::uint32_t endpoint = 0; endpoint < 2; ++endpoint) { const auto separation = (outside ? endpoint_surfaces[endpoint].signed_distance : -endpoint_surfaces[endpoint].signed_distance) - cell.radius; + if (separation < contact_parameters.activation_margin) { add_external_contact(result, endpoint, endpoints[endpoint], endpoint_surfaces[endpoint], cell.radius, outside); } } + return result; } @@ -495,9 +567,11 @@ __global__ void count_cell_contacts(const std::uint64_t* ids, const float4* cent ContactParametersGpu parameters, std::uint32_t candidate_count) { const auto pair_index = blockIdx.x * blockDim.x + threadIdx.x; + if (pair_index >= candidate_count) { return; } + const auto first_slot = candidates[pair_index].x; const auto second_slot = candidates[pair_index].y; @@ -506,25 +580,31 @@ __global__ void count_cell_contacts(const std::uint64_t* ids, const float4* cent canonicalize(first, second); const auto points = contact_points(first, second, parameters); std::uint32_t active_count = 0; + for (std::uint32_t ordinal = 0; ordinal < points.count; ++ordinal) { const auto separation = magnitude(subtract(points.values[ordinal].second, points.values[ordinal].first)) - (first.radius + second.radius); active_count += separation < parameters.activation_margin ? 1U : 0U; } + counts[pair_index] = active_count; } __global__ void inclusive_scan_step(const std::uint32_t* input, std::uint32_t* output, std::uint32_t offset, std::uint32_t element_count) { const auto index = blockIdx.x * blockDim.x + threadIdx.x; + if (index >= element_count) { return; } + auto value = input[index]; + if (index >= offset) { value += input[index - offset]; } + output[index] = value; } @@ -538,12 +618,15 @@ __global__ void fill_cell_contacts(const std::uint64_t* ids, const float4* cente float* weights, ContactParametersGpu parameters, std::uint32_t candidate_count) { const auto pair_index = blockIdx.x * blockDim.x + threadIdx.x; + if (pair_index >= candidate_count) { return; } + const auto first_slot = candidates[pair_index].x; const auto second_slot = candidates[pair_index].y; const auto pair_contact_count = counts[pair_index]; + if (pair_contact_count == 0) { return; } @@ -554,12 +637,15 @@ __global__ void fill_cell_contacts(const std::uint64_t* ids, const float4* cente const auto points = contact_points(first, second, parameters); const auto weight = points.count == 2 ? inverse_sqrt_two : 1.0F; auto output_index = inclusive_counts[pair_index] - pair_contact_count; + for (std::uint32_t ordinal = 0; ordinal < points.count; ++ordinal) { const auto point_delta = subtract(points.values[ordinal].second, points.values[ordinal].first); const auto separation = magnitude(point_delta) - (first.radius + second.radius); + if (separation >= parameters.activation_margin) { continue; } + const auto normal = deterministic_normal(first, second, points.values[ordinal], parameters.degeneracy_epsilon); const auto surface_point = add(points.values[ordinal].first, multiply(normal, first.radius)); @@ -585,9 +671,11 @@ __global__ void count_external_contacts(const std::uint64_t* ids, const float4* std::uint32_t cell_count, std::uint32_t constraint_count) { const auto constraint_index = blockIdx.x * blockDim.x + threadIdx.x; const auto cell_slot = blockIdx.y * blockDim.y + threadIdx.y; + if (cell_slot >= cell_count || constraint_index >= constraint_count) { return; } + const auto pair_index = cell_slot * constraint_count + constraint_index; const auto cell = load_capsule(ids, centers, axes, geometry, cell_slot); counts[pair_index] = @@ -604,11 +692,14 @@ __global__ void fill_external_contacts( std::uint32_t constraint_count) { const auto constraint_index = blockIdx.x * blockDim.x + threadIdx.x; const auto cell_slot = blockIdx.y * blockDim.y + threadIdx.y; + if (cell_slot >= cell_count || constraint_index >= constraint_count) { return; } + const auto pair_index = cell_slot * constraint_count + constraint_index; const auto pair_contact_count = counts[pair_index]; + if (pair_contact_count == 0) { return; } @@ -619,6 +710,7 @@ __global__ void fill_external_contacts( const auto weight = constraint.parameters.w * (evaluation.active_count == 2 ? inverse_sqrt_two : 1.0F); auto output_index = inclusive_counts[pair_index] - pair_contact_count; + for (std::uint32_t contact = 0; contact < evaluation.active_count; ++contact) { const auto point = add(evaluation.centerline_points[contact], multiply(evaluation.normals[contact], cell.radius)); diff --git a/cpp/cuda/kernels/coupled_rates.cu b/cpp/cuda/kernels/coupled_rates.cu index 12707a6..4ed7d00 100644 --- a/cpp/cuda/kernels/coupled_rates.cu +++ b/cpp/cuda/kernels/coupled_rates.cu @@ -21,6 +21,7 @@ __device__ float axis_coordinate(float position, float origin, float spacing, if (dimension == 1) { return 0.0F; } + return fminf(fmaxf((position - origin) / spacing, 0.0F), static_cast(dimension - 1)); } @@ -28,17 +29,23 @@ __device__ float axis_site_weight(float coordinate, std::uint32_t dimension, std if (dimension == 1) { return site == 0 ? 1.0F : 0.0F; } + const auto lower = static_cast(floorf(coordinate)); + if (lower == dimension - 1) { return site == lower ? 1.0F : 0.0F; } + const auto fraction = coordinate - static_cast(lower); + if (site == lower) { return 1.0F - fraction; } + if (site == lower + 1) { return fraction; } + return 0.0F; } @@ -48,6 +55,7 @@ __device__ float cell_site_weight(float4 center, SignalGridShapeGpu shape, float const auto coordinate_x = axis_coordinate(center.x, origin.x, spacing.x, shape.x); const auto coordinate_y = axis_coordinate(center.y, origin.y, spacing.y, shape.y); const auto coordinate_z = axis_coordinate(center.z, origin.z, spacing.z, shape.z); + return axis_site_weight(coordinate_x, shape.x, x) * axis_site_weight(coordinate_y, shape.y, y) * axis_site_weight(coordinate_z, shape.z, z); } @@ -60,25 +68,41 @@ __device__ unsigned stencil_component(float4 center, SignalGridShapeGpu shape, f unsigned lx = (unsigned)floor(cx), ly = (unsigned)floor(cy), lz = (unsigned)floor(cz); unsigned fluid = 0, seed = 0; float best = 0; + for (unsigned bit = 0; bit < 8; ++bit) { unsigned x = lx + (bit >> 2), y = ly + ((bit >> 1) & 1u), z = lz + (bit & 1u); - if (x >= shape.x || y >= shape.y || z >= shape.z) continue; + + if (x >= shape.x || y >= shape.y || z >= shape.z) { + continue; + } + float w = axis_site_weight(cx, shape.x, x) * axis_site_weight(cy, shape.y, y) * axis_site_weight(cz, shape.z, z); - if (w <= 0 || obstacles[site_index(shape, x, y, z)] != 0) continue; + + if (w <= 0 || obstacles[site_index(shape, x, y, z)] != 0) { + continue; + } + fluid |= 1u << bit; + if (w > best) { best = w; seed = 1u << bit; } } + unsigned connected = seed; + for (unsigned pass = 0; pass < 8; ++pass) { for (unsigned bit = 0; bit < 8; ++bit) { - if ((connected & (1u << bit)) == 0) continue; + if ((connected & (1u << bit)) == 0) { + continue; + } + connected |= fluid & ((1u << (bit ^ 1u)) | (1u << (bit ^ 2u)) | (1u << (bit ^ 4u))); } } + return connected; } @@ -98,35 +122,47 @@ __device__ float sample_signal(const float* levels, SignalGridShapeGpu shape, fl const auto component = stencil_component(center, shape, origin, spacing, obstacles); float fluid_weight = 0.0F; bool dropped = false; + for (std::uint32_t dx = 0; dx < count_x; ++dx) { const auto x = lower_x + dx; const auto weight_x = axis_site_weight(coordinate_x, shape.x, x); + for (std::uint32_t dy = 0; dy < count_y; ++dy) { const auto y = lower_y + dy; const auto weight_y = axis_site_weight(coordinate_y, shape.y, y); + for (std::uint32_t dz = 0; dz < count_z; ++dz) { const auto z = lower_z + dz; const auto weight_z = axis_site_weight(coordinate_z, shape.z, z); const auto weight = weight_x * weight_y * weight_z; + if ((component & (1u << ((dx << 2) | (dy << 1) | dz))) == 0u) { if (weight != 0.0F) { dropped = true; } + continue; } + fluid_weight += weight; result += weight * grid_level(levels, shape, signal, x, y, z); } } } + // A stencil with no fluid corner is rejected by the host's coupled-step // validation before any kernel runs, so the fluid weight is positive here. if (dropped) { result /= fluid_weight; } + return result; } +__device__ std::uint32_t stencil_axis_count(std::uint32_t dimension, std::uint32_t lower) { + return dimension == 1u || lower == dimension - 1u ? 1u : 2u; +} + __device__ float cell_scatter_weight(float4 center, SignalGridShapeGpu shape, float4 origin, float4 spacing, const std::uint8_t* obstacles, std::uint32_t x, std::uint32_t y, std::uint32_t z) { @@ -134,52 +170,118 @@ __device__ float cell_scatter_weight(float4 center, SignalGridShapeGpu shape, fl // weight is pure arithmetic, so testing it first keeps the obstacle mask out // of the sites a cell cannot reach - which is nearly all of them. const auto raw = cell_site_weight(center, shape, origin, spacing, x, y, z); + if (raw == 0.0F) { return 0.0F; } + if (obstacles[site_index(shape, x, y, z)] != 0) { return 0.0F; } + const auto coordinate_x = axis_coordinate(center.x, origin.x, spacing.x, shape.x); const auto coordinate_y = axis_coordinate(center.y, origin.y, spacing.y, shape.y); const auto coordinate_z = axis_coordinate(center.z, origin.z, spacing.z, shape.z); const auto lower_x = static_cast(floorf(coordinate_x)); const auto lower_y = static_cast(floorf(coordinate_y)); const auto lower_z = static_cast(floorf(coordinate_z)); - const auto count_x = shape.x == 1 || lower_x == shape.x - 1 ? 1U : 2U; - const auto count_y = shape.y == 1 || lower_y == shape.y - 1 ? 1U : 2U; - const auto count_z = shape.z == 1 || lower_z == shape.z - 1 ? 1U : 2U; + const auto count_x = stencil_axis_count(shape.x, lower_x); + const auto count_y = stencil_axis_count(shape.y, lower_y); + const auto count_z = stencil_axis_count(shape.z, lower_z); const auto component = stencil_component(center, shape, origin, spacing, obstacles); float fluid_weight = 0.0F; - if (raw == 0.0f) return 0.0f; + + if (raw == 0.0f) { + return 0.0f; + } + unsigned target_bit = ((x - lower_x) << 2) | ((y - lower_y) << 1) | (z - lower_z); - if ((component & (1u << target_bit)) == 0u) return 0.0f; + + if ((component & (1u << target_bit)) == 0u) { + return 0.0f; + } + bool dropped = false; + for (std::uint32_t dx = 0; dx < count_x; ++dx) { const auto sx = lower_x + dx; const auto weight_x = axis_site_weight(coordinate_x, shape.x, sx); + for (std::uint32_t dy = 0; dy < count_y; ++dy) { const auto sy = lower_y + dy; const auto weight_y = axis_site_weight(coordinate_y, shape.y, sy); + for (std::uint32_t dz = 0; dz < count_z; ++dz) { const auto sz = lower_z + dz; const auto weight_z = axis_site_weight(coordinate_z, shape.z, sz); const auto weight = weight_x * weight_y * weight_z; + if ((component & (1u << ((dx << 2) | (dy << 1) | dz))) == 0u) { if (weight != 0.0F) { dropped = true; } + continue; } + fluid_weight += weight; } } } + // A stencil with no fluid corner is rejected by the host's coupled-step // validation before any kernel runs, so the fluid weight is positive here. return dropped ? raw / fluid_weight : raw; } +__device__ float evaluate_predicate(const RateInstructionGpu& instruction, const float* workspace) { + switch (instruction.operation) { + case 21: + return workspace[instruction.first] < workspace[instruction.second] ? 1.0F : 0.0F; + case 22: + return workspace[instruction.first] <= workspace[instruction.second] ? 1.0F : 0.0F; + case 23: + return workspace[instruction.first] > workspace[instruction.second] ? 1.0F : 0.0F; + case 24: + return workspace[instruction.first] >= workspace[instruction.second] ? 1.0F : 0.0F; + case 25: + return workspace[instruction.first] == workspace[instruction.second] ? 1.0F : 0.0F; + case 26: + return workspace[instruction.first] != 0.0F ? workspace[instruction.second] + : workspace[instruction.third]; + default: + return nanf(""); + } +} + +__device__ float evaluate_arithmetic(const RateInstructionGpu& instruction, + const float* workspace) { + switch (instruction.operation) { + case 11: + return workspace[instruction.first] + workspace[instruction.second]; + case 12: + return workspace[instruction.first] - workspace[instruction.second]; + case 13: + return workspace[instruction.first] * workspace[instruction.second]; + case 14: + return workspace[instruction.first] / workspace[instruction.second]; + case 15: + return powf(workspace[instruction.first], workspace[instruction.second]); + case 16: + return fminf(workspace[instruction.first], workspace[instruction.second]); + case 17: + return fmaxf(workspace[instruction.first], workspace[instruction.second]); + case 18: + return -workspace[instruction.first]; + case 19: + return expf(workspace[instruction.first]); + case 20: + return logf(workspace[instruction.first]); + default: + return evaluate_predicate(instruction, workspace); + } +} + __device__ float evaluate_instruction(const RateInstructionGpu& instruction, const float* workspace, const float* species, const float* signals, float4 center, float4 geometry, float growth_rate, std::int32_t cell_type, @@ -209,43 +311,10 @@ __device__ float evaluate_instruction(const RateInstructionGpu& instruction, con return effective_volume(geometry.x, geometry.y); case 10: return effective_surface_area(geometry.x, geometry.y); - case 11: - return workspace[instruction.first] + workspace[instruction.second]; - case 12: - return workspace[instruction.first] - workspace[instruction.second]; - case 13: - return workspace[instruction.first] * workspace[instruction.second]; - case 14: - return workspace[instruction.first] / workspace[instruction.second]; - case 15: - return powf(workspace[instruction.first], workspace[instruction.second]); - case 16: - return fminf(workspace[instruction.first], workspace[instruction.second]); - case 17: - return fmaxf(workspace[instruction.first], workspace[instruction.second]); - case 18: - return -workspace[instruction.first]; - case 19: - return expf(workspace[instruction.first]); - case 20: - return logf(workspace[instruction.first]); - case 21: - return workspace[instruction.first] < workspace[instruction.second] ? 1.0F : 0.0F; - case 22: - return workspace[instruction.first] <= workspace[instruction.second] ? 1.0F : 0.0F; - case 23: - return workspace[instruction.first] > workspace[instruction.second] ? 1.0F : 0.0F; - case 24: - return workspace[instruction.first] >= workspace[instruction.second] ? 1.0F : 0.0F; - case 25: - return workspace[instruction.first] == workspace[instruction.second] ? 1.0F : 0.0F; - case 26: - return workspace[instruction.first] != 0.0F ? workspace[instruction.second] - : workspace[instruction.third]; case 27: return signals[instruction.first]; default: - return nanf(""); + return evaluate_arithmetic(instruction, workspace); } } @@ -258,6 +327,7 @@ __global__ void advance_coupled_cells( SignalGridShapeGpu shape, float4 origin, float4 spacing, float dt, std::uint32_t species_count, std::uint32_t signal_count, std::uint32_t instruction_count, std::uint32_t cell_count) { const auto cell = (blockIdx.x * blockDim.x) + threadIdx.x; + if (cell >= cell_count) { return; } @@ -266,12 +336,14 @@ __global__ void advance_coupled_cells( const auto radius = geometry[cell].y; const auto dilution = effective_volume(previous_lengths[cell], radius) / effective_volume(geometry[cell].x, radius); + for (std::uint32_t species = 0; species < species_count; ++species) { species_levels[species_offset + species] *= dilution; } const auto signal_offset = cell * signal_count; auto* cell_signals = cell_signal_rates + signal_offset; + for (std::uint32_t signal = 0; signal < signal_count; ++signal) { cell_signals[signal] = sample_signal(grid_levels, shape, origin, spacing, obstacles, centers[cell], signal); @@ -280,6 +352,7 @@ __global__ void advance_coupled_cells( const auto workspace_offset = cell * instruction_count; auto* cell_workspace = workspace + workspace_offset; const auto* cell_species = species_levels + species_offset; + for (std::uint32_t index = 0; index < instruction_count; ++index) { const auto value = evaluate_instruction(instructions[index], cell_workspace, cell_species, cell_signals, @@ -289,23 +362,73 @@ __global__ void advance_coupled_cells( effective_volume(previous_lengths[cell], radius)) / dt); cell_workspace[index] = value; + if (!isfinite(value)) { atomicOr(error, 1U); } } + for (std::uint32_t species = 0; species < species_count; ++species) { const auto value = species_levels[species_offset + species] + dt * cell_workspace[species_outputs[species]]; species_levels[species_offset + species] = value; + if (!isfinite(value)) { atomicOr(error, 1U); } } + for (std::uint32_t signal = 0; signal < signal_count; ++signal) { cell_signals[signal] = cell_workspace[signal_outputs[signal]]; } } +__device__ float coupled_transport_stencil(float diffusion_value, float current, + const GridFaceState& faces, + const std::uint32_t* dimensions, float4 spacing, + float* lower, float* upper) { + const bool* closed_lower = faces.closed_lower; + const bool* closed_upper = faces.closed_upper; + const float* face_lower = faces.lower; + const float* face_upper = faces.upper; + const float grid_spacing[3]{spacing.x, spacing.y, spacing.z}; + float rate = 0.0F; + + for (std::uint32_t axis = 0; axis < 3; ++axis) { + if (dimensions[axis] == 1) { + continue; + } + + if (closed_lower[axis]) { + lower[axis] = current; + } + + if (closed_upper[axis]) { + upper[axis] = current; + } + + const auto inverse_spacing = 1.0F / grid_spacing[axis]; + rate += diffusion_value * (lower[axis] - 2.0F * current + upper[axis]) * inverse_spacing * + inverse_spacing; + auto lower_flux = + face_lower[axis] >= 0.0F ? face_lower[axis] * lower[axis] : face_lower[axis] * current; + auto upper_flux = + face_upper[axis] >= 0.0F ? face_upper[axis] * current : face_upper[axis] * upper[axis]; + + if (closed_lower[axis]) { + lower_flux = 0.0F; + } + + if (closed_upper[axis]) { + upper_flux = 0.0F; + } + + rate -= (upper_flux - lower_flux) * inverse_spacing; + } + + return rate; +} + __global__ void advance_coupled_grid( const float* levels, float* output, const float* diffusion, const float4* advection, const float* fixed_values, const float* reaction_source, const float* reaction_loss, @@ -315,6 +438,7 @@ __global__ void advance_coupled_grid( SignalGridShapeGpu shape, float4 origin, float4 spacing, float dt, std::uint32_t signal_count, std::uint32_t cell_count, std::uint32_t level_count, std::uint32_t crank_nicolson) { const auto index = (blockIdx.x * blockDim.x) + threadIdx.x; + if (index >= level_count) { return; } @@ -326,8 +450,10 @@ __global__ void advance_coupled_grid( const auto y = yz / shape.z; const auto z = yz - y * shape.z; const auto current = levels[index]; + if (obstacles[site] != 0) { output[index] = current; + return; } @@ -358,49 +484,24 @@ __global__ void advance_coupled_grid( const std::uint32_t dimensions[3]{shape.x, shape.y, shape.z}; const auto faces = grid_face_state(shape, boundaries, obstacles, x_faces, y_faces, z_faces, has_velocity_field, advection[signal], x, y, z); - const bool* closed_lower = faces.closed_lower; - const bool* closed_upper = faces.closed_upper; - const float* face_lower = faces.lower; - const float* face_upper = faces.upper; - const float grid_spacing[3]{spacing.x, spacing.y, spacing.z}; - float rate = 0.0F; - for (std::uint32_t axis = 0; axis < 3; ++axis) { - if (dimensions[axis] == 1) { - continue; - } - if (closed_lower[axis]) { - lower[axis] = current; - } - if (closed_upper[axis]) { - upper[axis] = current; - } - const auto inverse_spacing = 1.0F / grid_spacing[axis]; - rate += diffusion[signal] * (lower[axis] - 2.0F * current + upper[axis]) * inverse_spacing * - inverse_spacing; - auto lower_flux = - face_lower[axis] >= 0.0F ? face_lower[axis] * lower[axis] : face_lower[axis] * current; - auto upper_flux = - face_upper[axis] >= 0.0F ? face_upper[axis] * current : face_upper[axis] * upper[axis]; - if (closed_lower[axis]) { - lower_flux = 0.0F; - } - if (closed_upper[axis]) { - upper_flux = 0.0F; - } - rate -= (upper_flux - lower_flux) * inverse_spacing; - } + float rate = coupled_transport_stencil(diffusion[signal], current, faces, dimensions, spacing, + lower, upper); + rate += reaction_source[index] - reaction_loss[index] * current; float source = 0.0F; const auto inverse_voxel_volume = 1.0F / (spacing.x * spacing.y * spacing.z); + for (std::uint32_t cell = 0; cell < cell_count; ++cell) { const auto weight = cell_scatter_weight(centers[cell], shape, origin, spacing, obstacles, x, y, z); source += weight * cell_signal_rates[cell * signal_count + signal] * inverse_voxel_volume; } + const auto transport_scale = crank_nicolson == 0 ? dt : (crank_nicolson == 1 ? 0.5F * dt : 0.0F); const auto candidate = current + transport_scale * rate + dt * source; output[index] = candidate; + if (!isfinite(candidate) || (!crank_nicolson && candidate < 0.0F)) { atomicOr(error, 2U); } @@ -429,16 +530,19 @@ cudaError_t launch_advance_coupled( error, shape, origin, spacing, dt, species_count, signal_count, instruction_count, cell_count); const auto cell_error = cudaGetLastError(); + if (cell_error != cudaSuccess) { return cell_error; } } + const auto grid_blocks = ((level_count - 1) / threads_per_block) + 1; advance_coupled_grid<<>>( grid_levels, grid_output, diffusion, advection, fixed_values, reaction_source, reaction_loss, centers, cell_signal_rates, obstacles, x_faces, y_faces, z_faces, has_velocity_field, error, boundaries, shape, origin, spacing, dt, signal_count, cell_count, level_count, crank_nicolson); + return cudaGetLastError(); } diff --git a/cpp/cuda/kernels/flow.cu b/cpp/cuda/kernels/flow.cu index 0c97204..719fe48 100644 --- a/cpp/cuda/kernels/flow.cu +++ b/cpp/cuda/kernels/flow.cu @@ -26,6 +26,7 @@ __device__ Coordinate site_coordinate(std::uint32_t index, const FlowGridParamet index /= grid.dimensions[2]; result.values[1] = index % grid.dimensions[1]; result.values[0] = index / grid.dimensions[1]; + return result; } @@ -34,13 +35,16 @@ __device__ FaceCoordinate face_coordinate(std::uint32_t index, const FlowGridPar index < grid.face_offsets[1] ? 0U : (index < grid.face_offsets[2] ? 1U : 2U); auto local = index - grid.face_offsets[component]; FaceCoordinate result{.component = component}; + for (std::uint32_t axis = 0; axis < 3; ++axis) { result.dimensions.values[axis] = grid.dimensions[axis] + (axis == component ? 1U : 0U); } + result.coordinate.values[2] = local % result.dimensions.values[2]; local /= result.dimensions.values[2]; result.coordinate.values[1] = local % result.dimensions.values[1]; result.coordinate.values[0] = local / result.dimensions.values[1]; + return result; } @@ -51,12 +55,14 @@ __device__ std::uint32_t face_index(std::uint32_t component, const Coordinate& c (coordinate.values[0] * grid.dimensions[1] + coordinate.values[1]) * grid.dimensions[2] + coordinate.values[2]; } + if (component == 1) { return grid.face_offsets[1] + (coordinate.values[0] * (grid.dimensions[1] + 1) + coordinate.values[1]) * grid.dimensions[2] + coordinate.values[2]; } + return grid.face_offsets[2] + (coordinate.values[0] * grid.dimensions[1] + coordinate.values[1]) * (grid.dimensions[2] + 1) + @@ -65,21 +71,27 @@ __device__ std::uint32_t face_index(std::uint32_t component, const Coordinate& c __device__ float harmonic_mean(float first, float second) { const auto sum = first + second; + return sum > 0.0F ? 2.0F * first * second / sum : 0.0F; } __global__ void depth_flow_operator(const float* input, const float* mobility, const float* diagonal, float* output, FlowGridParameters grid) { const auto index = blockIdx.x * blockDim.x + threadIdx.x; + if (index >= grid.site_count) { return; } + if (diagonal[index] == 0.0F) { output[index] = 0.0F; + return; } + const auto coordinate = site_coordinate(index, grid); auto result = diagonal[index] * input[index]; + for (std::uint32_t axis = 0; axis < 3; ++axis) { if (coordinate.values[axis] > 0) { auto neighbor = coordinate; @@ -88,6 +100,7 @@ __global__ void depth_flow_operator(const float* input, const float* mobility, result += harmonic_mean(mobility[index], mobility[neighbor_index]) * grid.inverse_spacing_squared[axis] * (input[index] - input[neighbor_index]); } + if (coordinate.values[axis] + 1 < grid.dimensions[axis]) { auto neighbor = coordinate; ++neighbor.values[axis]; @@ -96,26 +109,32 @@ __global__ void depth_flow_operator(const float* input, const float* mobility, grid.inverse_spacing_squared[axis] * (input[index] - input[neighbor_index]); } } + output[index] = result; } __global__ void depth_flow_velocity(const float* pressure, const float* mobility, float* velocity, FlowGridParameters grid) { const auto index = blockIdx.x * blockDim.x + threadIdx.x; + if (index >= grid.total_face_count) { return; } + const auto face = face_coordinate(index, grid); const auto component = face.component; const auto has_lower = face.coordinate.values[component] > 0; const auto has_upper = face.coordinate.values[component] < grid.dimensions[component]; auto lower_coordinate = face.coordinate; + if (has_lower) { --lower_coordinate.values[component]; } + const auto lower = has_lower ? site_index(lower_coordinate, grid) : 0; const auto upper = has_upper ? site_index(face.coordinate, grid) : 0; auto value = 0.0F; + if (has_lower && has_upper) { value = -harmonic_mean(mobility[lower], mobility[upper]) * (pressure[upper] - pressure[lower]) / grid.spacing[component]; @@ -124,6 +143,7 @@ __global__ void depth_flow_velocity(const float* pressure, const float* mobility } else if (component == grid.flow_axis && has_lower) { value = 2.0F * mobility[lower] * pressure[lower] / grid.spacing[component]; } + velocity[index] = value; } @@ -131,34 +151,45 @@ __global__ void resolved_flow_momentum(const float* input, const std::uint8_t* a const std::uint8_t* exists, const float* face_drag, float* output, FlowGridParameters grid) { const auto index = blockIdx.x * blockDim.x + threadIdx.x; + if (index >= grid.total_face_count) { return; } + if (active[index] == 0) { output[index] = 0.0F; + return; } + const auto face = face_coordinate(index, grid); auto result = face_drag[index] * input[index]; + for (std::uint32_t axis = 0; axis < 3; ++axis) { if (grid.dimensions[axis] == 1) { continue; } + for (int offset = -1; offset <= 1; offset += 2) { const auto in_bounds = offset < 0 ? face.coordinate.values[axis] > 0 : face.coordinate.values[axis] + 1 < face.dimensions.values[axis]; std::uint32_t neighbor_index = 0; + if (in_bounds) { auto coordinate = face.coordinate; + if (offset < 0) { --coordinate.values[axis]; } else { ++coordinate.values[axis]; } + neighbor_index = face_index(face.component, coordinate, grid); } + float neighbor = 0.0F; + if (axis == face.component) { neighbor = in_bounds ? input[neighbor_index] : input[index]; } else if (in_bounds && exists[neighbor_index] != 0) { @@ -166,9 +197,11 @@ __global__ void resolved_flow_momentum(const float* input, const std::uint8_t* a } else { neighbor = -input[index]; } + result -= (neighbor - input[index]) * grid.inverse_spacing_squared[axis]; } } + output[index] = result; } @@ -176,21 +209,27 @@ __global__ void resolved_flow_gradient(const float* pressure, const std::uint8_t const std::uint8_t* active, float* gradient, FlowGridParameters grid) { const auto index = blockIdx.x * blockDim.x + threadIdx.x; + if (index >= grid.total_face_count) { return; } + if (active[index] == 0) { gradient[index] = 0.0F; + return; } + const auto face = face_coordinate(index, grid); const auto component = face.component; const auto has_lower = face.coordinate.values[component] > 0; const auto has_upper = face.coordinate.values[component] < grid.dimensions[component]; auto lower_coordinate = face.coordinate; + if (has_lower) { --lower_coordinate.values[component]; } + const auto lower = has_lower ? site_index(lower_coordinate, grid) : 0; const auto upper = has_upper ? site_index(face.coordinate, grid) : 0; const auto lower_value = has_lower && fluid[lower] != 0 ? pressure[lower] : 0.0F; @@ -201,15 +240,20 @@ __global__ void resolved_flow_gradient(const float* pressure, const std::uint8_t __global__ void resolved_flow_divergence(const float* velocity, const std::uint8_t* fluid, float* divergence, FlowGridParameters grid) { const auto index = blockIdx.x * blockDim.x + threadIdx.x; + if (index >= grid.site_count) { return; } + if (fluid[index] == 0) { divergence[index] = 0.0F; + return; } + const auto coordinate = site_coordinate(index, grid); auto result = 0.0F; + for (std::uint32_t component = 0; component < 3; ++component) { auto upper = coordinate; ++upper.values[component]; @@ -217,6 +261,7 @@ __global__ void resolved_flow_divergence(const float* velocity, const std::uint8 velocity[face_index(component, coordinate, grid)]) / grid.spacing[component]; } + divergence[index] = result; } @@ -224,9 +269,11 @@ __global__ void flow_pcg_initialize(const float* right_hand_side, const float* d float* solution, float* residual, float* preconditioned, float* direction, std::uint32_t count) { const auto index = blockIdx.x * blockDim.x + threadIdx.x; + if (index >= count) { return; } + const auto value = right_hand_side[index]; const auto scaled = diagonal[index] > 0.0F ? value / diagonal[index] : 0.0F; solution[index] = 0.0F; @@ -238,6 +285,7 @@ __global__ void flow_pcg_initialize(const float* right_hand_side, const float* d __global__ void flow_pcg_update(float* solution, float* residual, const float* direction, const float* transformed, float alpha, std::uint32_t count) { const auto index = blockIdx.x * blockDim.x + threadIdx.x; + if (index < count) { solution[index] += alpha * direction[index]; residual[index] -= alpha * transformed[index]; @@ -247,6 +295,7 @@ __global__ void flow_pcg_update(float* solution, float* residual, const float* d __global__ void flow_pcg_precondition(const float* residual, const float* diagonal, float* preconditioned, std::uint32_t count) { const auto index = blockIdx.x * blockDim.x + threadIdx.x; + if (index < count) { preconditioned[index] = diagonal[index] > 0.0F ? residual[index] / diagonal[index] : 0.0F; } @@ -255,6 +304,7 @@ __global__ void flow_pcg_precondition(const float* residual, const float* diagon __global__ void flow_pcg_direction(const float* preconditioned, float* direction, float beta, std::uint32_t count) { const auto index = blockIdx.x * blockDim.x + threadIdx.x; + if (index < count) { direction[index] = preconditioned[index] + beta * direction[index]; } @@ -262,6 +312,7 @@ __global__ void flow_pcg_direction(const float* preconditioned, float* direction __global__ void flow_vector_negate(const float* input, float* output, std::uint32_t count) { const auto index = blockIdx.x * blockDim.x + threadIdx.x; + if (index < count) { output[index] = -input[index]; } @@ -270,12 +321,16 @@ __global__ void flow_vector_negate(const float* input, float* output, std::uint3 __global__ void flow_vector_combine(const float* source, float* target, float alpha, float beta, std::uint32_t count) { const auto i = blockIdx.x * blockDim.x + threadIdx.x; - if (i < count) target[i] = alpha * source[i] + beta * target[i]; + + if (i < count) { + target[i] = alpha * source[i] + beta * target[i]; + } } __global__ void flow_vector_subtract(const float* left, const float* right, float* output, std::uint32_t count) { const auto index = blockIdx.x * blockDim.x + threadIdx.x; + if (index < count) { output[index] = left[index] - right[index]; } @@ -287,12 +342,15 @@ __global__ void flow_dot_partial(const float* left, const float* right, float* p const auto index = blockIdx.x * blockDim.x + threadIdx.x; values[threadIdx.x] = index < count ? left[index] * right[index] : 0.0F; __syncthreads(); + for (std::uint32_t stride = flow_reduction_width / 2; stride > 0; stride >>= 1) { if (threadIdx.x < stride) { values[threadIdx.x] += values[threadIdx.x + stride]; } + __syncthreads(); } + if (threadIdx.x == 0) { partials[blockIdx.x] = values[0]; } diff --git a/cpp/cuda/kernels/growth.cu b/cpp/cuda/kernels/growth.cu index 49d15d3..2f9666d 100644 --- a/cpp/cuda/kernels/growth.cu +++ b/cpp/cuda/kernels/growth.cu @@ -6,9 +6,11 @@ namespace { __global__ void advance_growth(float* lengths, const float* growth_rates, float dt, std::uint32_t count) { const auto index = (blockIdx.x * blockDim.x) + threadIdx.x; + if (index >= count) { return; } + lengths[index] += growth_rates[index] * lengths[index] * dt; } diff --git a/cpp/cuda/kernels/mechanics.cu b/cpp/cuda/kernels/mechanics.cu index ae5536c..3f11d12 100644 --- a/cpp/cuda/kernels/mechanics.cu +++ b/cpp/cuda/kernels/mechanics.cu @@ -64,6 +64,7 @@ __device__ MechanicsDofsGpu added(const MechanicsDofsGpu& left, const MechanicsD __device__ MechanicsDofsGpu contact_jacobian(float3 normal, float3 arm, float3 axis, float total_length, float weight) { const auto angular = cross_product(arm, normal); + return { make_float4(weight * normal.x, weight * normal.y, weight * normal.z, weight * dot_product(axis, arm) * dot_product(axis, normal) / total_length), @@ -79,9 +80,11 @@ __global__ void build_mechanics_rows(const float4* centers, const float4* axes, MechanicsDofsGpu* second_rows, float* right_hand_side, std::uint32_t contact_count) { const auto index = blockIdx.x * blockDim.x + threadIdx.x; + if (index >= contact_count) { return; } + const auto first = first_slots[index]; const auto second = second_slots[index]; const auto point = make_float3(points[index].x, points[index].y, points[index].z); @@ -91,6 +94,7 @@ __global__ void build_mechanics_rows(const float4* centers, const float4* axes, const auto weight = weights[index]; first_rows[index] = contact_jacobian(normal, subtract(point, first_center), first_axis, geometry[first].x + 2.0F * geometry[first].y, weight); + if (second == 0xffffffffU) { second_rows[index] = zero_dofs(); } else { @@ -99,6 +103,7 @@ __global__ void build_mechanics_rows(const float4* centers, const float4* axes, second_rows[index] = contact_jacobian(normal, subtract(point, second_center), second_axis, geometry[second].x + 2.0F * geometry[second].y, weight); } + right_hand_side[index] = weight * separations[index]; } @@ -109,13 +114,16 @@ __global__ void apply_mechanics_b(const MechanicsDofsGpu* first_rows, const std::uint8_t* fixed, float* row_values, std::uint32_t contact_count) { const auto index = blockIdx.x * blockDim.x + threadIdx.x; + if (index >= contact_count) { return; } + const auto first = first_slots[index]; const auto second = second_slots[index]; const auto first_input = fixed[first] == 0 ? input[first] : zero_dofs(); row_values[index] = dof_dot(first_rows[index], first_input); + if (second != 0xffffffffU) { const auto second_input = fixed[second] == 0 ? input[second] : zero_dofs(); row_values[index] -= dof_dot(second_rows[index], second_input); @@ -130,16 +138,20 @@ __global__ void apply_mechanics_transpose(const MechanicsDofsGpu* first_rows, const std::uint32_t* first_slots, MechanicsDofsGpu* output, std::uint32_t cell_count) { const auto cell = blockIdx.x * blockDim.x + threadIdx.x; + if (cell >= cell_count) { return; } + auto result = zero_dofs(); + for (auto offset = incidence_offsets[cell]; offset < incidence_offsets[cell + 1]; ++offset) { const auto row = incidence_indices[offset]; const auto is_first = first_slots[row] == cell; const auto jacobian = is_first ? first_rows[row] : second_rows[row]; result = added(result, scaled(jacobian, (is_first ? 1.0F : -1.0F) * row_values[row])); } + output[cell] = result; } @@ -148,13 +160,17 @@ __global__ void add_mechanics_regularizer(const float4* axes, const float4* geom const std::uint8_t* fixed, float mu_a, float gamma, std::uint32_t cell_count) { const auto cell = blockIdx.x * blockDim.x + threadIdx.x; + if (cell >= cell_count) { return; } + if (fixed[cell] != 0) { output[cell] = input[cell]; + return; } + const auto total_length = geometry[cell].x + 2.0F * geometry[cell].y; const auto radius = geometry[cell].y; const auto mass = mu_a * total_length; @@ -183,9 +199,11 @@ __global__ void initialize_mechanics_vectors(MechanicsDofsGpu* right_hand_side, MechanicsDofsGpu* search_direction, const std::uint8_t* fixed, std::uint32_t cell_count) { const auto cell = blockIdx.x * blockDim.x + threadIdx.x; + if (cell >= cell_count) { return; } + solution[cell] = zero_dofs(); const auto projected_rhs = fixed[cell] == 0 ? right_hand_side[cell] : zero_dofs(); right_hand_side[cell] = projected_rhs; @@ -199,9 +217,11 @@ __global__ void update_mechanics_solution_residual(MechanicsDofsGpu* solution, const MechanicsDofsGpu* applied, float alpha, std::uint32_t cell_count) { const auto cell = blockIdx.x * blockDim.x + threadIdx.x; + if (cell >= cell_count) { return; } + solution[cell] = added(solution[cell], scaled(search_direction[cell], alpha)); residual[cell] = added(residual[cell], scaled(applied[cell], -alpha)); } @@ -210,9 +230,11 @@ __global__ void update_mechanics_search_direction(const MechanicsDofsGpu* residu MechanicsDofsGpu* search_direction, float beta, std::uint32_t cell_count) { const auto cell = blockIdx.x * blockDim.x + threadIdx.x; + if (cell >= cell_count) { return; } + search_direction[cell] = added(residual[cell], scaled(search_direction[cell], beta)); } @@ -220,35 +242,45 @@ __global__ void subtract_mechanics_vectors(const MechanicsDofsGpu* left, const MechanicsDofsGpu* right, MechanicsDofsGpu* output, std::uint32_t cell_count) { const auto cell = blockIdx.x * blockDim.x + threadIdx.x; + if (cell >= cell_count) { return; } + output[cell] = added(left[cell], scaled(right[cell], -1.0F)); } __global__ void mechanics_dot_terms(const MechanicsDofsGpu* left, const MechanicsDofsGpu* right, float* terms, std::uint32_t cell_count) { const auto cell = blockIdx.x * blockDim.x + threadIdx.x; + if (cell >= cell_count) { return; } + terms[cell] = dof_dot(left[cell], right[cell]); } __global__ void reduce_sum_pairs(const float* input, float* output, std::uint32_t element_count) { const auto index = blockIdx.x * blockDim.x + threadIdx.x; const auto first = index * 2; + if (first >= element_count) { return; } + auto value = input[first]; + if (first + 1 < element_count) { value += input[first + 1]; } + output[index] = value; } -std::uint32_t block_count(std::uint32_t count) { return ((count - 1) / threads_per_block) + 1; } +std::uint32_t block_count(std::uint32_t count) { + return ((count - 1) / threads_per_block) + 1; +} } // namespace diff --git a/cpp/cuda/kernels/occupancy.cu b/cpp/cuda/kernels/occupancy.cu new file mode 100644 index 0000000..2accdba --- /dev/null +++ b/cpp/cuda/kernels/occupancy.cu @@ -0,0 +1,442 @@ +#include + +#include "occupancy.cuh" + +namespace cm::cuda { +namespace { +using uint = unsigned int; +using detail::OccupancyParameters; + +struct Vector3 { + float x, y, z; + + __device__ Vector3(float a, float b, float c) : x(a), y(b), z(c) {} + + __device__ Vector3 operator+(Vector3 v) const { + return {x + v.x, y + v.y, z + v.z}; + } + + __device__ Vector3 operator+(float v) const { + return {x + v, y + v, z + v}; + } + + __device__ Vector3 operator-(Vector3 v) const { + return {x - v.x, y - v.y, z - v.z}; + } + + __device__ Vector3 operator*(Vector3 v) const { + return {x * v.x, y * v.y, z * v.z}; + } + + __device__ Vector3 operator*(float v) const { + return {x * v, y * v, z * v}; + } + + __device__ Vector3 operator/(float v) const { + return {x / v, y / v, z / v}; + } + + __device__ void operator/=(float v) { + x /= v; + y /= v; + z /= v; + } +}; + +__device__ Vector3 operator*(float a, Vector3 b) { + return b * a; +} + +__device__ float dot(Vector3 a, Vector3 b) { + return a.x * b.x + a.y * b.y + a.z * b.z; +} + +__device__ float clamp(float x, float lo, float hi) { + return fminf(hi, fmaxf(lo, x)); +} + +// Compensated, ordered sums avoid nondeterministic floating-point atomics. +__device__ void occupancy_add(float value, float& total, float& correction) { + float adjusted = value - correction; + float next = total + adjusted; + correction = (next - total) - adjusted; + total = next; +} + +__global__ void occupancy_geometry(const float* centers, const float* cells, const uint* walls, + float* out, OccupancyParameters p) { + uint i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i >= p.count) { + return; + } + + if (walls[i]) { + out[i] = 0; + + return; + } + + uint occupied = 0; + Vector3 center(centers[3 * i], centers[3 * i + 1], centers[3 * i + 2]); + + for (uint x = 0; x < p.subdivisions; ++x) { + for (uint y = 0; y < p.subdivisions; ++y) { + for (uint z = 0; z < p.subdivisions; ++z) { + Vector3 sample = + center + (Vector3(x, y, z) + 0.5f) / float(p.subdivisions) * Vector3(p.hx, p.hy, p.hz) - + 0.5f * Vector3(p.hx, p.hy, p.hz); + + for (uint cell = 0; cell < p.auxiliary; ++cell) { + uint base = 8 * cell; + Vector3 delta = sample - Vector3(cells[base], cells[base + 1], cells[base + 2]); + Vector3 direction(cells[base + 4], cells[base + 5], cells[base + 6]); + // Scaling first avoids overflow/underflow in direction normalization. + direction /= max(abs(direction.x), max(abs(direction.y), abs(direction.z))); + direction /= sqrt(dot(direction, direction)); + float axial = + clamp(dot(delta, direction), -0.5f * cells[base + 3], 0.5f * cells[base + 3]); + Vector3 distance = delta - axial * direction; + float radius = cells[base + 7]; + distance /= radius; + + if (dot(distance, distance) <= 1) { + ++occupied; + break; + } + } + } + } + } + + uint samples = p.subdivisions * p.subdivisions * p.subdivisions; + float epsilon = float(samples - occupied) / float(samples); + out[i] = epsilon < p.cutoff ? 0 : epsilon; +} + +__global__ void occupancy_volumes(const float* epsilon, float* out, OccupancyParameters p) { + uint i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i < p.count) { + out[i] = epsilon[i] < p.cutoff ? 0 : epsilon[i] * p.scalar; + } +} + +__global__ void occupancy_concentration(const float* amount, const float* volume, float* out, + OccupancyParameters p) { + uint i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i < p.count) { + out[i] = volume[i] > 0 ? amount[i] / volume[i] : 0; + } +} + +__global__ void occupancy_face(const float* data, float* out, OccupancyParameters p) { + uint i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i != 0) { + return; + } + + float aperture = + min(data[0], data[1]) < p.cutoff ? 0 : 2 * data[0] * data[1] / (data[0] + data[1]); + out[0] = data[2] * aperture * data[3] / data[4]; + out[1] = aperture * data[3] * data[5]; +} + +__global__ void occupancy_product(const float* a, const float* b, float* out, + OccupancyParameters p) { + uint i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i < p.count) { + out[i] = a[i] * b[i]; + } +} + +__global__ void occupancy_sum(const float* input, float* out, OccupancyParameters p) { + uint i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i != 0) { + return; + } + + float total = 0, correction = 0; + + for (uint j = 0; j < p.count; ++j) { + occupancy_add(p.absolute ? abs(input[j]) : input[j], total, correction); + } + + out[0] = total; +} + +__global__ void occupancy_normalize(const float* values, const float* total, float* out, + OccupancyParameters p) { + uint i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i < p.count) { + out[i] = values[i] / total[0]; + } +} + +__global__ void occupancy_labels_init(const float* old, const float* next, uint* out, + OccupancyParameters p) { + uint i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i < p.count) { + out[i] = old[i] > 0 || next[i] > 0 ? i : 0xffffffffu; + } +} + +__global__ void occupancy_labels_step(const uint* offsets, const uint* indices, const uint* labels, + uint* out, OccupancyParameters p) { + uint i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i >= p.count) { + return; + } + + uint label = labels[i]; + + if (label != 0xffffffffu) { + for (uint j = offsets[i]; j < offsets[i + 1]; ++j) { + label = min(label, labels[indices[j]]); + } + } + + out[i] = label; +} + +__global__ void occupancy_component_sums(const uint* offsets, const uint* indices, + const float* amount, const float* volume, float* out, + OccupancyParameters p) { + uint i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i >= p.count) { + return; + } + + float expelled = 0, capacity = 0, ec = 0, vc = 0; + + for (uint entry = offsets[i]; entry < offsets[i + 1]; ++entry) { + uint j = indices[entry]; + + if (volume[j] == 0) { + occupancy_add(amount[j], expelled, ec); + } else { + occupancy_add(volume[j], capacity, vc); + } + } + + out[2 * i] = expelled; + out[2 * i + 1] = capacity; +} + +__global__ void occupancy_remap(const float* amount, const float* volume, const uint* labels, + const float* totals, float* out, OccupancyParameters p) { + uint i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i >= p.count) { + return; + } + + if (volume[i] == 0) { + out[i] = 0; + + return; + } + + uint component = labels[i]; + out[i] = amount[i] + totals[2 * component] * (volume[i] / totals[2 * component + 1]); +} + +__global__ void occupancy_assemble(const uint* offsets, const uint* indices, const float* edges, + const float* volume, const float* amount, const float* source, + const float* loss, float* diagonal, float* rhs, + OccupancyParameters p) { + uint i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i >= p.count) { + return; + } + + float d = volume[i] * (1 + p.scalar * loss[i]); + float b = amount[i] + p.scalar * source[i]; + + for (uint j = offsets[i]; j < offsets[i + 1]; ++j) { + float g = edges[3 * j], q = edges[3 * j + 1]; + d += p.scalar * (g + max(q, 0.0f)); + + if (indices[j] == 0xffffffffu) { + b += p.scalar * (g + max(-q, 0.0f)) * edges[3 * j + 2]; + } + } + + diagonal[i] = volume[i] > 0 ? d : 1; + rhs[i] = b; +} + +__global__ void occupancy_jacobi(const float* diagonal, const float* current, const float* residual, + float* out, OccupancyParameters p) { + uint i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i >= p.count) { + return; + } + + out[i] = current[i] - residual[i] / diagonal[i]; +} + +__global__ void occupancy_residual(const uint* offsets, const uint* indices, const float* edges, + const float* volume, const float* amount, const float* source, + const float* loss, const float* current, float* out, + OccupancyParameters p) { + uint i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i >= p.count) { + return; + } + + float c = current[i]; + float value = volume[i] * c - amount[i], correction = 0; + occupancy_add(p.scalar * (loss[i] * volume[i] * c - source[i]), value, correction); + + for (uint j = offsets[i]; j < offsets[i + 1]; ++j) { + float neighbor = indices[j] == 0xffffffffu ? edges[3 * j + 2] : current[indices[j]]; + float g = edges[3 * j], q = edges[3 * j + 1]; + occupancy_add(p.scalar * (g * (c - neighbor) + q * (q >= 0 ? c : neighbor)), value, correction); + } + + out[i] = value; +} + +__global__ void occupancy_finish(const uint* offsets, const uint* indices, const float* edges, + const float* volume, const float* loss, const float* current, + float* amount, float* reaction, float* boundary, + OccupancyParameters p) { + uint i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i >= p.count) { + return; + } + + amount[i] = volume[i] * current[i]; + reaction[i] = -p.scalar * loss[i] * amount[i]; + float exchange = 0, correction = 0; + + for (uint j = offsets[i]; j < offsets[i + 1]; ++j) { + if (indices[j] == 0xffffffffu) { + float g = edges[3 * j], q = edges[3 * j + 1], reservoir = edges[3 * j + 2]; + occupancy_add( + p.scalar * (g * (reservoir - current[i]) - q * (q >= 0 ? current[i] : reservoir)), + exchange, correction); + } + } + + boundary[i] = exchange; +} +} // namespace + +void launch_occupancy_transport(detail::OccupancyKernel kernel, + const detail::OccupancyParameters& p, void* const* buffers, + unsigned blocks, cudaStream_t stream) { + switch (kernel) { + case detail::OccupancyKernel::assemble: + occupancy_assemble<<>>( + static_cast(buffers[0]), static_cast(buffers[1]), + static_cast(buffers[2]), static_cast(buffers[3]), + static_cast(buffers[4]), static_cast(buffers[5]), + static_cast(buffers[6]), static_cast(buffers[7]), + static_cast(buffers[8]), p); + break; + case detail::OccupancyKernel::jacobi: + occupancy_jacobi<<>>( + static_cast(buffers[0]), static_cast(buffers[1]), + static_cast(buffers[2]), static_cast(buffers[3]), p); + break; + case detail::OccupancyKernel::residual: + occupancy_residual<<>>( + static_cast(buffers[0]), static_cast(buffers[1]), + static_cast(buffers[2]), static_cast(buffers[3]), + static_cast(buffers[4]), static_cast(buffers[5]), + static_cast(buffers[6]), static_cast(buffers[7]), + static_cast(buffers[8]), p); + break; + case detail::OccupancyKernel::finish: + occupancy_finish<<>>( + static_cast(buffers[0]), static_cast(buffers[1]), + static_cast(buffers[2]), static_cast(buffers[3]), + static_cast(buffers[4]), static_cast(buffers[5]), + static_cast(buffers[6]), static_cast(buffers[7]), + static_cast(buffers[8]), p); + break; + default: + break; + } +} + +void launch_occupancy(detail::OccupancyKernel kernel, const detail::OccupancyParameters& p, + void* const* buffers, cudaStream_t stream) { + const unsigned count = kernel == detail::OccupancyKernel::sum ? 1 : p.count; + const unsigned blocks = (count - 1) / 128 + 1; + + switch (kernel) { + case detail::OccupancyKernel::geometry: + occupancy_geometry<<>>( + static_cast(buffers[0]), static_cast(buffers[1]), + static_cast(buffers[2]), static_cast(buffers[3]), p); + break; + case detail::OccupancyKernel::volumes: + occupancy_volumes<<>>(static_cast(buffers[0]), + static_cast(buffers[1]), p); + break; + case detail::OccupancyKernel::concentration: + occupancy_concentration<<>>(static_cast(buffers[0]), + static_cast(buffers[1]), + static_cast(buffers[2]), p); + break; + case detail::OccupancyKernel::face: + occupancy_face<<>>(static_cast(buffers[0]), + static_cast(buffers[1]), p); + break; + case detail::OccupancyKernel::product: + occupancy_product<<>>(static_cast(buffers[0]), + static_cast(buffers[1]), + static_cast(buffers[2]), p); + break; + case detail::OccupancyKernel::sum: + occupancy_sum<<>>(static_cast(buffers[0]), + static_cast(buffers[1]), p); + break; + case detail::OccupancyKernel::normalize: + occupancy_normalize<<>>(static_cast(buffers[0]), + static_cast(buffers[1]), + static_cast(buffers[2]), p); + break; + case detail::OccupancyKernel::labels_init: + occupancy_labels_init<<>>(static_cast(buffers[0]), + static_cast(buffers[1]), + static_cast(buffers[2]), p); + break; + case detail::OccupancyKernel::labels_step: + occupancy_labels_step<<>>( + static_cast(buffers[0]), static_cast(buffers[1]), + static_cast(buffers[2]), static_cast(buffers[3]), p); + break; + case detail::OccupancyKernel::component_sums: + occupancy_component_sums<<>>( + static_cast(buffers[0]), static_cast(buffers[1]), + static_cast(buffers[2]), static_cast(buffers[3]), + static_cast(buffers[4]), p); + break; + case detail::OccupancyKernel::remap: + occupancy_remap<<>>( + static_cast(buffers[0]), static_cast(buffers[1]), + static_cast(buffers[2]), static_cast(buffers[3]), + static_cast(buffers[4]), p); + break; + default: + launch_occupancy_transport(kernel, p, buffers, blocks, stream); + break; + } +} +} // namespace cm::cuda diff --git a/cpp/cuda/kernels/occupancy.cuh b/cpp/cuda/kernels/occupancy.cuh new file mode 100644 index 0000000..4988c15 --- /dev/null +++ b/cpp/cuda/kernels/occupancy.cuh @@ -0,0 +1,9 @@ +#pragma once +#include + +#include "core/occupancy_device.hpp" + +namespace cm::cuda { +void launch_occupancy(detail::OccupancyKernel kernel, const detail::OccupancyParameters& parameters, + void* const* buffers, cudaStream_t stream); +} diff --git a/cpp/cuda/kernels/signals.cu b/cpp/cuda/kernels/signals.cu index d89a79b..b3aa638 100644 --- a/cpp/cuda/kernels/signals.cu +++ b/cpp/cuda/kernels/signals.cu @@ -10,50 +10,10 @@ struct TransportPoint { float diagonal; }; -__device__ TransportPoint transport_point( - const float* levels, const float* diffusion, const float4* advection, const float* fixed_values, - const float* reaction_source, const float* reaction_loss, const std::uint8_t* obstacles, - const float* x_faces, const float* y_faces, const float* z_faces, - std::uint32_t has_velocity_field, SignalGridBoundariesGpu boundaries, SignalGridShapeGpu shape, - float4 spacing, std::uint32_t signal_count, std::uint32_t index) { - const auto signal = index / shape.sites; - const auto site = index - signal * shape.sites; - const auto x = site / (shape.y * shape.z); - const auto yz = site - x * shape.y * shape.z; - const auto y = yz / shape.z; - const auto z = yz - y * shape.z; - if (obstacles[site] != 0) { - return {.rate = 0.0F, .diagonal = 0.0F}; - } - const auto current = levels[index]; - - float lower[3]; - float upper[3]; - lower[0] = x == 0 ? exterior_value(boundaries.x_lower, fixed_values, 0, signal, signal_count, - current, grid_level(levels, shape, signal, shape.x - 1, y, z)) - : grid_level(levels, shape, signal, x - 1, y, z); - upper[0] = x + 1 == shape.x - ? exterior_value(boundaries.x_upper, fixed_values, 1, signal, signal_count, - current, grid_level(levels, shape, signal, 0, y, z)) - : grid_level(levels, shape, signal, x + 1, y, z); - lower[1] = y == 0 ? exterior_value(boundaries.y_lower, fixed_values, 2, signal, signal_count, - current, grid_level(levels, shape, signal, x, shape.y - 1, z)) - : grid_level(levels, shape, signal, x, y - 1, z); - upper[1] = y + 1 == shape.y - ? exterior_value(boundaries.y_upper, fixed_values, 3, signal, signal_count, - current, grid_level(levels, shape, signal, x, 0, z)) - : grid_level(levels, shape, signal, x, y + 1, z); - lower[2] = z == 0 ? exterior_value(boundaries.z_lower, fixed_values, 4, signal, signal_count, - current, grid_level(levels, shape, signal, x, y, shape.z - 1)) - : grid_level(levels, shape, signal, x, y, z - 1); - upper[2] = z + 1 == shape.z - ? exterior_value(boundaries.z_upper, fixed_values, 5, signal, signal_count, - current, grid_level(levels, shape, signal, x, y, 0)) - : grid_level(levels, shape, signal, x, y, z + 1); - - const std::uint32_t dimensions[3]{shape.x, shape.y, shape.z}; - const auto faces = grid_face_state(shape, boundaries, obstacles, x_faces, y_faces, z_faces, - has_velocity_field, advection[signal], x, y, z); +__device__ TransportPoint signal_transport_stencil(float diffusion_value, float current, + const GridFaceState& faces, + const std::uint32_t* dimensions, float4 spacing, + float* lower, float* upper) { const bool* closed_lower = faces.closed_lower; const bool* closed_upper = faces.closed_upper; const float* face_lower = faces.lower; @@ -61,46 +21,114 @@ __device__ TransportPoint transport_point( const float grid_spacing[3]{spacing.x, spacing.y, spacing.z}; float rate = 0.0F; float diagonal = 0.0F; + for (std::uint32_t axis = 0; axis < 3; ++axis) { if (dimensions[axis] == 1) { continue; } + if (closed_lower[axis]) { lower[axis] = current; } + if (closed_upper[axis]) { upper[axis] = current; } + const auto inverse_spacing = 1.0F / grid_spacing[axis]; - const auto diffusion_scale = diffusion[signal] * inverse_spacing * inverse_spacing; + const auto diffusion_scale = diffusion_value * inverse_spacing * inverse_spacing; rate += diffusion_scale * (lower[axis] - 2.0F * current + upper[axis]); diagonal -= 2.0F * diffusion_scale; + if (closed_lower[axis]) { diagonal += diffusion_scale; } + if (closed_upper[axis]) { diagonal += diffusion_scale; } + auto lower_flux = face_lower[axis] >= 0.0F ? face_lower[axis] * lower[axis] : face_lower[axis] * current; auto upper_flux = face_upper[axis] >= 0.0F ? face_upper[axis] * current : face_upper[axis] * upper[axis]; + if (closed_lower[axis]) { lower_flux = 0.0F; } + if (closed_upper[axis]) { upper_flux = 0.0F; } + rate -= (upper_flux - lower_flux) * inverse_spacing; + if (!closed_upper[axis] && face_upper[axis] > 0.0F) { diagonal -= face_upper[axis] * inverse_spacing; } + if (!closed_lower[axis] && face_lower[axis] < 0.0F) { diagonal += face_lower[axis] * inverse_spacing; } } + + return {rate, diagonal}; +} + +__device__ TransportPoint transport_point( + const float* levels, const float* diffusion, const float4* advection, const float* fixed_values, + const float* reaction_source, const float* reaction_loss, const std::uint8_t* obstacles, + const float* x_faces, const float* y_faces, const float* z_faces, + std::uint32_t has_velocity_field, SignalGridBoundariesGpu boundaries, SignalGridShapeGpu shape, + float4 spacing, std::uint32_t signal_count, std::uint32_t index) { + const auto signal = index / shape.sites; + const auto site = index - signal * shape.sites; + const auto x = site / (shape.y * shape.z); + const auto yz = site - x * shape.y * shape.z; + const auto y = yz / shape.z; + const auto z = yz - y * shape.z; + + if (obstacles[site] != 0) { + return {.rate = 0.0F, .diagonal = 0.0F}; + } + + const auto current = levels[index]; + + float lower[3]; + float upper[3]; + lower[0] = x == 0 ? exterior_value(boundaries.x_lower, fixed_values, 0, signal, signal_count, + current, grid_level(levels, shape, signal, shape.x - 1, y, z)) + : grid_level(levels, shape, signal, x - 1, y, z); + upper[0] = x + 1 == shape.x + ? exterior_value(boundaries.x_upper, fixed_values, 1, signal, signal_count, + current, grid_level(levels, shape, signal, 0, y, z)) + : grid_level(levels, shape, signal, x + 1, y, z); + lower[1] = y == 0 ? exterior_value(boundaries.y_lower, fixed_values, 2, signal, signal_count, + current, grid_level(levels, shape, signal, x, shape.y - 1, z)) + : grid_level(levels, shape, signal, x, y - 1, z); + upper[1] = y + 1 == shape.y + ? exterior_value(boundaries.y_upper, fixed_values, 3, signal, signal_count, + current, grid_level(levels, shape, signal, x, 0, z)) + : grid_level(levels, shape, signal, x, y + 1, z); + lower[2] = z == 0 ? exterior_value(boundaries.z_lower, fixed_values, 4, signal, signal_count, + current, grid_level(levels, shape, signal, x, y, shape.z - 1)) + : grid_level(levels, shape, signal, x, y, z - 1); + upper[2] = z + 1 == shape.z + ? exterior_value(boundaries.z_upper, fixed_values, 5, signal, signal_count, + current, grid_level(levels, shape, signal, x, y, 0)) + : grid_level(levels, shape, signal, x, y, z + 1); + + const std::uint32_t dimensions[3]{shape.x, shape.y, shape.z}; + const auto faces = grid_face_state(shape, boundaries, obstacles, x_faces, y_faces, z_faces, + has_velocity_field, advection[signal], x, y, z); + const auto transport = signal_transport_stencil(diffusion[signal], current, faces, dimensions, + spacing, lower, upper); + float rate = transport.rate; + float diagonal = transport.diagonal; + rate += reaction_source[index] - reaction_loss[index] * current; diagonal -= reaction_loss[index]; + return {.rate = rate, .diagonal = diagonal}; } @@ -114,6 +142,7 @@ __global__ void advance_signal_grid(const float* levels, float* output, const fl float4 spacing, float dt, std::uint32_t signal_count, std::uint32_t level_count, std::uint32_t crank_nicolson) { const auto index = (blockIdx.x * blockDim.x) + threadIdx.x; + if (index >= level_count) { return; } @@ -125,6 +154,7 @@ __global__ void advance_signal_grid(const float* levels, float* output, const fl const auto scale = crank_nicolson == 0 ? dt : (crank_nicolson == 1 ? 0.5F * dt : 0.0F); const auto candidate = levels[index] + scale * transport.rate; output[index] = candidate; + if (!isfinite(candidate) || (!crank_nicolson && candidate < 0.0F)) { atomicOr(error, 1U); } @@ -132,6 +162,7 @@ __global__ void advance_signal_grid(const float* levels, float* output, const fl __global__ void signal_square_terms(const float* input, float* terms, std::uint32_t level_count) { const auto index = (blockIdx.x * blockDim.x) + threadIdx.x; + if (index < level_count) { terms[index] = input[index] * input[index]; } @@ -145,9 +176,11 @@ __global__ void signal_crank_nicolson_jacobi( std::uint32_t* error, SignalGridBoundariesGpu boundaries, SignalGridShapeGpu shape, float4 spacing, float half_dt, std::uint32_t signal_count, std::uint32_t level_count) { const auto index = (blockIdx.x * blockDim.x) + threadIdx.x; + if (index >= level_count) { return; } + const auto transport = transport_point(current, diffusion, advection, fixed_values, reaction_source, reaction_loss, obstacles, x_faces, y_faces, z_faces, has_velocity_field, boundaries, shape, @@ -156,6 +189,7 @@ __global__ void signal_crank_nicolson_jacobi( const auto candidate = (right_hand_side[index] + half_dt * remainder) / (1.0F - half_dt * transport.diagonal); output[index] = candidate; + if (!isfinite(candidate)) { atomicOr(error, 1U); } @@ -169,9 +203,11 @@ __global__ void signal_crank_nicolson_residual_terms( SignalGridBoundariesGpu boundaries, SignalGridShapeGpu shape, float4 spacing, float half_dt, std::uint32_t signal_count, std::uint32_t level_count) { const auto index = (blockIdx.x * blockDim.x) + threadIdx.x; + if (index >= level_count) { return; } + const auto transport = transport_point(current, diffusion, advection, fixed_values, reaction_source, reaction_loss, obstacles, x_faces, y_faces, z_faces, has_velocity_field, boundaries, shape, diff --git a/cpp/cuda/kernels/signals.cuh b/cpp/cuda/kernels/signals.cuh index 801d0b2..9988623 100644 --- a/cpp/cuda/kernels/signals.cuh +++ b/cpp/cuda/kernels/signals.cuh @@ -43,9 +43,11 @@ __device__ inline float exterior_value(std::uint32_t kind, const float* fixed_va if (kind == 0) { return current; } + if (kind == 1) { return periodic; } + return fixed_values[face * signal_count + signal]; } @@ -59,13 +61,10 @@ struct GridFaceState { float upper[3]; }; -__device__ inline GridFaceState grid_face_state(SignalGridShapeGpu shape, - SignalGridBoundariesGpu boundaries, - const std::uint8_t* obstacles, const float* x_faces, - const float* y_faces, const float* z_faces, - std::uint32_t has_velocity_field, float4 advection, - std::uint32_t x, std::uint32_t y, std::uint32_t z) { - GridFaceState faces{}; +__device__ inline void close_grid_x_faces(GridFaceState& faces, SignalGridShapeGpu shape, + SignalGridBoundariesGpu boundaries, + const std::uint8_t* obstacles, std::uint32_t x, + std::uint32_t y, std::uint32_t z) { faces.closed_lower[0] = x == 0 ? (boundaries.x_lower == 0 || (boundaries.x_lower == 1 && obstacles[site_index(shape, shape.x - 1, y, z)] != 0)) @@ -74,6 +73,12 @@ __device__ inline GridFaceState grid_face_state(SignalGridShapeGpu shape, x + 1 == shape.x ? (boundaries.x_upper == 0 || (boundaries.x_upper == 1 && obstacles[site_index(shape, 0, y, z)] != 0)) : obstacles[site_index(shape, x + 1, y, z)] != 0; +} + +__device__ inline void close_grid_y_faces(GridFaceState& faces, SignalGridShapeGpu shape, + SignalGridBoundariesGpu boundaries, + const std::uint8_t* obstacles, std::uint32_t x, + std::uint32_t y, std::uint32_t z) { faces.closed_lower[1] = y == 0 ? (boundaries.y_lower == 0 || (boundaries.y_lower == 1 && obstacles[site_index(shape, x, shape.y - 1, z)] != 0)) @@ -82,6 +87,12 @@ __device__ inline GridFaceState grid_face_state(SignalGridShapeGpu shape, y + 1 == shape.y ? (boundaries.y_upper == 0 || (boundaries.y_upper == 1 && obstacles[site_index(shape, x, 0, z)] != 0)) : obstacles[site_index(shape, x, y + 1, z)] != 0; +} + +__device__ inline void close_grid_z_faces(GridFaceState& faces, SignalGridShapeGpu shape, + SignalGridBoundariesGpu boundaries, + const std::uint8_t* obstacles, std::uint32_t x, + std::uint32_t y, std::uint32_t z) { faces.closed_lower[2] = z == 0 ? (boundaries.z_lower == 0 || (boundaries.z_lower == 1 && obstacles[site_index(shape, x, y, shape.z - 1)] != 0)) @@ -90,6 +101,19 @@ __device__ inline GridFaceState grid_face_state(SignalGridShapeGpu shape, z + 1 == shape.z ? (boundaries.z_upper == 0 || (boundaries.z_upper == 1 && obstacles[site_index(shape, x, y, 0)] != 0)) : obstacles[site_index(shape, x, y, z + 1)] != 0; +} + +__device__ inline GridFaceState grid_face_state(SignalGridShapeGpu shape, + SignalGridBoundariesGpu boundaries, + const std::uint8_t* obstacles, const float* x_faces, + const float* y_faces, const float* z_faces, + std::uint32_t has_velocity_field, float4 advection, + std::uint32_t x, std::uint32_t y, std::uint32_t z) { + GridFaceState faces{}; + close_grid_x_faces(faces, shape, boundaries, obstacles, x, y, z); + close_grid_y_faces(faces, shape, boundaries, obstacles, x, y, z); + close_grid_z_faces(faces, shape, boundaries, obstacles, x, y, z); + if (has_velocity_field != 0) { faces.lower[0] = x_faces[x * shape.y * shape.z + y * shape.z + z]; faces.upper[0] = x_faces[(x + 1) * shape.y * shape.z + y * shape.z + z]; @@ -99,11 +123,13 @@ __device__ inline GridFaceState grid_face_state(SignalGridShapeGpu shape, faces.upper[2] = z_faces[x * shape.y * (shape.z + 1) + y * (shape.z + 1) + z + 1]; } else { const float velocity[3]{advection.x, advection.y, advection.z}; + for (std::uint32_t axis = 0; axis < 3; ++axis) { faces.lower[axis] = velocity[axis]; faces.upper[axis] = velocity[axis]; } } + return faces; } diff --git a/cpp/cuda/kernels/species.cu b/cpp/cuda/kernels/species.cu index afbce91..9e5a091 100644 --- a/cpp/cuda/kernels/species.cu +++ b/cpp/cuda/kernels/species.cu @@ -14,6 +14,54 @@ __device__ float effective_surface_area(float length, float radius) { return 2.0F * pi * radius * (length + 2.0F * radius); } +__device__ float evaluate_predicate(const RateInstructionGpu& instruction, const float* workspace) { + switch (instruction.operation) { + case 21: + return workspace[instruction.first] < workspace[instruction.second] ? 1.0F : 0.0F; + case 22: + return workspace[instruction.first] <= workspace[instruction.second] ? 1.0F : 0.0F; + case 23: + return workspace[instruction.first] > workspace[instruction.second] ? 1.0F : 0.0F; + case 24: + return workspace[instruction.first] >= workspace[instruction.second] ? 1.0F : 0.0F; + case 25: + return workspace[instruction.first] == workspace[instruction.second] ? 1.0F : 0.0F; + case 26: + return workspace[instruction.first] != 0.0F ? workspace[instruction.second] + : workspace[instruction.third]; + default: + return nanf(""); + } +} + +__device__ float evaluate_arithmetic(const RateInstructionGpu& instruction, + const float* workspace) { + switch (instruction.operation) { + case 11: + return workspace[instruction.first] + workspace[instruction.second]; + case 12: + return workspace[instruction.first] - workspace[instruction.second]; + case 13: + return workspace[instruction.first] * workspace[instruction.second]; + case 14: + return workspace[instruction.first] / workspace[instruction.second]; + case 15: + return powf(workspace[instruction.first], workspace[instruction.second]); + case 16: + return fminf(workspace[instruction.first], workspace[instruction.second]); + case 17: + return fmaxf(workspace[instruction.first], workspace[instruction.second]); + case 18: + return -workspace[instruction.first]; + case 19: + return expf(workspace[instruction.first]); + case 20: + return logf(workspace[instruction.first]); + default: + return evaluate_predicate(instruction, workspace); + } +} + __device__ float evaluate_instruction(const RateInstructionGpu& instruction, const float* workspace, const float* species, float4 center, float4 geometry, float growth_rate, std::int32_t cell_type, @@ -40,44 +88,11 @@ __device__ float evaluate_instruction(const RateInstructionGpu& instruction, con case 28: return volume_change_rate; case 9: - return effective_volume(geometry.x, geometry.y); + return geometry.z > 0 ? geometry.z : effective_volume(geometry.x, geometry.y); case 10: return effective_surface_area(geometry.x, geometry.y); - case 11: - return workspace[instruction.first] + workspace[instruction.second]; - case 12: - return workspace[instruction.first] - workspace[instruction.second]; - case 13: - return workspace[instruction.first] * workspace[instruction.second]; - case 14: - return workspace[instruction.first] / workspace[instruction.second]; - case 15: - return powf(workspace[instruction.first], workspace[instruction.second]); - case 16: - return fminf(workspace[instruction.first], workspace[instruction.second]); - case 17: - return fmaxf(workspace[instruction.first], workspace[instruction.second]); - case 18: - return -workspace[instruction.first]; - case 19: - return expf(workspace[instruction.first]); - case 20: - return logf(workspace[instruction.first]); - case 21: - return workspace[instruction.first] < workspace[instruction.second] ? 1.0F : 0.0F; - case 22: - return workspace[instruction.first] <= workspace[instruction.second] ? 1.0F : 0.0F; - case 23: - return workspace[instruction.first] > workspace[instruction.second] ? 1.0F : 0.0F; - case 24: - return workspace[instruction.first] >= workspace[instruction.second] ? 1.0F : 0.0F; - case 25: - return workspace[instruction.first] == workspace[instruction.second] ? 1.0F : 0.0F; - case 26: - return workspace[instruction.first] != 0.0F ? workspace[instruction.second] - : workspace[instruction.third]; default: - return nanf(""); + return evaluate_arithmetic(instruction, workspace); } } @@ -89,14 +104,19 @@ __global__ void advance_species(float* levels, const float* previous_lengths, co std::uint32_t* error, float dt, std::uint32_t species_count, std::uint32_t instruction_count, std::uint32_t cell_count) { const auto cell = blockIdx.x * blockDim.x + threadIdx.x; + if (cell >= cell_count) { return; } const auto species_offset = cell * species_count; const auto radius = geometry[cell].y; - const auto dilution = - effective_volume(previous_lengths[cell], radius) / effective_volume(geometry[cell].x, radius); + const auto previous_volume = + geometry[cell].z > 0 ? geometry[cell].w : effective_volume(previous_lengths[cell], radius); + const auto current_volume = + geometry[cell].z > 0 ? geometry[cell].z : effective_volume(geometry[cell].x, radius); + const auto dilution = previous_volume / current_volume; + for (std::uint32_t species = 0; species < species_count; ++species) { levels[species_offset + species] *= dilution; } @@ -104,15 +124,14 @@ __global__ void advance_species(float* levels, const float* previous_lengths, co const auto workspace_offset = cell * instruction_count; auto* cell_workspace = workspace + workspace_offset; const auto* cell_species = levels + species_offset; + for (std::uint32_t index = 0; index < instruction_count; ++index) { const auto value = evaluate_instruction(instructions[index], cell_workspace, cell_species, centers[cell], geometry[cell], growth_rates[cell], cell_types[cell], - dt == 0.0f ? 0.0f - : (effective_volume(geometry[cell].x, radius) - - effective_volume(previous_lengths[cell], radius)) / - dt); + dt == 0.0f ? 0.0f : (current_volume - previous_volume) / dt); cell_workspace[index] = value; + if (!isfinite(value)) { atomicOr(error, 1U); } @@ -121,6 +140,7 @@ __global__ void advance_species(float* levels, const float* previous_lengths, co for (std::uint32_t species = 0; species < species_count; ++species) { const auto value = levels[species_offset + species] + dt * cell_workspace[outputs[species]]; levels[species_offset + species] = value; + if (!isfinite(value)) { atomicOr(error, 1U); } diff --git a/cpp/include/cm/backend.hpp b/cpp/include/cm/backend.hpp index 38ac0a7..532ac59 100644 --- a/cpp/include/cm/backend.hpp +++ b/cpp/include/cm/backend.hpp @@ -25,7 +25,8 @@ class ComputeBackend { [[nodiscard]] virtual bool supports(BackendFeature feature) const noexcept = 0; virtual void advance_growth(WorldState& state, float dt) = 0; virtual void advance_species(WorldState& state, const SpeciesRatePlan& plan, - std::span previous_lengths, float dt) = 0; + std::span previous_lengths, float dt, + BiochemicalVolumeView volumes = {}) = 0; [[nodiscard]] virtual SignalSolveReport advance_signal_grid(SignalGrid& grid, float dt) = 0; [[nodiscard]] virtual SignalSolveReport advance_coupled(WorldState& state, SignalGrid& grid, const CoupledRatePlan& plan, diff --git a/cpp/include/cm/checkpoint.hpp b/cpp/include/cm/checkpoint.hpp index 39b78bd..9cd2be3 100644 --- a/cpp/include/cm/checkpoint.hpp +++ b/cpp/include/cm/checkpoint.hpp @@ -5,13 +5,14 @@ #include "cm/constraints.hpp" #include "cm/coupled_rates.hpp" +#include "cm/culture_simulation.hpp" #include "cm/signals.hpp" #include "cm/species.hpp" #include "cm/world_state.hpp" namespace cm { -inline constexpr std::uint32_t checkpoint_schema_version = 4; +inline constexpr std::uint32_t checkpoint_schema_version = 5; struct SimulationCheckpoint { std::uint32_t schema_version{checkpoint_schema_version}; @@ -21,6 +22,7 @@ struct SimulationCheckpoint { SpeciesRatePlan species_rate_plan; std::optional signal_grid; std::optional coupled_rate_plan; + std::optional culture; void validate() const; }; diff --git a/cpp/include/cm/culture_simulation.hpp b/cpp/include/cm/culture_simulation.hpp new file mode 100644 index 0000000..2dd8ab2 --- /dev/null +++ b/cpp/include/cm/culture_simulation.hpp @@ -0,0 +1,71 @@ +#pragma once + +#include "cm/growth.hpp" +#include "cm/solute_transport.hpp" +#include "cm/world_state.hpp" + +namespace cm { +struct CellSurfaceExchange { + CellId body_id{0}; + std::uint32_t solute{0}, species{0}; + double uptake_velocity{0}, secretion_rate{0}; +}; + +struct ReserveRequirement { + std::uint32_t species{0}; + double amount_per_biomass{0}; +}; + +struct CultureEvent { + double time{0}; // model time, strictly increasing + std::vector ports; + std::vector reservoirs; +}; + +struct CultureConfiguration { + FluidGridSpec grid; + FluidProperties fluid; + std::vector ports; + std::vector solutes; + std::vector reservoirs; + std::vector exchange; + std::vector biomass_requirements; + double biomass_per_geometric_volume{1}; + LinearSolveParameters solver; + FluidBodyStepParameters stepping; + FluidGeometryParameters geometry; + std::uint32_t maximum_substeps{1024}, maximum_retries{16}; + std::vector growth; + std::vector events; + std::string authoring_json; // closed, data-only authoring provenance + double coupling_tolerance{2e-6}; + std::uint32_t maximum_coupling_iterations{64}; + void validate(std::size_t species_count) const; +}; + +struct CultureCellState { + CapsuleBody body; + double biochemical_volume{0}; + std::vector species_amounts; + std::vector uptake_totals; + double realized_specific_rate{0}; + double biomass_produced{0}; +}; + +struct CultureReport { + std::uint32_t substeps{0}, retries{0}; + FluidSolveReport flow; + SoluteTransportReport transport; +}; + +struct CultureCheckpoint { + CultureConfiguration configuration; + std::vector cells; + std::vector extracellular_amounts; + std::vector reservoir_totals; + CultureReport last_report; + double time{0}; + std::uint32_t event_index{0}; + void validate(const WorldStateCheckpoint&) const; +}; +} // namespace cm diff --git a/cpp/include/cm/flow.hpp b/cpp/include/cm/flow.hpp index 65347e1..59edf8b 100644 --- a/cpp/include/cm/flow.hpp +++ b/cpp/include/cm/flow.hpp @@ -7,12 +7,6 @@ namespace cm { -enum class FlowAxis : std::uint8_t { - x, - y, - z, -}; - struct DepthAveragedFlowParameters { float mean_inlet_speed{1.0F}; FlowAxis axis{FlowAxis::y}; diff --git a/cpp/include/cm/fluid_geometry.hpp b/cpp/include/cm/fluid_geometry.hpp new file mode 100644 index 0000000..6ce2c58 --- /dev/null +++ b/cpp/include/cm/fluid_geometry.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include +#include + +#include "cm/stokes_flow.hpp" + +namespace cm { +inline constexpr std::uint32_t fluid_boundary = std::numeric_limits::max(); + +struct FluidGeometryParameters { + std::uint32_t surface_resolution{32}; + double maximum_surface_error_fraction{0.05}; + std::uint64_t memory_limit_bytes{1024ULL * 1024 * 1024}; + void validate() const; +}; + +struct FluidFragment { + std::uint32_t site{0}, component{0}; + double volume{0}; + std::array centroid{}; +}; + +struct FluidFace { + std::uint32_t first{0}, second{fluid_boundary}; + double area{0}; + std::array centroid{}, normal{}; // normal out of first + // Nonzero for a cell membrane, zero for a grid wall or open device boundary. + CellId body_id{0}; + // Grid faces use the MAC axis/index. Internal partition and membrane faces + // have grid_face == fluid_boundary. + FlowAxis axis{FlowAxis::x}; + std::uint32_t grid_face{fluid_boundary}; +}; + +struct FluidGeometryReport { + double fluid_volume{0}, expected_fluid_volume{0}, volume_error{0}; + double maximum_surface_error{0}; + std::uint32_t component_count{0}; + std::uint64_t estimated_memory_bytes{0}; +}; + +struct FluidOverlap { + std::uint32_t first{0}, second{0}; + double volume{0}; +}; + +// Immutable extracellular polyhedral mesh. Volumes, areas and coordinates use +// model units. Convex pieces retain their internal faces, so disconnected fluid +// fragments within a voxel can never be mixed merely by sharing a voxel index. +class FluidGeometry { + public: + FluidGeometry(const FluidGridSpec& grid, const std::vector& bodies, + const FluidGeometryParameters& parameters = {}); + [[nodiscard]] const FluidGridSpec& grid() const noexcept; + [[nodiscard]] const std::vector& bodies() const noexcept; + [[nodiscard]] const std::vector& fragments() const noexcept; + [[nodiscard]] const std::vector& faces() const noexcept; + [[nodiscard]] const FluidGeometryReport& report() const noexcept; + [[nodiscard]] std::vector overlaps(const FluidGeometry& other) const; + + private: + struct Impl; + std::shared_ptr impl_; +}; +} // namespace cm diff --git a/cpp/include/cm/grid.hpp b/cpp/include/cm/grid.hpp new file mode 100644 index 0000000..2a8c209 --- /dev/null +++ b/cpp/include/cm/grid.hpp @@ -0,0 +1,23 @@ +#pragma once +#include +#include + +namespace cm { +enum class FlowAxis : std::uint8_t { + x, + y, + z, +}; + +struct GridShape { + std::uint32_t x{1}; + std::uint32_t y{1}; + std::uint32_t z{1}; +}; + +struct MacVelocityField { + std::vector x_faces; + std::vector y_faces; + std::vector z_faces; +}; +} // namespace cm diff --git a/cpp/include/cm/growth.hpp b/cpp/include/cm/growth.hpp new file mode 100644 index 0000000..4c189f1 --- /dev/null +++ b/cpp/include/cm/growth.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include +#include + +#include "cm/types.hpp" + +namespace cm { +enum class GrowthKind : std::uint8_t { monod, essential }; + +struct GrowthRequirement { + std::uint32_t solute{0}; + double half_saturation{1}; // amount / model volume + double biomass_yield{1}; // grams / amount +}; + +struct CellGrowthModel { + CellId cell_id{0}; + GrowthKind kind{GrowthKind::monod}; + double mu_max{0}; // 1 / model time + double biomass_density{1}; // grams / biochemical model volume + double volume_ratio{1}; // biochemical / geometric volume + std::vector requirements; + void validate(std::size_t solute_count) const; +}; + +struct GrowthInput { + double biochemical_volume{0}, surface_area{0}; + std::vector concentrations, uptake; // requirement order, canonical units +}; + +struct GrowthEvaluation { + std::vector uptake_velocities; + double biomass_gain{0}, biochemical_volume_gain{0}, geometric_volume_gain{0}; + double specific_rate{0}, stoichiometric_residual{0}; +}; + +class GrowthExecutor { + public: + explicit GrowthExecutor(BackendKind = BackendKind::cpu, std::uint32_t device_index = 0); + ~GrowthExecutor(); + std::vector evaluate(const std::vector&, + const std::vector&, double dt); + + private: + struct Impl; + std::unique_ptr impl_; +}; +} // namespace cm diff --git a/cpp/include/cm/numerics.hpp b/cpp/include/cm/numerics.hpp new file mode 100644 index 0000000..f2961ce --- /dev/null +++ b/cpp/include/cm/numerics.hpp @@ -0,0 +1,13 @@ +#pragma once +#include + +namespace cm { +struct LinearSolveParameters { + double relative_tolerance{1.0e-5}; + double absolute_tolerance{1.0e-7}; + std::uint32_t max_iterations{5000}; + std::uint64_t memory_limit_bytes{1024ULL * 1024 * 1024}; + void validate() const; +}; + +} // namespace cm diff --git a/cpp/include/cm/occupancy.hpp b/cpp/include/cm/occupancy.hpp new file mode 100644 index 0000000..1626638 --- /dev/null +++ b/cpp/include/cm/occupancy.hpp @@ -0,0 +1,93 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "cm/types.hpp" + +namespace cm { + +struct OccupancyCapsule { + std::array center; + std::array direction; + float length; + float radius; +}; + +struct OccupancyFace { + std::uint32_t first; + std::uint32_t second; + float conductance; + float volume_flux{0}; +}; + +struct OccupancyReservoir { + std::uint32_t site; + float concentration; + float conductance{0}; + float volume_flux{0}; +}; + +struct OccupancyBalance { + double before{0}, after{0}, source{0}, reaction{0}, boundary{0}; + + [[nodiscard]] double residual() const noexcept { + return after - before - source - reaction - boundary; + } +}; + +struct OccupancyStep { + std::vector amount; + OccupancyBalance balance; + std::uint32_t iterations{0}; + double relative_residual{0}; +}; + +namespace detail { +class OccupancyDevice; +} + +// Standalone native counterpart of ADR 0025's reference. This object does not +// change Simulation, controller staging, flow interpretation, or checkpoints. +// Every operation returns a candidate; caller-owned inputs are never modified. +class OccupancySolver { + public: + explicit OccupancySolver(BackendKind backend, std::uint32_t device_index = 0, + float epsilon_cutoff = 1e-8F); + ~OccupancySolver(); + OccupancySolver(const OccupancySolver&) = delete; + OccupancySolver& operator=(const OccupancySolver&) = delete; + + [[nodiscard]] std::vector geometric_porosity( + const std::vector>& centers, std::array spacing, + const std::vector& cells, std::uint32_t subdivisions = 8, + const std::vector& walls = {}); + [[nodiscard]] std::vector accessible_volumes(const std::vector& porosity, + float voxel_volume); + [[nodiscard]] std::vector concentration(const std::vector& amount, + const std::vector& volume); + [[nodiscard]] OccupancyFace porosity_face(std::uint32_t first, std::uint32_t second, + float epsilon_first, float epsilon_second, + float diffusion, float area, float distance, + float intrinsic_velocity = 0); + [[nodiscard]] std::vector remap_amounts( + const std::vector& amount, const std::vector& old_volume, + const std::vector& new_volume, + const std::vector>& neighbors); + [[nodiscard]] std::vector exchange_weights(const std::vector& kernel, + const std::vector& volume); + [[nodiscard]] OccupancyStep backward_euler( + const std::vector& amount, const std::vector& volume, + const std::vector& faces, float dt, const std::vector& source = {}, + const std::vector& loss = {}, const std::vector& reservoirs = {}, + std::uint32_t max_iterations = 20000, float relative_tolerance = 1e-7F); + + private: + std::unique_ptr device_; + float cutoff_; +}; + +} // namespace cm diff --git a/cpp/include/cm/signals.hpp b/cpp/include/cm/signals.hpp index d65abd1..9ea602a 100644 --- a/cpp/include/cm/signals.hpp +++ b/cpp/include/cm/signals.hpp @@ -7,6 +7,7 @@ #include #include +#include "cm/grid.hpp" #include "cm/types.hpp" namespace cm { @@ -49,12 +50,6 @@ struct GridBoundary { void validate(std::size_t signal_count) const; }; -struct GridShape { - std::uint32_t x{1}; - std::uint32_t y{1}; - std::uint32_t z{1}; -}; - struct SignalGridAffineReaction { std::vector source_rates; std::vector loss_rates; @@ -62,11 +57,7 @@ struct SignalGridAffineReaction { void validate(std::size_t level_count) const; }; -struct SignalGridVelocityField { - std::vector x_faces; - std::vector y_faces; - std::vector z_faces; -}; +using SignalGridVelocityField = MacVelocityField; struct SignalGridSpec { std::uint32_t signal_count{0}; diff --git a/cpp/include/cm/simulation.hpp b/cpp/include/cm/simulation.hpp index 00e8c55..a32871c 100644 --- a/cpp/include/cm/simulation.hpp +++ b/cpp/include/cm/simulation.hpp @@ -11,6 +11,9 @@ #include "cm/checkpoint.hpp" namespace cm { +namespace detail { +class CultureSimulation; +} class Simulation { public: @@ -18,6 +21,18 @@ class Simulation { std::size_t species_count = 0, std::uint32_t device_index = 0); Simulation(BackendKind backend, const SimulationCheckpoint& checkpoint, std::uint32_t device_index = 0); + ~Simulation(); + Simulation(Simulation&&) noexcept; + Simulation& operator=(Simulation&&) noexcept; + void restore_checkpoint(const SimulationCheckpoint&); + void configure_culture(const CultureConfiguration&, + const std::vector& concentrations = {}, + const std::vector& biochemical_volumes = {}); + [[nodiscard]] bool has_culture() const noexcept; + [[nodiscard]] std::optional culture_checkpoint() const; + [[nodiscard]] std::vector fluid_fragments() const; + void set_cell_force(CellId, std::array force_n, std::array torque_nm = {}); + [[nodiscard]] std::vector cell_surface_concentrations(CellId) const; [[nodiscard]] BackendInfo backend_info() const; [[nodiscard]] bool supports(BackendFeature feature) const noexcept; @@ -81,6 +96,8 @@ class Simulation { void validate() const; private: + void validate_step_backend(std::span previous_lengths, float dt) const; + WorldState state_; ConstraintSet constraints_; std::unique_ptr backend_; @@ -88,6 +105,7 @@ class Simulation { std::optional signal_grid_; std::optional last_signal_solve_report_; std::optional coupled_rate_plan_; + std::unique_ptr culture_; double time_{0.0}; }; diff --git a/cpp/include/cm/solute_transport.hpp b/cpp/include/cm/solute_transport.hpp new file mode 100644 index 0000000..5d660e0 --- /dev/null +++ b/cpp/include/cm/solute_transport.hpp @@ -0,0 +1,80 @@ +#pragma once + +#include "cm/transport_geometry.hpp" + +namespace cm { +struct Solute { + std::string name; + double diffusion{0}; // model length squared / model time + std::string amount_unit{"model"}; +}; +enum class ChemicalBoundaryKind : std::uint8_t { reservoir_contact, advective, outflow }; + +struct ChemicalBoundary { + std::string port; + std::vector concentrations; // amount / model volume + ChemicalBoundaryKind kind{ChemicalBoundaryKind::reservoir_contact}; + bool allow_backflow{true}; +}; + +struct SurfaceTransferLaw { + CellId body_id{0}; + std::uint32_t solute{0}; + double uptake_velocity{0}; // model length / model time + double secretion_rate{0}; // total amount / model time, distributed by area +}; + +struct ChemicalTransfer { + std::string port; + CellId body_id{0}; + // Reservoir: positive into fluid. Cell: positive into cell. + std::vector amounts; +}; + +struct SoluteTransportReport { + std::uint32_t projection_iterations{0}, transport_iterations{0}; + double maximum_volume_residual{0}; + std::vector mass_balance_error; +}; + +struct SurfaceEnvironment { + CellId body_id{0}; + double area{0}; + std::vector concentrations; +}; + +struct SoluteTransportResult { + // Fragment-major, solute-minor. Amounts are authoritative. + std::vector amounts, concentrations; + std::vector reservoirs, cells; + SoluteTransportReport report; + std::vector surfaces; +}; + +class SoluteTransportSolver { + public: + explicit SoluteTransportSolver(BackendKind backend = BackendKind::cpu, + std::uint32_t device_index = 0); + ~SoluteTransportSolver(); + SoluteTransportSolver(const SoluteTransportSolver&) = delete; + SoluteTransportSolver& operator=(const SoluteTransportSolver&) = delete; + [[nodiscard]] SoluteTransportResult step(const FluidGeometry& before, const FluidGeometry& after, + const MacVelocityField& velocity, + const std::vector& ports, + const std::vector& solutes, + const std::vector& reservoirs, + const std::vector& amounts, double dt, + const std::vector& exchange = {}, + const LinearSolveParameters& parameters = {}); + + [[nodiscard]] SoluteTransportResult propose(const TransportGeometry&, const std::vector&, + const std::vector&, + const std::vector& amounts, + const std::vector& exchange = {}, + const LinearSolveParameters& parameters = {}); + + private: + struct Impl; + std::unique_ptr impl_; +}; +} // namespace cm diff --git a/cpp/include/cm/species.hpp b/cpp/include/cm/species.hpp index 1dc4a86..6f09a1b 100644 --- a/cpp/include/cm/species.hpp +++ b/cpp/include/cm/species.hpp @@ -73,7 +73,13 @@ class SpeciesRatePlan { [[nodiscard]] float effective_cell_volume(float length, float radius) noexcept; [[nodiscard]] float effective_cell_surface_area(float length, float radius) noexcept; +struct BiochemicalVolumeView { + std::span previous, current; + void validate(std::size_t cell_count) const; +}; + void advance_species_cpu(WorldState& state, const SpeciesRatePlan& plan, - std::span previous_lengths, float dt); + std::span previous_lengths, float dt, + BiochemicalVolumeView volumes = {}); } // namespace cm diff --git a/cpp/include/cm/stokes_flow.hpp b/cpp/include/cm/stokes_flow.hpp new file mode 100644 index 0000000..5437ead --- /dev/null +++ b/cpp/include/cm/stokes_flow.hpp @@ -0,0 +1,158 @@ +#pragma once +#include +#include +#include +#include +#include + +#include "cm/grid.hpp" +#include "cm/numerics.hpp" +#include "cm/types.hpp" + +namespace cm { + +// Coordinates use model lengths; time uses model time. Hydraulic boundary +// values, viscosity, density, pressures and flow rates use the SI units named +// below. A fluid grid has no dependency on the number of chemical signals. +struct FluidGridSpec { + GridShape shape; + Vec3 origin; + float spacing{1.0F}; + double length_unit_m{1.0e-6}; + double time_unit_s{1.0}; + std::vector obstacles; + void validate() const; + [[nodiscard]] std::size_t site_count() const; + + [[nodiscard]] bool solid_site(std::size_t i) const noexcept { + return !obstacles.empty() && obstacles[i] != 0; + } +}; + +struct FluidProperties { + double viscosity_pa_s{1.0e-3}; + double density_kg_m3{1000.0}; + void validate() const; +}; + +enum class FlowPortKind : std::uint8_t { pressure, flow_rate }; + +struct FlowPort { + std::string name; + FlowAxis axis{FlowAxis::y}; + bool upper{false}; + FlowPortKind kind{FlowPortKind::pressure}; + // Pressure in Pa or flow in m^3/s. Flow is positive OUT OF the domain. + double value{0.0}; + // Boundary-adjacent site indices, in x-major/z-minor order. Empty selects + // every non-wall site on this boundary. Each port must belong to one fluid + // component. Different ports may not own the same face. + std::vector sites; +}; + +struct FlowPortResult { + std::string name; + double pressure_pa{0}; + double flow_rate_m3_s{0}; + double area_m2{0}; +}; + +struct CapsuleBody { + CellId id{1}; + std::array position{0, 0, 0}; + // Unit quaternion (w,x,y,z); the local capsule axis is +x. + std::array orientation{1, 0, 0, 0}; + double length{0}, radius{1}; // model lengths, cylindrical length excludes caps + double length_rate{0}; // model length/model time, prescribed elongation + bool fixed{false}; + std::array force_n{0, 0, 0}, torque_nm{0, 0, 0}; + void validate() const; + [[nodiscard]] double geometric_volume() const; +}; + +struct FluidBodyResult { + CellId id{0}; + std::array velocity{0, 0, 0}; // model length/model time + std::array angular_velocity{0, 0, 0}; // radians/model time + std::array hydrodynamic_force_n{0, 0, 0}, hydrodynamic_torque_nm{0, 0, 0}; + double no_slip_rms_m_s{0}; + double volume_change_rate_m3_s{0}; + std::uint32_t marker_count{0}; +}; + +struct FluidSolveReport { + std::uint32_t iterations{0}; + double relative_residual{0}; + double absolute_residual{0}; + double divergence_rms_per_s{0}; + double continuity_rms_per_s{0}; + double source_volume_rate_m3_s{0}; + double max_speed_m_s{0}; + double reynolds_number{0}; + double viscous_relaxation_time_s{0}; + double net_flow_rate_m3_s{0}; + std::uint64_t estimated_memory_bytes{0}; +}; + +struct FluidFlowResult { + // Velocity in model length/model time, for compatibility with transport. + MacVelocityField field; + std::vector pressure_pa; + std::vector ports; + std::vector bodies; + FluidSolveReport report; +}; + +struct FluidBodyStepParameters { + double minimum_gap_m{1e-8}; + double maximum_displacement_fraction{0.25}; + std::uint32_t max_halvings{16}; + std::uint32_t max_contact_iterations{64}; + void validate() const; +}; + +struct FluidContactResult { + CellId first_id{0}, second_id{0}; // second_id zero denotes a device wall + std::array normal{0, 0, 0}, point_on_first{0, 0, 0}; + // normal points from first to second; force on first is -normal_force_n*normal. + double initial_gap_m{0}; + double normal_force_n{0}; +}; + +struct FluidBodyStep { + double accepted_dt{0}; // model time; may be smaller than maximum_dt + std::vector bodies; + FluidFlowResult flow; + std::vector contacts; + std::uint32_t halvings{0}, contact_iterations{0}; +}; + +// Standalone physical device-flow and rigid-body solve. Simulation mode and +// state changes are managed by the caller. +class StokesFlowSolver { + public: + explicit StokesFlowSolver(BackendKind backend = BackendKind::cpu, std::uint32_t device_index = 0); + ~StokesFlowSolver(); + StokesFlowSolver(const StokesFlowSolver&) = delete; + StokesFlowSolver& operator=(const StokesFlowSolver&) = delete; + [[nodiscard]] FluidFlowResult solve( + const FluidGridSpec& grid, const FluidProperties& fluid, const std::vector& ports, + const LinearSolveParameters& parameters = LinearSolveParameters{}); + [[nodiscard]] FluidFlowResult solve_bodies( + const FluidGridSpec& grid, const FluidProperties& fluid, const std::vector& ports, + const std::vector& bodies, + const LinearSolveParameters& parameters = LinearSolveParameters{}); + // Propose one bounded substep. The caller commits the returned bodies and + // advances its clock by accepted_dt only after coupled transport succeeds. + [[nodiscard]] FluidBodyStep propose_body_step( + const FluidGridSpec& grid, const FluidProperties& fluid, const std::vector& ports, + const std::vector& bodies, double maximum_dt, + const LinearSolveParameters& solve_parameters = LinearSolveParameters{}, + const FluidBodyStepParameters& step_parameters = FluidBodyStepParameters{}); + + private: + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace cm diff --git a/cpp/include/cm/transport_geometry.hpp b/cpp/include/cm/transport_geometry.hpp new file mode 100644 index 0000000..5ec5bfc --- /dev/null +++ b/cpp/include/cm/transport_geometry.hpp @@ -0,0 +1,32 @@ +#pragma once +#include "cm/fluid_geometry.hpp" + +namespace cm { +namespace detail { +class NumericsDevice; +} + +struct GeometricFluxReport { + std::uint32_t projection_iterations{0}; + double maximum_volume_residual{0}; +}; + +// Immutable conservative space-time mesh. No species or biology is required. +class TransportGeometry { + public: + TransportGeometry(const FluidGeometry& before, const FluidGeometry& after, + const MacVelocityField&, const std::vector&, double dt, + BackendKind backend = BackendKind::cpu, std::uint32_t device_index = 0, + const LinearSolveParameters& parameters = {}); + const GeometricFluxReport& report() const; + + private: + friend class SoluteTransportSolver; + struct Impl; + std::shared_ptr impl_; + explicit TransportGeometry(std::shared_ptr); + static TransportGeometry prepare(const FluidGeometry&, const FluidGeometry&, + const MacVelocityField&, const std::vector&, double, + const LinearSolveParameters&, detail::NumericsDevice*); +}; +} // namespace cm diff --git a/cpp/include/cm/types.hpp b/cpp/include/cm/types.hpp index c05b510..212dc6a 100644 --- a/cpp/include/cm/types.hpp +++ b/cpp/include/cm/types.hpp @@ -44,13 +44,17 @@ struct Vec3 { }; } -[[nodiscard]] inline float norm(const Vec3& value) noexcept { return std::sqrt(dot(value, value)); } +[[nodiscard]] inline float norm(const Vec3& value) noexcept { + return std::sqrt(dot(value, value)); +} [[nodiscard]] inline Vec3 normalized(const Vec3& value) { const auto magnitude = norm(value); + if (!std::isfinite(magnitude) || magnitude <= 0.0F) { throw std::invalid_argument("cell direction must be finite and non-zero"); } + return value * (1.0F / magnitude); } @@ -70,6 +74,7 @@ enum class BackendFeature : std::uint8_t { coupled_rates, depth_averaged_flow, resolved_flow, + culture, }; struct BackendInfo { diff --git a/cpp/include/cm/world_state.hpp b/cpp/include/cm/world_state.hpp index 4481932..501fb5d 100644 --- a/cpp/include/cm/world_state.hpp +++ b/cpp/include/cm/world_state.hpp @@ -66,7 +66,9 @@ struct CellGeometryView { std::span lengths; std::span radii; - [[nodiscard]] std::size_t size() const noexcept { return ids.size(); } + [[nodiscard]] std::size_t size() const noexcept { + return ids.size(); + } }; struct CellAttributeView { @@ -121,6 +123,8 @@ class WorldState { void validate() const; private: + void validate_lineage() const; + [[nodiscard]] CellId allocate_id(); [[nodiscard]] Slot slot_for(CellId id) const; void append(CellId id, const CellInit& cell); diff --git a/cpp/metal/kernels/contacts.metal b/cpp/metal/kernels/contacts.metal index 29a8c91..3b30a52 100644 --- a/cpp/metal/kernels/contacts.metal +++ b/cpp/metal/kernels/contacts.metal @@ -61,6 +61,7 @@ Capsule load_capsule(device const ulong* ids, device const float4* centers, result.axis = axes[slot].xyz; result.length = geometry[slot].x; result.radius = geometry[slot].y; + return result; } @@ -89,13 +90,16 @@ PointPair closest_points(const Capsule first, const Capsule second, float epsilo float first_parameter = 0.0f; float second_parameter = 0.0f; + if (first_length_squared <= epsilon_squared && second_length_squared <= epsilon_squared) { return {first_start, second_start}; } + if (first_length_squared <= epsilon_squared) { second_parameter = clamp(second_projection / second_length_squared, 0.0f, 1.0f); } else { float first_projection = dot(first_delta, between_starts); + if (second_length_squared <= epsilon_squared) { first_parameter = clamp(-first_projection / first_length_squared, 0.0f, 1.0f); } else { @@ -103,14 +107,17 @@ PointPair closest_points(const Capsule first, const Capsule second, float epsilo float denominator = first_length_squared * second_length_squared - cross_projection * cross_projection; float parallel_tolerance = float_epsilon * first_length_squared * second_length_squared; + if (denominator > parallel_tolerance) { first_parameter = clamp( (cross_projection * second_projection - first_projection * second_length_squared) / denominator, 0.0f, 1.0f); } + second_parameter = (cross_projection * first_parameter + second_projection) / second_length_squared; + if (second_parameter < 0.0f) { second_parameter = 0.0f; first_parameter = clamp(-first_projection / first_length_squared, 0.0f, 1.0f); @@ -133,9 +140,11 @@ PairPoints contact_points(const Capsule first, const Capsule second, float4 para float axis_dot = clamp(dot(first.axis, second.axis), -1.0f, 1.0f); float sine = sqrt(max(0.0f, 1.0f - axis_dot * axis_dot)); PairPoints result; + if (sine > parameters.y || first.length <= epsilon || second.length <= epsilon) { result.values[0] = closest_points(first, second, epsilon); result.count = 1; + return result; } @@ -145,43 +154,52 @@ PairPoints contact_points(const Capsule first, const Capsule second, float4 para float projected_second_half = second_half * abs(axis_dot); float overlap_begin = max(-first_half, center_coordinate - projected_second_half); float overlap_end = min(first_half, center_coordinate + projected_second_half); + if (overlap_end - overlap_begin <= epsilon) { result.values[0] = closest_points(first, second, epsilon); result.count = 1; + return result; } float first_parameters[2] = {overlap_begin, overlap_end}; + for (uint index = 0; index < 2; ++index) { float3 point_on_first = first.center + first.axis * first_parameters[index]; float second_parameter = clamp(dot(point_on_first - second.center, second.axis), -second_half, second_half); result.values[index] = {point_on_first, second.center + second.axis * second_parameter}; } + result.count = 2; + return result; } float3 deterministic_normal(const Capsule first, const Capsule second, const PointPair points, float epsilon) { float3 point_delta = points.second - points.first; + if (length(point_delta) > epsilon) { return normalize(point_delta); } float3 axes_cross = cross(first.axis, second.axis); + if (length(axes_cross) > epsilon) { return normalize(axes_cross); } float3 center_delta = second.center - first.center; float3 transverse_center_delta = center_delta - first.axis * dot(center_delta, first.axis); + if (length(transverse_center_delta) > epsilon) { return normalize(transverse_center_delta); } float3 absolute_axis = abs(first.axis); float3 basis; + if (absolute_axis.x <= absolute_axis.y && absolute_axis.x <= absolute_axis.z) { basis = float3(1.0f, 0.0f, 0.0f); } else if (absolute_axis.y <= absolute_axis.z) { @@ -189,39 +207,56 @@ float3 deterministic_normal(const Capsule first, const Capsule second, const Poi } else { basis = float3(0.0f, 0.0f, 1.0f); } + return normalize(cross(first.axis, basis)); } +SurfacePoint box_surface(float3 point, const ExternalConstraint constraint, float epsilon) { + float3 half_extents = constraint.parameters.xyz; + float3 delta = point - constraint.geometry.xyz; + float3 outside_vector = delta - clamp(delta, -half_extents, half_extents); + float outside_distance = length(outside_vector); + + if (outside_distance > epsilon) { + return {outside_distance, outside_vector / outside_distance}; + } + + float3 clearances = half_extents - abs(delta); + + if (clearances.x <= clearances.y && clearances.x <= clearances.z) { + float sign = fabs(delta.x) <= epsilon || delta.x >= 0.0f ? 1.0f : -1.0f; + + return {-clearances.x, float3(sign, 0.0f, 0.0f)}; + } + + if (clearances.y <= clearances.z) { + float sign = fabs(delta.y) <= epsilon || delta.y >= 0.0f ? 1.0f : -1.0f; + + return {-clearances.y, float3(0.0f, sign, 0.0f)}; + } + + float sign = fabs(delta.z) <= epsilon || delta.z >= 0.0f ? 1.0f : -1.0f; + + return {-clearances.z, float3(0.0f, 0.0f, sign)}; +} + SurfacePoint external_surface(float3 point, const ExternalConstraint constraint, float epsilon) { if (constraint.kind == 0) { float3 inward_normal = constraint.parameters.xyz; + return {dot(point - constraint.geometry.xyz, inward_normal), inward_normal}; } + if (constraint.kind == 1) { float3 delta = point - constraint.geometry.xyz; float distance = length(delta); float3 outward = distance > epsilon ? delta / distance : float3(1.0f, 0.0f, 0.0f); + return {distance - constraint.geometry.w, outward}; } + if (constraint.kind == 2) { - float3 half_extents = constraint.parameters.xyz; - float3 delta = point - constraint.geometry.xyz; - float3 outside_vector = delta - clamp(delta, -half_extents, half_extents); - float outside_distance = length(outside_vector); - if (outside_distance > epsilon) { - return {outside_distance, outside_vector / outside_distance}; - } - float3 clearances = half_extents - abs(delta); - if (clearances.x <= clearances.y && clearances.x <= clearances.z) { - float sign = fabs(delta.x) <= epsilon || delta.x >= 0.0f ? 1.0f : -1.0f; - return {-clearances.x, float3(sign, 0.0f, 0.0f)}; - } - if (clearances.y <= clearances.z) { - float sign = fabs(delta.y) <= epsilon || delta.y >= 0.0f ? 1.0f : -1.0f; - return {-clearances.y, float3(0.0f, sign, 0.0f)}; - } - float sign = fabs(delta.z) <= epsilon || delta.z >= 0.0f ? 1.0f : -1.0f; - return {-clearances.z, float3(0.0f, 0.0f, sign)}; + return box_surface(point, constraint, epsilon); } float3 delta = float3(point.xy - constraint.geometry.xy, 0.0f); @@ -231,19 +266,25 @@ SurfacePoint external_surface(float3 point, const ExternalConstraint constraint, float3 axial = float3(0.0f, 0.0f, z_offset >= 0.0f ? 1.0f : -1.0f); float radial_excess = radial_distance - constraint.geometry.w; float axial_excess = fabs(z_offset) - constraint.parameters.x; + if (radial_excess > 0.0f && axial_excess > 0.0f) { float distance = sqrt(radial_excess * radial_excess + axial_excess * axial_excess); + return {distance, (radial * radial_excess + axial * axial_excess) / distance}; } + if (radial_excess > 0.0f) { return {radial_excess, radial}; } + if (axial_excess > 0.0f) { return {axial_excess, axial}; } + if (-radial_excess <= -axial_excess) { return {radial_excess, radial}; } + return {axial_excess, axial}; } @@ -255,26 +296,33 @@ bool segment_intersects_bounds(float3 start, float3 end, float3 lower, float3 up float uppers[3] = {upper.x, upper.y, upper.z}; float entry = 0.0f; float exit = 1.0f; + for (uint axis = 0; axis < 3; ++axis) { if (deltas[axis] == 0.0f) { if (starts[axis] < lowers[axis] || starts[axis] > uppers[axis]) { return false; } + continue; } + float first = (lowers[axis] - starts[axis]) / deltas[axis]; float second = (uppers[axis] - starts[axis]) / deltas[axis]; + if (first > second) { float temporary = first; first = second; second = temporary; } + entry = max(entry, first); exit = min(exit, second); + if (entry > exit) { return false; } } + return true; } @@ -283,11 +331,13 @@ CenterlineMinimum minimize_surface_on_segment(float3 start, float3 end, float3 delta = end - start; float lower = 0.0f; float upper = 1.0f; + for (uint iteration = 0; iteration < segment_minimization_iterations; ++iteration) { float first_parameter = lower + (upper - lower) / 3.0f; float second_parameter = upper - (upper - lower) / 3.0f; SurfacePoint first = external_surface(start + delta * first_parameter, constraint, epsilon); SurfacePoint second = external_surface(start + delta * second_parameter, constraint, epsilon); + if (first.signed_distance < second.signed_distance) { upper = second_parameter; } else if (second.signed_distance < first.signed_distance) { @@ -300,23 +350,29 @@ CenterlineMinimum minimize_surface_on_segment(float3 start, float3 end, CenterlineMinimum result = {start, external_surface(start, constraint, epsilon)}; float candidates[5] = {1.0f, 0.5f, lower, (lower + upper) * 0.5f, upper}; + for (uint index = 0; index < 5; ++index) { float3 point = start + delta * candidates[index]; SurfacePoint surface = external_surface(point, constraint, epsilon); + if (surface.signed_distance < result.surface.signed_distance) { result = {point, surface}; } } + if (constraint.kind == 3) { if (fabs(delta.z) > epsilon) { float parameter = clamp((constraint.geometry.z - start.z) / delta.z, 0.0f, 1.0f); float3 point = start + delta * parameter; SurfacePoint surface = external_surface(point, constraint, epsilon); + if (surface.signed_distance <= result.surface.signed_distance) { result = {point, surface}; } } + float radial_length_squared = delta.x * delta.x + delta.y * delta.y; + if (radial_length_squared > epsilon * epsilon) { float parameter = clamp(-((start.x - constraint.geometry.x) * delta.x + (start.y - constraint.geometry.y) * delta.y) / @@ -324,11 +380,13 @@ CenterlineMinimum minimize_surface_on_segment(float3 start, float3 end, 0.0f, 1.0f); float3 point = start + delta * parameter; SurfacePoint surface = external_surface(point, constraint, epsilon); + if (surface.signed_distance <= result.surface.signed_distance) { result = {point, surface}; } } } + return result; } @@ -341,6 +399,7 @@ CenterlineMinimum sphere_minimum(float3 start, float3 end, const ExternalConstra ? clamp(-dot(start - constraint.geometry.xyz, delta) / length_squared, 0.0f, 1.0f) : 0.0f; float3 point = start + delta * parameter; + return {point, external_surface(point, constraint, epsilon)}; } @@ -370,11 +429,13 @@ ExternalEvaluation evaluate_external_constraint(const Capsule cell, }; bool finite_outside = constraint.kind != 0 && constraint.allowed_region == 0; + if (finite_outside) { if (constraint.kind >= 2) { float reach = cell.radius + contact_parameters.x; float3 lower; float3 upper; + if (constraint.kind == 2) { lower = constraint.geometry.xyz - constraint.parameters.xyz - reach; upper = constraint.geometry.xyz + constraint.parameters.xyz + reach; @@ -384,6 +445,7 @@ ExternalEvaluation evaluate_external_constraint(const Capsule cell, lower = constraint.geometry.xyz - extents - reach; upper = constraint.geometry.xyz + extents + reach; } + if (!segment_intersects_bounds(endpoints[0], endpoints[1], lower, upper)) { return result; } @@ -394,11 +456,14 @@ ExternalEvaluation evaluate_external_constraint(const Capsule cell, ? sphere_minimum(endpoints[0], endpoints[1], constraint, contact_parameters.y) : minimize_surface_on_segment(endpoints[0], endpoints[1], constraint, contact_parameters.y); + if (minimum.surface.signed_distance - cell.radius >= contact_parameters.x) { return result; } + for (uint endpoint = 0; endpoint < 2; ++endpoint) { float separation = endpoint_surfaces[endpoint].signed_distance - cell.radius; + if (separation < contact_parameters.x && fabs(endpoint_surfaces[endpoint].signed_distance - minimum.surface.signed_distance) <= contact_parameters.y) { @@ -406,23 +471,28 @@ ExternalEvaluation evaluate_external_constraint(const Capsule cell, cell.radius, true); } } + if (result.active_count == 0) { add_external_contact(result, interior_location, minimum.point, minimum.surface, cell.radius, true); } + return result; } bool outside = constraint.allowed_region == 0; + for (uint endpoint = 0; endpoint < 2; ++endpoint) { float separation = (outside ? endpoint_surfaces[endpoint].signed_distance : -endpoint_surfaces[endpoint].signed_distance) - cell.radius; + if (separation < contact_parameters.x) { add_external_contact(result, endpoint, endpoints[endpoint], endpoint_surfaces[endpoint], cell.radius, outside); } } + return result; } @@ -435,6 +505,7 @@ kernel void count_cell_contacts( if (pair_index >= candidate_count) { return; } + uint first_slot = candidates[pair_index].x; uint second_slot = candidates[pair_index].y; @@ -443,11 +514,13 @@ kernel void count_cell_contacts( canonicalize(first, second); PairPoints points = contact_points(first, second, parameters); uint active_count = 0; + for (uint ordinal = 0; ordinal < points.count; ++ordinal) { float separation = length(points.values[ordinal].second - points.values[ordinal].first) - (first.radius + second.radius); active_count += separation < parameters.x; } + counts[pair_index] = active_count; } @@ -459,10 +532,13 @@ kernel void inclusive_scan_step(device const uint* input [[buffer(0)]], if (index >= element_count) { return; } + uint value = input[index]; + if (index >= offset) { value += input[index - offset]; } + output[index] = value; } @@ -480,9 +556,11 @@ kernel void fill_cell_contacts( if (pair_index >= candidate_count) { return; } + uint first_slot = candidates[pair_index].x; uint second_slot = candidates[pair_index].y; uint pair_contact_count = counts[pair_index]; + if (pair_contact_count == 0) { return; } @@ -493,12 +571,15 @@ kernel void fill_cell_contacts( PairPoints points = contact_points(first, second, parameters); float weight = points.count == 2 ? inverse_sqrt_two : 1.0f; uint output_index = inclusive_counts[pair_index] - pair_contact_count; + for (uint ordinal = 0; ordinal < points.count; ++ordinal) { float3 point_delta = points.values[ordinal].second - points.values[ordinal].first; float separation = length(point_delta) - (first.radius + second.radius); + if (separation >= parameters.x) { continue; } + float3 normal = deterministic_normal(first, second, points.values[ordinal], parameters.z); first_ids[output_index] = first.id; second_ids[output_index] = second.id; @@ -523,6 +604,7 @@ kernel void count_external_contacts( if (position.x >= constraint_count || position.y >= cell_count) { return; } + uint cell_slot = position.y; uint constraint_index = position.x; uint pair_index = cell_slot * constraint_count + constraint_index; @@ -547,10 +629,12 @@ kernel void fill_external_contacts( if (position.x >= constraint_count || position.y >= cell_count) { return; } + uint cell_slot = position.y; uint constraint_index = position.x; uint pair_index = cell_slot * constraint_count + constraint_index; uint pair_contact_count = counts[pair_index]; + if (pair_contact_count == 0) { return; } @@ -560,6 +644,7 @@ kernel void fill_external_contacts( ExternalEvaluation evaluation = evaluate_external_constraint(cell, constraint, parameters); float weight = constraint.parameters.w * (evaluation.active_count == 2 ? inverse_sqrt_two : 1.0f); uint output_index = inclusive_counts[pair_index] - pair_contact_count; + for (uint contact = 0; contact < evaluation.active_count; ++contact) { cell_ids[output_index] = cell.id; constraint_ids[output_index] = constraint.id; diff --git a/cpp/metal/kernels/coupled_rates.metal b/cpp/metal/kernels/coupled_rates.metal index cdeb378..79cbb8d 100644 --- a/cpp/metal/kernels/coupled_rates.metal +++ b/cpp/metal/kernels/coupled_rates.metal @@ -26,6 +26,7 @@ float axis_coordinate(float position, float origin, float spacing, uint dimensio if (dimension == 1u) { return 0.0f; } + return clamp((position - origin) / spacing, 0.0f, float(dimension - 1u)); } @@ -33,17 +34,23 @@ float axis_site_weight(float coordinate, uint dimension, uint site) { if (dimension == 1u) { return site == 0u ? 1.0f : 0.0f; } + uint lower = uint(floor(coordinate)); + if (lower == dimension - 1u) { return site == lower ? 1.0f : 0.0f; } + float fraction = coordinate - float(lower); + if (site == lower) { return 1.0f - fraction; } + if (site == lower + 1u) { return fraction; } + return 0.0f; } @@ -52,6 +59,7 @@ float cell_site_weight(float4 center, GridShape shape, float4 origin, float4 spa float coordinate_x = axis_coordinate(center.x, origin.x, spacing.x, shape.x); float coordinate_y = axis_coordinate(center.y, origin.y, spacing.y, shape.y); float coordinate_z = axis_coordinate(center.z, origin.z, spacing.z, shape.z); + return axis_site_weight(coordinate_x, shape.x, x) * axis_site_weight(coordinate_y, shape.y, y) * axis_site_weight(coordinate_z, shape.z, z); } @@ -64,25 +72,41 @@ uint stencil_component(float4 center, GridShape shape, float4 origin, float4 spa unsigned lx = (unsigned)floor(cx), ly = (unsigned)floor(cy), lz = (unsigned)floor(cz); unsigned fluid = 0, seed = 0; float best = 0; + for (unsigned bit = 0; bit < 8; ++bit) { unsigned x = lx + (bit >> 2), y = ly + ((bit >> 1) & 1u), z = lz + (bit & 1u); - if (x >= shape.x || y >= shape.y || z >= shape.z) continue; + + if (x >= shape.x || y >= shape.y || z >= shape.z) { + continue; + } + float w = axis_site_weight(cx, shape.x, x) * axis_site_weight(cy, shape.y, y) * axis_site_weight(cz, shape.z, z); - if (w <= 0 || obstacles[site_index(shape, x, y, z)] != 0) continue; + + if (w <= 0 || obstacles[site_index(shape, x, y, z)] != 0) { + continue; + } + fluid |= 1u << bit; + if (w > best) { best = w; seed = 1u << bit; } } + unsigned connected = seed; + for (unsigned pass = 0; pass < 8; ++pass) { for (unsigned bit = 0; bit < 8; ++bit) { - if ((connected & (1u << bit)) == 0) continue; + if ((connected & (1u << bit)) == 0) { + continue; + } + connected |= fluid & ((1u << (bit ^ 1u)) | (1u << (bit ^ 2u)) | (1u << (bit ^ 4u))); } } + return connected; } @@ -101,87 +125,164 @@ float sample_signal(device const float* levels, GridShape shape, float4 origin, const auto component = stencil_component(center, shape, origin, spacing, obstacles); float fluid_weight = 0.0f; bool dropped = false; + for (uint dx = 0; dx < count_x; ++dx) { uint x = lower_x + dx; float wx = axis_site_weight(coordinate_x, shape.x, x); + for (uint dy = 0; dy < count_y; ++dy) { uint y = lower_y + dy; float wy = axis_site_weight(coordinate_y, shape.y, y); + for (uint dz = 0; dz < count_z; ++dz) { uint z = lower_z + dz; float wz = axis_site_weight(coordinate_z, shape.z, z); float weight = wx * wy * wz; + if ((component & (1u << ((dx << 2) | (dy << 1) | dz))) == 0u) { if (weight != 0.0f) { dropped = true; } + continue; } + fluid_weight += weight; result += weight * grid_level(levels, shape, signal, x, y, z); } } } + // A stencil with no fluid corner is rejected by the host's coupled-step // validation before any kernel runs, so the fluid weight is positive here. if (dropped) { result /= fluid_weight; } + return result; } +uint stencil_axis_count(uint dimension, uint lower) { + return dimension == 1u || lower == dimension - 1u ? 1u : 2u; +} + float cell_scatter_weight(float4 center, GridShape shape, float4 origin, float4 spacing, device const uchar* obstacles, uint x, uint y, uint z) { // A cell only scatters into the eight sites of its own stencil, and the // weight is pure arithmetic, so testing it first keeps the obstacle mask out // of the sites a cell cannot reach - which is nearly all of them. float raw = cell_site_weight(center, shape, origin, spacing, x, y, z); + if (raw == 0.0f) { return 0.0f; } + if (obstacles[site_index(shape, x, y, z)] != 0u) { return 0.0f; } + float coordinate_x = axis_coordinate(center.x, origin.x, spacing.x, shape.x); float coordinate_y = axis_coordinate(center.y, origin.y, spacing.y, shape.y); float coordinate_z = axis_coordinate(center.z, origin.z, spacing.z, shape.z); uint lower_x = uint(floor(coordinate_x)); uint lower_y = uint(floor(coordinate_y)); uint lower_z = uint(floor(coordinate_z)); - uint count_x = shape.x == 1u || lower_x == shape.x - 1u ? 1u : 2u; - uint count_y = shape.y == 1u || lower_y == shape.y - 1u ? 1u : 2u; - uint count_z = shape.z == 1u || lower_z == shape.z - 1u ? 1u : 2u; + uint count_x = stencil_axis_count(shape.x, lower_x); + uint count_y = stencil_axis_count(shape.y, lower_y); + uint count_z = stencil_axis_count(shape.z, lower_z); const auto component = stencil_component(center, shape, origin, spacing, obstacles); float fluid_weight = 0.0f; - if (raw == 0.0f) return 0.0f; + + if (raw == 0.0f) { + return 0.0f; + } + unsigned target_bit = ((x - lower_x) << 2) | ((y - lower_y) << 1) | (z - lower_z); - if ((component & (1u << target_bit)) == 0u) return 0.0f; + + if ((component & (1u << target_bit)) == 0u) { + return 0.0f; + } + bool dropped = false; + for (uint dx = 0; dx < count_x; ++dx) { uint sx = lower_x + dx; float wx = axis_site_weight(coordinate_x, shape.x, sx); + for (uint dy = 0; dy < count_y; ++dy) { uint sy = lower_y + dy; float wy = axis_site_weight(coordinate_y, shape.y, sy); + for (uint dz = 0; dz < count_z; ++dz) { uint sz = lower_z + dz; float wz = axis_site_weight(coordinate_z, shape.z, sz); float weight = wx * wy * wz; + if ((component & (1u << ((dx << 2) | (dy << 1) | dz))) == 0u) { if (weight != 0.0f) { dropped = true; } + continue; } + fluid_weight += weight; } } } + // A stencil with no fluid corner is rejected by the host's coupled-step // validation before any kernel runs, so the fluid weight is positive here. return dropped ? raw / fluid_weight : raw; } +float evaluate_predicate(const RateInstruction instruction, device const float* workspace) { + switch (instruction.operation) { + case 21: + return workspace[instruction.first] < workspace[instruction.second] ? 1.0f : 0.0f; + case 22: + return workspace[instruction.first] <= workspace[instruction.second] ? 1.0f : 0.0f; + case 23: + return workspace[instruction.first] > workspace[instruction.second] ? 1.0f : 0.0f; + case 24: + return workspace[instruction.first] >= workspace[instruction.second] ? 1.0f : 0.0f; + case 25: + return workspace[instruction.first] == workspace[instruction.second] ? 1.0f : 0.0f; + case 26: + return workspace[instruction.first] != 0.0f ? workspace[instruction.second] + : workspace[instruction.third]; + default: + return NAN; + } +} + +float evaluate_arithmetic(const RateInstruction instruction, device const float* workspace) { + switch (instruction.operation) { + case 11: + return workspace[instruction.first] + workspace[instruction.second]; + case 12: + return workspace[instruction.first] - workspace[instruction.second]; + case 13: + return workspace[instruction.first] * workspace[instruction.second]; + case 14: + return workspace[instruction.first] / workspace[instruction.second]; + case 15: + return pow(workspace[instruction.first], workspace[instruction.second]); + case 16: + return min(workspace[instruction.first], workspace[instruction.second]); + case 17: + return max(workspace[instruction.first], workspace[instruction.second]); + case 18: + return -workspace[instruction.first]; + case 19: + return exp(workspace[instruction.first]); + case 20: + return log(workspace[instruction.first]); + default: + return evaluate_predicate(instruction, workspace); + } +} + float evaluate_instruction(const RateInstruction instruction, device const float* workspace, device const float* species, device const float* signals, float4 center, float4 geometry, float growth_rate, int cell_type, @@ -211,43 +312,10 @@ float evaluate_instruction(const RateInstruction instruction, device const float return effective_volume(geometry.x, geometry.y); case 10: return effective_surface_area(geometry.x, geometry.y); - case 11: - return workspace[instruction.first] + workspace[instruction.second]; - case 12: - return workspace[instruction.first] - workspace[instruction.second]; - case 13: - return workspace[instruction.first] * workspace[instruction.second]; - case 14: - return workspace[instruction.first] / workspace[instruction.second]; - case 15: - return pow(workspace[instruction.first], workspace[instruction.second]); - case 16: - return min(workspace[instruction.first], workspace[instruction.second]); - case 17: - return max(workspace[instruction.first], workspace[instruction.second]); - case 18: - return -workspace[instruction.first]; - case 19: - return exp(workspace[instruction.first]); - case 20: - return log(workspace[instruction.first]); - case 21: - return workspace[instruction.first] < workspace[instruction.second] ? 1.0f : 0.0f; - case 22: - return workspace[instruction.first] <= workspace[instruction.second] ? 1.0f : 0.0f; - case 23: - return workspace[instruction.first] > workspace[instruction.second] ? 1.0f : 0.0f; - case 24: - return workspace[instruction.first] >= workspace[instruction.second] ? 1.0f : 0.0f; - case 25: - return workspace[instruction.first] == workspace[instruction.second] ? 1.0f : 0.0f; - case 26: - return workspace[instruction.first] != 0.0f ? workspace[instruction.second] - : workspace[instruction.third]; case 27: return signals[instruction.first]; default: - return NAN; + return evaluate_arithmetic(instruction, workspace); } } @@ -273,12 +341,14 @@ kernel void advance_coupled_cells( float radius = geometry[cell].y; float dilution = effective_volume(previous_lengths[cell], radius) / effective_volume(geometry[cell].x, radius); + for (uint species = 0; species < species_count; ++species) { species_levels[species_offset + species] *= dilution; } uint signal_offset = cell * signal_count; device float* cell_signals = cell_signal_rates + signal_offset; + for (uint signal = 0; signal < signal_count; ++signal) { cell_signals[signal] = sample_signal(grid_levels, shape, origin, spacing, obstacles, centers[cell], signal); @@ -287,6 +357,7 @@ kernel void advance_coupled_cells( uint workspace_offset = cell * instruction_count; device float* cell_workspace = workspace + workspace_offset; device const float* cell_species = species_levels + species_offset; + for (uint index = 0; index < instruction_count; ++index) { float value = evaluate_instruction(instructions[index], cell_workspace, cell_species, cell_signals, @@ -296,6 +367,7 @@ kernel void advance_coupled_cells( effective_volume(previous_lengths[cell], radius)) / dt); cell_workspace[index] = value; + if (!isfinite(value)) { atomic_fetch_or_explicit(error, 1u, memory_order_relaxed); } @@ -305,15 +377,59 @@ kernel void advance_coupled_cells( float value = species_levels[species_offset + species] + dt * cell_workspace[species_outputs[species]]; species_levels[species_offset + species] = value; + if (!isfinite(value)) { atomic_fetch_or_explicit(error, 1u, memory_order_relaxed); } } + for (uint signal = 0; signal < signal_count; ++signal) { cell_signals[signal] = cell_workspace[signal_outputs[signal]]; } } +float coupled_transport_stencil(float diffusion_value, float current, GridFaceState faces, + uint3 dimensions, float4 spacing, float3 lower, float3 upper) { + bool3 closed_lower = faces.closed_lower; + bool3 closed_upper = faces.closed_upper; + float3 grid_spacing = spacing.xyz; + float rate = 0.0f; + + for (uint axis = 0; axis < 3u; ++axis) { + if (dimensions[axis] == 1u) { + continue; + } + + if (closed_lower[axis]) { + lower[axis] = current; + } + + if (closed_upper[axis]) { + upper[axis] = current; + } + + float inverse_spacing = 1.0f / grid_spacing[axis]; + rate += diffusion_value * (lower[axis] - 2.0f * current + upper[axis]) * inverse_spacing * + inverse_spacing; + float lower_flux = + faces.lower[axis] >= 0.0f ? faces.lower[axis] * lower[axis] : faces.lower[axis] * current; + float upper_flux = + faces.upper[axis] >= 0.0f ? faces.upper[axis] * current : faces.upper[axis] * upper[axis]; + + if (closed_lower[axis]) { + lower_flux = 0.0f; + } + + if (closed_upper[axis]) { + upper_flux = 0.0f; + } + + rate -= (upper_flux - lower_flux) * inverse_spacing; + } + + return rate; +} + kernel void advance_coupled_grid( device const float* levels [[buffer(0)]], device float* output [[buffer(1)]], device const float* diffusion [[buffer(2)]], device const float4* advection [[buffer(3)]], @@ -340,8 +456,10 @@ kernel void advance_coupled_grid( uint y = yz / shape.z; uint z = yz - y * shape.z; float current = levels[index]; + if (obstacles[site] != 0u) { output[index] = current; + return; } @@ -372,46 +490,23 @@ kernel void advance_coupled_grid( uint3 dimensions = uint3(shape.x, shape.y, shape.z); GridFaceState faces = grid_face_state(shape, boundary_kinds, obstacles, x_faces, y_faces, z_faces, has_velocity_field, advection[signal], x, y, z); - bool3 closed_lower = faces.closed_lower; - bool3 closed_upper = faces.closed_upper; - float3 grid_spacing = spacing.xyz; - float rate = 0.0f; - for (uint axis = 0; axis < 3u; ++axis) { - if (dimensions[axis] == 1u) { - continue; - } - if (closed_lower[axis]) { - lower[axis] = current; - } - if (closed_upper[axis]) { - upper[axis] = current; - } - float inverse_spacing = 1.0f / grid_spacing[axis]; - rate += diffusion[signal] * (lower[axis] - 2.0f * current + upper[axis]) * inverse_spacing * - inverse_spacing; - float lower_flux = - faces.lower[axis] >= 0.0f ? faces.lower[axis] * lower[axis] : faces.lower[axis] * current; - float upper_flux = - faces.upper[axis] >= 0.0f ? faces.upper[axis] * current : faces.upper[axis] * upper[axis]; - if (closed_lower[axis]) { - lower_flux = 0.0f; - } - if (closed_upper[axis]) { - upper_flux = 0.0f; - } - rate -= (upper_flux - lower_flux) * inverse_spacing; - } + float rate = coupled_transport_stencil(diffusion[signal], current, faces, dimensions, spacing, + lower, upper); + rate += reaction_source[index] - reaction_loss[index] * current; float source = 0.0f; float inverse_voxel_volume = 1.0f / (spacing.x * spacing.y * spacing.z); + for (uint cell = 0; cell < cell_count; ++cell) { float weight = cell_scatter_weight(centers[cell], shape, origin, spacing, obstacles, x, y, z); source += weight * cell_signal_rates[cell * signal_count + signal] * inverse_voxel_volume; } + float transport_scale = crank_nicolson == 0u ? dt : (crank_nicolson == 1u ? 0.5f * dt : 0.0f); float candidate = current + transport_scale * rate + dt * source; output[index] = candidate; + if (!isfinite(candidate) || (crank_nicolson == 0u && candidate < 0.0f)) { atomic_fetch_or_explicit(error, 2u, memory_order_relaxed); } diff --git a/cpp/metal/kernels/flow.metal b/cpp/metal/kernels/flow.metal index 14971c3..f7e3183 100644 --- a/cpp/metal/kernels/flow.metal +++ b/cpp/metal/kernels/flow.metal @@ -28,6 +28,7 @@ inline uint3 site_coordinate(uint index, constant FlowGridParameters& grid) { const uint z = index % grid.dimensions.z; index /= grid.dimensions.z; const uint y = index % grid.dimensions.y; + return uint3(index / grid.dimensions.y, y, z); } @@ -41,26 +42,31 @@ inline FaceCoordinate face_coordinate(uint index, constant FlowGridParameters& g const uint z = local % dimensions.z; local /= dimensions.z; const uint y = local % dimensions.y; + return {component, uint3(local / dimensions.y, y, z), dimensions}; } inline uint face_index(uint component, uint3 coordinate, constant FlowGridParameters& grid) { const uint offset = component == 0 ? grid.face_offsets.x : (component == 1 ? grid.face_offsets.y : grid.face_offsets.z); + if (component == 0) { return offset + (coordinate.x * grid.dimensions.y + coordinate.y) * grid.dimensions.z + coordinate.z; } + if (component == 1) { return offset + (coordinate.x * (grid.dimensions.y + 1) + coordinate.y) * grid.dimensions.z + coordinate.z; } + return offset + (coordinate.x * grid.dimensions.y + coordinate.y) * (grid.dimensions.z + 1) + coordinate.z; } inline float harmonic_mean(float first, float second) { const float sum = first + second; + return sum > 0.0f ? 2.0f * first * second / sum : 0.0f; } @@ -73,15 +79,25 @@ kernel void depth_flow_operator(device const float* input [[buffer(0)]], if (index >= grid.site_count) { return; } + if (diagonal[index] == 0.0f) { output[index] = 0.0f; + return; } + const uint3 coordinate = site_coordinate(index, grid); float result = 0.0f; const float boundary = 2.0f * mobility[index] * grid.inverse_spacing_squared[grid.flow_axis]; - if (coordinate[grid.flow_axis] == 0) result += boundary * input[index]; - if (coordinate[grid.flow_axis]+1 == grid.dimensions[grid.flow_axis]) result += boundary * input[index]; + + if (coordinate[grid.flow_axis] == 0) { + result += boundary * input[index]; + } + + if (coordinate[grid.flow_axis] + 1 == grid.dimensions[grid.flow_axis]) { + result += boundary * input[index]; + } + for (uint axis = 0; axis < 3; ++axis) { if (coordinate[axis] > 0) { uint3 neighbor = coordinate; @@ -90,6 +106,7 @@ kernel void depth_flow_operator(device const float* input [[buffer(0)]], result += harmonic_mean(mobility[index], mobility[neighbor_index]) * grid.inverse_spacing_squared[axis] * (input[index] - input[neighbor_index]); } + if (coordinate[axis] + 1 < grid.dimensions[axis]) { uint3 neighbor = coordinate; neighbor[axis] += 1; @@ -98,6 +115,7 @@ kernel void depth_flow_operator(device const float* input [[buffer(0)]], grid.inverse_spacing_squared[axis] * (input[index] - input[neighbor_index]); } } + output[index] = result; } @@ -109,17 +127,21 @@ kernel void depth_flow_velocity(device const float* pressure [[buffer(0)]], if (index >= grid.total_face_count) { return; } + const FaceCoordinate face = face_coordinate(index, grid); const uint component = face.component; const bool has_lower = face.coordinate[component] > 0; const bool has_upper = face.coordinate[component] < grid.dimensions[component]; uint3 lower_coordinate = face.coordinate; + if (has_lower) { lower_coordinate[component] -= 1; } + const uint lower = has_lower ? site_index(lower_coordinate, grid) : 0; const uint upper = has_upper ? site_index(face.coordinate, grid) : 0; float value = 0.0f; + if (has_lower && has_upper) { value = -harmonic_mean(mobility[lower], mobility[upper]) * (pressure[upper] - pressure[lower]) / grid.spacing[component]; @@ -128,6 +150,7 @@ kernel void depth_flow_velocity(device const float* pressure [[buffer(0)]], } else if (component == grid.flow_axis && has_lower) { value = 2.0f * mobility[lower] * pressure[lower] / grid.spacing[component]; } + velocity[index] = value; } @@ -141,30 +164,39 @@ kernel void resolved_flow_momentum(device const float* input [[buffer(0)]], if (index >= grid.total_face_count) { return; } + if (active[index] == 0) { output[index] = 0.0f; + return; } + const FaceCoordinate face = face_coordinate(index, grid); float result = face_drag[index] * input[index]; + for (uint axis = 0; axis < 3; ++axis) { if (grid.dimensions[axis] == 1) { continue; } + for (int offset = -1; offset <= 1; offset += 2) { const bool in_bounds = offset < 0 ? face.coordinate[axis] > 0 : face.coordinate[axis] + 1 < face.dimensions[axis]; float neighbor = 0.0f; uint neighbor_index = 0; + if (in_bounds) { uint3 coordinate = face.coordinate; + if (offset < 0) { coordinate[axis] -= 1; } else { coordinate[axis] += 1; } + neighbor_index = face_index(face.component, coordinate, grid); } + if (axis == face.component) { neighbor = in_bounds ? input[neighbor_index] : input[index]; } else if (in_bounds && exists[neighbor_index] != 0) { @@ -172,9 +204,11 @@ kernel void resolved_flow_momentum(device const float* input [[buffer(0)]], } else { neighbor = -input[index]; } + result -= (neighbor - input[index]) * grid.inverse_spacing_squared[axis]; } } + output[index] = result; } @@ -187,18 +221,23 @@ kernel void resolved_flow_gradient(device const float* pressure [[buffer(0)]], if (index >= grid.total_face_count) { return; } + if (active[index] == 0) { gradient[index] = 0.0f; + return; } + const FaceCoordinate face = face_coordinate(index, grid); const uint component = face.component; const bool has_lower = face.coordinate[component] > 0; const bool has_upper = face.coordinate[component] < grid.dimensions[component]; uint3 lower_coordinate = face.coordinate; + if (has_lower) { lower_coordinate[component] -= 1; } + const uint lower = has_lower ? site_index(lower_coordinate, grid) : 0; const uint upper = has_upper ? site_index(face.coordinate, grid) : 0; const float lower_value = has_lower && fluid[lower] != 0 ? pressure[lower] : 0.0f; @@ -214,12 +253,16 @@ kernel void resolved_flow_divergence(device const float* velocity [[buffer(0)]], if (index >= grid.site_count) { return; } + if (fluid[index] == 0) { divergence[index] = 0.0f; + return; } + const uint3 coordinate = site_coordinate(index, grid); float result = 0.0f; + for (uint component = 0; component < 3; ++component) { uint3 upper = coordinate; upper[component] += 1; @@ -227,6 +270,7 @@ kernel void resolved_flow_divergence(device const float* velocity [[buffer(0)]], velocity[face_index(component, coordinate, grid)]) / grid.spacing[component]; } + divergence[index] = result; } @@ -238,6 +282,7 @@ kernel void flow_pcg_initialize( if (index >= count) { return; } + const float value = right_hand_side[index]; const float scaled = diagonal[index] > 0.0f ? value / diagonal[index] : 0.0f; solution[index] = 0.0f; @@ -307,12 +352,15 @@ kernel void flow_dot_partial(device const float* left [[buffer(0)]], threadgroup float values[64]; values[local_index] = index < count ? left[index] * right[index] : 0.0f; threadgroup_barrier(mem_flags::mem_threadgroup); + for (uint stride = 32; stride > 0; stride >>= 1) { if (local_index < stride) { values[local_index] += values[local_index + stride]; } + threadgroup_barrier(mem_flags::mem_threadgroup); } + if (local_index == 0) { partials[group_index] = values[0]; } @@ -324,5 +372,7 @@ kernel void flow_vector_combine(device const float* source [[buffer(0)]], constant float& beta [[buffer(3)]], constant uint& count [[buffer(4)]], uint i [[thread_position_in_grid]]) { - if (i < count) target[i] = alpha*source[i] + beta*target[i]; + if (i < count) { + target[i] = alpha * source[i] + beta * target[i]; + } } diff --git a/cpp/metal/kernels/grid_transport.metal b/cpp/metal/kernels/grid_transport.metal index 7e3d744..450f593 100644 --- a/cpp/metal/kernels/grid_transport.metal +++ b/cpp/metal/kernels/grid_transport.metal @@ -27,9 +27,11 @@ float exterior_value(uint kind, device const float* fixed_values, uint face, uin if (kind == 0u) { return current; } + if (kind == 1u) { return periodic; } + return fixed_values[face * signal_count + signal]; } @@ -43,11 +45,8 @@ struct GridFaceState { float upper[3]; }; -GridFaceState grid_face_state(GridShape shape, constant uint* boundary_kinds, - device const uchar* obstacles, device const float* x_faces, - device const float* y_faces, device const float* z_faces, - uint has_velocity_field, float4 advection, uint x, uint y, uint z) { - GridFaceState faces; +void close_grid_x_faces(thread GridFaceState& faces, GridShape shape, constant uint* boundary_kinds, + device const uchar* obstacles, uint x, uint y, uint z) { faces.closed_lower.x = x == 0u ? (boundary_kinds[0] == 0u || @@ -58,6 +57,10 @@ GridFaceState grid_face_state(GridShape shape, constant uint* boundary_kinds, ? (boundary_kinds[1] == 0u || (boundary_kinds[1] == 1u && obstacles[site_index(shape, 0u, y, z)] != 0u)) : obstacles[site_index(shape, x + 1u, y, z)] != 0u; +} + +void close_grid_y_faces(thread GridFaceState& faces, GridShape shape, constant uint* boundary_kinds, + device const uchar* obstacles, uint x, uint y, uint z) { faces.closed_lower.y = y == 0u ? (boundary_kinds[2] == 0u || @@ -68,6 +71,10 @@ GridFaceState grid_face_state(GridShape shape, constant uint* boundary_kinds, ? (boundary_kinds[3] == 0u || (boundary_kinds[3] == 1u && obstacles[site_index(shape, x, 0u, z)] != 0u)) : obstacles[site_index(shape, x, y + 1u, z)] != 0u; +} + +void close_grid_z_faces(thread GridFaceState& faces, GridShape shape, constant uint* boundary_kinds, + device const uchar* obstacles, uint x, uint y, uint z) { faces.closed_lower.z = z == 0u ? (boundary_kinds[4] == 0u || @@ -78,6 +85,17 @@ GridFaceState grid_face_state(GridShape shape, constant uint* boundary_kinds, ? (boundary_kinds[5] == 0u || (boundary_kinds[5] == 1u && obstacles[site_index(shape, x, y, 0u)] != 0u)) : obstacles[site_index(shape, x, y, z + 1u)] != 0u; +} + +GridFaceState grid_face_state(GridShape shape, constant uint* boundary_kinds, + device const uchar* obstacles, device const float* x_faces, + device const float* y_faces, device const float* z_faces, + uint has_velocity_field, float4 advection, uint x, uint y, uint z) { + GridFaceState faces; + close_grid_x_faces(faces, shape, boundary_kinds, obstacles, x, y, z); + close_grid_y_faces(faces, shape, boundary_kinds, obstacles, x, y, z); + close_grid_z_faces(faces, shape, boundary_kinds, obstacles, x, y, z); + if (has_velocity_field != 0u) { faces.lower[0] = x_faces[x * shape.y * shape.z + y * shape.z + z]; faces.upper[0] = x_faces[(x + 1u) * shape.y * shape.z + y * shape.z + z]; @@ -87,10 +105,12 @@ GridFaceState grid_face_state(GridShape shape, constant uint* boundary_kinds, faces.upper[2] = z_faces[x * shape.y * (shape.z + 1u) + y * (shape.z + 1u) + z + 1u]; } else { float3 velocity = advection.xyz; + for (uint axis = 0; axis < 3u; ++axis) { faces.lower[axis] = velocity[axis]; faces.upper[axis] = velocity[axis]; } } + return faces; } diff --git a/cpp/metal/kernels/growth.metal b/cpp/metal/kernels/growth.metal index acec88c..9522b71 100644 --- a/cpp/metal/kernels/growth.metal +++ b/cpp/metal/kernels/growth.metal @@ -9,5 +9,6 @@ kernel void advance_growth(device float* lengths [[buffer(0)]], if (index >= count) { return; } + lengths[index] += growth_rates[index] * lengths[index] * dt; } diff --git a/cpp/metal/kernels/mechanics.metal b/cpp/metal/kernels/mechanics.metal index b88fbe0..09ae145 100644 --- a/cpp/metal/kernels/mechanics.metal +++ b/cpp/metal/kernels/mechanics.metal @@ -11,6 +11,7 @@ MechanicsDofs zero_dofs() { MechanicsDofs result; result.linear_length = 0.0f; result.rotation = 0.0f; + return result; } @@ -22,6 +23,7 @@ MechanicsDofs scaled(const MechanicsDofs value, float scale) { MechanicsDofs result; result.linear_length = value.linear_length * scale; result.rotation = value.rotation * scale; + return result; } @@ -29,6 +31,7 @@ MechanicsDofs added(const MechanicsDofs left, const MechanicsDofs right) { MechanicsDofs result; result.linear_length = left.linear_length + right.linear_length; result.rotation = left.rotation + right.rotation; + return result; } @@ -38,6 +41,7 @@ MechanicsDofs contact_jacobian(float3 normal, float3 arm, float3 axis, float tot result.linear_length = float4(weight * normal, weight * dot(axis, arm) * dot(axis, normal) / total_length); result.rotation = float4(weight * cross(arm, normal), 0.0f); + return result; } @@ -52,6 +56,7 @@ kernel void build_mechanics_rows( if (index >= contact_count) { return; } + uint first = first_slots[index]; uint second = second_slots[index]; float weight = weights[index]; @@ -79,10 +84,12 @@ kernel void apply_mechanics_b(device const MechanicsDofs* first_rows [[buffer(0) if (index >= contact_count) { return; } + uint first = first_slots[index]; uint second = second_slots[index]; MechanicsDofs first_input = fixed[first] == 0 ? input[first] : zero_dofs(); row_values[index] = dof_dot(first_rows[index], first_input); + if (second != 0xffffffffu) { MechanicsDofs second_input = fixed[second] == 0 ? input[second] : zero_dofs(); row_values[index] -= dof_dot(second_rows[index], second_input); @@ -101,7 +108,9 @@ kernel void apply_mechanics_transpose(device const MechanicsDofs* first_rows [[b if (cell >= cell_count) { return; } + MechanicsDofs result = zero_dofs(); + for (uint offset = incidence_offsets[cell]; offset < incidence_offsets[cell + 1]; ++offset) { uint row = incidence_indices[offset]; bool is_first = first_slots[row] == cell; @@ -109,6 +118,7 @@ kernel void apply_mechanics_transpose(device const MechanicsDofs* first_rows [[b float sign = is_first ? 1.0f : -1.0f; result = added(result, scaled(jacobian, sign * row_values[row])); } + output[cell] = result; } @@ -120,10 +130,13 @@ kernel void add_mechanics_regularizer( if (cell >= cell_count) { return; } + if (fixed[cell] != 0) { output[cell] = input[cell]; + return; } + float mu_a = parameters.x; float gamma = parameters.y; float total_length = geometry[cell].x + 2.0f * geometry[cell].y; @@ -154,6 +167,7 @@ kernel void initialize_mechanics_vectors(device MechanicsDofs* right_hand_side [ if (cell >= cell_count) { return; } + solution[cell] = zero_dofs(); MechanicsDofs projected_rhs = fixed[cell] == 0 ? right_hand_side[cell] : zero_dofs(); right_hand_side[cell] = projected_rhs; @@ -169,6 +183,7 @@ kernel void update_mechanics_solution_residual( if (cell >= cell_count) { return; } + solution[cell] = added(solution[cell], scaled(search_direction[cell], alpha)); residual[cell] = added(residual[cell], scaled(applied[cell], -alpha)); } @@ -181,6 +196,7 @@ kernel void update_mechanics_search_direction(device const MechanicsDofs* residu if (cell >= cell_count) { return; } + search_direction[cell] = added(residual[cell], scaled(search_direction[cell], beta)); } @@ -192,6 +208,7 @@ kernel void subtract_mechanics_vectors(device const MechanicsDofs* left [[buffer if (cell >= cell_count) { return; } + output[cell] = added(left[cell], scaled(right[cell], -1.0f)); } @@ -203,6 +220,7 @@ kernel void mechanics_dot_terms(device const MechanicsDofs* left [[buffer(0)]], if (cell >= cell_count) { return; } + terms[cell] = dof_dot(left[cell], right[cell]); } @@ -211,12 +229,16 @@ kernel void reduce_sum_pairs(device const float* input [[buffer(0)]], constant uint& element_count [[buffer(2)]], uint index [[thread_position_in_grid]]) { uint first = index * 2; + if (first >= element_count) { return; } + float value = input[first]; + if (first + 1 < element_count) { value += input[first + 1]; } + output[index] = value; } diff --git a/cpp/metal/kernels/numerics.metal b/cpp/metal/kernels/numerics.metal new file mode 100644 index 0000000..e9c5af4 --- /dev/null +++ b/cpp/metal/kernels/numerics.metal @@ -0,0 +1,178 @@ +#include +using namespace metal; + +struct NumericsParameters { + uint count; + float scalar; +}; + +kernel void numerics_ilu(device const uint* offsets [[buffer(0)]], + device const uint* columns [[buffer(1)]], + device const float* values [[buffer(2)]], + device const uint* diagonal [[buffer(3)]], + device const float* rhs [[buffer(4)]], device float* x [[buffer(5)]], + constant NumericsParameters& p [[buffer(15)]], + uint index [[thread_position_in_grid]]) { + if (index) { + return; + } + + for (uint i = 0; i < p.count; ++i) { + float value = rhs[i]; + + for (uint j = offsets[i]; j < diagonal[i]; ++j) { + value -= values[j] * x[columns[j]]; + } + + x[i] = value; + } + + for (uint i = p.count; i-- > 0;) { + float value = x[i]; + + for (uint j = diagonal[i] + 1; j < offsets[i + 1]; ++j) { + value -= values[j] * x[columns[j]]; + } + + x[i] = value / values[diagonal[i]]; + } +} + +kernel void numerics_fill(device float* y [[buffer(0)]], + constant NumericsParameters& p [[buffer(15)]], + uint i [[thread_position_in_grid]]) { + if (i < p.count) { + y[i] = p.scalar; + } +} + +kernel void numerics_copy(device const float* x [[buffer(0)]], device float* y [[buffer(1)]], + constant NumericsParameters& p [[buffer(15)]], + uint i [[thread_position_in_grid]]) { + if (i < p.count) { + y[i] = x[i]; + } +} + +kernel void numerics_axpy(device const float* x [[buffer(0)]], device float* y [[buffer(1)]], + constant NumericsParameters& p [[buffer(15)]], + uint i [[thread_position_in_grid]]) { + if (i < p.count) { + y[i] += p.scalar * x[i]; + } +} + +kernel void numerics_jacobi(device const float* rhs [[buffer(0)]], + device const float* ax [[buffer(1)]], + device const float* inverse_diagonal [[buffer(2)]], + device float* x [[buffer(3)]], + constant NumericsParameters& p [[buffer(15)]], + uint i [[thread_position_in_grid]]) { + if (i < p.count) { + x[i] += p.scalar * inverse_diagonal[i] * (rhs[i] - ax[i]); + } +} + +kernel void numerics_dot(device const float* x [[buffer(0)]], device const float* y [[buffer(1)]], + device float* out [[buffer(2)]], + constant NumericsParameters& p [[buffer(15)]], + uint i [[thread_position_in_grid]], + uint local [[thread_index_in_threadgroup]], + uint group [[threadgroup_position_in_grid]]) { + threadgroup float values[128]; + values[local] = i < p.count ? x[i] * y[i] : 0; + threadgroup_barrier(mem_flags::mem_threadgroup); + + for (uint s = 64; s; s /= 2) { + if (local < s) { + values[local] += values[local + s]; + } + + threadgroup_barrier(mem_flags::mem_threadgroup); + } + + if (!local) { + out[group] = values[0]; + } +} + +kernel void numerics_apply(device const uint* offsets [[buffer(0)]], + device const uint* columns [[buffer(1)]], + device const float* values [[buffer(2)]], + device const float* x [[buffer(3)]], device float* y [[buffer(4)]], + constant NumericsParameters& p [[buffer(15)]], + uint i [[thread_position_in_grid]]) { + if (i >= p.count) { + return; + } + + float total = 0, correction = 0; + + for (uint j = offsets[i]; j < offsets[i + 1]; ++j) { + const float term = values[j] * x[columns[j]] - correction; + const float next = total + term; + correction = (next - total) - term; + total = next; + } + + y[i] = total; +} + +kernel void nutrient_growth(device const uint* offsets [[buffer(0)]], + device const float* cells [[buffer(1)]], + device const float* requirements [[buffer(2)]], + device const float* concentration [[buffer(3)]], + device const float* uptake [[buffer(4)]], + device float* alpha [[buffer(5)]], device float* output [[buffer(6)]], + constant NumericsParameters& p [[buffer(15)]], + uint i [[thread_position_in_grid]]) { + if (i >= p.count) { + return; + } + + const uint begin = offsets[i], end = offsets[i + 1]; + const float mass = cells[6 * i], mu = cells[6 * i + 1], area = cells[6 * i + 2]; + float limitation = 1.0f, extent = INFINITY, largest = 0.0f; + + for (uint j = begin; j < end; ++j) { + const float c = concentration[j], k = requirements[2 * j], y = requirements[2 * j + 1]; + limitation = fmin(limitation, c / (k + c)); + extent = fmin(extent, uptake[j] * y); + largest = fmax(largest, uptake[j] * y); + } + + for (uint j = begin; j < end; ++j) { + const float c = concentration[j], k = requirements[2 * j], y = requirements[2 * j + 1]; + alpha[j] = end - begin == 1 ? mu * mass / (y * area * (k + c)) + : (c > 0 ? mu * mass * limitation / (y * area * c) : 0.0f); + } + + output[5 * i] = extent; + output[5 * i + 1] = extent / cells[6 * i + 3]; + output[5 * i + 2] = output[5 * i + 1] / cells[6 * i + 4]; + output[5 * i + 3] = extent / (mass * cells[6 * i + 5]); + output[5 * i + 4] = largest > 0 ? (largest - extent) / largest : 0.0f; +} + +kernel void conservative_apply(device const uint* offsets [[buffer(0)]], + device const uint* columns [[buffer(1)]], + device const float* values [[buffer(2)]], + device const float* x [[buffer(3)]], device float* y [[buffer(4)]], + device const float* row_sums [[buffer(5)]], + constant NumericsParameters& p [[buffer(15)]], + uint i [[thread_position_in_grid]]) { + if (i >= p.count) { + return; + } + + float total = 0, correction = 0; + + for (unsigned j = offsets[i]; j < offsets[i + 1]; ++j) { + const float term = values[j] * (x[columns[j]] - x[i]) - correction; + const float next = total + term; + correction = (next - total) - term; + total = next; + } + + y[i] = total + row_sums[i] * x[i]; +} diff --git a/cpp/metal/kernels/occupancy.metal b/cpp/metal/kernels/occupancy.metal new file mode 100644 index 0000000..e6e6998 --- /dev/null +++ b/cpp/metal/kernels/occupancy.metal @@ -0,0 +1,305 @@ +#include +using namespace metal; + +struct OccupancyParameters { + uint count, auxiliary, subdivisions, absolute; + float scalar, cutoff, hx, hy, hz; +}; + +// Compensated, ordered sums avoid nondeterministic floating-point atomics. +void occupancy_add(float value, thread float& total, thread float& correction) { + float adjusted = value - correction; + float next = total + adjusted; + correction = (next - total) - adjusted; + total = next; +} + +kernel void occupancy_geometry(device const float* centers [[buffer(0)]], + device const float* cells [[buffer(1)]], + device const uint* walls [[buffer(2)]], + device float* out [[buffer(3)]], + constant OccupancyParameters& p [[buffer(15)]], + uint i [[thread_position_in_grid]]) { + if (i >= p.count) { + return; + } + + if (walls[i]) { + out[i] = 0; + + return; + } + + uint occupied = 0; + float3 center(centers[3 * i], centers[3 * i + 1], centers[3 * i + 2]); + + for (uint x = 0; x < p.subdivisions; ++x) { + for (uint y = 0; y < p.subdivisions; ++y) { + for (uint z = 0; z < p.subdivisions; ++z) { + float3 sample = + center + (float3(x, y, z) + 0.5f) / float(p.subdivisions) * float3(p.hx, p.hy, p.hz) - + 0.5f * float3(p.hx, p.hy, p.hz); + + for (uint cell = 0; cell < p.auxiliary; ++cell) { + uint base = 8 * cell; + float3 delta = sample - float3(cells[base], cells[base + 1], cells[base + 2]); + float3 direction(cells[base + 4], cells[base + 5], cells[base + 6]); + // Scaling first avoids overflow/underflow in direction normalization. + direction /= max(abs(direction.x), max(abs(direction.y), abs(direction.z))); + direction /= sqrt(dot(direction, direction)); + float axial = + clamp(dot(delta, direction), -0.5f * cells[base + 3], 0.5f * cells[base + 3]); + float3 distance = delta - axial * direction; + float radius = cells[base + 7]; + distance /= radius; + + if (dot(distance, distance) <= 1) { + ++occupied; + break; + } + } + } + } + } + + uint samples = p.subdivisions * p.subdivisions * p.subdivisions; + float epsilon = float(samples - occupied) / float(samples); + out[i] = epsilon < p.cutoff ? 0 : epsilon; +} + +kernel void occupancy_volumes(device const float* epsilon [[buffer(0)]], + device float* out [[buffer(1)]], + constant OccupancyParameters& p [[buffer(15)]], + uint i [[thread_position_in_grid]]) { + if (i < p.count) { + out[i] = epsilon[i] < p.cutoff ? 0 : epsilon[i] * p.scalar; + } +} + +kernel void occupancy_concentration(device const float* amount [[buffer(0)]], + device const float* volume [[buffer(1)]], + device float* out [[buffer(2)]], + constant OccupancyParameters& p [[buffer(15)]], + uint i [[thread_position_in_grid]]) { + if (i < p.count) { + out[i] = volume[i] > 0 ? amount[i] / volume[i] : 0; + } +} + +kernel void occupancy_face(device const float* data [[buffer(0)]], device float* out [[buffer(1)]], + constant OccupancyParameters& p [[buffer(15)]], + uint i [[thread_position_in_grid]]) { + if (i != 0) { + return; + } + + float aperture = + min(data[0], data[1]) < p.cutoff ? 0 : 2 * data[0] * data[1] / (data[0] + data[1]); + out[0] = data[2] * aperture * data[3] / data[4]; + out[1] = aperture * data[3] * data[5]; +} + +kernel void occupancy_product(device const float* a [[buffer(0)]], + device const float* b [[buffer(1)]], device float* out [[buffer(2)]], + constant OccupancyParameters& p [[buffer(15)]], + uint i [[thread_position_in_grid]]) { + if (i < p.count) { + out[i] = a[i] * b[i]; + } +} + +kernel void occupancy_sum(device const float* input [[buffer(0)]], device float* out [[buffer(1)]], + constant OccupancyParameters& p [[buffer(15)]], + uint i [[thread_position_in_grid]]) { + if (i != 0) { + return; + } + + float total = 0, correction = 0; + + for (uint j = 0; j < p.count; ++j) { + occupancy_add(p.absolute ? abs(input[j]) : input[j], total, correction); + } + + out[0] = total; +} + +kernel void occupancy_normalize(device const float* values [[buffer(0)]], + device const float* total [[buffer(1)]], + device float* out [[buffer(2)]], + constant OccupancyParameters& p [[buffer(15)]], + uint i [[thread_position_in_grid]]) { + if (i < p.count) { + out[i] = values[i] / total[0]; + } +} + +kernel void occupancy_labels_init(device const float* old [[buffer(0)]], + device const float* next [[buffer(1)]], + device uint* out [[buffer(2)]], + constant OccupancyParameters& p [[buffer(15)]], + uint i [[thread_position_in_grid]]) { + if (i < p.count) { + out[i] = old[i] > 0 || next[i] > 0 ? i : 0xffffffffu; + } +} + +kernel void occupancy_labels_step(device const uint* offsets [[buffer(0)]], + device const uint* indices [[buffer(1)]], + device const uint* labels [[buffer(2)]], + device uint* out [[buffer(3)]], + constant OccupancyParameters& p [[buffer(15)]], + uint i [[thread_position_in_grid]]) { + if (i >= p.count) { + return; + } + + uint label = labels[i]; + + if (label != 0xffffffffu) { + for (uint j = offsets[i]; j < offsets[i + 1]; ++j) { + label = min(label, labels[indices[j]]); + } + } + + out[i] = label; +} + +kernel void occupancy_component_sums(device const uint* offsets [[buffer(0)]], + device const uint* indices [[buffer(1)]], + device const float* amount [[buffer(2)]], + device const float* volume [[buffer(3)]], + device float* out [[buffer(4)]], + constant OccupancyParameters& p [[buffer(15)]], + uint i [[thread_position_in_grid]]) { + if (i >= p.count) { + return; + } + + float expelled = 0, capacity = 0, ec = 0, vc = 0; + + for (uint entry = offsets[i]; entry < offsets[i + 1]; ++entry) { + uint j = indices[entry]; + + if (volume[j] == 0) { + occupancy_add(amount[j], expelled, ec); + } else { + occupancy_add(volume[j], capacity, vc); + } + } + + out[2 * i] = expelled; + out[2 * i + 1] = capacity; +} + +kernel void occupancy_remap(device const float* amount [[buffer(0)]], + device const float* volume [[buffer(1)]], + device const uint* labels [[buffer(2)]], + device const float* totals [[buffer(3)]], + device float* out [[buffer(4)]], + constant OccupancyParameters& p [[buffer(15)]], + uint i [[thread_position_in_grid]]) { + if (i >= p.count) { + return; + } + + if (volume[i] == 0) { + out[i] = 0; + + return; + } + + uint component = labels[i]; + out[i] = amount[i] + totals[2 * component] * (volume[i] / totals[2 * component + 1]); +} + +kernel void occupancy_assemble( + device const uint* offsets [[buffer(0)]], device const uint* indices [[buffer(1)]], + device const float* edges [[buffer(2)]], device const float* volume [[buffer(3)]], + device const float* amount [[buffer(4)]], device const float* source [[buffer(5)]], + device const float* loss [[buffer(6)]], device float* diagonal [[buffer(7)]], + device float* rhs [[buffer(8)]], constant OccupancyParameters& p [[buffer(15)]], + uint i [[thread_position_in_grid]]) { + if (i >= p.count) { + return; + } + + float d = volume[i] * (1 + p.scalar * loss[i]); + float b = amount[i] + p.scalar * source[i]; + + for (uint j = offsets[i]; j < offsets[i + 1]; ++j) { + float g = edges[3 * j], q = edges[3 * j + 1]; + d += p.scalar * (g + max(q, 0.0f)); + + if (indices[j] == 0xffffffffu) { + b += p.scalar * (g + max(-q, 0.0f)) * edges[3 * j + 2]; + } + } + + diagonal[i] = volume[i] > 0 ? d : 1; + rhs[i] = b; +} + +kernel void occupancy_jacobi(device const float* diagonal [[buffer(0)]], + device const float* current [[buffer(1)]], + device const float* residual [[buffer(2)]], + device float* out [[buffer(3)]], + constant OccupancyParameters& p [[buffer(15)]], + uint i [[thread_position_in_grid]]) { + if (i >= p.count) { + return; + } + + out[i] = current[i] - residual[i] / diagonal[i]; +} + +kernel void occupancy_residual( + device const uint* offsets [[buffer(0)]], device const uint* indices [[buffer(1)]], + device const float* edges [[buffer(2)]], device const float* volume [[buffer(3)]], + device const float* amount [[buffer(4)]], device const float* source [[buffer(5)]], + device const float* loss [[buffer(6)]], device const float* current [[buffer(7)]], + device float* out [[buffer(8)]], constant OccupancyParameters& p [[buffer(15)]], + uint i [[thread_position_in_grid]]) { + if (i >= p.count) { + return; + } + + float c = current[i]; + float value = volume[i] * c - amount[i], correction = 0; + occupancy_add(p.scalar * (loss[i] * volume[i] * c - source[i]), value, correction); + + for (uint j = offsets[i]; j < offsets[i + 1]; ++j) { + float neighbor = indices[j] == 0xffffffffu ? edges[3 * j + 2] : current[indices[j]]; + float g = edges[3 * j], q = edges[3 * j + 1]; + occupancy_add(p.scalar * (g * (c - neighbor) + q * (q >= 0 ? c : neighbor)), value, correction); + } + + out[i] = value; +} + +kernel void occupancy_finish( + device const uint* offsets [[buffer(0)]], device const uint* indices [[buffer(1)]], + device const float* edges [[buffer(2)]], device const float* volume [[buffer(3)]], + device const float* loss [[buffer(4)]], device const float* current [[buffer(5)]], + device float* amount [[buffer(6)]], device float* reaction [[buffer(7)]], + device float* boundary [[buffer(8)]], constant OccupancyParameters& p [[buffer(15)]], + uint i [[thread_position_in_grid]]) { + if (i >= p.count) { + return; + } + + amount[i] = volume[i] * current[i]; + reaction[i] = -p.scalar * loss[i] * amount[i]; + float exchange = 0, correction = 0; + + for (uint j = offsets[i]; j < offsets[i + 1]; ++j) { + if (indices[j] == 0xffffffffu) { + float g = edges[3 * j], q = edges[3 * j + 1], reservoir = edges[3 * j + 2]; + occupancy_add( + p.scalar * (g * (reservoir - current[i]) - q * (q >= 0 ? current[i] : reservoir)), + exchange, correction); + } + } + + boundary[i] = exchange; +} diff --git a/cpp/metal/kernels/signals.metal b/cpp/metal/kernels/signals.metal index 8810bfa..95488cc 100644 --- a/cpp/metal/kernels/signals.metal +++ b/cpp/metal/kernels/signals.metal @@ -9,6 +9,68 @@ struct TransportPoint { float diagonal; }; +TransportPoint signal_transport_stencil(float diffusion_value, float current, GridFaceState faces, + uint3 dimensions, float4 spacing, float3 lower, + float3 upper) { + bool3 closed_lower = faces.closed_lower; + bool3 closed_upper = faces.closed_upper; + float3 grid_spacing = spacing.xyz; + float rate = 0.0f; + float diagonal = 0.0f; + + for (uint axis = 0; axis < 3u; ++axis) { + if (dimensions[axis] == 1u) { + continue; + } + + if (closed_lower[axis]) { + lower[axis] = current; + } + + if (closed_upper[axis]) { + upper[axis] = current; + } + + float inverse_spacing = 1.0f / grid_spacing[axis]; + float diffusion_scale = diffusion_value * inverse_spacing * inverse_spacing; + rate += diffusion_scale * (lower[axis] - 2.0f * current + upper[axis]); + diagonal -= 2.0f * diffusion_scale; + + if (closed_lower[axis]) { + diagonal += diffusion_scale; + } + + if (closed_upper[axis]) { + diagonal += diffusion_scale; + } + + float lower_flux = + faces.lower[axis] >= 0.0f ? faces.lower[axis] * lower[axis] : faces.lower[axis] * current; + float upper_flux = + faces.upper[axis] >= 0.0f ? faces.upper[axis] * current : faces.upper[axis] * upper[axis]; + + if (closed_lower[axis]) { + lower_flux = 0.0f; + } + + if (closed_upper[axis]) { + upper_flux = 0.0f; + } + + rate -= (upper_flux - lower_flux) * inverse_spacing; + + if (!closed_upper[axis] && faces.upper[axis] > 0.0f) { + diagonal -= faces.upper[axis] * inverse_spacing; + } + + if (!closed_lower[axis] && faces.lower[axis] < 0.0f) { + diagonal += faces.lower[axis] * inverse_spacing; + } + } + + return {rate, diagonal}; +} + TransportPoint transport_point(device const float* levels, device const float* diffusion, device const float4* advection, device const float* fixed_values, device const float* reaction_source, @@ -23,9 +85,11 @@ TransportPoint transport_point(device const float* levels, device const float* d uint yz = site - x * shape.y * shape.z; uint y = yz / shape.z; uint z = yz - y * shape.z; + if (obstacles[site] != 0u) { return {0.0f, 0.0f}; } + float current = levels[index]; float3 lower; @@ -55,51 +119,14 @@ TransportPoint transport_point(device const float* levels, device const float* d uint3 dimensions = uint3(shape.x, shape.y, shape.z); GridFaceState faces = grid_face_state(shape, boundary_kinds, obstacles, x_faces, y_faces, z_faces, has_velocity_field, advection[signal], x, y, z); - bool3 closed_lower = faces.closed_lower; - bool3 closed_upper = faces.closed_upper; - float3 grid_spacing = spacing.xyz; - float rate = 0.0f; - float diagonal = 0.0f; - for (uint axis = 0; axis < 3u; ++axis) { - if (dimensions[axis] == 1u) { - continue; - } - if (closed_lower[axis]) { - lower[axis] = current; - } - if (closed_upper[axis]) { - upper[axis] = current; - } - float inverse_spacing = 1.0f / grid_spacing[axis]; - float diffusion_scale = diffusion[signal] * inverse_spacing * inverse_spacing; - rate += diffusion_scale * (lower[axis] - 2.0f * current + upper[axis]); - diagonal -= 2.0f * diffusion_scale; - if (closed_lower[axis]) { - diagonal += diffusion_scale; - } - if (closed_upper[axis]) { - diagonal += diffusion_scale; - } - float lower_flux = - faces.lower[axis] >= 0.0f ? faces.lower[axis] * lower[axis] : faces.lower[axis] * current; - float upper_flux = - faces.upper[axis] >= 0.0f ? faces.upper[axis] * current : faces.upper[axis] * upper[axis]; - if (closed_lower[axis]) { - lower_flux = 0.0f; - } - if (closed_upper[axis]) { - upper_flux = 0.0f; - } - rate -= (upper_flux - lower_flux) * inverse_spacing; - if (!closed_upper[axis] && faces.upper[axis] > 0.0f) { - diagonal -= faces.upper[axis] * inverse_spacing; - } - if (!closed_lower[axis] && faces.lower[axis] < 0.0f) { - diagonal += faces.lower[axis] * inverse_spacing; - } - } + const auto transport = signal_transport_stencil(diffusion[signal], current, faces, dimensions, + spacing, lower, upper); + float rate = transport.rate; + float diagonal = transport.diagonal; + rate += reaction_source[index] - reaction_loss[index] * current; diagonal -= reaction_loss[index]; + return {rate, diagonal}; } @@ -128,6 +155,7 @@ kernel void advance_signal_grid( float candidate = levels[index] + scale * transport.rate; output[index] = candidate; + if (!isfinite(candidate) || (crank_nicolson == 0u && candidate < 0.0f)) { atomic_fetch_or_explicit(error, 1u, memory_order_relaxed); } @@ -148,6 +176,7 @@ kernel void crank_nicolson_jacobi( if (index >= level_count) { return; } + TransportPoint transport = transport_point(current, diffusion, advection, fixed_values, reaction_source, reaction_loss, obstacles, x_faces, y_faces, z_faces, has_velocity_field, boundary_kinds, @@ -156,6 +185,7 @@ kernel void crank_nicolson_jacobi( float candidate = (right_hand_side[index] + half_dt * remainder) / (1.0f - half_dt * transport.diagonal); output[index] = candidate; + if (!isfinite(candidate)) { atomic_fetch_or_explicit(error, 1u, memory_order_relaxed); } @@ -176,6 +206,7 @@ kernel void crank_nicolson_residual_terms( if (index >= level_count) { return; } + TransportPoint transport = transport_point(current, diffusion, advection, fixed_values, reaction_source, reaction_loss, obstacles, x_faces, y_faces, z_faces, has_velocity_field, boundary_kinds, @@ -191,6 +222,7 @@ kernel void signal_square_terms(device const float* input [[buffer(0)]], if (index >= element_count) { return; } + terms[index] = input[index] * input[index]; } @@ -199,12 +231,16 @@ kernel void reduce_signal_sum_pairs(device const float* input [[buffer(0)]], constant uint& element_count [[buffer(2)]], uint index [[thread_position_in_grid]]) { uint first = index * 2u; + if (first >= element_count) { return; } + float value = input[first]; + if (first + 1u < element_count) { value += input[first + 1u]; } + output[index] = value; } diff --git a/cpp/metal/kernels/species.metal b/cpp/metal/kernels/species.metal index acc5498..73e510b 100644 --- a/cpp/metal/kernels/species.metal +++ b/cpp/metal/kernels/species.metal @@ -20,6 +20,53 @@ float effective_surface_area(float length, float radius) { return 2.0f * pi * radius * (length + 2.0f * radius); } +float evaluate_predicate(const RateInstruction instruction, device const float* workspace) { + switch (instruction.operation) { + case 21: + return workspace[instruction.first] < workspace[instruction.second] ? 1.0f : 0.0f; + case 22: + return workspace[instruction.first] <= workspace[instruction.second] ? 1.0f : 0.0f; + case 23: + return workspace[instruction.first] > workspace[instruction.second] ? 1.0f : 0.0f; + case 24: + return workspace[instruction.first] >= workspace[instruction.second] ? 1.0f : 0.0f; + case 25: + return workspace[instruction.first] == workspace[instruction.second] ? 1.0f : 0.0f; + case 26: + return workspace[instruction.first] != 0.0f ? workspace[instruction.second] + : workspace[instruction.third]; + default: + return NAN; + } +} + +float evaluate_arithmetic(const RateInstruction instruction, device const float* workspace) { + switch (instruction.operation) { + case 11: + return workspace[instruction.first] + workspace[instruction.second]; + case 12: + return workspace[instruction.first] - workspace[instruction.second]; + case 13: + return workspace[instruction.first] * workspace[instruction.second]; + case 14: + return workspace[instruction.first] / workspace[instruction.second]; + case 15: + return pow(workspace[instruction.first], workspace[instruction.second]); + case 16: + return min(workspace[instruction.first], workspace[instruction.second]); + case 17: + return max(workspace[instruction.first], workspace[instruction.second]); + case 18: + return -workspace[instruction.first]; + case 19: + return exp(workspace[instruction.first]); + case 20: + return log(workspace[instruction.first]); + default: + return evaluate_predicate(instruction, workspace); + } +} + float evaluate_instruction(const RateInstruction instruction, device const float* workspace, device const float* species, float4 center, float4 geometry, float growth_rate, int cell_type, float volume_change_rate) { @@ -45,44 +92,11 @@ float evaluate_instruction(const RateInstruction instruction, device const float case 28: return volume_change_rate; case 9: - return effective_volume(geometry.x, geometry.y); + return geometry.z > 0 ? geometry.z : effective_volume(geometry.x, geometry.y); case 10: return effective_surface_area(geometry.x, geometry.y); - case 11: - return workspace[instruction.first] + workspace[instruction.second]; - case 12: - return workspace[instruction.first] - workspace[instruction.second]; - case 13: - return workspace[instruction.first] * workspace[instruction.second]; - case 14: - return workspace[instruction.first] / workspace[instruction.second]; - case 15: - return pow(workspace[instruction.first], workspace[instruction.second]); - case 16: - return min(workspace[instruction.first], workspace[instruction.second]); - case 17: - return max(workspace[instruction.first], workspace[instruction.second]); - case 18: - return -workspace[instruction.first]; - case 19: - return exp(workspace[instruction.first]); - case 20: - return log(workspace[instruction.first]); - case 21: - return workspace[instruction.first] < workspace[instruction.second] ? 1.0f : 0.0f; - case 22: - return workspace[instruction.first] <= workspace[instruction.second] ? 1.0f : 0.0f; - case 23: - return workspace[instruction.first] > workspace[instruction.second] ? 1.0f : 0.0f; - case 24: - return workspace[instruction.first] >= workspace[instruction.second] ? 1.0f : 0.0f; - case 25: - return workspace[instruction.first] == workspace[instruction.second] ? 1.0f : 0.0f; - case 26: - return workspace[instruction.first] != 0.0f ? workspace[instruction.second] - : workspace[instruction.third]; default: - return NAN; + return evaluate_arithmetic(instruction, workspace); } } @@ -101,8 +115,12 @@ kernel void advance_species( uint species_offset = cell * species_count; float radius = geometry[cell].y; - float dilution = - effective_volume(previous_lengths[cell], radius) / effective_volume(geometry[cell].x, radius); + float previous_volume = + geometry[cell].z > 0 ? geometry[cell].w : effective_volume(previous_lengths[cell], radius); + float current_volume = + geometry[cell].z > 0 ? geometry[cell].z : effective_volume(geometry[cell].x, radius); + float dilution = previous_volume / current_volume; + for (uint species = 0; species < species_count; ++species) { levels[species_offset + species] *= dilution; } @@ -110,15 +128,14 @@ kernel void advance_species( uint workspace_offset = cell * instruction_count; device float* cell_workspace = workspace + workspace_offset; device const float* cell_species = levels + species_offset; + for (uint index = 0; index < instruction_count; ++index) { float value = evaluate_instruction(instructions[index], cell_workspace, cell_species, centers[cell], geometry[cell], growth_rates[cell], cell_types[cell], - dt == 0.0f ? 0.0f - : (effective_volume(geometry[cell].x, radius) - - effective_volume(previous_lengths[cell], radius)) / - dt); + dt == 0.0f ? 0.0f : (current_volume - previous_volume) / dt); cell_workspace[index] = value; + if (!isfinite(value)) { atomic_fetch_or_explicit(error, 1u, memory_order_relaxed); } @@ -127,6 +144,7 @@ kernel void advance_species( for (uint species = 0; species < species_count; ++species) { float value = levels[species_offset + species] + dt * cell_workspace[outputs[species]]; levels[species_offset + species] = value; + if (!isfinite(value)) { atomic_fetch_or_explicit(error, 1u, memory_order_relaxed); } diff --git a/cpp/metal/metal_backend.mm b/cpp/metal/metal_backend.mm index b3e4a6e..2e4ab9f 100644 --- a/cpp/metal/metal_backend.mm +++ b/cpp/metal/metal_backend.mm @@ -95,29 +95,37 @@ struct alignas(16) MetalExternalConstraint { id compile_library(id device, const char* source_text, const char* operation) { NSString* source = [NSString stringWithUTF8String:source_text]; + if (source == nil) { throw std::runtime_error(std::string(operation) + ": source is not valid UTF-8"); } + NSError* error = nil; id library = [device newLibraryWithSource:source options:nil error:&error]; + if (library == nil) { throw_metal_error(operation, error); } + return library; } id compile_pipeline(id device, id library, NSString* function_name, const char* operation) { id function = [library newFunctionWithName:function_name]; + if (function == nil) { throw std::runtime_error(std::string(operation) + ": function is missing from the library"); } + NSError* error = nil; id pipeline = [device newComputePipelineStateWithFunction:function error:&error]; + if (pipeline == nil) { throw_metal_error(operation, error); } + return pipeline; } @@ -125,15 +133,18 @@ struct alignas(16) MetalExternalConstraint { const char* description) { id buffer = [device newBufferWithLength:byte_count options:MTLResourceStorageModeShared]; + if (buffer == nil) { throw std::runtime_error(std::string("failed to allocate Metal ") + description); } + return buffer; } void wait_for_command(id command_buffer, const char* operation) { [command_buffer commit]; [command_buffer waitUntilCompleted]; + if (command_buffer.status == MTLCommandBufferStatusError) { throw_metal_error(operation, command_buffer.error); } @@ -147,18 +158,23 @@ void dispatch_1d(id encoder, id select_metal_device(std::uint32_t device_index) { NSArray>* devices = MTLCopyAllDevices(); + if (devices.count == 0) { id default_device = MTLCreateSystemDefaultDevice(); + if (device_index == 0 && default_device != nil) { return default_device; } + if (default_device == nil) { throw std::runtime_error("Metal is unavailable on this system"); } } + if (static_cast(device_index) >= devices.count) { throw std::out_of_range("Metal device index is unavailable"); } + return devices[device_index]; } @@ -168,6 +184,7 @@ explicit MetalBackend(std::uint32_t device_index) : device_index_(device_index) @autoreleasepool { device_ = select_metal_device(device_index_); queue_ = [device_ newCommandQueue]; + if (queue_ == nil) { throw std::runtime_error("failed to create a Metal command queue"); } @@ -260,6 +277,7 @@ explicit MetalBackend(std::uint32_t device_index) : device_index_(device_index) [[nodiscard]] BackendInfo info() const override { @autoreleasepool { const char* device_name = device_.name.UTF8String; + return { .kind = BackendKind::metal, .name = "metal", @@ -276,17 +294,20 @@ explicit MetalBackend(std::uint32_t device_index) : device_index_(device_index) feature == BackendFeature::external_constraints || feature == BackendFeature::signals || feature == BackendFeature::coupled_rates || feature == BackendFeature::depth_averaged_flow || - feature == BackendFeature::resolved_flow; + feature == BackendFeature::resolved_flow || feature == BackendFeature::culture; } void advance_growth(WorldState& state, float dt) override { auto view = state.growth_state(); + if (view.lengths.empty()) { return; } + if (view.lengths.size() > std::numeric_limits::max()) { throw std::overflow_error("Metal growth launch exceeds the uint32 index space"); } + ensure_growth_capacity(view.lengths.size()); const auto byte_count = view.lengths.size_bytes(); @@ -318,49 +339,29 @@ void advance_growth(WorldState& state, float dt) override { } void advance_species(WorldState& state, const SpeciesRatePlan& plan, - std::span previous_lengths, float dt) override { + std::span previous_lengths, float dt, + BiochemicalVolumeView volumes = {}) override { if (!std::isfinite(dt) || dt < 0.0F) { throw std::invalid_argument("species time step must be finite and non-negative"); } + state.validate(); + volumes.validate(state.size()); plan.validate(); + if (plan.species_count() != state.species_count()) { throw std::invalid_argument("species rate plan and world state species counts disagree"); } + if (previous_lengths.size() != state.size()) { throw std::invalid_argument("previous cell lengths and world state cell counts disagree"); } + if (state.empty() || state.species_count() == 0) { return; } - if (state.size() > std::numeric_limits::max() || - state.species_count() > std::numeric_limits::max() || - plan.instructions().size() > std::numeric_limits::max()) { - throw std::overflow_error("Metal species launch exceeds the uint32 index space"); - } - if (!std::ranges::all_of(previous_lengths, - [](float value) { return std::isfinite(value) && value >= 0.0F; })) { - throw std::invalid_argument("previous cell lengths must be finite and non-negative"); - } - if (state.size() > std::numeric_limits::max() / state.species_count() || - state.size() > std::numeric_limits::max() / plan.instructions().size()) { - throw std::overflow_error("Metal species buffer size overflow"); - } - const auto level_count = state.size() * state.species_count(); - const auto workspace_count = state.size() * plan.instructions().size(); - if (level_count > std::numeric_limits::max() || - workspace_count > std::numeric_limits::max()) { - throw std::overflow_error("Metal flattened species storage exceeds the uint32 index space"); - } - if (level_count > std::numeric_limits::max() / sizeof(float) || - workspace_count > std::numeric_limits::max() / sizeof(float) || - plan.instructions().size() > - std::numeric_limits::max() / sizeof(MetalRateInstruction)) { - throw std::overflow_error("Metal species allocation size overflow"); - } - ensure_species_capacity(state.size(), level_count, plan.instructions().size(), - state.species_count(), workspace_count); + prepare_species_storage(state, plan, previous_lengths); const auto geometry = state.geometry_state(); const auto attributes = state.cell_attributes(); @@ -375,12 +376,17 @@ void advance_species(WorldState& state, const SpeciesRatePlan& plan, attributes.cell_types.size_bytes()); auto* centers = static_cast(species_centers_.contents); auto* shapes = static_cast(species_geometry_.contents); + for (std::size_t index = 0; index < state.size(); ++index) { centers[index] = {geometry.position_x[index], geometry.position_y[index], geometry.position_z[index], 0.0F}; - shapes[index] = {geometry.lengths[index], geometry.radii[index], 0.0F, 0.0F}; + shapes[index] = {geometry.lengths[index], geometry.radii[index], + volumes.current.empty() ? 0.0F : volumes.current[index], + volumes.previous.empty() ? 0.0F : volumes.previous[index]}; } + auto* instructions = static_cast(species_instructions_.contents); + for (std::size_t index = 0; index < plan.instructions().size(); ++index) { const auto& instruction = plan.instructions()[index]; instructions[index] = { @@ -391,6 +397,7 @@ void advance_species(WorldState& state, const SpeciesRatePlan& plan, .value = instruction.value, }; } + std::memcpy(species_outputs_.contents, plan.outputs().data(), plan.outputs().size_bytes()); *static_cast(species_error_.contents) = 0; @@ -432,60 +439,19 @@ void advance_species(WorldState& state, const SpeciesRatePlan& plan, SignalSolveReport advance_signal_grid(SignalGrid& grid, float dt) override { grid.validate(); grid.validate_step(dt); + if (dt == 0.0F) { return {}; } + const auto& spec = grid.spec(); const auto levels = grid.levels(); const auto level_count = static_cast(levels.size()); const auto signal_count = spec.signal_count; ensure_signal_capacity(levels.size(), signal_count); - std::memcpy(signal_levels_.contents, levels.data(), levels.size_bytes()); - std::memcpy(signal_diffusion_.contents, spec.diffusion.data(), - spec.diffusion.size() * sizeof(float)); - auto* reaction_source = static_cast(signal_reaction_source_.contents); - auto* reaction_loss = static_cast(signal_reaction_loss_.contents); - if (spec.reaction.has_value()) { - std::memcpy(reaction_source, spec.reaction->source_rates.data(), levels.size_bytes()); - std::memcpy(reaction_loss, spec.reaction->loss_rates.data(), levels.size_bytes()); - } else { - std::fill_n(reaction_source, levels.size(), 0.0F); - std::fill_n(reaction_loss, levels.size(), 0.0F); - } - auto* obstacles = static_cast(signal_obstacles_.contents); - if (spec.has_obstacles()) { - std::memcpy(obstacles, spec.obstacles.data(), spec.obstacles.size()); - } else { - std::fill_n(obstacles, spec.site_count(), std::uint8_t{0}); - } - ensure_signal_face_capacity(largest_face_count(spec)); - fill_velocity_faces(spec, signal_x_faces_, signal_y_faces_, signal_z_faces_); + const auto boundary_kinds = upload_signal_inputs(spec, levels); const auto has_velocity_field = static_cast(spec.velocity_field.has_value()); - auto* advection = static_cast(signal_advection_.contents); - for (std::size_t signal = 0; signal < signal_count; ++signal) { - advection[signal] = { - spec.advection[signal].x, - spec.advection[signal].y, - spec.advection[signal].z, - 0.0F, - }; - } - - const std::array boundaries{ - &spec.x_lower, &spec.x_upper, &spec.y_lower, &spec.y_upper, &spec.z_lower, &spec.z_upper, - }; - auto* fixed_values = static_cast(signal_fixed_values_.contents); - std::fill_n(fixed_values, static_cast(6) * signal_count, 0.0F); - std::array boundary_kinds{}; - for (std::size_t face = 0; face < boundaries.size(); ++face) { - boundary_kinds[face] = static_cast(boundaries[face]->kind); - if (boundaries[face]->kind == GridBoundaryKind::fixed) { - std::copy(boundaries[face]->values.begin(), boundaries[face]->values.end(), - fixed_values + (face * signal_count)); - } - } - *static_cast(signal_error_.contents) = 0; const MetalUInt4 shape{spec.shape.x, spec.shape.y, spec.shape.z, static_cast(spec.site_count())}; @@ -539,6 +505,7 @@ SignalSolveReport advance_signal_grid(SignalGrid& grid, float dt) override { level_count, spec.solver); result_buffer = solve.first; report = solve.second; + if (!report.converged) { throw std::runtime_error("Metal Implicit signal solve did not converge after " + std::to_string(report.iterations) + " iterations"); @@ -557,6 +524,7 @@ SignalSolveReport advance_coupled(WorldState& state, SignalGrid& grid, if (right != 0 && left > std::numeric_limits::max() / right) { throw std::overflow_error(std::string("Metal coupled ") + name + " size overflow"); } + return left * right; }; const auto cell_count_size = state.size(); @@ -570,6 +538,7 @@ SignalSolveReport advance_coupled(WorldState& state, SignalGrid& grid, const auto cell_signal_count = checked_product(cell_count_size, signal_count_size, "cell signal"); const auto grid_level_count = grid.levels().size(); + for (const auto count : {cell_count_size, species_count_size, signal_count_size, instruction_count_size, species_level_count, workspace_count, cell_signal_count, grid_level_count}) { @@ -577,91 +546,18 @@ SignalSolveReport advance_coupled(WorldState& state, SignalGrid& grid, throw std::overflow_error("Metal coupled launch exceeds the uint32 index space"); } } + ensure_coupled_capacity(cell_count_size, species_level_count, instruction_count_size, species_count_size, signal_count_size, workspace_count, cell_signal_count, grid_level_count); - const auto geometry = state.geometry_state(); - const auto attributes = state.cell_attributes(); + upload_coupled_cells(state, plan, previous_lengths); auto species_state = state.species_state(); const auto& spec = grid.spec(); const auto grid_levels = grid.levels(); - if (!species_state.levels.empty()) { - std::memcpy(coupled_species_levels_.contents, species_state.levels.data(), - species_state.levels.size_bytes()); - } - if (!previous_lengths.empty()) { - std::memcpy(coupled_previous_lengths_.contents, previous_lengths.data(), - previous_lengths.size_bytes()); - std::memcpy(coupled_growth_rates_.contents, attributes.growth_rates.data(), - attributes.growth_rates.size_bytes()); - std::memcpy(coupled_cell_types_.contents, attributes.cell_types.data(), - attributes.cell_types.size_bytes()); - } - auto* centers = static_cast(coupled_centers_.contents); - auto* cell_geometry = static_cast(coupled_geometry_.contents); - for (std::size_t index = 0; index < cell_count_size; ++index) { - centers[index] = {geometry.position_x[index], geometry.position_y[index], - geometry.position_z[index], 0.0F}; - cell_geometry[index] = {geometry.lengths[index], geometry.radii[index], 0.0F, 0.0F}; - } - auto* instructions = static_cast(coupled_instructions_.contents); - for (std::size_t index = 0; index < instruction_count_size; ++index) { - const auto& instruction = plan.instructions()[index]; - instructions[index] = { - .operation = static_cast(instruction.operation), - .first = instruction.first, - .second = instruction.second, - .third = instruction.third, - .value = instruction.value, - }; - } - if (!plan.species_outputs().empty()) { - std::memcpy(coupled_species_outputs_.contents, plan.species_outputs().data(), - plan.species_outputs().size_bytes()); - } - std::memcpy(coupled_signal_outputs_.contents, plan.signal_outputs().data(), - plan.signal_outputs().size_bytes()); - std::memcpy(coupled_grid_levels_.contents, grid_levels.data(), grid_levels.size_bytes()); - std::memcpy(coupled_diffusion_.contents, spec.diffusion.data(), - spec.diffusion.size() * sizeof(float)); - auto* reaction_source = static_cast(coupled_reaction_source_.contents); - auto* reaction_loss = static_cast(coupled_reaction_loss_.contents); - if (spec.reaction.has_value()) { - std::memcpy(reaction_source, spec.reaction->source_rates.data(), grid_levels.size_bytes()); - std::memcpy(reaction_loss, spec.reaction->loss_rates.data(), grid_levels.size_bytes()); - } else { - std::fill_n(reaction_source, grid_level_count, 0.0F); - std::fill_n(reaction_loss, grid_level_count, 0.0F); - } - auto* obstacles = static_cast(coupled_obstacles_.contents); - if (spec.has_obstacles()) { - std::memcpy(obstacles, spec.obstacles.data(), spec.obstacles.size()); - } else { - std::fill_n(obstacles, spec.site_count(), std::uint8_t{0}); - } - ensure_coupled_face_capacity(largest_face_count(spec)); - fill_velocity_faces(spec, coupled_x_faces_, coupled_y_faces_, coupled_z_faces_); + + const auto boundary_kinds = upload_coupled_grid(spec, grid_levels); const auto has_velocity_field = static_cast(spec.velocity_field.has_value()); - auto* advection = static_cast(coupled_advection_.contents); - for (std::size_t signal = 0; signal < signal_count_size; ++signal) { - advection[signal] = {spec.advection[signal].x, spec.advection[signal].y, - spec.advection[signal].z, 0.0F}; - } - const std::array boundaries{ - &spec.x_lower, &spec.x_upper, &spec.y_lower, &spec.y_upper, &spec.z_lower, &spec.z_upper, - }; - auto* fixed_values = static_cast(coupled_fixed_values_.contents); - std::fill_n(fixed_values, static_cast(6) * signal_count_size, 0.0F); - std::array boundary_kinds{}; - for (std::size_t face = 0; face < boundaries.size(); ++face) { - boundary_kinds[face] = static_cast(boundaries[face]->kind); - if (boundaries[face]->kind == GridBoundaryKind::fixed) { - std::copy(boundaries[face]->values.begin(), boundaries[face]->values.end(), - fixed_values + (face * signal_count_size)); - } - } - *static_cast(coupled_error_.contents) = 0; const auto cell_count = static_cast(cell_count_size); const auto species_count = static_cast(species_count_size); @@ -673,78 +569,19 @@ SignalSolveReport advance_coupled(WorldState& state, SignalGrid& grid, const MetalFloat4 origin{spec.origin.x, spec.origin.y, spec.origin.z, 0.0F}; const MetalFloat4 spacing{spec.spacing.x, spec.spacing.y, spec.spacing.z, 0.0F}; const auto crank_nicolson = static_cast(spec.integration); - @autoreleasepool { - id command_buffer = [queue_ commandBuffer]; - id encoder = [command_buffer computeCommandEncoder]; - if (command_buffer == nil || encoder == nil) { - throw std::runtime_error("failed to create a Metal coupled-rate command"); - } - if (cell_count != 0) { - [encoder setComputePipelineState:coupled_cells_pipeline_]; - [encoder setBuffer:coupled_species_levels_ offset:0 atIndex:0]; - [encoder setBuffer:coupled_previous_lengths_ offset:0 atIndex:1]; - [encoder setBuffer:coupled_centers_ offset:0 atIndex:2]; - [encoder setBuffer:coupled_geometry_ offset:0 atIndex:3]; - [encoder setBuffer:coupled_growth_rates_ offset:0 atIndex:4]; - [encoder setBuffer:coupled_cell_types_ offset:0 atIndex:5]; - [encoder setBuffer:coupled_instructions_ offset:0 atIndex:6]; - [encoder setBuffer:coupled_species_outputs_ offset:0 atIndex:7]; - [encoder setBuffer:coupled_signal_outputs_ offset:0 atIndex:8]; - [encoder setBuffer:coupled_workspace_ offset:0 atIndex:9]; - [encoder setBuffer:coupled_grid_levels_ offset:0 atIndex:10]; - [encoder setBuffer:coupled_cell_signal_rates_ offset:0 atIndex:11]; - [encoder setBuffer:coupled_error_ offset:0 atIndex:12]; - [encoder setBytes:&shape length:sizeof(shape) atIndex:13]; - [encoder setBytes:&origin length:sizeof(origin) atIndex:14]; - [encoder setBytes:&spacing length:sizeof(spacing) atIndex:15]; - [encoder setBytes:&dt length:sizeof(dt) atIndex:16]; - [encoder setBytes:&species_count length:sizeof(species_count) atIndex:17]; - [encoder setBytes:&signal_count length:sizeof(signal_count) atIndex:18]; - [encoder setBytes:&instruction_count length:sizeof(instruction_count) atIndex:19]; - [encoder setBytes:&cell_count length:sizeof(cell_count) atIndex:20]; - [encoder setBuffer:coupled_obstacles_ offset:0 atIndex:21]; - dispatch_1d(encoder, coupled_cells_pipeline_, cell_count); - [encoder memoryBarrierWithScope:MTLBarrierScopeBuffers]; - } - - [encoder setComputePipelineState:coupled_grid_pipeline_]; - [encoder setBuffer:coupled_grid_levels_ offset:0 atIndex:0]; - [encoder setBuffer:coupled_grid_output_ offset:0 atIndex:1]; - [encoder setBuffer:coupled_diffusion_ offset:0 atIndex:2]; - [encoder setBuffer:coupled_advection_ offset:0 atIndex:3]; - [encoder setBuffer:coupled_fixed_values_ offset:0 atIndex:4]; - [encoder setBuffer:coupled_centers_ offset:0 atIndex:5]; - [encoder setBuffer:coupled_cell_signal_rates_ offset:0 atIndex:6]; - [encoder setBuffer:coupled_error_ offset:0 atIndex:7]; - [encoder setBytes:boundary_kinds.data() - length:boundary_kinds.size() * sizeof(std::uint32_t) - atIndex:8]; - [encoder setBytes:&shape length:sizeof(shape) atIndex:9]; - [encoder setBytes:&origin length:sizeof(origin) atIndex:10]; - [encoder setBytes:&spacing length:sizeof(spacing) atIndex:11]; - [encoder setBytes:&dt length:sizeof(dt) atIndex:12]; - [encoder setBytes:&signal_count length:sizeof(signal_count) atIndex:13]; - [encoder setBytes:&cell_count length:sizeof(cell_count) atIndex:14]; - [encoder setBytes:&level_count length:sizeof(level_count) atIndex:15]; - [encoder setBytes:&crank_nicolson length:sizeof(crank_nicolson) atIndex:16]; - [encoder setBuffer:coupled_reaction_source_ offset:0 atIndex:17]; - [encoder setBuffer:coupled_reaction_loss_ offset:0 atIndex:18]; - [encoder setBuffer:coupled_obstacles_ offset:0 atIndex:19]; - [encoder setBuffer:coupled_x_faces_ offset:0 atIndex:20]; - [encoder setBuffer:coupled_y_faces_ offset:0 atIndex:21]; - [encoder setBuffer:coupled_z_faces_ offset:0 atIndex:22]; - [encoder setBytes:&has_velocity_field length:sizeof(has_velocity_field) atIndex:23]; - dispatch_1d(encoder, coupled_grid_pipeline_, level_count); - [encoder endEncoding]; - wait_for_command(command_buffer, "Metal coupled-rate command failed"); - } + dispatch_coupled(shape, origin, spacing, boundary_kinds, dt, cell_count, species_count, + signal_count, instruction_count, level_count, crank_nicolson, + has_velocity_field); const auto error = *static_cast(coupled_error_.contents); + if (error != 0) { throw std::domain_error("Metal coupled-rate kernel produced an invalid value"); } + id result_buffer = coupled_grid_output_; SignalSolveReport report; + if (crank_nicolson != 0) { const auto solve = solve_signal_crank_nicolson( coupled_grid_levels_, coupled_grid_output_, coupled_diffusion_, coupled_advection_, @@ -754,20 +591,24 @@ SignalSolveReport advance_coupled(WorldState& state, SignalGrid& grid, (crank_nicolson == 2 ? dt : 0.5F * dt), signal_count, level_count, spec.solver); result_buffer = solve.first; report = solve.second; + if (!report.converged) { - throw std::runtime_error( - "Metal Implicit coupled signal solve did not converge after " + - std::to_string(report.iterations) + " iterations"); + throw std::runtime_error("Metal Implicit coupled signal solve did not converge after " + + std::to_string(report.iterations) + " iterations"); } } + const auto* output = static_cast(result_buffer.contents); std::vector next_grid(output, output + grid_level_count); SignalGridCheckpoint{.spec = spec, .levels = next_grid}.validate(); + if (!species_state.levels.empty()) { std::memcpy(species_state.levels.data(), coupled_species_levels_.contents, species_state.levels.size_bytes()); } + grid.replace_levels(std::move(next_grid)); + return report; } @@ -775,16 +616,21 @@ SignalSolveReport advance_coupled(WorldState& state, SignalGrid& grid, const ContactParameters& parameters) override { validate_contact_parameters(parameters); const auto geometry = state.geometry_state(); + if (geometry.size() == 0) { return ContactGraph{}; } + if (geometry.size() > std::numeric_limits::max()) { throw std::overflow_error("Metal contact launch exceeds the uint32 cell index space"); } + const auto candidates = find_cell_contact_candidates(state, parameters); + if (candidates.empty()) { return ContactGraph(geometry.size(), {}); } + if (candidates.size() > std::numeric_limits::max() / 2) { throw std::overflow_error("Metal contact candidates exceed the uint32 scan space"); } @@ -796,12 +642,14 @@ SignalSolveReport advance_coupled(WorldState& state, SignalGrid& grid, upload_contact_candidates(candidates); const auto candidate_count = static_cast(candidates.size()); const auto contact_count = count_contacts(candidate_count, parameters); + if (contact_count == 0) { return ContactGraph(geometry.size(), {}); } ensure_contact_output_capacity(contact_count); fill_contacts(candidate_count, parameters); + return download_contacts(geometry.size(), contact_count); } @@ -811,17 +659,22 @@ SignalSolveReport advance_coupled(WorldState& state, SignalGrid& grid, validate_constraint_contact_parameters(parameters); state.validate(); const auto geometry = state.geometry_state(); + if (geometry.size() == 0 || constraints.empty()) { return ExternalContactGraph(geometry.size(), {}); } + if (geometry.size() > std::numeric_limits::max() || constraints.size() > std::numeric_limits::max()) { throw std::overflow_error("Metal external-contact launch exceeds the uint32 index space"); } + if (geometry.size() > std::numeric_limits::max() / constraints.size()) { throw std::overflow_error("Metal external-contact pair count overflow"); } + const auto pair_count = geometry.size() * constraints.size(); + if (pair_count > std::numeric_limits::max() / 2) { throw std::overflow_error("Metal external-contact staging exceeds the uint32 scan space"); } @@ -834,6 +687,7 @@ SignalSolveReport advance_coupled(WorldState& state, SignalGrid& grid, const auto contact_count = count_external_contacts( static_cast(geometry.size()), static_cast(constraints.size()), static_cast(pair_count), parameters); + if (contact_count == 0) { return ExternalContactGraph(geometry.size(), {}); } @@ -841,6 +695,7 @@ SignalSolveReport advance_coupled(WorldState& state, SignalGrid& grid, ensure_contact_output_capacity(contact_count); fill_external_contacts(static_cast(geometry.size()), static_cast(constraints.size()), parameters); + return download_external_contacts(geometry.size(), contact_count); } @@ -851,122 +706,427 @@ SignalSolveReport advance_coupled(WorldState& state, SignalGrid& grid, validate_mechanics_parameters(parameters); state.validate(); const auto geometry = state.geometry_state(); + if (contacts.cell_count() != geometry.size()) { throw std::invalid_argument("contact graph and world state cell counts disagree"); } + if (external_contacts.cell_count() != geometry.size()) { throw std::invalid_argument("external contact graph and world state cell counts disagree"); } + if (external_contacts.size() > std::numeric_limits::max() - contacts.size()) { throw std::overflow_error("Metal mechanics row count overflow"); } + const auto row_count = contacts.size() + external_contacts.size(); + if (geometry.size() > std::numeric_limits::max() || row_count > std::numeric_limits::max() / 2) { throw std::overflow_error("Metal mechanics exceeds the uint32 index space"); } - MechanicsSolveResult result; - result.corrections.resize(geometry.size()); - if (geometry.size() == 0 || row_count == 0) { - return result; + MechanicsSolveResult result; + result.corrections.resize(geometry.size()); + + if (geometry.size() == 0 || row_count == 0) { + return result; + } + + validate_mechanics_contacts(geometry, contacts); + validate_external_mechanics_contacts(geometry, external_contacts); + ensure_contact_cell_capacity(geometry.size()); + ensure_contact_output_capacity(row_count); + ensure_mechanics_capacity(geometry.size(), row_count); + upload_contact_cells(geometry); + upload_mechanics_fixed(state.cell_attributes().fixed); + upload_mechanics_contacts(contacts, external_contacts); + upload_mechanics_incidence(contacts, external_contacts); + + const auto cell_count = static_cast(geometry.size()); + const auto contact_count = static_cast(row_count); + auto residual_squared = initialize_mechanics(cell_count, contact_count); + result.report.initial_residual_rms = + std::sqrt(residual_squared / static_cast(cell_count)); + result.report.final_residual_rms = result.report.initial_residual_rms; + + if (!std::isfinite(result.report.initial_residual_rms)) { + result.report.status = SolverStatus::breakdown; + result.report.breakdown = SolverBreakdown::non_finite_residual; + + return result; + } + + if (result.report.initial_residual_rms <= parameters.residual_rms_tolerance) { + return result; + } + + result.report.status = SolverStatus::iteration_limit; + const auto maximum_iterations = mechanics_iteration_limit(parameters, geometry.size()); + + iterate_mechanics(cell_count, contact_count, maximum_iterations, parameters, residual_squared, + result.report); + + residual_squared = recompute_residual(cell_count, contact_count, parameters); + result.report.final_residual_rms = std::sqrt(residual_squared / static_cast(cell_count)); + + if (!std::isfinite(result.report.final_residual_rms) && + result.report.status != SolverStatus::breakdown) { + result.report.status = SolverStatus::breakdown; + result.report.breakdown = SolverBreakdown::non_finite_residual; + } + + result.corrections = download_mechanics_solution(geometry.size()); + + return result; + } + + [[nodiscard]] DepthAveragedFlowResult solve_depth_averaged_flow( + const SignalGridSpec& spec, std::span mobility, + const DepthAveragedFlowParameters& parameters) override { + return flow_solver_->solve_depth_averaged(spec, mobility, parameters); + } + + [[nodiscard]] ResolvedFlowResult solve_resolved_flow( + const SignalGridSpec& spec, std::span drag, + const ResolvedFlowParameters& parameters) override { + return flow_solver_->solve_resolved(spec, drag, parameters); + } + + private: + void prepare_species_storage(const WorldState& state, const SpeciesRatePlan& plan, + std::span previous_lengths) { + if (state.size() > std::numeric_limits::max() || + state.species_count() > std::numeric_limits::max() || + plan.instructions().size() > std::numeric_limits::max()) { + throw std::overflow_error("Metal species launch exceeds the uint32 index space"); + } + + if (!std::ranges::all_of(previous_lengths, [](float value) { + return std::isfinite(value) && value >= 0.0F; + })) { + throw std::invalid_argument("previous cell lengths must be finite and non-negative"); + } + + if (state.size() > std::numeric_limits::max() / state.species_count() || + state.size() > std::numeric_limits::max() / plan.instructions().size()) { + throw std::overflow_error("Metal species buffer size overflow"); + } + + const auto level_count = state.size() * state.species_count(); + const auto workspace_count = state.size() * plan.instructions().size(); + + if (level_count > std::numeric_limits::max() || + workspace_count > std::numeric_limits::max()) { + throw std::overflow_error("Metal flattened species storage exceeds the uint32 index space"); + } + + if (level_count > std::numeric_limits::max() / sizeof(float) || + workspace_count > std::numeric_limits::max() / sizeof(float) || + plan.instructions().size() > + std::numeric_limits::max() / sizeof(MetalRateInstruction)) { + throw std::overflow_error("Metal species allocation size overflow"); + } + + ensure_species_capacity(state.size(), level_count, plan.instructions().size(), + state.species_count(), workspace_count); + } + + void iterate_mechanics(std::uint32_t cell_count, std::uint32_t contact_count, + std::uint32_t maximum_iterations, const MechanicsParameters& parameters, + float& residual_squared, SolverReport& report) { + for (std::uint32_t iteration = 0; iteration < maximum_iterations; ++iteration) { + const auto curvature = apply_search_direction(cell_count, contact_count, parameters); + + if (!std::isfinite(curvature)) { + report.status = SolverStatus::breakdown; + report.breakdown = SolverBreakdown::non_finite_curvature; + break; + } + + if (curvature <= 0.0F) { + report.status = SolverStatus::breakdown; + report.breakdown = SolverBreakdown::non_positive_curvature; + break; + } + + const auto alpha = residual_squared / curvature; + const auto next_residual_squared = update_solution_residual(cell_count, alpha); + report.iterations = iteration + 1; + const auto recurrence_rms = std::sqrt(next_residual_squared / static_cast(cell_count)); + + if (!std::isfinite(recurrence_rms)) { + report.status = SolverStatus::breakdown; + report.breakdown = SolverBreakdown::non_finite_residual; + break; + } + + if (recurrence_rms <= parameters.residual_rms_tolerance) { + residual_squared = recompute_residual(cell_count, contact_count, parameters); + const auto recomputed_rms = std::sqrt(residual_squared / static_cast(cell_count)); + + if (!std::isfinite(recomputed_rms)) { + report.status = SolverStatus::breakdown; + report.breakdown = SolverBreakdown::non_finite_residual; + break; + } + + if (recomputed_rms <= parameters.residual_rms_tolerance) { + report.status = SolverStatus::converged; + break; + } + + update_search_direction(cell_count, 0.0F); + continue; + } + + const auto beta = next_residual_squared / residual_squared; + update_search_direction(cell_count, beta); + residual_squared = next_residual_squared; + } + } + + std::array upload_signal_inputs(const SignalGridSpec& spec, + std::span levels) { + const auto signal_count = spec.signal_count; + std::memcpy(signal_levels_.contents, levels.data(), levels.size_bytes()); + std::memcpy(signal_diffusion_.contents, spec.diffusion.data(), + spec.diffusion.size() * sizeof(float)); + auto* reaction_source = static_cast(signal_reaction_source_.contents); + auto* reaction_loss = static_cast(signal_reaction_loss_.contents); + + if (spec.reaction.has_value()) { + std::memcpy(reaction_source, spec.reaction->source_rates.data(), levels.size_bytes()); + std::memcpy(reaction_loss, spec.reaction->loss_rates.data(), levels.size_bytes()); + } else { + std::fill_n(reaction_source, levels.size(), 0.0F); + std::fill_n(reaction_loss, levels.size(), 0.0F); + } + + auto* obstacles = static_cast(signal_obstacles_.contents); + + if (spec.has_obstacles()) { + std::memcpy(obstacles, spec.obstacles.data(), spec.obstacles.size()); + } else { + std::fill_n(obstacles, spec.site_count(), std::uint8_t{0}); + } + + ensure_signal_face_capacity(largest_face_count(spec)); + fill_velocity_faces(spec, signal_x_faces_, signal_y_faces_, signal_z_faces_); + auto* advection = static_cast(signal_advection_.contents); + + for (std::size_t signal = 0; signal < signal_count; ++signal) { + advection[signal] = { + spec.advection[signal].x, + spec.advection[signal].y, + spec.advection[signal].z, + 0.0F, + }; + } + + const std::array boundaries{ + &spec.x_lower, &spec.x_upper, &spec.y_lower, &spec.y_upper, &spec.z_lower, &spec.z_upper, + }; + auto* fixed_values = static_cast(signal_fixed_values_.contents); + std::fill_n(fixed_values, static_cast(6) * signal_count, 0.0F); + std::array boundary_kinds{}; + + for (std::size_t face = 0; face < boundaries.size(); ++face) { + boundary_kinds[face] = static_cast(boundaries[face]->kind); + + if (boundaries[face]->kind == GridBoundaryKind::fixed) { + std::copy(boundaries[face]->values.begin(), boundaries[face]->values.end(), + fixed_values + (face * signal_count)); + } + } + + *static_cast(signal_error_.contents) = 0; + + return boundary_kinds; + } + + void upload_coupled_cells(WorldState& state, const CoupledRatePlan& plan, + std::span previous_lengths) { + const auto cell_count_size = state.size(), instruction_count_size = plan.instructions().size(); + const auto geometry = state.geometry_state(); + const auto attributes = state.cell_attributes(); + auto species_state = state.species_state(); + + if (!species_state.levels.empty()) { + std::memcpy(coupled_species_levels_.contents, species_state.levels.data(), + species_state.levels.size_bytes()); + } + + if (!previous_lengths.empty()) { + std::memcpy(coupled_previous_lengths_.contents, previous_lengths.data(), + previous_lengths.size_bytes()); + std::memcpy(coupled_growth_rates_.contents, attributes.growth_rates.data(), + attributes.growth_rates.size_bytes()); + std::memcpy(coupled_cell_types_.contents, attributes.cell_types.data(), + attributes.cell_types.size_bytes()); + } + + auto* centers = static_cast(coupled_centers_.contents); + auto* cell_geometry = static_cast(coupled_geometry_.contents); + + for (std::size_t index = 0; index < cell_count_size; ++index) { + centers[index] = {geometry.position_x[index], geometry.position_y[index], + geometry.position_z[index], 0.0F}; + cell_geometry[index] = {geometry.lengths[index], geometry.radii[index], 0.0F, 0.0F}; + } + + auto* instructions = static_cast(coupled_instructions_.contents); + + for (std::size_t index = 0; index < instruction_count_size; ++index) { + const auto& instruction = plan.instructions()[index]; + instructions[index] = { + .operation = static_cast(instruction.operation), + .first = instruction.first, + .second = instruction.second, + .third = instruction.third, + .value = instruction.value, + }; + } + + if (!plan.species_outputs().empty()) { + std::memcpy(coupled_species_outputs_.contents, plan.species_outputs().data(), + plan.species_outputs().size_bytes()); + } + + std::memcpy(coupled_signal_outputs_.contents, plan.signal_outputs().data(), + plan.signal_outputs().size_bytes()); + } + + std::array upload_coupled_grid(const SignalGridSpec& spec, + std::span grid_levels) { + const auto signal_count_size = spec.signal_count; + const auto grid_level_count = grid_levels.size(); + std::memcpy(coupled_grid_levels_.contents, grid_levels.data(), grid_levels.size_bytes()); + std::memcpy(coupled_diffusion_.contents, spec.diffusion.data(), + spec.diffusion.size() * sizeof(float)); + auto* reaction_source = static_cast(coupled_reaction_source_.contents); + auto* reaction_loss = static_cast(coupled_reaction_loss_.contents); + + if (spec.reaction.has_value()) { + std::memcpy(reaction_source, spec.reaction->source_rates.data(), grid_levels.size_bytes()); + std::memcpy(reaction_loss, spec.reaction->loss_rates.data(), grid_levels.size_bytes()); + } else { + std::fill_n(reaction_source, grid_level_count, 0.0F); + std::fill_n(reaction_loss, grid_level_count, 0.0F); } - validate_mechanics_contacts(geometry, contacts); - validate_external_mechanics_contacts(geometry, external_contacts); - ensure_contact_cell_capacity(geometry.size()); - ensure_contact_output_capacity(row_count); - ensure_mechanics_capacity(geometry.size(), row_count); - upload_contact_cells(geometry); - upload_mechanics_fixed(state.cell_attributes().fixed); - upload_mechanics_contacts(contacts, external_contacts); - upload_mechanics_incidence(contacts, external_contacts); + auto* obstacles = static_cast(coupled_obstacles_.contents); - const auto cell_count = static_cast(geometry.size()); - const auto contact_count = static_cast(row_count); - auto residual_squared = initialize_mechanics(cell_count, contact_count); - result.report.initial_residual_rms = - std::sqrt(residual_squared / static_cast(cell_count)); - result.report.final_residual_rms = result.report.initial_residual_rms; - if (!std::isfinite(result.report.initial_residual_rms)) { - result.report.status = SolverStatus::breakdown; - result.report.breakdown = SolverBreakdown::non_finite_residual; - return result; + if (spec.has_obstacles()) { + std::memcpy(obstacles, spec.obstacles.data(), spec.obstacles.size()); + } else { + std::fill_n(obstacles, spec.site_count(), std::uint8_t{0}); } - if (result.report.initial_residual_rms <= parameters.residual_rms_tolerance) { - return result; + + ensure_coupled_face_capacity(largest_face_count(spec)); + fill_velocity_faces(spec, coupled_x_faces_, coupled_y_faces_, coupled_z_faces_); + auto* advection = static_cast(coupled_advection_.contents); + + for (std::size_t signal = 0; signal < signal_count_size; ++signal) { + advection[signal] = {spec.advection[signal].x, spec.advection[signal].y, + spec.advection[signal].z, 0.0F}; } - result.report.status = SolverStatus::iteration_limit; - const auto maximum_iterations = mechanics_iteration_limit(parameters, geometry.size()); - for (std::uint32_t iteration = 0; iteration < maximum_iterations; ++iteration) { - const auto curvature = apply_search_direction(cell_count, contact_count, parameters); - if (!std::isfinite(curvature)) { - result.report.status = SolverStatus::breakdown; - result.report.breakdown = SolverBreakdown::non_finite_curvature; - break; - } - if (curvature <= 0.0F) { - result.report.status = SolverStatus::breakdown; - result.report.breakdown = SolverBreakdown::non_positive_curvature; - break; - } + const std::array boundaries{ + &spec.x_lower, &spec.x_upper, &spec.y_lower, &spec.y_upper, &spec.z_lower, &spec.z_upper, + }; + auto* fixed_values = static_cast(coupled_fixed_values_.contents); + std::fill_n(fixed_values, static_cast(6) * signal_count_size, 0.0F); + std::array boundary_kinds{}; - const auto alpha = residual_squared / curvature; - const auto next_residual_squared = update_solution_residual(cell_count, alpha); - result.report.iterations = iteration + 1; - const auto recurrence_rms = std::sqrt(next_residual_squared / static_cast(cell_count)); - if (!std::isfinite(recurrence_rms)) { - result.report.status = SolverStatus::breakdown; - result.report.breakdown = SolverBreakdown::non_finite_residual; - break; - } + for (std::size_t face = 0; face < boundaries.size(); ++face) { + boundary_kinds[face] = static_cast(boundaries[face]->kind); - if (recurrence_rms <= parameters.residual_rms_tolerance) { - residual_squared = recompute_residual(cell_count, contact_count, parameters); - const auto recomputed_rms = std::sqrt(residual_squared / static_cast(cell_count)); - if (!std::isfinite(recomputed_rms)) { - result.report.status = SolverStatus::breakdown; - result.report.breakdown = SolverBreakdown::non_finite_residual; - break; - } - if (recomputed_rms <= parameters.residual_rms_tolerance) { - result.report.status = SolverStatus::converged; - break; - } - update_search_direction(cell_count, 0.0F); - continue; + if (boundaries[face]->kind == GridBoundaryKind::fixed) { + std::copy(boundaries[face]->values.begin(), boundaries[face]->values.end(), + fixed_values + (face * signal_count_size)); } - - const auto beta = next_residual_squared / residual_squared; - update_search_direction(cell_count, beta); - residual_squared = next_residual_squared; } - residual_squared = recompute_residual(cell_count, contact_count, parameters); - result.report.final_residual_rms = std::sqrt(residual_squared / static_cast(cell_count)); - if (!std::isfinite(result.report.final_residual_rms) && - result.report.status != SolverStatus::breakdown) { - result.report.status = SolverStatus::breakdown; - result.report.breakdown = SolverBreakdown::non_finite_residual; - } - result.corrections = download_mechanics_solution(geometry.size()); - return result; - } + *static_cast(coupled_error_.contents) = 0; - [[nodiscard]] DepthAveragedFlowResult solve_depth_averaged_flow( - const SignalGridSpec& spec, std::span mobility, - const DepthAveragedFlowParameters& parameters) override { - return flow_solver_->solve_depth_averaged(spec, mobility, parameters); + return boundary_kinds; } - [[nodiscard]] ResolvedFlowResult solve_resolved_flow( - const SignalGridSpec& spec, std::span drag, - const ResolvedFlowParameters& parameters) override { - return flow_solver_->solve_resolved(spec, drag, parameters); + void dispatch_coupled(const MetalUInt4& shape, const MetalFloat4& origin, + const MetalFloat4& spacing, + const std::array& boundary_kinds, float dt, + std::uint32_t cell_count, std::uint32_t species_count, + std::uint32_t signal_count, std::uint32_t instruction_count, + std::uint32_t level_count, std::uint32_t crank_nicolson, + std::uint32_t has_velocity_field) { + @autoreleasepool { + id command_buffer = [queue_ commandBuffer]; + id encoder = [command_buffer computeCommandEncoder]; + if (command_buffer == nil || encoder == nil) { + throw std::runtime_error("failed to create a Metal coupled-rate command"); + } + if (cell_count != 0) { + [encoder setComputePipelineState:coupled_cells_pipeline_]; + [encoder setBuffer:coupled_species_levels_ offset:0 atIndex:0]; + [encoder setBuffer:coupled_previous_lengths_ offset:0 atIndex:1]; + [encoder setBuffer:coupled_centers_ offset:0 atIndex:2]; + [encoder setBuffer:coupled_geometry_ offset:0 atIndex:3]; + [encoder setBuffer:coupled_growth_rates_ offset:0 atIndex:4]; + [encoder setBuffer:coupled_cell_types_ offset:0 atIndex:5]; + [encoder setBuffer:coupled_instructions_ offset:0 atIndex:6]; + [encoder setBuffer:coupled_species_outputs_ offset:0 atIndex:7]; + [encoder setBuffer:coupled_signal_outputs_ offset:0 atIndex:8]; + [encoder setBuffer:coupled_workspace_ offset:0 atIndex:9]; + [encoder setBuffer:coupled_grid_levels_ offset:0 atIndex:10]; + [encoder setBuffer:coupled_cell_signal_rates_ offset:0 atIndex:11]; + [encoder setBuffer:coupled_error_ offset:0 atIndex:12]; + [encoder setBytes:&shape length:sizeof(shape) atIndex:13]; + [encoder setBytes:&origin length:sizeof(origin) atIndex:14]; + [encoder setBytes:&spacing length:sizeof(spacing) atIndex:15]; + [encoder setBytes:&dt length:sizeof(dt) atIndex:16]; + [encoder setBytes:&species_count length:sizeof(species_count) atIndex:17]; + [encoder setBytes:&signal_count length:sizeof(signal_count) atIndex:18]; + [encoder setBytes:&instruction_count length:sizeof(instruction_count) atIndex:19]; + [encoder setBytes:&cell_count length:sizeof(cell_count) atIndex:20]; + [encoder setBuffer:coupled_obstacles_ offset:0 atIndex:21]; + dispatch_1d(encoder, coupled_cells_pipeline_, cell_count); + [encoder memoryBarrierWithScope:MTLBarrierScopeBuffers]; + } + + [encoder setComputePipelineState:coupled_grid_pipeline_]; + [encoder setBuffer:coupled_grid_levels_ offset:0 atIndex:0]; + [encoder setBuffer:coupled_grid_output_ offset:0 atIndex:1]; + [encoder setBuffer:coupled_diffusion_ offset:0 atIndex:2]; + [encoder setBuffer:coupled_advection_ offset:0 atIndex:3]; + [encoder setBuffer:coupled_fixed_values_ offset:0 atIndex:4]; + [encoder setBuffer:coupled_centers_ offset:0 atIndex:5]; + [encoder setBuffer:coupled_cell_signal_rates_ offset:0 atIndex:6]; + [encoder setBuffer:coupled_error_ offset:0 atIndex:7]; + [encoder setBytes:boundary_kinds.data() + length:boundary_kinds.size() * sizeof(std::uint32_t) + atIndex:8]; + [encoder setBytes:&shape length:sizeof(shape) atIndex:9]; + [encoder setBytes:&origin length:sizeof(origin) atIndex:10]; + [encoder setBytes:&spacing length:sizeof(spacing) atIndex:11]; + [encoder setBytes:&dt length:sizeof(dt) atIndex:12]; + [encoder setBytes:&signal_count length:sizeof(signal_count) atIndex:13]; + [encoder setBytes:&cell_count length:sizeof(cell_count) atIndex:14]; + [encoder setBytes:&level_count length:sizeof(level_count) atIndex:15]; + [encoder setBytes:&crank_nicolson length:sizeof(crank_nicolson) atIndex:16]; + [encoder setBuffer:coupled_reaction_source_ offset:0 atIndex:17]; + [encoder setBuffer:coupled_reaction_loss_ offset:0 atIndex:18]; + [encoder setBuffer:coupled_obstacles_ offset:0 atIndex:19]; + [encoder setBuffer:coupled_x_faces_ offset:0 atIndex:20]; + [encoder setBuffer:coupled_y_faces_ offset:0 atIndex:21]; + [encoder setBuffer:coupled_z_faces_ offset:0 atIndex:22]; + [encoder setBytes:&has_velocity_field length:sizeof(has_velocity_field) atIndex:23]; + dispatch_1d(encoder, coupled_grid_pipeline_, level_count); + [encoder endEncoding]; + wait_for_command(command_buffer, "Metal coupled-rate command failed"); + } } - private: void ensure_growth_capacity(std::size_t count) { if (count <= growth_capacity_) { return; @@ -993,27 +1153,32 @@ void ensure_species_capacity(std::size_t cell_count, std::size_t level_count, species_cell_types_ = allocate_shared_buffer( device_, species_cell_capacity_ * sizeof(std::int32_t), "species cell types"); } + if (level_count > species_level_capacity_) { species_level_capacity_ = std::bit_ceil(level_count); species_levels_ = allocate_shared_buffer(device_, species_level_capacity_ * sizeof(float), "species levels"); } + if (instruction_count > species_instruction_capacity_) { species_instruction_capacity_ = std::bit_ceil(instruction_count); species_instructions_ = allocate_shared_buffer( device_, species_instruction_capacity_ * sizeof(MetalRateInstruction), "species rate instructions"); } + if (species_count > species_output_capacity_) { species_output_capacity_ = std::bit_ceil(species_count); species_outputs_ = allocate_shared_buffer( device_, species_output_capacity_ * sizeof(std::uint32_t), "species rate outputs"); } + if (workspace_count > species_workspace_capacity_) { species_workspace_capacity_ = std::bit_ceil(workspace_count); species_workspace_ = allocate_shared_buffer( device_, species_workspace_capacity_ * sizeof(float), "species rate workspace"); } + if (species_error_ == nil) { species_error_ = allocate_shared_buffer(device_, sizeof(std::uint32_t), "species error flag"); } @@ -1029,6 +1194,7 @@ void fill_velocity_faces(const SignalGridSpec& spec, id x_buffer, if (!spec.velocity_field.has_value()) { return; } + const auto& field = *spec.velocity_field; std::memcpy(x_buffer.contents, field.x_faces.data(), field.x_faces.size() * sizeof(float)); std::memcpy(y_buffer.contents, field.y_faces.data(), field.y_faces.size() * sizeof(float)); @@ -1048,6 +1214,7 @@ void ensure_signal_capacity(std::size_t level_count, std::size_t signal_count) { signal_obstacles_ = allocate_shared_buffer(device_, signal_level_capacity_, "signal-grid obstacles"); } + if (signal_count > signal_count_capacity_) { signal_count_capacity_ = std::bit_ceil(signal_count); signal_diffusion_ = allocate_shared_buffer(device_, signal_count_capacity_ * sizeof(float), @@ -1057,6 +1224,7 @@ void ensure_signal_capacity(std::size_t level_count, std::size_t signal_count) { signal_fixed_values_ = allocate_shared_buffer( device_, 6 * signal_count_capacity_ * sizeof(float), "signal-grid boundary values"); } + if (signal_error_ == nil) { signal_error_ = allocate_shared_buffer(device_, sizeof(std::uint32_t), "signal-grid error flag"); @@ -1087,6 +1255,7 @@ void ensure_signal_solve_capacity(std::uint32_t level_count) { if (level_count <= signal_solve_capacity_) { return; } + signal_solve_capacity_ = std::bit_ceil(static_cast(level_count)); const auto byte_count = signal_solve_capacity_ * sizeof(float); signal_cn_a_ = allocate_shared_buffer(device_, byte_count, "signal Jacobi field A"); @@ -1101,6 +1270,7 @@ void ensure_signal_solve_capacity(std::uint32_t level_count) { id input = signal_cn_terms_; id output = signal_cn_reduce_a_; auto count = element_count; + while (count > 1) { const auto output_count = (count + 1) / 2; [encoder setComputePipelineState:signals_reduce_pipeline_]; @@ -1113,6 +1283,7 @@ void ensure_signal_solve_capacity(std::uint32_t level_count) { output = output == signal_cn_reduce_a_ ? signal_cn_reduce_b_ : signal_cn_reduce_a_; count = output_count; } + return input; } @@ -1148,6 +1319,7 @@ void ensure_signal_solve_capacity(std::uint32_t level_count) { [encoder setBytes:&has_velocity_field length:sizeof(has_velocity_field) atIndex:18]; dispatch_1d(encoder, signals_cn_residual_pipeline_, level_count); [encoder memoryBarrierWithScope:MTLBarrierScopeBuffers]; + return encode_signal_reduction(encoder, level_count); } @@ -1174,6 +1346,7 @@ void ensure_signal_solve_capacity(std::uint32_t level_count) { [encoder endEncoding]; wait_for_command(command_buffer, "Metal signal residual failed"); const auto sum = *static_cast(reduction.contents); + return std::sqrt(sum / static_cast(level_count)); } } @@ -1195,6 +1368,7 @@ void ensure_signal_solve_capacity(std::uint32_t level_count) { [encoder endEncoding]; wait_for_command(command_buffer, "Metal signal norm failed"); const auto sum = *static_cast(reduction.contents); + return std::sqrt(sum / static_cast(level_count)); } } @@ -1226,16 +1400,20 @@ void ensure_signal_solve_capacity(std::uint32_t level_count) { std::numeric_limits::epsilon() * signal_rhs_rms(right_hand_side, level_count); const auto threshold = std::max(parameters.absolute_tolerance, floor) + (parameters.relative_tolerance * report.residual_rms); + if (std::isfinite(report.residual_rms) && report.residual_rms <= threshold) { return {initial, report}; } + if (!std::isfinite(report.residual_rms) || !std::isfinite(threshold)) { report.converged = false; + return {initial, report}; } *static_cast(error.contents) = 0; id current = initial; + for (std::uint32_t iteration = 1; iteration <= parameters.max_iterations; ++iteration) { id output = current == signal_cn_a_ ? signal_cn_b_ : signal_cn_a_; @autoreleasepool { @@ -1278,15 +1456,20 @@ void ensure_signal_solve_capacity(std::uint32_t level_count) { const auto sum = *static_cast(reduction.contents); report.residual_rms = std::sqrt(sum / static_cast(level_count)); } + report.iterations = iteration; current = output; + if (*static_cast(error.contents) != 0 || !std::isfinite(report.residual_rms)) { report.converged = false; + return {current, report}; } + if (report.residual_rms <= threshold) { report.converged = true; + return {current, report}; } } @@ -1298,8 +1481,11 @@ void ensure_coupled_capacity(std::size_t cell_count, std::size_t species_level_c std::size_t instruction_count, std::size_t species_count, std::size_t signal_count, std::size_t workspace_count, std::size_t cell_signal_count, std::size_t grid_level_count) { - const auto at_least_one = [](std::size_t count) { return std::max(count, 1); }; + const auto at_least_one = [](std::size_t count) { + return std::max(count, 1); + }; const auto requested_cells = at_least_one(cell_count); + if (requested_cells > coupled_cell_capacity_) { coupled_cell_capacity_ = std::bit_ceil(requested_cells); coupled_previous_lengths_ = allocate_shared_buffer( @@ -1313,27 +1499,35 @@ void ensure_coupled_capacity(std::size_t cell_count, std::size_t species_level_c coupled_cell_types_ = allocate_shared_buffer( device_, coupled_cell_capacity_ * sizeof(std::int32_t), "coupled cell types"); } + const auto requested_species_levels = at_least_one(species_level_count); + if (requested_species_levels > coupled_species_level_capacity_) { coupled_species_level_capacity_ = std::bit_ceil(requested_species_levels); coupled_species_levels_ = allocate_shared_buffer( device_, coupled_species_level_capacity_ * sizeof(float), "coupled species levels"); } + const auto requested_instructions = at_least_one(instruction_count); + if (requested_instructions > coupled_instruction_capacity_) { coupled_instruction_capacity_ = std::bit_ceil(requested_instructions); coupled_instructions_ = allocate_shared_buffer( device_, coupled_instruction_capacity_ * sizeof(MetalRateInstruction), "coupled rate instructions"); } + const auto requested_species_outputs = at_least_one(species_count); + if (requested_species_outputs > coupled_species_output_capacity_) { coupled_species_output_capacity_ = std::bit_ceil(requested_species_outputs); coupled_species_outputs_ = allocate_shared_buffer(device_, coupled_species_output_capacity_ * sizeof(std::uint32_t), "coupled species outputs"); } + const auto requested_signal_outputs = at_least_one(signal_count); + if (requested_signal_outputs > coupled_signal_output_capacity_) { coupled_signal_output_capacity_ = std::bit_ceil(requested_signal_outputs); coupled_signal_outputs_ = @@ -1346,18 +1540,23 @@ void ensure_coupled_capacity(std::size_t cell_count, std::size_t species_level_c coupled_fixed_values_ = allocate_shared_buffer( device_, 6 * coupled_signal_output_capacity_ * sizeof(float), "coupled boundary values"); } + const auto requested_workspace = at_least_one(workspace_count); + if (requested_workspace > coupled_workspace_capacity_) { coupled_workspace_capacity_ = std::bit_ceil(requested_workspace); coupled_workspace_ = allocate_shared_buffer( device_, coupled_workspace_capacity_ * sizeof(float), "coupled workspace"); } + const auto requested_cell_signals = at_least_one(cell_signal_count); + if (requested_cell_signals > coupled_cell_signal_capacity_) { coupled_cell_signal_capacity_ = std::bit_ceil(requested_cell_signals); coupled_cell_signal_rates_ = allocate_shared_buffer( device_, coupled_cell_signal_capacity_ * sizeof(float), "coupled cell signal rates"); } + if (grid_level_count > coupled_grid_level_capacity_) { coupled_grid_level_capacity_ = std::bit_ceil(grid_level_count); const auto byte_count = coupled_grid_level_capacity_ * sizeof(float); @@ -1369,6 +1568,7 @@ void ensure_coupled_capacity(std::size_t cell_count, std::size_t species_level_c coupled_obstacles_ = allocate_shared_buffer(device_, coupled_grid_level_capacity_, "coupled grid obstacles"); } + if (coupled_error_ == nil) { coupled_error_ = allocate_shared_buffer(device_, sizeof(std::uint32_t), "coupled error flag"); } @@ -1378,6 +1578,7 @@ void ensure_contact_cell_capacity(std::size_t count) { if (count <= contact_cell_capacity_) { return; } + contact_cell_capacity_ = std::bit_ceil(count); contact_ids_ = allocate_shared_buffer(device_, contact_cell_capacity_ * sizeof(std::uint64_t), "contact cell IDs"); @@ -1393,6 +1594,7 @@ void ensure_contact_pair_capacity(std::size_t count) { if (count <= contact_pair_capacity_) { return; } + contact_pair_capacity_ = std::bit_ceil(count); const auto byte_count = contact_pair_capacity_ * sizeof(std::uint32_t); contact_counts_ = allocate_shared_buffer(device_, byte_count, "contact counts"); @@ -1404,6 +1606,7 @@ void ensure_contact_candidate_capacity(std::size_t count) { if (count <= contact_candidate_capacity_) { return; } + contact_candidate_capacity_ = std::bit_ceil(count); contact_candidates_ = allocate_shared_buffer( device_, contact_candidate_capacity_ * sizeof(MetalUInt2), "contact candidates"); @@ -1413,6 +1616,7 @@ void ensure_external_constraint_capacity(std::size_t count) { if (count <= external_constraint_capacity_) { return; } + external_constraint_capacity_ = std::bit_ceil(count); external_constraints_ = allocate_shared_buffer( device_, external_constraint_capacity_ * sizeof(MetalExternalConstraint), @@ -1423,6 +1627,7 @@ void ensure_contact_output_capacity(std::size_t count) { if (count <= contact_output_capacity_) { return; } + contact_output_capacity_ = std::bit_ceil(count); const auto id_bytes = contact_output_capacity_ * sizeof(std::uint64_t); const auto index_bytes = contact_output_capacity_ * sizeof(std::uint32_t); @@ -1444,6 +1649,7 @@ void upload_contact_cells(const CellGeometryView& geometry) { auto* centers = static_cast(contact_centers_.contents); auto* axes = static_cast(contact_axes_.contents); auto* shapes = static_cast(contact_geometry_.contents); + for (std::size_t index = 0; index < geometry.size(); ++index) { centers[index] = { geometry.position_x[index], @@ -1463,6 +1669,7 @@ void upload_contact_cells(const CellGeometryView& geometry) { void upload_contact_candidates(std::span candidates) { auto* output = static_cast(contact_candidates_.contents); + for (std::size_t index = 0; index < candidates.size(); ++index) { output[index] = {candidates[index].first_slot, candidates[index].second_slot}; } @@ -1471,6 +1678,7 @@ void upload_contact_candidates(std::span candidates) { void upload_external_constraints(const ConstraintSet& constraints) { std::vector values; values.reserve(constraints.size()); + for (const auto& plane : constraints.planes()) { values.push_back({ .id = plane.id, @@ -1481,6 +1689,7 @@ void upload_external_constraints(const ConstraintSet& constraints) { plane.coefficient}, }); } + for (const auto& sphere : constraints.spheres()) { values.push_back({ .id = sphere.id, @@ -1490,6 +1699,7 @@ void upload_external_constraints(const ConstraintSet& constraints) { .parameters = {0.0F, 0.0F, 0.0F, sphere.coefficient}, }); } + for (const auto& box : constraints.boxes()) { values.push_back({ .id = box.id, @@ -1500,6 +1710,7 @@ void upload_external_constraints(const ConstraintSet& constraints) { box.coefficient}, }); } + for (const auto& cylinder : constraints.cylinders()) { values.push_back({ .id = cylinder.id, @@ -1509,6 +1720,7 @@ void upload_external_constraints(const ConstraintSet& constraints) { .parameters = {cylinder.half_height, 0.0F, 0.0F, cylinder.coefficient}, }); } + std::ranges::sort(values, {}, &MetalExternalConstraint::id); std::memcpy(external_constraints_.contents, values.data(), values.size() * sizeof(MetalExternalConstraint)); @@ -1518,6 +1730,7 @@ void encode_contact_scan(id encoder, std::uint32_t ele id scan_input = contact_counts_; id scan_output = contact_scan_a_; std::uint32_t offset = 1; + while (offset < element_count) { [encoder setComputePipelineState:contact_scan_pipeline_]; [encoder setBuffer:scan_input offset:0 atIndex:0]; @@ -1528,11 +1741,14 @@ void encode_contact_scan(id encoder, std::uint32_t ele [encoder memoryBarrierWithScope:MTLBarrierScopeBuffers]; scan_input = scan_output; scan_output = scan_output == contact_scan_a_ ? contact_scan_b_ : contact_scan_a_; + if (offset > element_count / 2) { break; } + offset *= 2; } + contact_inclusive_counts_ = scan_input; } @@ -1628,10 +1844,12 @@ void fill_contacts(std::uint32_t candidate_count, const ContactParameters& param std::vector contacts; contacts.reserve(contact_count); + for (std::uint32_t index = 0; index < contact_count; ++index) { if (ordinals[index] > 1) { throw std::runtime_error("Metal contact kernel produced an invalid ordinal"); } + contacts.push_back({ .first_id = first_ids[index], .second_id = second_ids[index], @@ -1644,9 +1862,11 @@ void fill_contacts(std::uint32_t candidate_count, const ContactParameters& param .weight = weights[index], }); } + std::ranges::sort(contacts, {}, [](const CellContact& contact) { return std::tuple{contact.first_id, contact.second_id, contact.ordinal}; }); + return ContactGraph(cell_count, std::move(contacts)); } @@ -1736,11 +1956,13 @@ void fill_external_contacts(std::uint32_t cell_count, std::uint32_t constraint_c std::vector contacts; contacts.reserve(contact_count); + for (std::uint32_t index = 0; index < contact_count; ++index) { if (constraint_kinds[index] > static_cast(ExternalConstraintKind::cylinder) || locations[index] > static_cast(RodContactLocation::interior)) { throw std::runtime_error("Metal external-contact kernel produced an invalid tag"); } + contacts.push_back({ .cell_id = cell_ids[index], .cell_slot = cell_slots[index], @@ -1753,9 +1975,11 @@ void fill_external_contacts(std::uint32_t cell_count, std::uint32_t constraint_c .weight = weights[index], }); } + std::ranges::sort(contacts, {}, [](const ExternalContact& contact) { return std::tuple{contact.cell_id, contact.constraint_id, contact.location}; }); + return ExternalContactGraph(cell_count, std::move(contacts)); } @@ -1764,6 +1988,7 @@ static void validate_mechanics_contacts(const CellGeometryView& geometry, for (const auto& contact : contacts.contacts()) { const auto first = static_cast(contact.first_slot); const auto second = static_cast(contact.second_slot); + if (geometry.ids[first] != contact.first_id || geometry.ids[second] != contact.second_id) { throw std::invalid_argument("contact graph identifiers do not match current state slots"); } @@ -1774,6 +1999,7 @@ static void validate_external_mechanics_contacts(const CellGeometryView& geometr const ExternalContactGraph& contacts) { for (const auto& contact : contacts.contacts()) { const auto cell = static_cast(contact.cell_slot); + if (geometry.ids[cell] != contact.cell_id) { throw std::invalid_argument( "external contact graph identifiers do not match current state slots"); @@ -1786,10 +2012,13 @@ static void validate_external_mechanics_contacts(const CellGeometryView& geometr if (parameters.max_iterations != 0) { return parameters.max_iterations; } + constexpr std::size_t degrees_of_freedom = 7; + if (cell_count > std::numeric_limits::max() / degrees_of_freedom) { throw std::overflow_error("default mechanics iteration limit exceeds uint32"); } + return static_cast(cell_count * degrees_of_freedom); } @@ -1812,6 +2041,7 @@ void ensure_mechanics_capacity(std::size_t cell_count, std::size_t contact_count mechanics_reduce_a_ = allocate_shared_buffer(device_, scalar_bytes, "mechanics reduction A"); mechanics_reduce_b_ = allocate_shared_buffer(device_, scalar_bytes, "mechanics reduction B"); } + if (contact_count > mechanics_contact_capacity_) { mechanics_contact_capacity_ = std::bit_ceil(contact_count); const auto dof_bytes = mechanics_contact_capacity_ * sizeof(MetalDofs); @@ -1841,6 +2071,7 @@ void upload_mechanics_contacts(const ContactGraph& contacts, auto* normals = static_cast(contact_normals_.contents); auto* separations = static_cast(contact_separations_.contents); auto* weights = static_cast(contact_weights_.contents); + for (std::size_t index = 0; index < contacts.size(); ++index) { const auto& contact = contacts.contacts()[index]; first_slots[index] = contact.first_slot; @@ -1851,6 +2082,7 @@ void upload_mechanics_contacts(const ContactGraph& contacts, separations[index] = contact.signed_separation; weights[index] = contact.weight; } + for (std::size_t index = 0; index < external_contacts.size(); ++index) { const auto output_index = contacts.size() + index; const auto& contact = external_contacts.contacts()[index]; @@ -1869,17 +2101,22 @@ void upload_mechanics_incidence(const ContactGraph& contacts, auto* offsets = static_cast(mechanics_incidence_offsets_.contents); auto* indices = static_cast(mechanics_incidence_indices_.contents); std::uint32_t cursor = 0; + for (std::size_t slot = 0; slot < contacts.cell_count(); ++slot) { offsets[slot] = cursor; + for (const auto contact_index : contacts.incident_contact_indices(static_cast(slot))) { indices[cursor++] = static_cast(contact_index); } + for (const auto contact_index : external_contacts.incident_contact_indices(static_cast(slot))) { indices[cursor++] = static_cast(contacts.size() + contact_index); } } + offsets[contacts.cell_count()] = cursor; + if (cursor != contacts.size() * 2 + external_contacts.size()) { throw std::logic_error("contact incidence size is inconsistent"); } @@ -1962,6 +2199,7 @@ void encode_mechanics_operator(id encoder, id input = mechanics_dot_terms_; id output = mechanics_reduce_a_; auto element_count = cell_count; + while (element_count > 1) { const auto output_count = (element_count + 1) / 2; [encoder setComputePipelineState:mechanics_reduce_pipeline_]; @@ -1974,6 +2212,7 @@ void encode_mechanics_operator(id encoder, id reduction) { encode_mechanics_dot(encoder, mechanics_residual_, mechanics_residual_, cell_count); [encoder endEncoding]; wait_for_command(command_buffer, "Metal mechanics initialization failed"); + return read_reduction(reduction); } } @@ -2026,6 +2266,7 @@ static float read_reduction(id reduction) { encode_mechanics_dot(encoder, mechanics_search_, mechanics_applied_, cell_count); [encoder endEncoding]; wait_for_command(command_buffer, "Metal mechanics operator application failed"); + return read_reduction(reduction); } } @@ -2050,6 +2291,7 @@ static float read_reduction(id reduction) { encode_mechanics_dot(encoder, mechanics_residual_, mechanics_residual_, cell_count); [encoder endEncoding]; wait_for_command(command_buffer, "Metal mechanics update failed"); + return read_reduction(reduction); } } @@ -2095,6 +2337,7 @@ void update_search_direction(std::uint32_t cell_count, float beta) { encode_mechanics_dot(encoder, mechanics_residual_, mechanics_residual_, cell_count); [encoder endEncoding]; wait_for_command(command_buffer, "Metal mechanics residual recomputation failed"); + return read_reduction(reduction); } } @@ -2104,6 +2347,7 @@ void update_search_direction(std::uint32_t cell_count, float beta) { const auto* values = static_cast(mechanics_solution_.contents); std::vector result; result.reserve(cell_count); + for (std::size_t index = 0; index < cell_count; ++index) { result.push_back({ .translation = {values[index].linear_length.x, values[index].linear_length.y, @@ -2113,6 +2357,7 @@ void update_search_direction(std::uint32_t cell_count, float beta) { .length = values[index].linear_length.w, }); } + return result; } @@ -2277,6 +2522,7 @@ void update_search_direction(std::uint32_t cell_count, float beta) { std::size_t metal_backend_device_count() noexcept { @autoreleasepool { const auto count = MTLCopyAllDevices().count; + return count == 0 && MTLCreateSystemDefaultDevice() != nil ? 1 : count; } } diff --git a/cpp/metal/metal_flow.mm b/cpp/metal/metal_flow.mm index acbacef..306195f 100644 --- a/cpp/metal/metal_flow.mm +++ b/cpp/metal/metal_flow.mm @@ -59,39 +59,49 @@ struct alignas(16) MetalFlowGridParameters { id select_device(std::uint32_t device_index) { NSArray>* devices = MTLCopyAllDevices(); + if (devices.count == 0) { id default_device = MTLCreateSystemDefaultDevice(); + if (device_index == 0 && default_device != nil) { return default_device; } + if (default_device == nil) { throw std::runtime_error("Metal is unavailable on this system"); } } + if (static_cast(device_index) >= devices.count) { throw std::out_of_range("Metal device index is unavailable"); } + return devices[device_index]; } id make_pipeline(id device, id library, NSString* name) { id function = [library newFunctionWithName:name]; + if (function == nil) { throw std::runtime_error(std::string("Metal flow function is missing: ") + name.UTF8String); } + NSError* error = nil; id pipeline = [device newComputePipelineStateWithFunction:function error:&error]; + if (pipeline == nil) { throw_metal_error("failed to create a Metal flow pipeline", error); } + return pipeline; } void wait_for_command(id command, const char* operation) { [command commit]; [command waitUntilCompleted]; + if (command.status == MTLCommandBufferStatusError) { throw_metal_error(operation, command.error); } @@ -102,6 +112,7 @@ void wait_for_command(id command, const char* operation) { throw std::overflow_error(std::string("Metal flow ") + description + " must fit the nonzero uint32 index space"); } + return static_cast(count); } @@ -110,13 +121,17 @@ MetalFlowGridParameters make_grid_parameters(const detail::FlowGridLayout& layou const auto face_count = checked_count(layout.total_face_count(), "face count"); const auto offsets = layout.face_offsets(); const auto counts = layout.face_counts(); + for (const auto value : offsets) { static_cast(checked_count(value == 0 ? 1 : value, "face offset")); } + for (const auto value : counts) { static_cast(checked_count(value, "component face count")); } + const auto spacing = layout.spacing(); + return { .dimensions = {layout.dimensions()[0], layout.dimensions()[1], layout.dimensions()[2], 0}, .spacing = {spacing[0], spacing[1], spacing[2], 0.0F}, @@ -151,18 +166,24 @@ explicit Impl(std::uint32_t device_index) { @autoreleasepool { device = select_device(device_index); queue = [device newCommandQueue]; + if (queue == nil) { throw std::runtime_error("failed to create a Metal flow command queue"); } + NSString* source = [NSString stringWithUTF8String:flow_source]; + if (source == nil) { throw std::runtime_error("Metal flow source is not valid UTF-8"); } + NSError* error = nil; id library = [device newLibraryWithSource:source options:nil error:&error]; + if (library == nil) { throw_metal_error("failed to compile Metal flow", error); } + depth_operator = make_pipeline(device, library, @"depth_flow_operator"); depth_velocity = make_pipeline(device, library, @"depth_flow_velocity"); momentum = make_pipeline(device, library, @"resolved_flow_momentum"); @@ -176,6 +197,7 @@ explicit Impl(std::uint32_t device_index) { vector_combine = make_pipeline(device, library, @"flow_vector_combine"); vector_subtract = make_pipeline(device, library, @"flow_vector_subtract"); dot_partial = make_pipeline(device, library, @"flow_dot_partial"); + if (dot_partial.maxTotalThreadsPerThreadgroup < reduction_width) { throw std::runtime_error("Metal flow reduction requires 64 threads per threadgroup"); } @@ -185,9 +207,11 @@ explicit Impl(std::uint32_t device_index) { id allocate(std::size_t byte_count, const char* description) const { id buffer = [device newBufferWithLength:byte_count options:MTLResourceStorageModeShared]; + if (buffer == nil) { throw std::runtime_error(std::string("failed to allocate Metal flow ") + description); } + return buffer; } @@ -196,8 +220,10 @@ explicit Impl(std::uint32_t device_index) { if (values.empty()) { throw std::logic_error(std::string("cannot upload an empty Metal flow ") + description); } + auto buffer = allocate(values.size_bytes(), description); std::memcpy(buffer.contents, values.data(), values.size_bytes()); + return buffer; } @@ -234,6 +260,7 @@ void dispatch(id pipeline, std::uint32_t count, const c PcgWorkspace make_workspace(std::uint32_t count, const char* description) const { const auto partial_count = (count + reduction_width - 1) / reduction_width; + return { .residual = float_buffer(count, (std::string(description) + " residual").c_str()), .preconditioned = @@ -287,26 +314,33 @@ PcgReport solve_pcg(id right_hand_side, id diagonal, id std::numeric_limits::max()) { throw std::runtime_error(std::string(label) + " conjugate gradient produced a non-finite step"); } + const auto alpha = static_cast(alpha_double); dispatch(pcg_update, count, "Metal flow PCG update failed", [&](id encoder) { @@ -320,13 +354,16 @@ PcgReport solve_pcg(id right_hand_side, id diagonal, id(relative)}; } + dispatch(pcg_precondition, count, "Metal flow PCG preconditioner failed", [&](id encoder) { [encoder setBuffer:workspace.residual offset:0 atIndex:0]; @@ -336,16 +373,20 @@ PcgReport solve_pcg(id right_hand_side, id diagonal, id std::numeric_limits::max()) { throw std::runtime_error(std::string(label) + " conjugate gradient produced a non-finite direction"); } + const auto beta = static_cast(beta_double); dispatch(pcg_direction, count, "Metal flow PCG direction update failed", [&](id encoder) { @@ -356,6 +397,7 @@ PcgReport solve_pcg(id right_hand_side, id diagonal, id left, id right, id output, void combine(id source, id target, float alpha, float beta, std::uint32_t count) const { - if (!std::isfinite(alpha)) throw std::runtime_error("non-finite Metal Krylov coefficient"); + if (!std::isfinite(alpha)) { + throw std::runtime_error("non-finite Metal Krylov coefficient"); + } + dispatch(vector_combine, count, "Metal Krylov vector update", [&](id encoder) { [encoder setBuffer:source offset:0 atIndex:0]; @@ -500,6 +545,7 @@ void combine(id source, id target, float alpha, float beta const auto scaled = detail::scale_velocity(spec, reduction.original_layout(), reduction.lift(velocity), reduction.open_inlet_faces(), parameters.mean_inlet_speed); + return { .field = scaled.field, .report = {.iterations = report.iterations, @@ -509,6 +555,25 @@ void combine(id source, id target, float alpha, float beta }; } +namespace { +struct ResolvedKrylovVector { + id u; + id p; +}; + +double resolved_divergence_rms(std::span fluid, double divergence_square, + double continuity_scale) { + const auto fluid_count = std::count(fluid.begin(), fluid.end(), std::uint8_t{1}); + const double divergence_rms = + fluid_count == 0 + ? 0.0 + : std::sqrt(divergence_square / static_cast(fluid_count)) / continuity_scale; + + return divergence_rms; +} + +} // namespace + ResolvedFlowResult FlowSolver::solve_resolved(const SignalGridSpec& spec, std::span drag, const ResolvedFlowParameters& parameters) { @@ -528,10 +593,9 @@ void combine(id source, id target, float alpha, float beta const auto gradient = impl_->float_buffer(grid.total_face_count, "block gradient"); auto inner_workspace = impl_->make_workspace(grid.total_face_count, "momentum"); auto outer_workspace = impl_->make_workspace(grid.site_count, "pressure"); - struct Vector { - id u; - id p; - }; + + using Vector = ResolvedKrylovVector; + const double continuity_scale = 1.0 / *std::min_element(system.layout().spacing().begin(), system.layout().spacing().end()); detail::FlexibleKrylovOperations ops; @@ -540,6 +604,7 @@ void combine(id source, id target, float alpha, float beta impl_->float_buffer(grid.site_count, "Krylov pressure")}; std::memset(v.u.contents, 0, grid.total_face_count * sizeof(float)); std::memset(v.p.contents, 0, grid.site_count * sizeof(float)); + return v; }; ops.copy = [&](const Vector& source, Vector& target) { @@ -593,16 +658,13 @@ void combine(id source, id target, float alpha, float beta impl_->dot(rhs.u, rhs.u, grid.total_face_count, inner_workspace.partials); const auto divergence_square = impl_->dot(residual.p, residual.p, grid.site_count, outer_workspace.partials); - const auto fluid_count = - std::count(system.fluid().begin(), system.fluid().end(), std::uint8_t{1}); const double divergence_rms = - fluid_count == 0 - ? 0.0 - : std::sqrt(divergence_square / static_cast(fluid_count)) / continuity_scale; + resolved_divergence_rms(system.fluid(), divergence_square, continuity_scale); const auto* values = static_cast(solution.solution.u.contents); const auto scaled = detail::scale_velocity( spec, system.layout(), std::span(values, grid.total_face_count), system.open_inlet_faces(), parameters.mean_inlet_speed); + return { .field = scaled.field, .report = {.outer_iterations = solution.iterations, diff --git a/cpp/metal/metal_numerics.mm b/cpp/metal/metal_numerics.mm new file mode 100644 index 0000000..a7aac01 --- /dev/null +++ b/cpp/metal/metal_numerics.mm @@ -0,0 +1,178 @@ +#import +#import + +#include +#include +#include +#include + +#include "cm/metal/numerics_source.hpp" +#include "core/numerics_device.hpp" + +namespace cm::detail { +namespace { +[[noreturn]] void fail(const char* action, NSError* error) { + throw std::runtime_error( + std::string(action) + ": " + + (error ? error.localizedDescription.UTF8String : "Metal resource unavailable")); +} + +struct MetalNumericsBuffer final : NumericsBuffer { + id value; + + explicit MetalNumericsBuffer(id buffer) : value(buffer) {} +}; + +class MetalNumericsDevice final : public NumericsDevice { + public: + explicit MetalNumericsDevice(std::uint32_t index) { + @autoreleasepool { + NSArray>* devices = MTLCopyAllDevices(); + + if (devices.count == 0 && index == 0) { + device_ = MTLCreateSystemDefaultDevice(); + } else if (index < devices.count) { + device_ = devices[index]; + } + + if (!device_) { + throw std::out_of_range("Metal numerics device index is unavailable"); + } + + queue_ = [device_ newCommandQueue]; + + if (!queue_) { + fail("create numerics command queue", nil); + } + + MTLCompileOptions* options = [MTLCompileOptions new]; + + if (@available(macOS 15.0, *)) { + options.mathMode = MTLMathModeSafe; + options.mathFloatingPointFunctions = MTLMathFloatingPointFunctionsPrecise; + } else { + options.fastMathEnabled = NO; + } + + NSError* error = nil; + id library = + [device_ newLibraryWithSource:[NSString stringWithUTF8String:cm::metal::numerics_source] + options:options + error:&error]; + if (!library) { + fail("compile Metal numerics kernels", error); + } + + for (const char* name : numerics_kernel_names) { + id function = + [library newFunctionWithName:[NSString stringWithUTF8String:name]]; + + if (!function) { + fail("find Metal numerics kernel", nil); + } + + auto pipeline = [device_ newComputePipelineStateWithFunction:function error:&error]; + + if (!pipeline) { + fail("create Metal numerics pipeline", error); + } + + pipelines_.push_back(pipeline); + } + } + } + + ~MetalNumericsDevice() override { + try { + finish(); + } catch (...) { /* Errors during a solve are reported by read(). */ + } + } + + NumericsBufferPtr allocate(std::size_t bytes, const void* data) override { + @autoreleasepool { + id buffer = [device_ newBufferWithLength:std::max(bytes, std::size_t{4}) + options:MTLResourceStorageModeShared]; + if (!buffer) { + fail("allocate Metal numerics buffer", nil); + } + + if (data && bytes) { + std::memcpy(buffer.contents, data, bytes); + } + + return std::make_shared(buffer); + } + } + + void read(const NumericsBufferPtr& buffer, void* data, std::size_t bytes) override { + finish(); + std::memcpy(data, static_cast(*buffer).value.contents, bytes); + } + + void dispatch(NumericsKernel kernel, const NumericsParameters& p, + std::initializer_list buffers) override { + if (p.count == 0) { + return; + } + @autoreleasepool { + auto pipeline = pipelines_[static_cast(kernel)]; + + if (!command_) { + command_ = [queue_ commandBuffer]; + encoder_ = [command_ computeCommandEncoder]; + } + + auto encoder = encoder_; + + if (!command_ || !encoder) { + fail("create Metal numerics command", nil); + } + [encoder setComputePipelineState:pipeline]; + NSUInteger index = 0; + + for (const auto& buffer : buffers) { + [encoder setBuffer:static_cast(*buffer).value + offset:0 + atIndex:index++]; + } + [encoder setBytes:&p length:sizeof(p) atIndex:15]; + const auto count = kernel == NumericsKernel::ilu ? 1U + : kernel == NumericsKernel::dot ? ((p.count + 127U) / 128U) * 128U + : p.count; + const auto width = kernel == NumericsKernel::ilu ? NSUInteger{1} : NSUInteger{128}; + [encoder dispatchThreads:MTLSizeMake(count, 1, 1) + threadsPerThreadgroup:MTLSizeMake(width, 1, 1)]; + } + } + + private: + void finish() { + @autoreleasepool { + if (!command_) { + return; + } + auto command = command_; + [encoder_ endEncoding]; + encoder_ = nil; + command_ = nil; + [command commit]; + [command waitUntilCompleted]; + if (command.status == MTLCommandBufferStatusError) { + fail("execute Metal numerics kernel", command.error); + } + } + } + + id device_; + id queue_; + id command_; + id encoder_; + std::vector> pipelines_; +}; +} // namespace + +std::unique_ptr make_metal_numerics_device(std::uint32_t index) { + return std::make_unique(index); +} +} // namespace cm::detail diff --git a/cpp/metal/metal_occupancy.mm b/cpp/metal/metal_occupancy.mm new file mode 100644 index 0000000..48767b8 --- /dev/null +++ b/cpp/metal/metal_occupancy.mm @@ -0,0 +1,148 @@ +#import +#import + +#include +#include +#include +#include + +#include "cm/metal/occupancy_source.hpp" +#include "core/occupancy_device.hpp" + +namespace cm::detail { +namespace { +[[noreturn]] void fail(const char* action, NSError* error) { + throw std::runtime_error( + std::string(action) + ": " + + (error ? error.localizedDescription.UTF8String : "Metal resource unavailable")); +} + +struct MetalOccupancyBuffer final : OccupancyBuffer { + id value; + + explicit MetalOccupancyBuffer(id buffer) : value(buffer) {} +}; + +class MetalOccupancyDevice final : public OccupancyDevice { + public: + explicit MetalOccupancyDevice(std::uint32_t index) { + @autoreleasepool { + NSArray>* devices = MTLCopyAllDevices(); + + if (devices.count == 0 && index == 0) { + device_ = MTLCreateSystemDefaultDevice(); + } else if (index < devices.count) { + device_ = devices[index]; + } + + if (!device_) { + throw std::out_of_range("Metal occupancy device index is unavailable"); + } + + queue_ = [device_ newCommandQueue]; + + if (!queue_) { + fail("create occupancy command queue", nil); + } + + MTLCompileOptions* options = [MTLCompileOptions new]; + + if (@available(macOS 15.0, *)) { + options.mathMode = MTLMathModeSafe; + options.mathFloatingPointFunctions = MTLMathFloatingPointFunctionsPrecise; + } else { + options.fastMathEnabled = NO; + } + + NSError* error = nil; + id library = + [device_ newLibraryWithSource:[NSString stringWithUTF8String:cm::metal::occupancy_source] + options:options + error:&error]; + if (!library) { + fail("compile Metal occupancy kernels", error); + } + + for (const char* name : occupancy_kernel_names) { + id function = + [library newFunctionWithName:[NSString stringWithUTF8String:name]]; + + if (!function) { + fail("find Metal occupancy kernel", nil); + } + + auto pipeline = [device_ newComputePipelineStateWithFunction:function error:&error]; + + if (!pipeline) { + fail("create Metal occupancy pipeline", error); + } + + pipelines_.push_back(pipeline); + } + } + } + + OccupancyBufferPtr allocate(std::size_t bytes, const void* data) override { + @autoreleasepool { + id buffer = [device_ newBufferWithLength:std::max(bytes, std::size_t{4}) + options:MTLResourceStorageModeShared]; + if (!buffer) { + fail("allocate Metal occupancy buffer", nil); + } + + if (data && bytes) { + std::memcpy(buffer.contents, data, bytes); + } + + return std::make_shared(buffer); + } + } + + void read(const OccupancyBufferPtr& buffer, void* data, std::size_t bytes) override { + std::memcpy(data, static_cast(*buffer).value.contents, bytes); + } + + void dispatch(OccupancyKernel kernel, const OccupancyParameters& p, + std::initializer_list buffers) override { + if (p.count == 0) { + return; + } + @autoreleasepool { + auto pipeline = pipelines_[static_cast(kernel)]; + id command = [queue_ commandBuffer]; + id encoder = [command computeCommandEncoder]; + if (!command || !encoder) { + fail("create Metal occupancy command", nil); + } + [encoder setComputePipelineState:pipeline]; + NSUInteger index = 0; + for (const auto& buffer : buffers) { + [encoder setBuffer:static_cast(*buffer).value + offset:0 + atIndex:index++]; + } + [encoder setBytes:&p length:sizeof(p) atIndex:15]; + const auto count = kernel == OccupancyKernel::sum ? 1 : p.count; + const auto width = std::min(NSUInteger{64}, pipeline.maxTotalThreadsPerThreadgroup); + [encoder dispatchThreads:MTLSizeMake(count, 1, 1) + threadsPerThreadgroup:MTLSizeMake(width, 1, 1)]; + [encoder endEncoding]; + [command commit]; + [command waitUntilCompleted]; + if (command.status == MTLCommandBufferStatusError) { + fail("execute Metal occupancy kernel", command.error); + } + } + } + + private: + id device_; + id queue_; + std::vector> pipelines_; +}; +} // namespace + +std::unique_ptr make_metal_occupancy_device(std::uint32_t index) { + return std::make_unique(index); +} +} // namespace cm::detail diff --git a/cpp/python/bindings.cpp b/cpp/python/bindings.cpp index 238cc3a..7df0b94 100644 --- a/cpp/python/bindings.cpp +++ b/cpp/python/bindings.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -8,8 +9,12 @@ namespace nb = nanobind; using namespace nb::literals; +void bind_occupancy(nb::module_& module); +void bind_culture(nb::module_& module); -NB_MODULE(_core, module) { +namespace { + +void bind_backend_enums(nb::module_& module) { module.doc() = "MicroSimulator native simulation core"; module.def("backend_device_count", &cm::backend_device_count, "backend"_a); @@ -20,6 +25,8 @@ NB_MODULE(_core, module) { .value("METAL", cm::BackendKind::metal) .value("CUDA", cm::BackendKind::cuda); + bind_occupancy(module); + nb::enum_(module, "BackendFeature") .value("GROWTH", cm::BackendFeature::growth) .value("SPECIES", cm::BackendFeature::species) @@ -29,7 +36,8 @@ NB_MODULE(_core, module) { .value("SIGNALS", cm::BackendFeature::signals) .value("COUPLED_RATES", cm::BackendFeature::coupled_rates) .value("DEPTH_AVERAGED_FLOW", cm::BackendFeature::depth_averaged_flow) - .value("RESOLVED_FLOW", cm::BackendFeature::resolved_flow); + .value("RESOLVED_FLOW", cm::BackendFeature::resolved_flow) + .value("CULTURE", cm::BackendFeature::culture); nb::enum_(module, "FlowAxis") .value("X", cm::FlowAxis::x) @@ -45,7 +53,9 @@ NB_MODULE(_core, module) { .value("FORWARD_EULER", cm::SignalIntegrationKind::forward_euler) .value("CRANK_NICOLSON", cm::SignalIntegrationKind::crank_nicolson) .value("BACKWARD_EULER", cm::SignalIntegrationKind::backward_euler); +} +void bind_rate_operations(nb::module_& module) { nb::enum_(module, "RateOp") .value("CONSTANT", cm::RateOp::constant) .value("SPECIES", cm::RateOp::species) @@ -76,7 +86,9 @@ NB_MODULE(_core, module) { .value("EQUAL", cm::RateOp::equal) .value("SELECT", cm::RateOp::select) .value("SIGNAL", cm::RateOp::signal); +} +void bind_mechanics_enums(nb::module_& module) { nb::enum_(module, "ConstraintRegion") .value("OUTSIDE", cm::ConstraintRegion::outside) .value("INSIDE", cm::ConstraintRegion::inside); @@ -104,7 +116,9 @@ NB_MODULE(_core, module) { .value("NON_FINITE_RESIDUAL", cm::SolverBreakdown::non_finite_residual) .value("NON_FINITE_CURVATURE", cm::SolverBreakdown::non_finite_curvature) .value("NON_POSITIVE_CURVATURE", cm::SolverBreakdown::non_positive_curvature); +} +void bind_grid_types(nb::module_& module) { nb::class_(module, "Vec3") .def(nb::init(), "x"_a = 0.0F, "y"_a = 0.0F, "z"_a = 0.0F) .def_rw("x", &cm::Vec3::x) @@ -153,7 +167,10 @@ NB_MODULE(_core, module) { .def_rw("x_faces", &cm::SignalGridVelocityField::x_faces) .def_rw("y_faces", &cm::SignalGridVelocityField::y_faces) .def_rw("z_faces", &cm::SignalGridVelocityField::z_faces); + module.attr("MacVelocityField") = module.attr("SignalGridVelocityField"); +} +void bind_flow_types(nb::module_& module) { nb::class_(module, "DepthAveragedFlowParameters") .def(nb::init<>()) .def_rw("mean_inlet_speed", &cm::DepthAveragedFlowParameters::mean_inlet_speed) @@ -164,9 +181,10 @@ NB_MODULE(_core, module) { nb::class_(module, "DepthAveragedFlowReport") .def_ro("iterations", &cm::DepthAveragedFlowReport::iterations) - .def_prop_ro( - "residual", - [](const cm::DepthAveragedFlowReport& report) { return report.relative_residual; }) + .def_prop_ro("residual", + [](const cm::DepthAveragedFlowReport& report) { + return report.relative_residual; + }) .def_ro("relative_residual", &cm::DepthAveragedFlowReport::relative_residual) .def_ro("mean_inlet_speed", &cm::DepthAveragedFlowReport::mean_inlet_speed) .def_ro("max_speed", &cm::DepthAveragedFlowReport::max_speed); @@ -198,7 +216,9 @@ NB_MODULE(_core, module) { nb::class_(module, "ResolvedFlowResult") .def_ro("field", &cm::ResolvedFlowResult::field) .def_ro("report", &cm::ResolvedFlowResult::report); +} +void bind_signal_grid(nb::module_& module) { nb::class_(module, "SignalGridSpec") .def(nb::init<>()) .def_rw("signal_count", &cm::SignalGridSpec::signal_count) @@ -228,7 +248,9 @@ NB_MODULE(_core, module) { .def_rw("spec", &cm::SignalGridCheckpoint::spec) .def_rw("levels", &cm::SignalGridCheckpoint::levels) .def("validate", &cm::SignalGridCheckpoint::validate); +} +void bind_world_state(nb::module_& module) { nb::class_(module, "CellInit") .def(nb::init<>()) .def_rw("position", &cm::CellInit::position) @@ -265,7 +287,9 @@ NB_MODULE(_core, module) { .def_rw("cells", &cm::WorldStateCheckpoint::cells) .def_rw("lineage", &cm::WorldStateCheckpoint::lineage) .def("validate", &cm::WorldStateCheckpoint::validate); +} +void bind_rate_plans(nb::module_& module) { nb::class_(module, "RateInstruction") .def(nb::init<>()) .def_rw("operation", &cm::RateInstruction::operation) @@ -314,7 +338,9 @@ NB_MODULE(_core, module) { plan.signal_outputs().end()); }) .def("validate", &cm::CoupledRatePlan::validate); +} +void bind_contacts(nb::module_& module) { nb::class_(module, "ContactParameters") .def(nb::init<>()) .def_rw("activation_margin", &cm::ContactParameters::activation_margin) @@ -345,6 +371,7 @@ NB_MODULE(_core, module) { "incident_contact_indices", [](const cm::ContactGraph& graph, cm::Slot slot) { const auto indices = graph.incident_contact_indices(slot); + return std::vector(indices.begin(), indices.end()); }, "slot"_a) @@ -352,10 +379,13 @@ NB_MODULE(_core, module) { "neighbor_ids", [](const cm::ContactGraph& graph, cm::Slot slot) { const auto ids = graph.neighbor_ids(slot); + return std::vector(ids.begin(), ids.end()); }, "slot"_a); +} +void bind_constraints(nb::module_& module) { nb::class_(module, "PlaneConstraintInit") .def(nb::init<>()) .def_rw("point", &cm::PlaneConstraintInit::point) @@ -434,8 +464,11 @@ NB_MODULE(_core, module) { .def_rw("species_rate_plan", &cm::SimulationCheckpoint::species_rate_plan) .def_rw("signal_grid", &cm::SimulationCheckpoint::signal_grid) .def_rw("coupled_rate_plan", &cm::SimulationCheckpoint::coupled_rate_plan) + .def_rw("culture", &cm::SimulationCheckpoint::culture) .def("validate", &cm::SimulationCheckpoint::validate); +} +void bind_external_contacts(nb::module_& module) { nb::class_(module, "ConstraintContactParameters") .def(nb::init<>()) .def_rw("activation_margin", &cm::ConstraintContactParameters::activation_margin) @@ -447,7 +480,10 @@ NB_MODULE(_core, module) { .def_ro("constraint_id", &cm::ExternalContact::constraint_id) .def_ro("constraint_kind", &cm::ExternalContact::constraint_kind) .def_ro("location", &cm::ExternalContact::location) - .def_prop_ro("endpoint", [](const cm::ExternalContact& contact) { return contact.location; }) + .def_prop_ro("endpoint", + [](const cm::ExternalContact& contact) { + return contact.location; + }) .def_ro("point_on_cell", &cm::ExternalContact::point_on_cell) .def_ro("normal", &cm::ExternalContact::normal) .def_ro("signed_separation", &cm::ExternalContact::signed_separation) @@ -466,10 +502,13 @@ NB_MODULE(_core, module) { "incident_contact_indices", [](const cm::ExternalContactGraph& graph, cm::Slot slot) { const auto indices = graph.incident_contact_indices(slot); + return std::vector(indices.begin(), indices.end()); }, "slot"_a); +} +void bind_mechanics(nb::module_& module) { nb::class_(module, "CellCorrection") .def_ro("translation", &cm::CellCorrection::translation) .def_ro("rotation", &cm::CellCorrection::rotation) @@ -497,42 +536,10 @@ NB_MODULE(_core, module) { nb::class_(module, "MechanicsSolveResult") .def_ro("corrections", &cm::MechanicsSolveResult::corrections) .def_ro("report", &cm::MechanicsSolveResult::report); +} - nb::class_(module, "Simulation") - .def(nb::init(), - "backend"_a = cm::BackendKind::cpu, "reserved_capacity"_a = 0, "species_count"_a = 0, - "device_index"_a = 0) - .def(nb::init(), "backend"_a, - "checkpoint"_a, "device_index"_a = 0) - .def_prop_ro("backend_info", &cm::Simulation::backend_info) - .def("supports", &cm::Simulation::supports, "feature"_a) - .def_prop_ro("time", &cm::Simulation::time) - .def_prop_ro("cell_count", &cm::Simulation::cell_count) - .def_prop_ro("species_count", &cm::Simulation::species_count) - .def_prop_ro("signal_count", &cm::Simulation::signal_count) - .def_prop_ro("has_signal_grid", &cm::Simulation::has_signal_grid) - .def_prop_ro("last_signal_solve_report", &cm::Simulation::last_signal_solve_report) - .def_prop_ro("has_coupled_rate_plan", &cm::Simulation::has_coupled_rate_plan) - .def("add_cell", &cm::Simulation::add_cell, "cell"_a) - .def("remove_cell", &cm::Simulation::remove_cell, "id"_a) - .def("apply_flow_drift", &cm::Simulation::apply_flow_drift, "dt"_a, - "integration"_a = cm::MechanicsIntegrationParameters{}) - .def("add_plane_constraint", &cm::Simulation::add_plane_constraint, "plane"_a) - .def("add_sphere_constraint", &cm::Simulation::add_sphere_constraint, "sphere"_a) - .def("add_box_constraint", &cm::Simulation::add_box_constraint, "box"_a) - .def("add_cylinder_constraint", &cm::Simulation::add_cylinder_constraint, "cylinder"_a) - .def("set_cell_geometry", &cm::Simulation::set_cell_geometry, "id"_a, "position"_a, - "direction"_a, "length"_a) - .def("set_cell_attributes", &cm::Simulation::set_cell_attributes, "id"_a, "growth_rate"_a, - "cell_type"_a) - .def("set_cell_fixed", &cm::Simulation::set_cell_fixed, "id"_a, "fixed"_a) - .def( - "set_species", - [](cm::Simulation& simulation, cm::CellId id, const std::vector& levels) { - simulation.set_species(id, levels); - }, - "id"_a, "levels"_a) - .def("set_species_rate_plan", &cm::Simulation::set_species_rate_plan, "plan"_a) +void bind_simulation_operations(nb::class_& simulation) { + simulation.def("set_species_rate_plan", &cm::Simulation::set_species_rate_plan, "plan"_a) .def("set_coupled_rate_plan", &cm::Simulation::set_coupled_rate_plan, "plan"_a) .def("clear_coupled_rate_plan", &cm::Simulation::clear_coupled_rate_plan) .def("configure_signal_grid", &cm::Simulation::configure_signal_grid, "spec"_a, @@ -583,5 +590,89 @@ NB_MODULE(_core, module) { .def_prop_ro("signal_levels", &cm::Simulation::signal_levels) .def("sample_signals", &cm::Simulation::sample_signals, "position"_a) .def("_checkpoint", &cm::Simulation::checkpoint) + .def("_restore_checkpoint", &cm::Simulation::restore_checkpoint, "checkpoint"_a) .def("validate", &cm::Simulation::validate); } + +void bind_simulation(nb::module_& module) { + auto simulation = + nb::class_(module, "Simulation") + .def(nb::init(), + "backend"_a = cm::BackendKind::cpu, "reserved_capacity"_a = 0, "species_count"_a = 0, + "device_index"_a = 0) + .def(nb::init(), + "backend"_a, "checkpoint"_a, "device_index"_a = 0) + .def_prop_ro("backend_info", &cm::Simulation::backend_info) + .def("supports", &cm::Simulation::supports, "feature"_a) + .def_prop_ro("time", &cm::Simulation::time) + .def_prop_ro("cell_count", &cm::Simulation::cell_count) + .def_prop_ro("species_count", &cm::Simulation::species_count) + .def_prop_ro("signal_count", &cm::Simulation::signal_count) + .def_prop_ro("has_signal_grid", &cm::Simulation::has_signal_grid) + .def_prop_ro("has_culture", &cm::Simulation::has_culture) + .def( + "configure_culture", + [](cm::Simulation& simulation, nb::object fluid, nb::object transport, + nb::dict growth) { + nb::module_::import_("microsimulator.culture") + .attr("configure_culture")(nb::cast(&simulation, nb::rv_policy::reference), + fluid, transport, growth); + }, + nb::kw_only(), "fluid"_a, "transport"_a, "cell_growth"_a = nb::dict()) + .def_prop_ro( + "culture_state", + [](const cm::Simulation& simulation) { + return nb::module_::import_("microsimulator.culture") + .attr("capture_culture")(nb::cast(&simulation, nb::rv_policy::reference)); + }) + .def_prop_ro("culture_checkpoint", &cm::Simulation::culture_checkpoint) + .def_prop_ro("fluid_fragments", &cm::Simulation::fluid_fragments) + .def("_configure_culture", &cm::Simulation::configure_culture, "configuration"_a, + "concentrations"_a = std::vector{}, + "biochemical_volumes"_a = std::vector{}) + .def("set_cell_force", &cm::Simulation::set_cell_force, "id"_a, "force_n"_a, + "torque_nm"_a = std::array{}) + .def("cell_surface_concentrations", &cm::Simulation::cell_surface_concentrations, "id"_a) + .def_prop_ro("last_signal_solve_report", &cm::Simulation::last_signal_solve_report) + .def_prop_ro("has_coupled_rate_plan", &cm::Simulation::has_coupled_rate_plan) + .def("add_cell", &cm::Simulation::add_cell, "cell"_a) + .def("remove_cell", &cm::Simulation::remove_cell, "id"_a) + .def("apply_flow_drift", &cm::Simulation::apply_flow_drift, "dt"_a, + "integration"_a = cm::MechanicsIntegrationParameters{}) + .def("add_plane_constraint", &cm::Simulation::add_plane_constraint, "plane"_a) + .def("add_sphere_constraint", &cm::Simulation::add_sphere_constraint, "sphere"_a) + .def("add_box_constraint", &cm::Simulation::add_box_constraint, "box"_a) + .def("add_cylinder_constraint", &cm::Simulation::add_cylinder_constraint, "cylinder"_a) + .def("set_cell_geometry", &cm::Simulation::set_cell_geometry, "id"_a, "position"_a, + "direction"_a, "length"_a) + .def("set_cell_attributes", &cm::Simulation::set_cell_attributes, "id"_a, "growth_rate"_a, + "cell_type"_a) + .def("set_cell_fixed", &cm::Simulation::set_cell_fixed, "id"_a, "fixed"_a) + .def( + "set_species", + [](cm::Simulation& simulation, cm::CellId id, const std::vector& levels) { + simulation.set_species(id, levels); + }, + "id"_a, "levels"_a); + + bind_simulation_operations(simulation); +} + +} // namespace + +NB_MODULE(_core, module) { + bind_backend_enums(module); + bind_rate_operations(module); + bind_mechanics_enums(module); + bind_grid_types(module); + bind_flow_types(module); + bind_signal_grid(module); + bind_world_state(module); + bind_rate_plans(module); + bind_contacts(module); + bind_constraints(module); + bind_external_contacts(module); + bind_mechanics(module); + bind_simulation(module); + bind_culture(module); +} diff --git a/cpp/python/culture_bindings.cpp b/cpp/python/culture_bindings.cpp new file mode 100644 index 0000000..34c6526 --- /dev/null +++ b/cpp/python/culture_bindings.cpp @@ -0,0 +1,586 @@ +#include +#include +#include +#include + +#include "cm/culture_simulation.hpp" +#include "cm/stokes_flow.hpp" + +namespace nb = nanobind; +using namespace nb::literals; + +namespace { +template +nb::tuple array_tuple(const std::array& value) { + if constexpr (N == 3) { + return nb::make_tuple(value[0], value[1], value[2]); + } else { + return nb::make_tuple(value[0], value[1], value[2], value[3]); + } +} +} // namespace + +namespace { + +void bind_ChemicalBoundaryKind(nb::module_& module) { + nb::enum_(module, "ChemicalBoundaryKind") + .value("RESERVOIR_CONTACT", cm::ChemicalBoundaryKind::reservoir_contact) + .value("ADVECTIVE", cm::ChemicalBoundaryKind::advective) + .value("OUTFLOW", cm::ChemicalBoundaryKind::outflow); +} + +void bind_GrowthKind(nb::module_& module) { + nb::enum_(module, "GrowthKind") + .value("MONOD", cm::GrowthKind::monod) + .value("ESSENTIAL", cm::GrowthKind::essential); +} + +void bind_GrowthRequirement(nb::module_& module) { + nb::class_(module, "GrowthRequirement") + .def(nb::init<>()) + .def_rw("solute", &cm::GrowthRequirement::solute) + .def_rw("half_saturation", &cm::GrowthRequirement::half_saturation) + .def_rw("biomass_yield", &cm::GrowthRequirement::biomass_yield); +} + +void bind_CellGrowthModel(nb::module_& module) { + nb::class_(module, "CellGrowthModel") + .def(nb::init<>()) + .def_rw("cell_id", &cm::CellGrowthModel::cell_id) + .def_rw("kind", &cm::CellGrowthModel::kind) + .def_rw("mu_max", &cm::CellGrowthModel::mu_max) + .def_rw("biomass_density", &cm::CellGrowthModel::biomass_density) + .def_rw("volume_ratio", &cm::CellGrowthModel::volume_ratio) + .def_rw("requirements", &cm::CellGrowthModel::requirements); +} + +void bind_GrowthInput(nb::module_& module) { + nb::class_(module, "GrowthInput") + .def(nb::init<>()) + .def_rw("biochemical_volume", &cm::GrowthInput::biochemical_volume) + .def_rw("surface_area", &cm::GrowthInput::surface_area) + .def_rw("concentrations", &cm::GrowthInput::concentrations) + .def_rw("uptake", &cm::GrowthInput::uptake); +} + +void bind_GrowthEvaluation(nb::module_& module) { + nb::class_(module, "GrowthEvaluation") + .def_ro("uptake_velocities", &cm::GrowthEvaluation::uptake_velocities) + .def_ro("biomass_gain", &cm::GrowthEvaluation::biomass_gain) + .def_ro("biochemical_volume_gain", &cm::GrowthEvaluation::biochemical_volume_gain) + .def_ro("geometric_volume_gain", &cm::GrowthEvaluation::geometric_volume_gain) + .def_ro("specific_rate", &cm::GrowthEvaluation::specific_rate) + .def_ro("stoichiometric_residual", &cm::GrowthEvaluation::stoichiometric_residual); +} + +void bind_GrowthExecutor(nb::module_& module) { + nb::class_(module, "GrowthExecutor") + .def(nb::init(), "backend"_a = cm::BackendKind::cpu, + "device_index"_a = 0) + .def("evaluate", &cm::GrowthExecutor::evaluate, "models"_a, "inputs"_a, "dt"_a); +} + +void bind_CultureEvent(nb::module_& module) { + nb::class_(module, "CultureEvent") + .def(nb::init<>()) + .def_rw("time", &cm::CultureEvent::time) + .def_rw("ports", &cm::CultureEvent::ports) + .def_rw("reservoirs", &cm::CultureEvent::reservoirs); +} + +void bind_FluidGridSpec(nb::module_& module) { + nb::class_(module, "FluidGridSpec") + .def(nb::init<>()) + .def_rw("shape", &cm::FluidGridSpec::shape) + .def_rw("origin", &cm::FluidGridSpec::origin) + .def_rw("spacing", &cm::FluidGridSpec::spacing) + .def_rw("length_unit_m", &cm::FluidGridSpec::length_unit_m) + .def_rw("time_unit_s", &cm::FluidGridSpec::time_unit_s) + .def_rw("obstacles", &cm::FluidGridSpec::obstacles) + .def("validate", &cm::FluidGridSpec::validate) + .def_prop_ro("site_count", &cm::FluidGridSpec::site_count); +} + +void bind_FluidProperties(nb::module_& module) { + nb::class_(module, "FluidProperties") + .def(nb::init<>()) + .def_rw("viscosity_pa_s", &cm::FluidProperties::viscosity_pa_s) + .def_rw("density_kg_m3", &cm::FluidProperties::density_kg_m3) + .def("validate", &cm::FluidProperties::validate); +} + +void bind_FlowPortKind(nb::module_& module) { + nb::enum_(module, "FlowPortKind") + .value("PRESSURE", cm::FlowPortKind::pressure) + .value("FLOW_RATE", cm::FlowPortKind::flow_rate); +} + +void bind_FlowPort(nb::module_& module) { + nb::class_(module, "FlowPort") + .def(nb::init<>()) + .def_rw("name", &cm::FlowPort::name) + .def_rw("axis", &cm::FlowPort::axis) + .def_rw("upper", &cm::FlowPort::upper) + .def_rw("kind", &cm::FlowPort::kind) + .def_rw("value", &cm::FlowPort::value) + .def_rw("sites", &cm::FlowPort::sites); +} + +void bind_LinearSolveParameters(nb::module_& module) { + nb::class_(module, "LinearSolveParameters") + .def(nb::init<>()) + .def_rw("relative_tolerance", &cm::LinearSolveParameters::relative_tolerance) + .def_rw("absolute_tolerance", &cm::LinearSolveParameters::absolute_tolerance) + .def_rw("max_iterations", &cm::LinearSolveParameters::max_iterations) + .def_rw("memory_limit_bytes", &cm::LinearSolveParameters::memory_limit_bytes) + .def("validate", &cm::LinearSolveParameters::validate); +} + +void bind_FlowPortResult(nb::module_& module) { + nb::class_(module, "FlowPortResult") + .def_ro("name", &cm::FlowPortResult::name) + .def_ro("pressure_pa", &cm::FlowPortResult::pressure_pa) + .def_ro("flow_rate_m3_s", &cm::FlowPortResult::flow_rate_m3_s) + .def_ro("area_m2", &cm::FlowPortResult::area_m2); +} + +void bind_CapsuleBody(nb::module_& module) { + nb::class_(module, "CapsuleBody") + .def(nb::init<>()) + .def_rw("id", &cm::CapsuleBody::id) + .def_prop_rw( + "position", + [](const cm::CapsuleBody& body) { + return array_tuple(body.position); + }, + [](cm::CapsuleBody& body, std::array value) { + body.position = value; + }) + .def_prop_rw( + "orientation", + [](const cm::CapsuleBody& body) { + return array_tuple(body.orientation); + }, + [](cm::CapsuleBody& body, std::array value) { + body.orientation = value; + }) + .def_rw("length", &cm::CapsuleBody::length) + .def_rw("length_rate", &cm::CapsuleBody::length_rate) + .def_rw("radius", &cm::CapsuleBody::radius) + .def_rw("fixed", &cm::CapsuleBody::fixed) + .def_prop_rw( + "force_n", + [](const cm::CapsuleBody& body) { + return array_tuple(body.force_n); + }, + [](cm::CapsuleBody& body, std::array value) { + body.force_n = value; + }) + .def_prop_rw( + "torque_nm", + [](const cm::CapsuleBody& body) { + return array_tuple(body.torque_nm); + }, + [](cm::CapsuleBody& body, std::array value) { + body.torque_nm = value; + }) + .def("validate", &cm::CapsuleBody::validate) + .def_prop_ro("geometric_volume", &cm::CapsuleBody::geometric_volume); +} + +void bind_FluidBodyResult(nb::module_& module) { + nb::class_(module, "FluidBodyResult") + .def_ro("id", &cm::FluidBodyResult::id) + .def_prop_ro("velocity", + [](const cm::FluidBodyResult& value) { + return array_tuple(value.velocity); + }) + .def_prop_ro("angular_velocity", + [](const cm::FluidBodyResult& value) { + return array_tuple(value.angular_velocity); + }) + .def_prop_ro("hydrodynamic_force_n", + [](const cm::FluidBodyResult& value) { + return array_tuple(value.hydrodynamic_force_n); + }) + .def_prop_ro("hydrodynamic_torque_nm", + [](const cm::FluidBodyResult& value) { + return array_tuple(value.hydrodynamic_torque_nm); + }) + .def_ro("no_slip_rms_m_s", &cm::FluidBodyResult::no_slip_rms_m_s) + .def_ro("volume_change_rate_m3_s", &cm::FluidBodyResult::volume_change_rate_m3_s) + .def_ro("marker_count", &cm::FluidBodyResult::marker_count); +} + +void bind_FluidSolveReport(nb::module_& module) { + nb::class_(module, "FluidSolveReport") + .def(nb::init<>()) + .def_rw("iterations", &cm::FluidSolveReport::iterations) + .def_rw("relative_residual", &cm::FluidSolveReport::relative_residual) + .def_rw("absolute_residual", &cm::FluidSolveReport::absolute_residual) + .def_rw("divergence_rms_per_s", &cm::FluidSolveReport::divergence_rms_per_s) + .def_rw("continuity_rms_per_s", &cm::FluidSolveReport::continuity_rms_per_s) + .def_rw("source_volume_rate_m3_s", &cm::FluidSolveReport::source_volume_rate_m3_s) + .def_rw("max_speed_m_s", &cm::FluidSolveReport::max_speed_m_s) + .def_rw("reynolds_number", &cm::FluidSolveReport::reynolds_number) + .def_rw("viscous_relaxation_time_s", &cm::FluidSolveReport::viscous_relaxation_time_s) + .def_rw("net_flow_rate_m3_s", &cm::FluidSolveReport::net_flow_rate_m3_s) + .def_rw("estimated_memory_bytes", &cm::FluidSolveReport::estimated_memory_bytes); +} + +void bind_FluidFlowResult(nb::module_& module) { + nb::class_(module, "FluidFlowResult") + .def_ro("field", &cm::FluidFlowResult::field) + .def_ro("pressure_pa", &cm::FluidFlowResult::pressure_pa) + .def_ro("ports", &cm::FluidFlowResult::ports) + .def_ro("bodies", &cm::FluidFlowResult::bodies) + .def_ro("report", &cm::FluidFlowResult::report); +} + +void bind_FluidBodyStepParameters(nb::module_& module) { + nb::class_(module, "FluidBodyStepParameters") + .def(nb::init<>()) + .def_rw("minimum_gap_m", &cm::FluidBodyStepParameters::minimum_gap_m) + .def_rw("maximum_displacement_fraction", + &cm::FluidBodyStepParameters::maximum_displacement_fraction) + .def_rw("max_halvings", &cm::FluidBodyStepParameters::max_halvings) + .def_rw("max_contact_iterations", &cm::FluidBodyStepParameters::max_contact_iterations) + .def("validate", &cm::FluidBodyStepParameters::validate); +} + +void bind_FluidContactResult(nb::module_& module) { + nb::class_(module, "FluidContactResult") + .def_ro("first_id", &cm::FluidContactResult::first_id) + .def_ro("second_id", &cm::FluidContactResult::second_id) + .def_prop_ro("normal", + [](const cm::FluidContactResult& value) { + return array_tuple(value.normal); + }) + .def_prop_ro("point_on_first", + [](const cm::FluidContactResult& value) { + return array_tuple(value.point_on_first); + }) + .def_ro("initial_gap_m", &cm::FluidContactResult::initial_gap_m) + .def_ro("normal_force_n", &cm::FluidContactResult::normal_force_n); +} + +void bind_FluidBodyStep(nb::module_& module) { + nb::class_(module, "FluidBodyStep") + .def_ro("accepted_dt", &cm::FluidBodyStep::accepted_dt) + .def_ro("bodies", &cm::FluidBodyStep::bodies) + .def_ro("flow", &cm::FluidBodyStep::flow) + .def_ro("contacts", &cm::FluidBodyStep::contacts) + .def_ro("halvings", &cm::FluidBodyStep::halvings) + .def_ro("contact_iterations", &cm::FluidBodyStep::contact_iterations); +} + +void bind_StokesFlowSolver(nb::module_& module) { + nb::class_(module, "StokesFlowSolver") + .def(nb::init(), "backend"_a = cm::BackendKind::cpu, + "device_index"_a = 0) + .def("solve", &cm::StokesFlowSolver::solve, "grid"_a, "fluid"_a, "ports"_a, + "parameters"_a = cm::LinearSolveParameters{}) + .def("solve_bodies", &cm::StokesFlowSolver::solve_bodies, "grid"_a, "fluid"_a, "ports"_a, + "bodies"_a, "parameters"_a = cm::LinearSolveParameters{}) + .def("propose_body_step", &cm::StokesFlowSolver::propose_body_step, "grid"_a, "fluid"_a, + "ports"_a, "bodies"_a, "maximum_dt"_a, + "solve_parameters"_a = cm::LinearSolveParameters{}, + "step_parameters"_a = cm::FluidBodyStepParameters{}); +} + +void bind_FluidGeometryParameters(nb::module_& module) { + nb::class_(module, "FluidGeometryParameters") + .def(nb::init<>()) + .def_rw("surface_resolution", &cm::FluidGeometryParameters::surface_resolution) + .def_rw("maximum_surface_error_fraction", + &cm::FluidGeometryParameters::maximum_surface_error_fraction) + .def_rw("memory_limit_bytes", &cm::FluidGeometryParameters::memory_limit_bytes) + .def("validate", &cm::FluidGeometryParameters::validate); +} + +void bind_FluidFragment(nb::module_& module) { + nb::class_(module, "FluidFragment") + .def_ro("site", &cm::FluidFragment::site) + .def_ro("component", &cm::FluidFragment::component) + .def_ro("volume", &cm::FluidFragment::volume) + .def_prop_ro("centroid", [](const cm::FluidFragment& f) { + return array_tuple(f.centroid); + }); +} + +void bind_FluidFace(nb::module_& module) { + nb::class_(module, "FluidFace") + .def_ro("first", &cm::FluidFace::first) + .def_ro("second", &cm::FluidFace::second) + .def_ro("area", &cm::FluidFace::area) + .def_ro("body_id", &cm::FluidFace::body_id) + .def_ro("axis", &cm::FluidFace::axis) + .def_ro("grid_face", &cm::FluidFace::grid_face) + .def_prop_ro("centroid", + [](const cm::FluidFace& f) { + return array_tuple(f.centroid); + }) + .def_prop_ro("normal", [](const cm::FluidFace& f) { + return array_tuple(f.normal); + }); +} + +void bind_FluidGeometryReport(nb::module_& module) { + nb::class_(module, "FluidGeometryReport") + .def_ro("fluid_volume", &cm::FluidGeometryReport::fluid_volume) + .def_ro("expected_fluid_volume", &cm::FluidGeometryReport::expected_fluid_volume) + .def_ro("volume_error", &cm::FluidGeometryReport::volume_error) + .def_ro("maximum_surface_error", &cm::FluidGeometryReport::maximum_surface_error) + .def_ro("component_count", &cm::FluidGeometryReport::component_count) + .def_ro("estimated_memory_bytes", &cm::FluidGeometryReport::estimated_memory_bytes); +} + +void bind_FluidOverlap(nb::module_& module) { + nb::class_(module, "FluidOverlap") + .def_ro("first", &cm::FluidOverlap::first) + .def_ro("second", &cm::FluidOverlap::second) + .def_ro("volume", &cm::FluidOverlap::volume); +} + +void bind_FluidGeometry(nb::module_& module) { + nb::class_(module, "FluidGeometry") + .def(nb::init&, + const cm::FluidGeometryParameters&>(), + "grid"_a, "bodies"_a, "parameters"_a = cm::FluidGeometryParameters{}) + .def_prop_ro("grid", + [](const cm::FluidGeometry& g) { + return g.grid(); + }) + .def_prop_ro("bodies", + [](const cm::FluidGeometry& g) { + return g.bodies(); + }) + .def_prop_ro("fragments", + [](const cm::FluidGeometry& g) { + return g.fragments(); + }) + .def_prop_ro("faces", + [](const cm::FluidGeometry& g) { + return g.faces(); + }) + .def_prop_ro("report", + [](const cm::FluidGeometry& g) { + return g.report(); + }) + .def("overlaps", &cm::FluidGeometry::overlaps, "other"_a); +} + +void bind_Solute(nb::module_& module) { + nb::class_(module, "Solute") + .def(nb::init<>()) + .def_rw("name", &cm::Solute::name) + .def_rw("diffusion", &cm::Solute::diffusion) + .def_rw("amount_unit", &cm::Solute::amount_unit); +} + +void bind_ChemicalBoundary(nb::module_& module) { + nb::class_(module, "ChemicalBoundary") + .def(nb::init<>()) + .def_rw("port", &cm::ChemicalBoundary::port) + .def_rw("concentrations", &cm::ChemicalBoundary::concentrations) + .def_rw("kind", &cm::ChemicalBoundary::kind) + .def_rw("allow_backflow", &cm::ChemicalBoundary::allow_backflow); +} + +void bind_SurfaceTransferLaw(nb::module_& module) { + nb::class_(module, "SurfaceTransferLaw") + .def(nb::init<>()) + .def_rw("body_id", &cm::SurfaceTransferLaw::body_id) + .def_rw("solute", &cm::SurfaceTransferLaw::solute) + .def_rw("uptake_velocity", &cm::SurfaceTransferLaw::uptake_velocity) + .def_rw("secretion_rate", &cm::SurfaceTransferLaw::secretion_rate); +} + +void bind_ChemicalTransfer(nb::module_& module) { + nb::class_(module, "ChemicalTransfer") + .def(nb::init<>()) + .def_rw("port", &cm::ChemicalTransfer::port) + .def_rw("body_id", &cm::ChemicalTransfer::body_id) + .def_rw("amounts", &cm::ChemicalTransfer::amounts); +} + +void bind_SoluteTransportReport(nb::module_& module) { + nb::class_(module, "SoluteTransportReport") + .def(nb::init<>()) + .def_rw("projection_iterations", &cm::SoluteTransportReport::projection_iterations) + .def_rw("transport_iterations", &cm::SoluteTransportReport::transport_iterations) + .def_rw("maximum_volume_residual", &cm::SoluteTransportReport::maximum_volume_residual) + .def_rw("mass_balance_error", &cm::SoluteTransportReport::mass_balance_error); +} + +void bind_SurfaceEnvironment(nb::module_& module) { + nb::class_(module, "SurfaceEnvironment") + .def_ro("body_id", &cm::SurfaceEnvironment::body_id) + .def_ro("area", &cm::SurfaceEnvironment::area) + .def_ro("concentrations", &cm::SurfaceEnvironment::concentrations); +} + +void bind_SoluteTransportResult(nb::module_& module) { + nb::class_(module, "SoluteTransportResult") + .def_ro("amounts", &cm::SoluteTransportResult::amounts) + .def_ro("concentrations", &cm::SoluteTransportResult::concentrations) + .def_ro("reservoirs", &cm::SoluteTransportResult::reservoirs) + .def_ro("cells", &cm::SoluteTransportResult::cells) + .def_ro("report", &cm::SoluteTransportResult::report) + .def_ro("surfaces", &cm::SoluteTransportResult::surfaces); +} + +void bind_GeometricFluxReport(nb::module_& module) { + nb::class_(module, "GeometricFluxReport") + .def_ro("projection_iterations", &cm::GeometricFluxReport::projection_iterations) + .def_ro("maximum_volume_residual", &cm::GeometricFluxReport::maximum_volume_residual); +} + +void bind_TransportGeometry(nb::module_& module) { + nb::class_(module, "TransportGeometry") + .def(nb::init&, double, cm::BackendKind, std::uint32_t, + const cm::LinearSolveParameters&>(), + "before"_a, "after"_a, "velocity"_a, "ports"_a, "dt"_a, + "backend"_a = cm::BackendKind::cpu, "device_index"_a = 0, + "parameters"_a = cm::LinearSolveParameters{}) + .def_prop_ro("report", &cm::TransportGeometry::report, nb::rv_policy::copy); +} + +void bind_SoluteTransportSolver(nb::module_& module) { + nb::class_(module, "SoluteTransportSolver") + .def(nb::init(), "backend"_a = cm::BackendKind::cpu, + "device_index"_a = 0) + .def("propose", &cm::SoluteTransportSolver::propose, "geometry"_a, "solutes"_a, + "boundaries"_a, "amounts"_a, "exchange"_a = std::vector{}, + "parameters"_a = cm::LinearSolveParameters{}) + .def("step", &cm::SoluteTransportSolver::step, "before"_a, "after"_a, "velocity"_a, "ports"_a, + "solutes"_a, "reservoirs"_a, "amounts"_a, "dt"_a, + "exchange"_a = std::vector{}, + "parameters"_a = cm::LinearSolveParameters{}); +} + +void bind_CellSurfaceExchange(nb::module_& module) { + nb::class_(module, "CellSurfaceExchange") + .def(nb::init<>()) + .def_rw("body_id", &cm::CellSurfaceExchange::body_id) + .def_rw("solute", &cm::CellSurfaceExchange::solute) + .def_rw("species", &cm::CellSurfaceExchange::species) + .def_rw("uptake_velocity", &cm::CellSurfaceExchange::uptake_velocity) + .def_rw("secretion_rate", &cm::CellSurfaceExchange::secretion_rate); +} + +void bind_ReserveRequirement(nb::module_& module) { + nb::class_(module, "ReserveRequirement") + .def(nb::init<>()) + .def_rw("species", &cm::ReserveRequirement::species) + .def_rw("amount_per_biomass", &cm::ReserveRequirement::amount_per_biomass); +} + +void bind_CultureConfiguration(nb::module_& module) { + nb::class_(module, "CultureConfiguration") + .def(nb::init<>()) + .def_rw("grid", &cm::CultureConfiguration::grid) + .def_rw("fluid", &cm::CultureConfiguration::fluid) + .def_rw("ports", &cm::CultureConfiguration::ports) + .def_rw("solutes", &cm::CultureConfiguration::solutes) + .def_rw("reservoirs", &cm::CultureConfiguration::reservoirs) + .def_rw("exchange", &cm::CultureConfiguration::exchange) + .def_rw("biomass_requirements", &cm::CultureConfiguration::biomass_requirements) + .def_rw("biomass_per_geometric_volume", + &cm::CultureConfiguration::biomass_per_geometric_volume) + .def_rw("solver", &cm::CultureConfiguration::solver) + .def_rw("stepping", &cm::CultureConfiguration::stepping) + .def_rw("geometry", &cm::CultureConfiguration::geometry) + .def_rw("maximum_substeps", &cm::CultureConfiguration::maximum_substeps) + .def_rw("maximum_retries", &cm::CultureConfiguration::maximum_retries) + .def("validate", &cm::CultureConfiguration::validate, "species_count"_a) + .def_rw("growth", &cm::CultureConfiguration::growth) + .def_rw("events", &cm::CultureConfiguration::events) + .def_rw("authoring_json", &cm::CultureConfiguration::authoring_json) + .def_rw("coupling_tolerance", &cm::CultureConfiguration::coupling_tolerance) + .def_rw("maximum_coupling_iterations", + &cm::CultureConfiguration::maximum_coupling_iterations); +} + +void bind_CultureCellState(nb::module_& module) { + nb::class_(module, "CultureCellState") + .def(nb::init<>()) + .def_rw("body", &cm::CultureCellState::body) + .def_rw("biochemical_volume", &cm::CultureCellState::biochemical_volume) + .def_rw("species_amounts", &cm::CultureCellState::species_amounts) + .def_rw("uptake_totals", &cm::CultureCellState::uptake_totals) + .def_rw("realized_specific_rate", &cm::CultureCellState::realized_specific_rate) + .def_rw("biomass_produced", &cm::CultureCellState::biomass_produced); +} + +void bind_CultureReport(nb::module_& module) { + nb::class_(module, "CultureReport") + .def(nb::init<>()) + .def_rw("substeps", &cm::CultureReport::substeps) + .def_rw("retries", &cm::CultureReport::retries) + .def_rw("flow", &cm::CultureReport::flow) + .def_rw("transport", &cm::CultureReport::transport); +} + +void bind_CultureCheckpoint(nb::module_& module) { + nb::class_(module, "CultureCheckpoint") + .def(nb::init<>()) + .def_rw("configuration", &cm::CultureCheckpoint::configuration) + .def_rw("cells", &cm::CultureCheckpoint::cells) + .def_rw("extracellular_amounts", &cm::CultureCheckpoint::extracellular_amounts) + .def_rw("reservoir_totals", &cm::CultureCheckpoint::reservoir_totals) + .def_rw("last_report", &cm::CultureCheckpoint::last_report) + .def("validate", &cm::CultureCheckpoint::validate, "world"_a) + .def_rw("time", &cm::CultureCheckpoint::time) + .def_rw("event_index", &cm::CultureCheckpoint::event_index); +} + +} // namespace + +void bind_culture(nb::module_& module) { + bind_ChemicalBoundaryKind(module); + bind_GrowthKind(module); + bind_GrowthRequirement(module); + bind_CellGrowthModel(module); + bind_GrowthInput(module); + bind_GrowthEvaluation(module); + bind_GrowthExecutor(module); + bind_CultureEvent(module); + bind_FluidGridSpec(module); + bind_FluidProperties(module); + bind_FlowPortKind(module); + bind_FlowPort(module); + bind_LinearSolveParameters(module); + bind_FlowPortResult(module); + bind_CapsuleBody(module); + bind_FluidBodyResult(module); + bind_FluidSolveReport(module); + bind_FluidFlowResult(module); + bind_FluidBodyStepParameters(module); + bind_FluidContactResult(module); + bind_FluidBodyStep(module); + bind_StokesFlowSolver(module); + bind_FluidGeometryParameters(module); + bind_FluidFragment(module); + bind_FluidFace(module); + bind_FluidGeometryReport(module); + bind_FluidOverlap(module); + bind_FluidGeometry(module); + bind_Solute(module); + bind_ChemicalBoundary(module); + bind_SurfaceTransferLaw(module); + bind_ChemicalTransfer(module); + bind_SoluteTransportReport(module); + bind_SurfaceEnvironment(module); + bind_SoluteTransportResult(module); + bind_GeometricFluxReport(module); + bind_TransportGeometry(module); + bind_SoluteTransportSolver(module); + bind_CellSurfaceExchange(module); + bind_ReserveRequirement(module); + bind_CultureConfiguration(module); + bind_CultureCellState(module); + bind_CultureReport(module); + bind_CultureCheckpoint(module); +} diff --git a/cpp/python/occupancy_bindings.cpp b/cpp/python/occupancy_bindings.cpp new file mode 100644 index 0000000..c0c3e87 --- /dev/null +++ b/cpp/python/occupancy_bindings.cpp @@ -0,0 +1,42 @@ +#include +#include +#include +#include + +#include "cm/occupancy.hpp" + +namespace nb = nanobind; +using namespace nb::literals; + +void bind_occupancy(nb::module_& module) { + nb::class_(module, "OccupancyCapsule") + .def(nb::init, std::array, float, float>()); + nb::class_(module, "OccupancyFace") + .def(nb::init()) + .def_ro("first", &cm::OccupancyFace::first) + .def_ro("second", &cm::OccupancyFace::second) + .def_ro("conductance", &cm::OccupancyFace::conductance) + .def_ro("volume_flux", &cm::OccupancyFace::volume_flux); + nb::class_(module, "OccupancyReservoir") + .def(nb::init()); + nb::class_(module, "OccupancyBalance") + .def_ro("before", &cm::OccupancyBalance::before) + .def_ro("after", &cm::OccupancyBalance::after) + .def_ro("source", &cm::OccupancyBalance::source) + .def_ro("reaction", &cm::OccupancyBalance::reaction) + .def_ro("boundary", &cm::OccupancyBalance::boundary); + nb::class_(module, "OccupancyStep") + .def_ro("amount", &cm::OccupancyStep::amount) + .def_ro("balance", &cm::OccupancyStep::balance) + .def_ro("iterations", &cm::OccupancyStep::iterations) + .def_ro("relative_residual", &cm::OccupancyStep::relative_residual); + nb::class_(module, "OccupancySolver") + .def(nb::init()) + .def("geometric_porosity", &cm::OccupancySolver::geometric_porosity) + .def("accessible_volumes", &cm::OccupancySolver::accessible_volumes) + .def("concentration", &cm::OccupancySolver::concentration) + .def("porosity_face", &cm::OccupancySolver::porosity_face) + .def("remap_amounts", &cm::OccupancySolver::remap_amounts) + .def("exchange_weights", &cm::OccupancySolver::exchange_weights) + .def("backward_euler", &cm::OccupancySolver::backward_euler); +} diff --git a/docs/README.md b/docs/README.md index e26712f..a3c32ee 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,30 +4,32 @@ MicroSimulator models microbial populations in microfluidic environments, connec ## Start here -| If you want to… | Read… | -| --- | --- | -| Understand how devices, flow, and cells fit together | [Microfluidics modeling guide](microfluidics.md) | -| Run a first simulation | [Getting started](tutorials/getting-started.md) | -| Build a trap or channel with growth and washout | [Microfluidic devices](tutorials/microfluidics.md) | -| Choose a flow solver and assess its numerical behavior | [Flow models](microfluidics.md#choosing-a-flow-model) and [flow benchmarks](tutorials/flow-solvers.md#numerical-evidence) | -| Measure nutrient penetration and growth | [Controlled nutrient study](tutorials/nutrient-validation.md) | -| Learn the modeling interface | [Tutorials](tutorials/README.md) | -| Understand numerical conventions | [Numerical contract](architecture/numerical-contract.md) | -| Analyze simulation output | [Analysis recipes](analysis/recipes.md) | -| Configure an accelerator | [Execution environments](#execution-environments) | -| Migrate a CellModeller model or snapshot | [Compatibility and migration](compatibility/README.md) | -| Test a contribution or backend | [Testing and validation](development/validation.md) | +| If you want to… | Read… | +| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| Understand how devices, flow, and cells fit together | [Microfluidics modeling guide](microfluidics.md) | +| Run a first simulation | [Getting started](tutorials/getting-started.md) | +| Select a backend or use PowerShell | [Copyable tutorial commands](tutorials/commands.md) | +| Build a trap or channel with growth and washout | [Microfluidic devices](tutorials/microfluidics.md) | +| Choose a flow solver and assess its numerical behavior | [Flow models](microfluidics.md#choosing-a-flow-model) and [flow benchmarks](tutorials/flow-solvers.md#numerical-evidence) | +| Couple perfusion, solute transport, and nutrient-limited growth | [Fluid, transport, and growth](models/fluid-culture.md) | +| Measure nutrient penetration and growth | [Controlled nutrient study](tutorials/nutrient-validation.md) | +| Learn the modeling interface | [Tutorials](tutorials/README.md) | +| Understand numerical conventions | [Numerical contract](architecture/numerical-contract.md) | +| Analyze simulation output | [Analysis recipes](analysis/recipes.md) | +| Configure an accelerator | [Execution environments](#execution-environments) | +| Migrate a CellModeller model or snapshot | [Compatibility and migration](compatibility/README.md) | +| Test a contribution or backend | [Testing and validation](development/validation.md) | ## Tutorials Start with [getting started](tutorials/getting-started.md) to install the tools, run a model, inspect it, and resume a checkpoint. Then follow the [tutorial index](tutorials/README.md) by topic. Each runnable model is self-contained. -| Topic | Guides | -| --- | --- | -| Devices, flow, and transport | [Walls, flow, and washout](tutorials/microfluidics.md); [pillar channels and flow solvers](tutorials/flow-solvers.md); [nutrient penetration and growth](tutorials/nutrient-validation.md) | -| Cell biology | [Growth and mechanics](tutorials/biophysics-and-growth.md); [gene circuits](tutorials/intracellular-dynamics.md); [signaling](tutorials/signaling.md); [plasmids and contacts](tutorials/discrete-state-and-contacts.md) | -| Circuits in populations | [SimBOL examples](tutorials/simbol.md); [Danino clock in a device](../examples/tutorials/danino_clock.py) | -| Quantitative output | [Checkpoints, contact graphs, and analysis](tutorials/analysis.md) | +| Topic | Guides | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Devices, flow, and transport | [Walls, flow, and washout](tutorials/microfluidics.md); [pillar channels and flow solvers](tutorials/flow-solvers.md); [nutrient penetration and growth](tutorials/nutrient-validation.md) | +| Cell biology | [Growth and mechanics](tutorials/biophysics-and-growth.md); [gene circuits](tutorials/intracellular-dynamics.md); [signaling](tutorials/signaling.md); [plasmids and contacts](tutorials/discrete-state-and-contacts.md) | +| Circuits in populations | [SimBOL examples](tutorials/simbol.md); [Danino clock in a device](../examples/tutorials/danino_clock.py) | +| Quantitative output | [Checkpoints, contact graphs, and analysis](tutorials/analysis.md) | Executable teaching models are under [`examples/tutorials`](../examples/tutorials). Smaller focused examples are available in [`examples`](../examples). @@ -38,6 +40,7 @@ The [architecture guide](architecture/README.md) introduces the engine design an Start with these documents when extending the engine: - [Shallow device flow](architecture/0022-brinkman-flow.md) and [resolved Stokes-Brinkman flow](architecture/0023-mac-stokes.md) +- [Resolved fluid flow with growing cells](architecture/0026-resolved-fluid-culture.md) - [Flow-driven cell motion](architecture/0021-flow-drift.md) - [Biomass, growth, and uptake](architecture/0024-biomass-accounting.md) - [Independent native backends](architecture/0001-native-backends.md) @@ -49,8 +52,9 @@ Start with these documents when extending the engine: ## Analysis and visualization - [Analysis recipes](analysis/recipes.md) covers lazy Polars workflows for colony geometry, species, lineage, contact graphs, and signal fields. +- [Species and signal labels](models/channel-labels.md) describes native and SBML channel metadata. - [Viewer guide](../viewer/README.md) covers static scenes, interactive sessions, controls, development, and tests. -- [Scene format v2](formats/scene-v2.md) defines the data exchanged with visualization clients. +- [Scene format v5](formats/scene-v5.md) defines the data exchanged with visualization clients. - [Live viewer protocol v1](protocols/live-viewer-v1.md) defines the authenticated loopback protocol for interactive sessions. ## Execution environments @@ -66,7 +70,7 @@ The [testing and validation guide](development/validation.md) distinguishes comp ## Formats and protocols - [Run manifest v1](formats/run-manifest-v1.md) defines reproducible batch jobs and parameter sweeps. -- [Scene format v2](formats/scene-v2.md) defines data-only visualization frames. +- [Scene format v5](formats/scene-v5.md) defines data-only visualization frames. - [Live viewer protocol v1](protocols/live-viewer-v1.md) defines interactive viewer messages and authority boundaries. - [Checkpoint design](architecture/0004-checkpoints.md) defines restart state and schema migration. - [Analysis dataset design](architecture/0013-analysis-datasets.md) defines Parquet/Zarr schemas and provenance. diff --git a/docs/architecture/0004-checkpoints.md b/docs/architecture/0004-checkpoints.md index 90e1e6f..135502b 100644 --- a/docs/architecture/0004-checkpoints.md +++ b/docs/architecture/0004-checkpoints.md @@ -11,17 +11,21 @@ MicroSimulator needs reproducible restart files that can cross CPU, Metal, and C ## Decision -The public checkpoint is UTF-8 JSON with the format identifier `microsimulator-checkpoint` and an integer schema version. Version 1 records: +The public checkpoint is UTF-8 JSON with the format identifier `microsimulator-checkpoint` and an integer schema version. Writers emit version 11, which records: - simulation time; - every active cell in compact slot order, including its stable identifier, geometry, attributes, and fixed-schema species levels; - the exact next cell identifier and complete parent map, including inactive ancestors; -- typed plane and sphere constraints and the exact next constraint identifier; +- typed plane, sphere, box, and cylinder constraints and the exact next constraint identifier; - the complete typed species-rate instruction plan and outputs; - producer, source-backend, and caller-supplied provenance; and - a SHA-256 digest of the canonical simulation payload. -Version 2 additionally records an optional validated signal-grid specification and its complete signal-major concentration field. Version 3 adds the typed coupled cell/grid rate plan. Version 4 adds an optional data-only controller payload with its own SHA-256 digest. Native checkpoints write a JSON `null` controller. A non-null controller cannot be silently discarded by `load_checkpoint`; callers use `load_checkpoint_bundle` and restore it with the matching controller. Version 5 records the signal integration kind and its iterative-solver parameters. Version 6 records whether each rod cell is fixed in mechanics. Version 7 adds an optional spatial affine source/loss field to the signal-grid specification. Writers emit only v7; readers explicitly migrate v1 through v6, using Forward Euler defaults for older signal grids, movable cells for checkpoints predating v6, and no affine field reaction for checkpoints predating v7. +The payload also includes an optional signal grid with concentration fields, boundaries, affine reactions, integration kind, and solver parameters; the typed coupled-rate plan; optional controller state; and channel metadata. A non-null controller cannot be silently discarded by `load_checkpoint`; callers use `load_checkpoint_bundle` and restore it with the matching controller. Channel metadata contains ordered `species` and `signals` arrays of strings or null, with lengths matching the native channel counts. Controller state and channel metadata carry separate SHA-256 digests over canonical JSON. `load_checkpoint_bundle` exposes labels without executing model code; `load_checkpoint` refuses named metadata it would otherwise discard. See the [channel authoring guide](../models/channel-labels.md). + +The optional `culture` field stores fluid, chemical, and biological configuration, persistent binary64 cell poses, biochemical volumes, intracellular amounts, extracellular fragment amounts, cumulative reservoir transfers, and diagnostics. Its closed data schema names only predefined native records, arrays, and enums. Restore reconstructs geometry and validates the fragment count and agreement between authoritative amounts and the float32 cell projection. The selected backend recreates numerical resources. Culture solutes determine the signal-channel count even though this mode does not own a legacy signal grid. + +Readers accept versions 1 through 11 and authenticate each original payload before applying explicit defaults for missing fields. Version 10 is migrated from `media_flow` to `culture` with explicit reserve-growth semantics; earlier formats restore without culture state. For versions predating channel metadata, absent groups remain compact as `ChannelMetadata(species=None, signals=None)` until native restoration validates counts. Scene export resolves labels only after enforcing its separate 4096-channel presentation budget per group. Native counts are not capped by that scene budget. Files are written to a temporary sibling, flushed, and atomically replaced. Loading rejects duplicate JSON keys, non-finite numbers, unknown fields for the declared version, unsupported versions, oversized files, digest mismatches, and any state that fails native domain validation. No module is imported and no source text, callback, pickle opcode, or other executable representation is accepted. diff --git a/docs/architecture/0013-analysis-datasets.md b/docs/architecture/0013-analysis-datasets.md index 8a92437..1a814f2 100644 --- a/docs/architecture/0013-analysis-datasets.md +++ b/docs/architecture/0013-analysis-datasets.md @@ -21,6 +21,10 @@ run.dataset/ species.parquet contacts.parquet external_contacts.parquet + culture_frames.parquet + culture_cells.parquet + fluid_fragments.parquet + chemical_transfers.parquet signals.zarr/ ``` @@ -37,12 +41,14 @@ Parquet tables use an explicit Arrow schema and deterministic row ordering: External constraint contacts use a separate typed table because their centerline-location and constraint-kind semantics differ from cell pairs. The location is encoded as `negative`, `positive`, or `interior`, so a finite-obstacle contact at the rod mid-span is not mislabeled as an endpoint. -Analysis format v3 renames the external-contact `endpoint` column to `location`. The reader continues to authenticate v1 and v2 datasets; the versioned schema makes the incompatible column rename explicit rather than silently changing an existing field's meaning. +Analysis format v5 includes optional culture tables. `culture_frames` records units, fluid properties, grid geometry, solute names, and diagnostics. `culture_cells` records binary64 biochemical volume, capsule volume, full pose, and intracellular amounts. `fluid_fragments` records fragment identity, voxel, fluid component, volume, centroid, and solute amounts. `chemical_transfers` records cumulative signed transfers into the fluid. Older v1–v4 datasets remain readable under their original schemas, including the historical external-contact `endpoint` column in v1 and v2. -Stable cell and constraint IDs are Arrow `uint64`; slots and channel indices are `uint32`; cell types are `int32`; engine state values are `float32`; and physical time is `float64`. Nullable parent IDs stay typed `uint64` rather than sentinel values. Derived quantities are named as derivations and never replace their source columns. +Stable cell and constraint IDs are Arrow `uint64`; slots and channel indices are `uint32`; cell types are `int32`; legacy engine state values are `float32`; and physical time and culture state are `float64`. Nullable parent IDs stay typed `uint64` rather than sentinel values. Derived quantities are named as derivations and never replace their source columns. Signal grids use Zarr arrays with logical dimension order `(frame, channel, x, y, z)`. Coordinates, origin, spacing, boundary conditions, frame indices, and physical times are explicit metadata or coordinate arrays. A change in grid shape or geometry starts a new signal epoch rather than padding or silently resampling values. Chunking favors one frame and one channel per access unit; compression and physical chunk sizes are recorded in the manifest. +For culture frames, Zarr signals are fluid-volume-weighted voxel concentration means. Use `fluid_fragments` for amount budgets and disconnected extracellular compartments; averaging is only a presentation projection. + Parquet and Zarr are optional Python analysis dependencies. The native engine, batch runner, checkpoint reader, scene writer, and live viewer do not import them. Dataframe recipes use Polars lazy scans; Arrow remains the schema and Parquet interchange boundary. Zarr is used directly for multidimensional signals rather than storing opaque arrays inside table cells. ## Derived analyses diff --git a/docs/architecture/0014-native-controllers.md b/docs/architecture/0014-native-controllers.md index 732dd4c..be68f52 100644 --- a/docs/architecture/0014-native-controllers.md +++ b/docs/architecture/0014-native-controllers.md @@ -28,8 +28,12 @@ A controller-backed model resumes through `resume(context, checkpoint)`. The ope 1. compute and validate host regulation; 2. apply cell attributes, species, and fixed-state updates; 3. apply division requests and division callbacks; -4. execute `Simulation.step(dt)` for growth and typed rate plans; and -5. execute exactly `MechanicsConfig.passes` contact/relaxation passes when mechanics is configured. +4. apply requested cell removals; +5. execute `Simulation.step(dt)` for growth and typed rate plans; +6. apply flow drift when enabled in the mechanics configuration; and +7. execute exactly `MechanicsConfig.passes` contact/relaxation passes when mechanics is configured and cells remain. + +`UniformLengthDivision.jitter_z` controls only the random perturbation added to daughter orientation: `None` disables jitter, `False` adds XY-only jitter, and `True` adds XYZ jitter. Native normalization can change an inherited nonzero Z component even when the added Z perturbation is zero. Division places daughters along the parent's three-dimensional axis; subsequent contact relaxation and flow drift remain three-dimensional. See the [planarity diagnostics and tutorial audit](../tutorials/planarity.md) for reproducible examples and the limits of finite-height confinement. The standard payload records a stable model ID and version, completed-step counter, model JSON state, random stream, and every mechanics parameter. `NativeController.from_checkpoint` validates and restores that payload while the checkpoint's native state retains rate plans, signal grids, geometry, and lineage. Exact mechanics passes are a new explicit native-controller contract. The legacy adapter separately preserves the intent of `max_substeps` as a bounded new-contact frontier: it performs at most `max_substeps - 1` solves and stops when rediscovery produces no contact identity not seen earlier in the biological step. This distinction is checkpointed and supported by recorded colony trajectories; native models never inherit the legacy heuristic silently. diff --git a/docs/architecture/0025-cell-occupied-volume.md b/docs/architecture/0025-cell-occupied-volume.md new file mode 100644 index 0000000..e2673b6 --- /dev/null +++ b/docs/architecture/0025-cell-occupied-volume.md @@ -0,0 +1,126 @@ +# ADR 0025: conservative extracellular storage with coarse geometric porosity + +- Status: selected numerical design; CPU reference and standalone native Metal/CUDA primitives +- Date: 2026-09-24 +- Scope: issue #13. Standalone numerical primitives are implemented; enabling this model in simulations remains separate work. + +## Decision and current behavior + +Select **coarse geometric porosity** as an opt-in transport model. Estimate the union of cell capsules inside each non-wall voxel, store solute amount per voxel, and use a declared porosity closure for face conductance. This approximates cell exclusion without claiming to resolve sub-voxel fluid passages, membrane boundary layers, displacement flow, or hydrodynamic forces. It is preferable here to treating smoothed biochemical biomass density as an exact solid fraction. + +Current production transport continues to use full non-wall voxel volume: `SignalGridSpec.voxel_volume()` is `hx*hy*hz`, and `cpu_coupled.cpp` divides scattered cell amount rates by that volume. `colony_volume_fraction()` deposits conserved biochemical biomass for empirical resistance; it may exceed one and is not this occupancy representation. No production defaults or checkpoint formats change in this contribution. + +The reference is `python/src/microsimulator/occupancy_reference.py`. It uses float64 midpoint quadrature and a dense backward-Euler solve, intentionally unsuitable for large simulations. Its interfaces carry explicit amounts, volumes, faces, and ledgers. `python/src/microsimulator/occupancy.py` provides the corresponding standalone float32 Metal/CUDA API, compared against that independent reference. Neither module changes `Simulation` transport or controller staging. + +## Standalone native API + +`OccupancySolver("metal")` or `OccupancySolver("cuda", device_index=0)` explicitly selects a GPU; unavailable backends and invalid device indices raise instead of falling back to CPU. The methods mirror the reference: `geometric_porosity`, `accessible_volumes`, `concentration`, `porosity_face`, `remap_amounts`, `exchange_weights`, and `backward_euler`. They share the reference's `Capsule`, `Face`, `ReservoirFace`, and `Balance` value types, but execute no reference numerical functions. Inputs remain caller-owned and unchanged, including when a candidate fails. + +```python +from microsimulator.occupancy import OccupancySolver + +solver = OccupancySolver("metal", epsilon_cutoff=1e-8) +volume = solver.accessible_volumes([0.25, 0.75], voxel_volume=2.0) +face = solver.porosity_face(0, 1, 0.25, 0.75, diffusion=1, area=1, distance=1) +amount, balance = solver.backward_euler([4, 0], volume, [face], dt=1) +concentration = solver.concentration(amount, volume) +``` + +Both backends implement their own geometry, storage, harmonic-face, exchange, connected-component, remap, sparse assembly, Jacobi, residual, and ledger kernels. GPU label propagation uses connectivity of old-or-new accessible storage. The host packs graph topology and sorted component membership; the GPU computes expelled amounts and recipient capacity in deterministic site order with compensated sums. Host float64 accumulation reports the returned per-site ledgers. The solver keeps iteration arrays on the GPU, checks the relative L1 residual of the amount equation every eight iterations, and rejects iteration-limit failures, nonfinite or negative results, and failed conservation checks. `last_report` describes only the last successful transport call. No clipping or amount renormalization is performed. + +Native arrays use float32. Quadrature subdivisions are limited to 1 through 256 so sample counts remain exactly representable. The constructor's cutoff is explicit and read-only. Empty arrays and all-zero storage are supported; exchange support must have positive accessible weight. Transport sources have amount/time units; callers convert accessible-fluid affine production to `W*b` once. Callers supply internal/periodic edges and reservoir coefficients explicitly, omit no-flux and degenerate-axis faces, and retain the exterior-lattice-center reservoir convention. These routines do not construct the physical cell-exchange support or certify occupancy-consistent velocity fields. + +This initial native implementation prioritizes deterministic numerical comparison. Geometry evaluates all capsules for each voxel's samples, component labels may require a domain-diameter number of dispatches, scalar reductions are ordered device loops, and Jacobi may converge slowly for stiff or nearly closed domains. Float32 concentration quantization also limits attainable residuals: the spatial-refinement fixture explicitly uses `relative_tolerance=2e-6`, while keeping the `5e-6` ledger gate. The default solve tolerance is `1e-7`; failure reports the measured residual and never silently relaxes the requested tolerance. It is not a throughput or large-colony performance claim. Geometry/controller transactions, uptake budgeting, automatic barriers, checkpoint conversion, weighted flow, and coupled scientific refinement still require integration work. + +Run `python -m pytest python/tests/test_occupancy_native.py python/tests/test_occupancy_reference.py`. The Python cases enumerate each available Metal/CUDA device and compare geometry, remapping, exchange, transport, signed ledgers, 1000-step drift, and separate quadrature/spatial/timestep refinement with the float64 reference. `occupancy_conformance` also runs through CTest and the existing Metal/CUDA conformance scripts; its checks remain active in optimized builds. A run without a GPU skips this scenario and supplies no GPU evidence. CUDA compilation alone does not establish NVIDIA runtime conformance. + +## State, geometry, and units + +For voxel i, geometric volume `V_i = hx*hy*hz`, accessible fraction `epsilon_i`, accessible storage `W_i = epsilon_i V_i`, and extracellular concentration `c_i`, define the authoritative solute amount `N_i = W_i c_i`. Concentration is amount per accessible fluid volume, including in partially occupied voxels. Length has units L, time T, concentration A/L³, and amount A. + +Occupancy depends only on the current cell centers, normalized directions, nonnegative centerline lengths, positive radii, lattice centers/spacing, and the existing binary transport-wall mask. It does not depend on species, growth-rate attributes, cell type, stationary attachment, or the biomass-resistance averaging radius. All cells, including mobile ones, exclude storage. Mechanical wall primitives do not define a second transport mask: only the declared voxel obstacle mask clips storage. + +Each voxel uses m³ midpoint samples on its physical box centered at `origin + index*spacing`. A sample is occupied when its distance to any capsule centerline segment is at most the corresponding radius. Count the union, never summed capsule fractions, so overlaps cannot make epsilon negative. Outside-domain capsule pieces are ignored. A wall voxel has epsilon zero regardless of cells. Default reference m is 8; m is a reproducible numerical parameter requiring convergence checks. Even a degenerate lattice axis retains its physical voxel thickness for occupancy and storage; it does not turn capsules into disks. + +Biochemical biomass remains `B = pi*r²*(length + 2*r)`. Geometric capsule volume is `V_geom = pi*r²*length + 4*pi*r³/3`. Native division preserves B but, for equal radii, reduces total capsule volume by `2*pi*r³/3`. This closure deliberately exposes the resulting extra fluid storage. It represents coarse division geometry, not physical septum formation or calibrated cell volume. Never transfer extracellular solute into daughters merely because their geometric occupancy changed. + +## Conservative transport balance + +For an internal oriented face i→j, use harmonic closure `a_f = 2 epsilon_i epsilon_j / (epsilon_i + epsilon_j)` when both voxels are accessible, otherwise zero. This is a coarse permeability/aperture approximation, not geometric face intersection. Let `K_f = D a_f A_f / d_f` and `Q_f = a_f A_f u_f`. D has units L²/T, K and Q have units L³/T, and u is intrinsic accessible-fluid velocity in L/T. Positive Q flows from i to j. The outward amount rate is + +```text +F_ij = K_f (c_i - c_j) + max(Q_f, 0) c_i + min(Q_f, 0) c_j +``` + +The identical face value enters the neighbor with the opposite sign. With amount source S_i, accessible-fluid reaction source b_i, and first-order loss lambda_i: + +```text +dN_i/dt = -sum_faces F_ij + S_i + W_i b_i - lambda_i N_i +``` + +After the geometric remap described below, freeze W and face coefficients over one transport step. The first native implementation should offer backward Euler: + +```text +(W + dt L) c_new = N_remapped + dt (S + W b + reservoir_inflow) +N_new = W c_new +``` + +L has diffusion conductances, upwind outgoing fluxes, and `lambda_i W_i` on its diagonal, with negative incoming neighbor coefficients. Zero-storage rows are isolated identity rows with zero right-hand side. The reference reports before/after total amount plus separately integrated source, reaction, and boundary amounts; internal faces cancel. Do not infer conservation from changes in concentration or from an unweighted sum of concentrations. + +No-flux boundaries have K=Q=0. Periodic pairs contribute one shared internal face. Fixed reservoirs retain the existing **exterior lattice-center** convention: distance d is one spacing, not a half spacing. For this closure, use exterior epsilon=1 and the same harmonic rule. Reservoir exchange is `K(c_res-c_i) - Q*c_upwind` with Q positive outward, and is included in the boundary ledger. In a singleton axis production transport has no face operator, matching the existing degenerate-axis rule. Affine b is concentration/time per accessible fluid; a physical source specified per whole voxel instead becomes an explicit amount rate, never an implicit second volume conversion. + +## Velocity convention and flow coupling + +The current face field is a velocity used directly by transport and also sampled by cell drift. With the existing binary wall mask and epsilon=1 on fluid sites, intrinsic velocity and whole-open-face volumetric velocity coincide. Partial porosity introduces a distinction that cannot be inferred from old arrays. + +In the new opt-in mode, retain **intrinsic velocity** in `SignalGridVelocityField` and derive Q by multiplying `a_f*A_f` once. If a flow solver produces integrated flux Q, convert to intrinsic u by dividing by `a_f*A_f` once on open faces and require Q=0 on closed faces. Never multiply an already aperture-weighted flux by epsilon again. Constant-advection inputs use the same explicit convention. + +Existing flow solutions generally satisfy continuity for their current binary fluid geometry, not for these new weighted faces. They must not be advertised as occupancy-consistent flow without a weighted projection/re-solve. For fixed occupancy, validate `sum Q_out = 0` in interior voxels. Moving occupancy would require `dW/dt + sum Q_out = 0` for incompressible displaced fluid; this initial model instead uses the explicit conservative remap below. That remap is a solute bookkeeping closure and does not solve fluid displacement. Prescribed velocity experiments must declare this limitation; coupling a resolved displacement-flow model is subsequent work. Transport remains amount-conservative even for a prescribed divergent field, but uniform concentration need not remain uniform. + +## Occupancy changes, closed storage, and transaction order + +Fractions smaller than `epsilon_cutoff = 1e-8` are treated as zero, including for face closure. The cutoff is part of the model configuration and checkpoint state, not a hidden denominator floor. Keep N fixed in each voxel whose new W remains positive, so concentration changes to N/W. Newly accessible voxels start with their existing amount, normally zero; removal does not invent extracellular solute. + +For voxels changing to zero storage, redistribute their entire amount over newly accessible recipient voxels in the same face-connected component of the **union of old and new accessible voxels**, weighted by recipients' new W. Connectivity uses regular voxel neighbors including declared periodic pairs and excludes persistent wall/closed voxels. Use sorted voxel order for deterministic accumulation. Existing recipient amounts are retained. If a component closes completely while holding any positive amount, reject the entire geometry/transport transaction. If it contains exactly zero amount, closure is valid. No epsilon floor, disappearing amount, cross-wall transfer, or silent clipping is allowed. A near-zero positive W can still create high concentrations; finite/positivity and solver checks may reject the step rather than alter its mass balance. + +This remap is intentionally nonlocal within its transition component and can instantaneously mix expelled solute. It does not reconstruct a membrane trajectory or predict a swept-volume velocity. Its domain, cutoff, and redistribution rule must therefore be stated in scientific use. A donor is emptied once even when several cells overlap it. Reject invalid input before changing simulation state. + +The proposed opt-in controller/native stage order is: + +1. Snapshot complete state and authoritative N at the last committed geometry. Apply validated regulation, divisions, removals, and their geometry callbacks. Recompute occupancy and conservatively remap if geometry changed. +2. Advance biological growth and intracellular dilution using B. Recompute occupancy for post-growth geometry and remap N again. Construct one set of accessible exchange weights, sample this remapped pre-transport concentration, evaluate biology, then solve transport/reactions and scatter cell amount rates using the same weights. This explicitly changes the sampling point from the legacy stage; it needs a separately named opt-in contract and native split-stage work. +3. Apply configured flow drift and mechanical relaxation, then recompute/remap once at final geometry. Any direct geometry edit or removal outside the controller must invoke the same barrier before the next transport/export/checkpoint operation. No geometry change may leave old W paired with new cells. +4. Commit geometry, biomass/species, N, W, time, and all ledgers together only after every stage validates. A failure restores the complete transaction, including controller/RNG state. Zero-time topology edits still require remapping. Unchanged geometry reuses occupancy without resampling. + +This ordering follows the existing regulation/topology → biology → drift/mechanics outline while making its new geometry barriers explicit. It is not implemented by the reference module. Production work must add atomic staging; installing only a new denominator inside `cpu_coupled.cpp` would be incorrect. + +## Cell sampling and scattering + +Choose a declared physical support that reaches accessible extracellular sites, restricted to the connected fluid component containing a deterministic nearest-accessible anchor. Resolve equal-distance anchors by flattened index and explicitly validate the maximum search radius; no cross-wall search. For nonnegative geometric kernel values phi_i in that support, use `w_i = phi_i W_i / sum(phi_j W_j)`. Sampling is `sum(w_i c_i)`; a cellular extracellular amount rate J scatters `w_i J`. The concentration-rate conversion is `(w_i J)/W_i` exactly once, for accessible sites only. The same weights give a partition of unity and the sampling/scattering adjoint relation. The reference `exchange_weights` receives an already connected support; topology construction remains native implementation work. + +With epsilon=1 and equal voxel volumes this reduces to the current normalized trilinear weights and J/V conversion. With resolved excluded cell centers, the old eight center-neighbor stencil may have no accessible sites; expanding or surface-based physical support is required, with a declared radius and refinement study. Reject zero accessible support. Uptake must be limited by an explicitly conservative coupled solve or reject an unaffordable step; never clamp negative extracellular concentrations. Opposite intracellular amount uses B, not geometric occupancy. Washout of a cell exports its intracellular amount through a separate biological ledger; it does not remove a voxel's extracellular solute. + +## Executable reference cases and tolerances + +Run `uv run python -m pytest python/tests/test_occupancy_reference.py -v`. The cases are deterministic CPU tests: + +| Case | Amount evidence | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Empty grid | Float64 reference equals existing native CPU backward Euler for `[0,1,0]`, within rtol 2e-6/atol 2e-7; total amount remains 1. Empty geometry gives epsilon=1. | +| Partially occupied closed domain and unequal storage | W=[0.5,1.5], initial c=[8,0], total N=4; diffusion tends to equal c=2 with unequal amounts [1,3]. Every-step ledger residual is below 1e-12. | +| Changing occupancy, closure, reopening | N=[2,3,1], W changes [1,1,1]→[0,0.25,0.75]; remap gives [0,3.5,2.5], total 6. Reopening keeps new-site amount zero. Entire-component closure rejects without mutation; a persistent wall prevents redistribution to an unrelated recipient. | +| Division/removal, overlap, wall-adjacent geometry | One native-shaped parent becomes two daughters with unchanged B and smaller geometric volume. Union quadrature does not double-count duplicate cells, wall voxels remain inaccessible, and remapping through division/removal preserves total N. | +| Boundaries, reactions, exchange and advection | Unequal storage, an internal advective face, inflow/outflow reservoirs, decay and cellular source all contribute to the signed ledger; residual below 1e-12. A face test verifies aperture enters Q once. | +| Refinement | Backward-Euler timestep error approximately halves for 10/20/40 steps; the empty-limit centered operator error approximately quarters for 10/20/40 voxels. Sphere quadrature at m=8/16/32 improves the finest volume error to below 2%. | + +The 1e-12 reference balance tolerance applies to these order-one float64 cases. General checks scale by `max(1, |N_before|, |N_after|, sum(abs(external terms)))`; scientific units must be normalized explicitly. Native float32 gates are per-step relative ledger residual <=5e-6, 1000-step closed-case drift <=5e-5, and concentrations versus float64 reference within rtol 2e-4/atol 2e-6 in normalized test units. Native calls enforce the per-step ledger gate; the conformance tests measure long-run drift and reference agreement on each enumerated GPU. + +Subsequent native validation must run h, h/2, h/4 at fixed physical cell/device dimensions and exchange support, with m, 2m, 4m independently; also dt, dt/2, dt/4. Track occupied volume, total amount, concentration L1/Linf errors, boundary/reaction/cell ledgers, cutoff crossings, and solver residuals. Require convergence of the scientific observable, not only conservation. The midpoint geometry estimate can oscillate across resolutions; never assert a universal smooth-interface order from one placement. Test rotated/translated rods, overlapping capsules, nearly blocked passages, all-zero storage, wall contacts, division/removal, and restart at a geometry barrier. Vary epsilon_cutoff by factors of ten. Native backends must implement their own kernels and pass the same cases without CPU fallback. + +## Compatibility and implementation work + +Old checkpoints and all ordinary runs retain occupancy-disabled full-voxel semantics. Do not reinterpret old concentration arrays as fluid-volume concentrations. Enabling occupancy on an existing state is an explicit conversion: preserve legacy amount `N=V*c`, compute W, then apply closure remapping and derive c. Such a conversion can reject a sealed component and must be recorded in provenance. + +A future checkpoint version must record model kind/version, lattice and wall geometry, quadrature resolution, cutoff, exchange-support/anchor rule, remap rule, velocity convention, authoritative per-species N and committed W/geometry revision, plus any required solver history. N and W participate in integrity checks. On restore, authenticate before migration, validate N>=0 and zero amount at W=0, verify geometry/occupancy consistency, and resume at a committed barrier without repeating remapping. Record precision/backend provenance; recomputing occupancy with another quadrature algorithm may change results and requires an explicit conversion. The current reference adds no fields to checkpoints. + +The standalone Metal/CUDA primitives implement geometry rasterization, transition connectivity/remapping, weighted-storage transport, and exchange-weight normalization. Required follow-up contributions are native state/configuration and checkpoint conversion, a production CPU weighted-storage operator and atomic coupled staging, physical exchange-support construction and uptake budgets, weighted-flow interface/projection with explicit drift semantics, and end-to-end geometry/removal/restart and coupled refinement validation. Existing flow resistance may coexist as a calibrated closure; it must not be relabeled as geometric exclusion or counted a second time in transport porosity. diff --git a/docs/architecture/0026-resolved-fluid-culture.md b/docs/architecture/0026-resolved-fluid-culture.md new file mode 100644 index 0000000..7d2c2dd --- /dev/null +++ b/docs/architecture/0026-resolved-fluid-culture.md @@ -0,0 +1,53 @@ +# Resolved fluid flow and coupled capsules + +`Simulation._configure_culture` couples steady Stokes flow, rigid growing capsules, geometric extracellular transport, and intracellular amounts. The mode has explicit CPU, Metal, and CUDA implementations, conservative division, transactional stepping, and persistent restart state. The [modeling guide](../models/fluid-culture.md) describes configuration, units, examples, and the scientific validation boundary. Existing simulation modes retain their current semantics. + +## Physical model and interfaces + +`StokesFlowSolver` selects CPU, Metal, or CUDA explicitly. `solve` computes steady incompressible Newtonian Stokes flow on a uniform three-dimensional MAC grid with arbitrary pressure and total-flow patches on its six outer boundaries. Unspecified boundary faces and obstacle voxels are stationary no-slip walls. `FluidGridSpec` has no chemical species count. Its origin denotes the center of voxel zero, matching `SignalGridSpec`; the lower physical boundary is half a spacing below the origin. Obstacle sites and port sites use x-major, z-minor flattening. + +Coordinates and returned face velocities use model lengths and times. `length_unit_m` and `time_unit_s` convert these to SI. Viscosity is in Pa s, density in kg/m³, reservoir pressures in Pa, and prescribed port flow in m³/s. Port flow is positive outward. Pressure ports impose normal traction, including the normal viscous stress; tangential traction is zero at the opening. A rate port has a single unknown reservoir pressure and a constraint on total signed volumetric flux. It does not prescribe a pointwise plug profile. Pressure and rate patches may coexist. Empty port site lists select all fluid faces on the declared boundary. Names must be unique, patches cannot overlap, and each patch must belong to a single face-connected fluid component. + +Every component without a pressure port receives its own pressure gauge. Its prescribed net outward rate must match its volume source. A sealed chamber at rest is supported; prescribed net inflow or growth in a sealed incompressible chamber is incompatible. Absolute pressure offsets are subtracted during assembly and restored in the returned pressure field. They do not change velocity. There is no velocity rescaling after the solve. + +`solve_bodies` adds capsule surface constraints and force/torque balance to the same linear system. `CapsuleBody` stores a persistent unit quaternion in `(w,x,y,z)` order, with its local capsule axis along x. Length excludes the hemispherical caps. Position, radius, length, elongation rate, translation, and angular velocity use model units. Applied forces and torques use N and N m. Fixed bodies have zero rigid translation and rotation, while their prescribed elongation may remain nonzero. Results report body velocities, hydrodynamic loads, marker counts, and measured no-slip residuals. + +The constrained system uses the smooth six-point immersed-boundary delta of [Bao et al.](https://arxiv.org/abs/1505.07529), with surface markers approximately two fluid spacings apart. Force spreading is the exact transpose of velocity interpolation. The discrete kernel is checked against its moment, nonnegativity, and constant sum-of-squares identities. Capsule radii require at least two fluid spacings; this is a minimum representation check, not an accuracy guarantee. Wall extension is bounded and does not resolve lubrication singularities. Bodies whose kernel reaches an open port are rejected. Independent spatial, marker, wall-gap, and body-drag refinement is required before interpreting quantitative cell hydrodynamics. + +Elongation prescribes the corresponding surface velocity and distributes the exact capsule volume rate `pi*r²*length_rate` over interior pressure cells. This interior source exists only in the fictitious fluid used by the immersed-boundary formulation. It represents displaced extracellular medium, not solute production. The solver reports physical divergence and the separate continuity residual after subtracting the source. Capsule geometry is validated against the domain and voxel walls before assembly. + +`propose_body_step` returns a candidate bounded substep and its `accepted_dt`; it does not advance an external clock. A frictionless active-set solve adds compressive contact forces to the fluid/body force balance. Contacts cover capsule pairs, domain walls, and obstacle voxels. The minimum gap is specified in metres, with an additional numerical guard of `1e-5` fluid spacings. Candidate translation, rotation, and elongation move any surface point by at most one quarter of a spacing. Quaternion updates use the exponential rotation, and final geometry is checked again. Excess motion or a failed final gap check halves the proposed interval, up to the configured limit. Infeasible contact constraints or failed linear solves reject the candidate. The returned flow belongs to the initial geometry of that substep; transport must use the same initial and candidate geometries before the caller commits either bodies or time. + +## Numerical execution + +The operator discretizes the symmetric viscous energy `2 mu e:e`, pressure continuity, reservoir constraints, marker no-slip constraints, and rigid-body force balance. One nondimensionalization is used for all backends. Host setup constructs the sparse operator and preconditioner coefficients. CPU Krylov arithmetic uses binary64. Metal and CUDA use native binary32 sparse matvecs, vector updates, and reductions, with Krylov vectors resident on the selected device. Reduction partials and scalar Arnoldi control are accumulated in host binary64. The final candidate is checked against the assembled operator before it is returned. + +Unoccupied flow uses a Galerkin velocity multigrid hierarchy with damped Jacobi smoothing and a small coarse inverse. Coupled-body flow uses a threshold incomplete factorization of the entire constrained matrix. Its triangular application currently runs serially on the selected device. This is a performance limit requiring scaling measurements and a parallel triangular solve before large-colony throughput claims. Preconditioner dropping and pivot safeguards do not modify the physical operator or the residual gate. + +FGMRES uses a restart length of 120, twice-modified Gram-Schmidt, and explicit true-residual checks. Default normalized tolerances are relative `1e-5` and absolute `1e-7`. Invalid units, incompatible port data, resource estimates above the configured memory limit, singular unconstrained motion, nonfinite results, and nonconvergence are errors. No backend fallback or automatic tolerance relaxation occurs. Calls return candidates and do not mutate caller-owned geometry or simulation state. + +## Extracellular transport and biochemical state + +`FluidGeometry` clips volume-preserving convex capsule approximations against voxels and retains separate connected extracellular fragments. Open face areas and membrane patches come from the same geometry. Surface approximation bounds reject unresolved gaps. Positive-volume slivers remain in the mesh. Old/new fragment intersections define temporal control volumes without connecting unrelated fluid pockets. + +`SoluteTransportSolver` corrects face fluxes to satisfy local geometric volume changes and prescribed total port rates. A residual-refinement loop checks local geometric conservation as well as the global Krylov norm. Backward-Euler upwind advection, diffusion, and membrane uptake advance authoritative amounts. Secretion and reservoir transfers use the same signed fluxes in both sides of their ledgers. Nonfinite or negative results reject the candidate; no clipping or amount redistribution hides a failed solve. + +Each cell stores biochemical volume and binary64 species amounts separately from its analytic geometric volume. Growth consumes configured species amounts per biochemical increment and cannot spend material beyond the current intracellular budget. Native intracellular rate plans receive explicit old/new biochemical volumes on all backends. Surface exchange credits the corresponding intracellular species, making imported material available for the following substep. This operator splitting and the transport scheme are first order in time. + +Division splits analytic capsule volume, biochemical volume, and intracellular amounts. Daughter cylinders account for the two additional hemispherical caps. The daughter placement and extracellular remap are validated before commit. The remap has no physical duration, hydraulic transient, or reservoir transfer. Instantaneous removal, external pose edits, and cell passage through an outlet are unsupported. + +## State ownership and transactions + +The simulation owns the persistent poses, biochemical state, extracellular amounts, reservoir totals, and reconstructed geometry. Public state and fragment accessors return copies. `Simulation.step` stages an entire requested interval and commits only after all adaptive substeps pass. A culture-enabled `NativeController` additionally restores regulation state, its RNG, lifecycle changes, and counters on failure. External side effects inside callbacks cannot be restored. + +Checkpoint version 11 records the complete configuration and authoritative amounts; geometry is reconstructed and verified on restore. Scene version 5 includes units, fragment volumes and amounts, biochemical state, and reservoir ledgers. Its voxel concentration field is a fluid-volume-weighted presentation mean. Analysis version 5 preserves quantitative binary64 state in separate culture, fluid-fragment, and chemical-transfer tables. Previous supported formats continue to load. + +## Validation boundary + +Conformance covers device hydraulics and square-duct refinement, immersed-boundary kernel identities, force/torque and no-slip constraints, contact, growth displacement, geometric volume and face closure, diffusion eigenmodes, moving-volume uniform concentration, exchange budgets, restart, and rollback. Tests enumerate the requested native devices and a GPU runtime gate must accompany hardware evidence. CUDA compilation alone is not runtime validation. + +Dense contact networks, quantitative drag and near-wall refinement, and large-colony memory/throughput studies remain validation work. The minimum grid resolution and linear-solver tolerance do not establish physical accuracy. Geometry and operator/factor setup run on the host; native GPU execution refers to the numerical linear-algebra operations described above. + +## Running the current interfaces + +`examples/growing_media.py --backend cpu` advances a growing nutrient-consuming cell in a perfusion-driven device and can export a checkpoint and scene. `examples/physical_flow.py` exercises the standalone hydraulic solver. Select `metal` or `cuda` explicitly for those backends. The fluid, transport, body, geometry, and culture CTest targets cover hydraulics, bodies, geometry, transport, and integrated simulation. Python integration tests cover the public API, persistence, controller transactions, division, scene interchange, and analysis output. diff --git a/docs/architecture/0027-fluid-transport-biology-code.md b/docs/architecture/0027-fluid-transport-biology-code.md new file mode 100644 index 0000000..59cd4b0 --- /dev/null +++ b/docs/architecture/0027-fluid-transport-biology-code.md @@ -0,0 +1,61 @@ +# Fluid mechanics, chemical transport, and growth: code ownership + +The simulation composes three independent physical and biological operations. Fluid mechanics determines velocity and moving-body geometry. Chemical transport uses that geometry to advance conserved solute amounts. Growth models convert accepted nutrient uptake into biomass and geometric growth. `CultureSimulation` couples those operations transactionally; biological medium recipes remain Python authoring data. + +## Native ownership + +| Layer | Public types | Implementation | +| ---------------------------- | ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| Grid | `GridShape`, `FlowAxis`, `MacVelocityField` | `cm/grid.hpp` | +| Numerical settings | `LinearSolveParameters` | `cm/numerics.hpp`, `core/numerics.cpp` | +| Sparse algebra and devices | `CsrMatrix`, `IncompleteLu`, `MultigridLevel`, `SparseMultigrid`, `NumericsDevice` | `core/numerics_*.hpp`, `cuda/cuda_numerics.cu`, `metal/metal_numerics.mm`, `metal/kernels/numerics.metal` | +| Fluid mechanics | `FluidGridSpec`, `FluidProperties`, `FlowPort`, `CapsuleBody`, `StokesFlowSolver` | `cm/stokes_flow.hpp`, `core/stokes_flow.cpp` | +| Capsule geometry and contact | `CapsuleContact`, `CapsulePolyhedron`, `ConvexPolyhedron`, `FluidGeometry` | `core/capsule_contacts.hpp`, `core/convex_polyhedron.hpp`, `core/fluid_geometry.cpp` | +| Geometric transport | `TransportGeometry`, `GeometricFluxReport` | `cm/transport_geometry.hpp`, `core/transport_geometry.cpp` | +| Chemical transport | `Solute`, `ChemicalBoundary`, `SurfaceTransferLaw`, `SurfaceEnvironment`, `SoluteTransportSolver` | `cm/solute_transport.hpp`, `core/solute_transport.cpp` | +| Biological growth | `CellGrowthModel`, `GrowthRequirement`, `GrowthExecutor` | `cm/growth.hpp`, `core/growth.cpp` | +| Coupled simulation | `CultureConfiguration`, `CultureEvent`, `CultureCheckpoint`, `CultureReport` | `cm/culture_simulation.hpp`, `core/culture_simulation.cpp` | + +Sparse storage, incomplete factorization, device allocation, and linear solves do not include the fluid solver or biological headers. `fluid_multigrid.hpp` constructs the velocity hierarchy from MAC coordinates; the generic multigrid representation and cycle live in `numerics_multigrid.hpp`. The fluid API has no solute or growth-model dependency. The chemical transport API has no biological medium or growth-model dependency. `GrowthExecutor` consumes concentrations and accepted uptake through a small numerical input record; it does not own the fluid domain or transport solver. + +The existing signal-grid API retains `SignalGridVelocityField` as an alias of `MacVelocityField`. Established shallow-flow and Stokes–Brinkman models retain their existing semantics. + +## Python authoring + +`flow.py` defines fluid properties, domain patches, pressure and volume-rate drives. `stokes.py` provides `StokesFlow`. `transport.py` defines solutes and chemical boundary conditions. `media.py` defines immutable `Medium` compositions and concentration units. `growth.py` defines organism-specific kinetics, nutrient yields, and biomass conversion. `schedules.py` provides piecewise-constant schedules. `culture.py` compiles these definitions to native records with stable solute indices and explicit unit conversion. + +```python +simulation.configure_culture( + fluid=StokesFlow(domain, properties, hydraulic_boundaries), + transport=SoluteTransport(solutes, chemical_boundaries, initial_medium), + cell_growth={cell_id: CellGrowth(kinetics, biomass_conversion)}, +) +simulation.step(dt) +state = simulation.culture_state +``` + +The complete runnable configuration is [growing_media.py](../../examples/growing_media.py). It starts with nutrient-free fluid and no intracellular nutrient reserve. Feed composition, volume rate, essential nutrient requirements, and a scheduled medium switch are explicit inputs. Medium composition does not prescribe a cell growth rate. + +`Simulation.configure_culture` is the Python authoring entry point. Native callers pass `CultureConfiguration` to `cm::Simulation::configure_culture`; Python exposes that record-based entry point as `_configure_culture`. `culture_state` is an immutable, named Python inspection snapshot. `culture_checkpoint` returns a copy of native restart state. `fluid_fragments` and `cell_surface_concentrations` expose geometry and membrane-local chemical state. `set_cell_force` changes the external force and torque on a coupled body. + +## A coupled step + +1. Split the requested interval at the next scheduled boundary event. +2. Stage cell poses, biochemical volumes, extracellular amounts, and transfer ledgers from the accepted old state. +3. Propose growth rates and a constrained Stokes body step; reject unresolved geometry or invalid contact. +4. Build `TransportGeometry` from old/new fluid fragments, temporal overlaps, apertures, and conservative face fluxes. +5. Evaluate surface uptake coefficients, solve implicit transport, and evaluate biomass production from accepted uptake. +6. Iterate until growth, uptake, and geometry agree within the coupling tolerance. Every trial starts from the same accepted old amounts. +7. Commit all state together. A failed trial reduces the substep; exhaustion restores the whole public step. + +A Monod model uses one substrate. An essential-nutrient model uses a shared growth extent limited by all required nutrients and their yields. Nutrient uptake and biomass increments share the same ledger; excess accepted uptake from the converged iteration is returned to adjacent extracellular fragments. Reserve-budget growth remains a separate explicit model. Native configuration rejects mixing reserve and kinetic growth mechanisms in one culture. + +Scheduled events store complete hydraulic and chemical boundary snapshots and a restartable event cursor. Schedules are compiled at configuration time. Division partitions extensive stores and uptake totals and inherits the parent's kinetic model. Biochemical volume and geometric capsule volume remain distinct quantities connected by the configured conversion. + +## Persistence and device execution + +Checkpoint version 11 writes `simulation.culture` and `configuration.fluid`. The version-10 reader recognizes the old `media_flow` and `medium` keys after verifying the original digest, then supplies explicit reserve-growth defaults. Scene version 5 writes `frame.culture`; version 4 remains readable through its original schema. Analysis version 5 writes `culture_frames`, `culture_cells`, `fluid_fragments`, and `chemical_transfers` tables. Version-4 datasets retain their original filenames and authenticated identities; readers resolve canonical names to those files. + +CPU algebra uses binary64. Metal and CUDA execute sparse operations, preconditioner application, reductions, and growth evaluation on the selected device. Host code owns geometry, matrix assembly, factorization setup, authoritative amounts, convergence control, and transactions. Scalar defect correction checks the original double-precision operator while executing correction solves on the selected device. Device unavailability and nonconvergence raise errors; no CPU fallback is selected. + +Native conformance tests cover each layer and their coupling. Python tests cover authoring, conserved uptake, schedules, restart, division, rollback, and old-format readers. CUDA compile/link checks establish build compatibility; numerical CUDA parity requires execution on NVIDIA hardware. Model limitations and refinement requirements are described in the [fluid and culture guide](../models/fluid-culture.md). diff --git a/docs/architecture/0027-fluid-transport-biology-plan.md b/docs/architecture/0027-fluid-transport-biology-plan.md new file mode 100644 index 0000000..bb9f2ad --- /dev/null +++ b/docs/architecture/0027-fluid-transport-biology-plan.md @@ -0,0 +1,145 @@ +# Fluid mechanics, chemical transport, and biological media + +This document records the implementation plan and its scientific acceptance criteria. The [code ownership document](0027-fluid-transport-biology-code.md) describes the current interfaces and execution boundaries; the plan is not a claim that every proposed validation study has been completed. +Status: proposed implementation plan. This document describes future work; the API names below are proposals. The objective is to simulate how the flow and composition of growth medium through a device determine local nutrient availability, cellular consumption, and growth. + +The companion [code and API sketch](0027-fluid-transport-biology-code.md) shows the intended Python configuration, native interfaces, coupled stepping algorithm, file changes, and representative acceptance tests. + +## Intended model + +Device geometry and hydraulic boundary conditions determine fluid velocity. Chemical transport uses that velocity, fluid-accessible volumes, diffusivities, and chemical boundary conditions to determine local concentrations. A biological model specifies how cells consume those chemicals and convert them into biomass. A medium is a composition supplied to the device; its nutritional effect depends on the cell model. + +```mermaid +flowchart LR + D[Device and hydraulic conditions] --> F[Fluid mechanics] + F --> T[Chemical transport] + M[Medium composition and feeding schedule] --> T + T --> B[Cell uptake and growth model] + B -->|Chemical consumption and secretion| T + B -->|Changing cell geometry| F +``` + +General fluid mechanics here means reusable physical interfaces. The initial numerical model remains steady, incompressible Newtonian Stokes flow. This refactor does not introduce inertial Navier–Stokes flow, turbulence, immiscible phases, or composition-dependent viscosity. The first media comparisons use a shared carrier fluid with explicitly specified constant properties. + +The first complete application will compare the same device and attached growing cells under several flow rates and nutrient compositions. It will report supplied, remaining, consumed, and discharged chemical amounts; biomass production; local concentrations; and spatial growth rates. Scheduled medium switches and a two-essential-nutrient example complete the initial user-facing scope. + +## What can be reused + +The current [physical flow solver](../../cpp/core/stokes_flow.cpp), [fluid geometry](../../cpp/core/fluid_geometry.cpp), and [transport solver](../../cpp/core/solute_transport.cpp) already implement most numerical infrastructure. Preserve their hydraulic, contact, geometric-conservation, transport, and backend checks while extracting biological assumptions from their interfaces. + +The current [simulation integration](../../cpp/core/culture_simulation.cpp) owns persistent cell poses, biochemical volume, chemical amounts, and reservoir ledgers. It currently converts a configured cylindrical elongation rate into growth demand, limits that demand by intracellular stores, and replenishes the stores through linear membrane uptake. Retain this behavior as an explicit reserve-budget model while adding a distinct nutrient-limited growth model. + +The existing [nutrient benchmark](../../scripts/run_nutrient_benchmarks.py) and [its documented equations](../tutorials/nutrient-validation.md) already connect transported nutrient loss to biomass gain. Reuse its conservation principle, metrics, and refinement procedure. Its full-voxel storage, smoothed cell coupling, biomass convention, and boundary discretization differ from the new geometric solver, so its numerical values are not an exact trajectory oracle for the new mode. Use matched limiting cases and an independent small reference model. + +## Layer boundaries and ownership + +| Layer | Owns or defines | Inputs from another layer | Excludes | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------- | +| Fluid mechanics | Grid, walls, physical properties, hydraulic patches, pressure, velocity, capsule responses, accessible fluid geometry | Body poses and prescribed geometric growth from simulation coupling | Solute identities, media recipes, biomass yields, cell species | +| Chemical transport | Solute identities and units, diffusivities, authoritative extracellular amounts, chemical boundary conditions, surface transfers, reaction accounting | Accessible geometry and conservative volume fluxes | Organism-specific nutritional meaning or an independent growth update | +| Biological media and growth | Immutable medium compositions, cell-specific kinetic parameters, uptake/growth stoichiometry, optional reserves, division inheritance | Local chemical environment and accepted transfer amounts | Fluid discretization and linear solvers | +| Simulation orchestration | Clock, schedule position, lifecycle, mapping between cells and physical bodies, staged candidates, commit/rollback | Candidates and reports from all three layers | A second copy of authoritative chemical or biomass state | + +The simulation owns authoritative state. Solvers consume views and return candidates; their retained resources are numerical caches. Python medium and growth definitions compile to validated data rather than becoming executable content in checkpoints. Physical body identifiers map to cells at the coupling boundary; a fluid-only run has no cell dependency. + +## Concrete extraction + +| Current interface or file | Proposed responsibility | +| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `FluidProperties` | `FluidProperties`: viscosity and density | +| `StokesFlowSolver`, `stokes_flow.cpp` | `StokesFlowSolver`, `stokes_flow.cpp`: physical device flow, with optional capsule coupling | +| `CapsuleBody` | Explicit capsule body geometry; do not imply arbitrary rigid-body shapes | +| `FluidGeometry`, `fluid_geometry.cpp` | `FluidGeometry`, `fluid_geometry.cpp`: accessible fragments, surfaces, overlaps, and volume changes | +| `Solute`, `SoluteTransportSolver`, `solute_transport.cpp` | `Solute`, `SoluteTransportSolver`, `solute_transport.cpp` | +| `ChemicalBoundary`, `SurfaceTransferLaw` | Chemical boundary and surface-transfer data, separate from hydraulic ports | +| `NumericsDevice`, `solve_numerics_linear` | Private shared numerical operations used by both fluid and transport solvers, with no biological names or types | +| `CultureConfiguration`, `culture_simulation.cpp` | A culture configuration composing independent fluid, chemistry, and biology definitions; simulation coupling remains explicit | + +Extract common grid/face-field primitives from their current dependency on `SignalGridSpec` and `SignalGridVelocityField`. Extend the existing `flow.py`/`stokes.py` authoring surface instead of creating another parallel fluid framework. Put chemical authoring in `transport.py`, medium recipes in `media.py`, and growth definitions in `growth.py`. + +Retain the shallow and Stokes–Brinkman solvers as distinct models. Their normalized inlet-speed and porosity assumptions must not silently acquire the pressure/rate semantics or cell-scale capabilities of the physical solver. Introduce adapters only for compatible units, geometry, and flux contracts; reject unsupported combinations explicitly. Existing device wall descriptions should materialize the physical grid and port patches without requiring a legacy signal grid or mechanical constraints. + +## Medium and feeding definitions + +A `Solute` has a stable identity, display label, diffusion coefficient, and declared amount basis. A `Medium` is a named immutable mapping from solute identities to concentrations, with units and optional composition provenance. The simulation compiles the union of solutes used by all media before stepping. An omitted concentration for a registered solute is zero; an unknown identity or incompatible unit is an error. Reordering labels or changing a recipe must not reorder numerical channels. + +`Perfusion` binds a medium to an existing port and a hydraulic drive. Permit either prescribed volume flow or pressure, with outlet pressure as the usual reference. Support piecewise-constant changes to medium composition and hydraulic drive independently. Define schedules as right-continuous at event times, split steps exactly at discontinuities, and checkpoint the schedule and its position. Validate hydraulic compatibility across connected components at every event. Interactive changes enter the same validated event mechanism. + +Convert common authoring units, such as microliters/minute and millimolar, at the boundary. Native transport retains amount per model volume with an explicit per-solute amount basis. Molecular solutes can use mol; a documented effective nutrient pool can use a mass basis. Cross-basis conversion requires the relevant molecular weight. Use a small supported conversion set; do not introduce a general units language. + +Keep hydraulic and chemical boundary semantics separate. A normal perfusion inlet specifies the supplied chemical flux associated with its incoming volume flow. A convective outlet removes chemicals at the interior concentration and has zero diffusive flux. Explicit reservoir-contact boundaries may supply chemicals by diffusion even when flow is zero. Backflow requires a defined incoming composition or rejects the configuration. The present transport solver applies diffusive reservoir contact at every port; preserve that behavior for existing configurations, but do not silently use it for the new perfusion default. + +Recipes do not contain universal growth rates or a single nutritional-quality score. Those are properties of an organism/model under stated conditions. Represent complex media through measured components or explicitly calibrated effective pools; a name such as LB does not establish its transport or nutritional parameters. Different recipes initially share one declared carrier-fluid property set. Mixing media with incompatible physical properties is unsupported until a property-mixture law is implemented. + +## Growth models and units + +Provide two explicit growth models initially: the existing intracellular-reserve budget behavior, and a single-substrate `MonodGrowth` model with maximum specific biomass growth rate, half-saturation concentration, biomass yield, and a substrate identity. Maintenance and decay default to zero. Add `EssentialNutrientGrowth` next, using the declared Liebig minimum rule `mu = mu_max * min_j(c_j / (K_j + c_j))` over explicitly required nutrients, with consumption requirements for each. Alternative carbon sources, diauxic switching, adaptation, and inhibition require their own stated models; they must not arise from an implicit sum or minimum over all recipe ingredients. + +For the single-substrate model, let M be biomass mass and c the membrane-area-weighted substrate concentration: + +```text +mu(c) = mu_max * c / (K_s + c) +dM/dt = mu(c) * M +substrate consumption rate = (dM/dt) / Y +``` + +The numerical implementation must derive actual biomass gain from accepted substrate consumption, `Delta M = Y * U`. It must not independently grow a cell by the requested rate and then attempt to remove unavailable nutrient. A positive implicit uptake formulation with bounded nonlinear iteration can reuse the present surface sink: its linearized coefficient is proportional to `mu_max * M / (Y * surface_area * (K_s + c))`. Recompute the concentration-dependent coefficient until the kinetic residual passes, rather than accepting an arbitrarily lagged denominator during a sharp feed change. Freeze old biomass consistently for the first-order scheme and verify temporal convergence. + +For multiple essential nutrients, use one accepted growth extent with stoichiometric consumption of every required nutrient. Do not solve independent unlimited uptake demands, take the minimum growth afterward, and lose excess nutrient. Explicit reserve models may retain excess uptake in their corresponding intracellular pools. Every chemical transfer and reaction has one owner; an intracellular rate plan must not consume a substrate a second time for the same growth event. + +Preserve the existing biochemical volume as authoritative state and declare a biomass density converting it to dry biomass mass: `M = rho_b * V_bio`. Use yields in mass or molar units compatible with the substrate's declared basis. For the current fixed-radius capsule mapping, `Delta V_geom = Delta V_bio / kappa` and `Delta length = Delta V_geom / (pi*r^2)`. Do not apply a biomass-specific growth rate directly to cylindrical length. Keep legacy `growth_rate`/rate-instruction meanings intact and expose the new realized specific biomass growth rate separately. Model bindings, biomass density, reserves, and parameters inherit explicitly at division. + +Reuse the existing typed rate arithmetic for custom intracellular biology and native execution. Introduce only the additional local-environment inputs and growth/transfer outputs needed by these concrete models. The first release does not require a universal metabolic-network compiler. Constant and saturating surface exchange plus declared consumption/product stoichiometry provide the initial chemical reaction scope; additional bulk reactions can reuse rate-plan expressions behind an amount-conserving source contract. + +Monod kinetics provides a baseline model, not a guarantee of physiological response during rapid medium switching. Experimental microfluidic work has measured history-dependent growth under nutrient fluctuations, so schedule fidelity and an optional future adaptation state are distinct from the first equilibrium growth law. See [Nguyen et al., 2021](https://pmc.ncbi.nlm.nih.gov/articles/PMC8209047/). Spatial nutrient gradients and local growth are also directly studied in [Hornung et al., 2018](https://arxiv.org/abs/1802.05858). + +## Coupled stepping and backend execution + +For each candidate interval, evaluate the feeding schedule, propose uptake/growth, predict changed cell geometry, solve the corresponding fluid displacement, and advance chemical amounts. Use the accepted chemical transfers to correct the biomass and geometry proposal. Repeat from the same beginning-of-step state until nutrient, growth, and geometric-volume residuals agree. A candidate iteration must never spend material or accumulate reservoir ledgers twice. If the bounded coupling iteration fails, reduce the interval and retry; retain complete rollback on failure. + +This driver keeps the existing geometric-conservation projection, minimum-gap checks, quaternion state, amount-preserving division, controller/RNG transaction, and checkpoint guarantees. Initially demonstrate attached cells with no translational motion, then add division and the existing freely moving capsule mode under the same biological definitions. Hydrodynamic forces must not be an obligatory part of defining a medium or testing a growth law. + +Implement every added numerical operation on CPU, Metal, and CUDA in the phase that introduces it. Python performs authoring and event scheduling; native code performs per-cell kinetics and numerical updates. Reuse shared validated operation definitions and immutable parameter arrays. Host geometry, assembly, and scalar convergence control remain explicit. CPU execution is the reference, and the declared GPU precision/tolerance contract remains visible. No Python per-cell loop or hidden CPU numerical fallback should stand in for a missing native growth operation. + +Retain the existing geometric and chemical precision contracts during extraction. For the new small acceptance fixtures, target CPU/GPU agreement within 0.1% for integrated biomass and uptake, and normalized chemical budget residual below 1e-5 with a documented absolute scale near zero. Use measured solver residuals to distinguish linear convergence from discretization error. Freeze justified fixture tolerances before accepting results; do not widen them solely to obtain a pass. + +## Ordered implementation phases + +| Phase | Changes | Completion evidence | +| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1. Establish contracts and references | Record current behavior; define units, port semantics, biomass conversion, and target model equations. Add independent well-mixed growth and transport reference cases. | Existing suites remain green; references distinguish biomass growth from elongation and nutrient supply from intracellular reserves. | +| 2. Extract general physics | Move/rename flow, geometry, capsule coupling, and shared numerical operations; remove chemical-grid dependencies; add a device-grid authoring adapter. | A fluid-only pressure/rate example runs with zero solutes and zero cells on each backend; existing hydraulic/body results are preserved. | +| 3. Extract chemical transport | Separate hydraulic patches from chemical boundaries; expose chemical state and paired surface transfers; add the perfusion boundary semantics. | An inert-tracer pulse, diffusion-only case, mixed inflow/outflow case, and moving-volume case conserve amounts without any growth model. | +| 4. Add media and feeding | Implement immutable compositions, stable solute indexing, bounded unit conversion, independent flow/composition schedules, and restartable events. | Two feeds mix predictably; composition switches propagate through transport; restart across a scheduled switch reproduces the same continuation. | +| 5. Couple nutritional growth | Add single-substrate Monod growth, explicit biomass conversion, and bounded uptake/geometry iteration; retain reserve mode; then add two-essential-nutrient growth and division inheritance. | No-substrate/no-reserve control produces no biomass; accepted substrate consumption determines biomass gain; changing the limiting nutrient changes the correct growth constraint. | +| 6. Deliver the experiment workflow | Compose fluid/chemistry/biology configuration in `Simulation` and the native model runner; update checkpoints, analysis, viewer labels, examples, and documentation. | Flow/composition sweep and starvation/refeeding examples run end to end; output includes realized growth, nutrient budgets, recipe identities, schedule, and parameter provenance. | +| 7. Validate and characterize | Run spatial, surface, timestep, and coupling-tolerance refinement; exercise CPU/Metal/CUDA hardware; record runtime/memory and remaining physical limitations. | Numerical acceptance gates pass on actual supported devices; throughput claims use measured evidence; compile-only CUDA results remain labeled as such. | + +Persistence work accompanies each state addition; phase 6 completes the application presentation rather than postponing restart design. Backend checks accompany every numerical phase. The first reviewable implementation should contain phases 1–2 only, with no change to existing biological behavior. The first biological milestone is phase 5's single-substrate flow/composition comparison; multi-nutrient behavior follows that gate before the complete workflow is declared done. + +## Acceptance experiments + +| Experiment | Required observation | +| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Fluid with no chemistry | Hydraulic results are independent of whether a medium definition exists. | +| Nonreactive chemical pulse | Correct transport, arrival/clearance behavior, and input/output inventory without cells. | +| Well-mixed single-substrate reference | Saturating specific growth and the expected yield relationship; depletion agrees with an independent reference integrator. | +| Same medium, different flow rates | In a deliberately supply-limited geometry, increased delivery changes local substrate and growth; a separately saturated control approaches its configured growth ceiling. Do not require universal monotonicity in every geometry. | +| Same flow, different medium compositions | Nutrient concentration and identity determine the response through the configured cell model; changing an inert constituent does not change growth. | +| Essential nutrient omitted | Growth is limited by the missing requirement even with abundant other substrate; explicit reserves, if present, are included in the budget. | +| Starvation and refeeding | A verified closed/no-feed control stops growth once any declared reserves are exhausted; refeeding restarts the selected kinetic model after transport delivers substrate. Diffusive reservoir supply is either counted or disabled explicitly. | +| Two feeds and a timed switch | The field reflects transport and mixing; cells do not instantly inherit a reservoir composition merely because its label changed. | +| Growth and division | Extracellular amounts, biomass, intracellular stores, lineage, and recipe/model bindings survive geometry changes without unaccounted creation or loss. | +| Failure and restart | Failures roll back schedule position, biochemical state, fluid/chemical state, RNG, ledgers, and time; restart resumes each phase of a feed schedule. | + +Use a few attached cells and defined nutrient compositions first, with initial nutrient/reserve amounts small enough that the response is supply-limited. Record local concentrations, growth, biomass, input and output nutrient amounts, uptake, and inferred limitation. Compare at least three spatial resolutions and successive timestep halvings; the initial target is less than 2% change in integrated biomass and uptake at the finest refinements, with decreasing errors and independent checks of surface geometry. Backend agreement alone does not establish physical accuracy. + +For a closed single-substrate test with no maintenance or reserves, the explicit check is `extracellular substrate + biomass gained / yield = initial substrate`. Open cases add initial/final reserves and the signed boundary ledger. Multi-nutrient and product models use their declared stoichiometric balances; do not sum unrelated chemical species amounts and call that mass conservation. + +## Compatibility and deliverables + +Rename the new, unreleased `Media*` APIs directly; add a temporary alias only if an actual consumer needs it. Preserve established flow/signal APIs and the existing checkpoint version-10 reader. Pure renaming should not change serialized meaning. Add a new version only for new state/contracts, with explicit migration of old runs to the reserve-budget behavior; never reinterpret old `growth_rate`, amount units, or port boundaries as the new model. Preserve supported legacy scene/analysis readers and document any renamed tables or fields. Continue to distinguish fragment quantities from voxel means in visualization. + +Checkpoint all active medium definitions, solute order and units, feeding schedules and event position, growth-model bindings, parameter provenance, and declared stores. Analysis should expose actual specific biomass growth, uptake by nutrient, biomass produced, reservoir supply/discharge, and model identity. Recipe names are metadata, not additional conserved chemical fields. + +The finished user workflow must allow a model author to choose a device, fluid model, medium composition, perfusion schedule, and cellular growth model independently, then compare experiments using the same analysis path. Ship one clearly labeled illustrative defined-medium example and one two-essential-nutrient example. Organism-specific recipes or kinetic parameters require provenance and calibration evidence. Cell exit/washout, singular lubrication, large-colony acceleration, physiological adaptation, gas exchange, and full metabolic models remain separate work with explicit boundaries. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index a823b8c..107f310 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -14,6 +14,9 @@ The [numerical contract](numerical-contract.md) is the best starting point for w - [Face-staggered signal velocity fields](0019-velocity-fields.md) - [Depth-integrated shallow flow](0022-brinkman-flow.md) - [Resolved MAC Stokes-Brinkman flow and benchmarks](0023-mac-stokes.md) +- [Resolved fluid flow and constrained bodies](0026-resolved-fluid-culture.md) +- [Proposed separation of fluid mechanics, chemical transport, and biological media](0027-fluid-transport-biology-plan.md) +- [Proposed code and API for fluid mechanics, chemical transport, and growth](0027-fluid-transport-biology-code.md) - [Finite-aspect flow drift on cells](0021-flow-drift.md) - [Cell removal and washout](0020-cell-removal.md) @@ -24,6 +27,7 @@ The [numerical contract](numerical-contract.md) is the best starting point for w - [Persistent fixed rod cells](0009-fixed-cells.md) - [Biomass, growth, division, and uptake](0024-biomass-accounting.md) - [Typed species rate plans](0003-species-rates.md) +- [Cell-occupied extracellular volume: design, CPU reference, and native GPU primitives](0025-cell-occupied-volume.md) - [Grid signaling and cell coupling](0006-grid-signaling.md) - [Crank-Nicolson signal transport](0008-crank-nicolson-signals.md) - [Neighbor diffusion](0010-neighbor-diffusion.md) diff --git a/docs/assets/capsules/colony-after.png b/docs/assets/capsules/colony-after.png new file mode 100644 index 0000000..eaf347a Binary files /dev/null and b/docs/assets/capsules/colony-after.png differ diff --git a/docs/assets/capsules/colony-before.png b/docs/assets/capsules/colony-before.png new file mode 100644 index 0000000..71fa385 Binary files /dev/null and b/docs/assets/capsules/colony-before.png differ diff --git a/docs/assets/capsules/isolated-after.png b/docs/assets/capsules/isolated-after.png new file mode 100644 index 0000000..374b885 Binary files /dev/null and b/docs/assets/capsules/isolated-after.png differ diff --git a/docs/assets/capsules/isolated-before.png b/docs/assets/capsules/isolated-before.png new file mode 100644 index 0000000..5ea415d Binary files /dev/null and b/docs/assets/capsules/isolated-before.png differ diff --git a/docs/capsule-rendering-validation.md b/docs/capsule-rendering-validation.md new file mode 100644 index 0000000..6cb0d5e --- /dev/null +++ b/docs/capsule-rendering-validation.md @@ -0,0 +1,40 @@ +# Capsule rendering validation + +The previous renderer (`b69193b9`) combined a closed cylinder with two complete spheres. The sphere tessellation remained in world orientation while the cylinder followed the cell direction. Their polygonal boundaries did not coincide, and the cylinder end disks intersected the spherical surfaces. The resulting narrow lines at the cap joins are visible in the baseline screenshots below. + +The replacement uses an open cylinder and two hemispheres, all sharing the cell orientation. Both hemispherical equators use the cylinder's exact radial samples and normals. Radius scales the caps uniformly; cylindrical length scales only the cylinder and displaces cap centers. The selection wireframe uses these same surfaces with an 8% radius expansion. Zero centerline length collapses the cylinder and joins two hemispheres into a sphere. + +## Identical-camera comparisons + +These are unmodified screenshots from `viewer/browser/capsules.mjs`, using the same geometry fixtures, camera, lights, colors, browser, and viewport. The baseline renderer was recorded before the implementation changed. Neither image comes from a different simulation trajectory. + +| Fixture | Before | After | +| ------------------------------------ | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| Isolated rod, arbitrary 3D direction | ![Baseline isolated rod with a visible cap join](assets/capsules/isolated-before.png) | ![Continuous isolated capsule](assets/capsules/isolated-after.png) | +| Dense colony, near view | ![Baseline cap rings throughout the colony](assets/capsules/colony-before.png) | ![Colony without the cap rings](assets/capsules/colony-after.png) | + +The cap-join lines disappear at the same camera positions in the near and distant fixtures. The browser harness also records 48 frames of prescribed changes in position and orientation, so the surface can be inspected during movement. Silhouette tessellation, pixel aliasing, and real motion remain possible; this change does not smooth simulation state or claim to eliminate every source of shimmer. + +## Geometry and interaction checks + +The Vitest geometry suite checks matched seam positions/normals, outward-facing hemisphere triangles, absence of cylinder end disks, constant distance from the centerline segment, exact axial extents, conservative bounds, arbitrary directions, and zero-length cells. It also ray-picks both capsule tips with real Three.js instanced meshes and checks selection-radius inflation. The browser harness exercises color attributes on every mesh, pointer picking, stable-ID selection through reordered frames and removal, actual selected-overlay surface positions, and a selected zero-length sphere. The largest measured overlay-surface error in the browser fixture was `1.29e-8` world units. + +## Local rendering and resource measurements + +Both comparisons ran on macOS in headless Chromium `153.0.8010.12`, with the same browser launch configuration. The corrected run reports ANGLE/Vulkan SwiftShader: these are **software WebGL measurements**, not physical GPU performance results. The fixture uses 512 cells, ten warmup frames, and 60 measured complete frame replacements, including transforms, coloring, rendering, and `gl.finish()` synchronization. Small timing differences are within local measurement noise. + +| Measurement | Before | After | +| ------------------------------------------------- | --------: | ------: | +| Instanced draw calls for the colony | 3 | 3 | +| Shared geometry vertices (cylinder + both caps) | 388 | 400 | +| Triangles per cell | 504 | 576 | +| Triangles for 512 cells | 258,048 | 294,912 | +| Median replacement/render time | 2.2 ms | 2.1 ms | +| p95 replacement/render time | 2.4 ms | 2.4 ms | +| Renderer geometry count at both sampled frames | 4 | 4 | +| Tracked live WebGL buffers across 59 replacements | 390 → 744 | 18 → 18 | +| Tracked buffers after an empty frame | 732 | 0 | + +The geometry count alone concealed an existing resource leak: removing an instanced mesh and disposing its geometry did not release `instanceMatrix` and `instanceColor` buffers. Frame replacement now calls `InstancedMesh.dispose()` as well as disposing each shared geometry/material once. Browser instrumentation observes actual WebGL buffer creation/deletion; buffers remain bounded across replacements and return to zero after clearing the colony and disposing the viewer. + +The modest tessellation change (24 radial samples and six rows per hemisphere) improves silhouettes, but it is not the basis for the seam fix: the tests verify the changed surface topology and exact equator agreement. For reproduction commands, fixture details, videos, and resource assertions, see [the browser harness instructions](../viewer/browser/README.md). diff --git a/docs/compatibility/feature-ledger.md b/docs/compatibility/feature-ledger.md index 775b94b..f9e4ee1 100644 --- a/docs/compatibility/feature-ledger.md +++ b/docs/compatibility/feature-ledger.md @@ -4,28 +4,28 @@ This page summarizes how familiar CellModeller capabilities map to MicroSimulato CPU and Metal support the current modeling surface described below. CUDA status and hardware requirements are maintained in the [testing and validation guide](../development/validation.md). -| Area | CellModeller source | MicroSimulator behavior | -| --- | --- | --- | -| Cell identity | `Simulator` | Stable IDs are distinct from compact storage slots; division records lineage. | -| Cell creation and division | `Simulator`, `CLBacterium` | Equal and fractional division use explicit, deterministic geometry and jitter policies. | -| Length growth | `CLBacterium` | The reference step is `length += rate * length * dt`. | -| Rod mechanics | `CLBacterium` | Contacts feed a diagnosed, matrix-free finite-radius mechanics solve. | -| Cell-cell contacts | `CLBacterium.cl` | Deterministic sweep-and-prune staging produces a dynamic incidence graph without a fixed contact cap. | -| Plane and sphere constraints | `CLBacterium.cl` | Typed constraint records participate directly in the mechanics system. | -| Intracellular species | `CLEulerIntegrator` | Typed rate plans use effective-volume dilution and simultaneous updates. | -| Grid signaling | `GridDiffusion` | Signal grids support diffusion, optional advection, declared boundaries, affine source/loss fields, stability checks, and checkpointed state. | -| Coupled cell-grid rates | Signal integrators | Sampling, intracellular rates, transport, scatter, and commit form one declared stage. | -| Checkpoint and resume | Pickle output | Versioned, non-executable JSON records state, provenance, integrity, and controller data. | -| Batch execution | Batch scripts | `microsimulator run` and run manifests declare backend, device, seed, parameters, stopping rules, and output policy. | -| Model orchestration | Simulator/module lifecycle | Restartable controllers own regulation, division, mechanics scheduling, model state, and runtime randomness. | -| Python callback models | Module regulator | Maintained `setup`/`init`/`update`/`divide` models run through the compatibility adapter within documented limits. | -| Pickle snapshots | `Simulator` | Trusted snapshots can be imported once into native state; exact continuation is not inferred. | -| Crank-Nicolson signaling | `CLCrankNicIntegrator` | A diagnosed native solver implements the intended semi-implicit equation. | -| Neighbor reporting | `CLBacterium` | Stable-ID neighbor views are derived from the current contact graph. | -| Fixed cells | `CLFixedPosition` | Rods can remain mechanically fixed while biological state continues to advance. | -| Neighbor diffusion | `NeighbourDiffusion` | The incomplete legacy module is not supported; contact-mediated transport requires an explicit graph-flux model. | -| SBML import | `SBMLImport` | A bounded SBML Core subset compiles to typed species-rate plans without generated source. | -| Interactive viewer | PyQt/OpenGL GUI | An independent scene consumer displays rods and signal grids without owning engine state. | -| Analysis | `Scripts` | Versioned Parquet/Zarr exports and documented dataframe recipes replace access to private solver objects. | +| Area | CellModeller source | MicroSimulator behavior | +| ---------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| Cell identity | `Simulator` | Stable IDs are distinct from compact storage slots; division records lineage. | +| Cell creation and division | `Simulator`, `CLBacterium` | Equal and fractional division use explicit, deterministic geometry and jitter policies. | +| Length growth | `CLBacterium` | The reference step is `length += rate * length * dt`. | +| Rod mechanics | `CLBacterium` | Contacts feed a diagnosed, matrix-free finite-radius mechanics solve. | +| Cell-cell contacts | `CLBacterium.cl` | Deterministic sweep-and-prune staging produces a dynamic incidence graph without a fixed contact cap. | +| Plane and sphere constraints | `CLBacterium.cl` | Typed constraint records participate directly in the mechanics system. | +| Intracellular species | `CLEulerIntegrator` | Typed rate plans use effective-volume dilution and simultaneous updates. | +| Grid signaling | `GridDiffusion` | Signal grids support diffusion, optional advection, declared boundaries, affine source/loss fields, stability checks, and checkpointed state. | +| Coupled cell-grid rates | Signal integrators | Sampling, intracellular rates, transport, scatter, and commit form one declared stage. | +| Checkpoint and resume | Pickle output | Versioned, non-executable JSON records state, provenance, integrity, and controller data. | +| Batch execution | Batch scripts | `microsimulator run` and run manifests declare backend, device, seed, parameters, stopping rules, and output policy. | +| Model orchestration | Simulator/module lifecycle | Restartable controllers own regulation, division, mechanics scheduling, model state, and runtime randomness. | +| Python callback models | Module regulator | Maintained `setup`/`init`/`update`/`divide` models run through the compatibility adapter within documented limits. | +| Pickle snapshots | `Simulator` | Trusted snapshots can be imported once into native state; exact continuation is not inferred. | +| Crank-Nicolson signaling | `CLCrankNicIntegrator` | A diagnosed native solver implements the intended semi-implicit equation. | +| Neighbor reporting | `CLBacterium` | Stable-ID neighbor views are derived from the current contact graph. | +| Fixed cells | `CLFixedPosition` | Rods can remain mechanically fixed while biological state continues to advance. | +| Neighbor diffusion | `NeighbourDiffusion` | The incomplete legacy module is not supported; contact-mediated transport requires an explicit graph-flux model. | +| SBML import | `SBMLImport` | A bounded SBML Core subset compiles to typed species-rate plans without generated source. | +| Interactive viewer | PyQt/OpenGL GUI | An independent scene consumer displays rods and signal grids without owning engine state. | +| Analysis | `Scripts` | Versioned Parquet/Zarr exports and documented dataframe recipes replace access to private solver objects. | For source-pinned comparisons, see the [example matrix](legacy-example-matrix.md), [recorded trajectories](legacy-trajectory-evidence.md), and the subsystem references collected in [compatibility and migration](README.md). diff --git a/docs/compatibility/legacy-analysis-audit.md b/docs/compatibility/legacy-analysis-audit.md index 6595e38..02267ec 100644 --- a/docs/compatibility/legacy-analysis-audit.md +++ b/docs/compatibility/legacy-analysis-audit.md @@ -4,21 +4,21 @@ The legacy `Scripts/` directory and `Scripts/Analysis.ipynb` illustrate how Cell ## Workflow map -| Source | CellModeller workflow | MicroSimulator approach | -| --- | --- | --- | -| `batch.py` | selects an OpenCL platform/device interactively, then runs until a fixed cell-capacity margin | `microsimulator devices` lists devices, while `microsimulator run` uses an explicit cell-count stopping criterion. | -| `multi_batch.py` | repeats the same unseeded run three times | Run manifests declare replicate IDs, seeds, parameters, and outputs as reviewable data. | -| `batch_iter.py` | hard-coded gamma sweep over eight values | A run manifest expresses the sweep; scheduler configuration remains outside the simulator. | -| `batchFile.py` | cluster-specific model defaults, timestamped pickle directories, and private physics setup calls | Explicit output paths, checkpoints, and provenance cover the portable parts of this workflow. | -| `CellModellerGUI.py` | starts the PyQt/OpenGL GUI | The independent viewer and live controller provide interactive inspection. | -| `LengthHistogram.py` | writes radial position and full capsule length to a global CSV | Typed cell tables expose both quantities, with `length + 2 * radius` named `capsule_length`. | -| `spatial_analyze.py` | computes counts and mean species channel 0 in 20 radial XY bins | The dataframe recipe accepts explicit bin edges, radius, and species channel. | -| `contactGraph.py` | reloads executable model text, reconstructs OpenCL contacts, collapses geometric rows into an undirected graph, and draws a PDF | Analysis export provides typed contact rows; a query derives unique neighbor edges without re-executing model source. | -| `Draw2DPDF.py` | renders 2D capsules and one signal slice from pickle fields | Scene v1 provides the required geometry and signals for a viewer or publication renderer. | -| `video.sh`, `contactVideo.sh` | shell loops through pickles, PDF conversion, and ffmpeg | Scene frames can feed an external rendering and video workflow without making ImageMagick conventions part of the engine. | -| `printTiming.py` | prints cell count beside checkpoint filesystem modification time | Checkpoints record simulation time; file modification time is not treated as physical time or measured runtime. | -| `gitPublish.sh` | merges a historical private repository into a public repository | Repository publication is outside the simulation and analysis interfaces. | -| `Analysis.ipynb` | demonstrates position/orientation plots, length and radial histograms, length-weighted 2D density, raw overlap inspection, lineage, and sister-neighbor counting | Documented recipes operate on versioned datasets rather than private solver buffers or local paths. | +| Source | CellModeller workflow | MicroSimulator approach | +| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `batch.py` | selects an OpenCL platform/device interactively, then runs until a fixed cell-capacity margin | `microsimulator devices` lists devices, while `microsimulator run` uses an explicit cell-count stopping criterion. | +| `multi_batch.py` | repeats the same unseeded run three times | Run manifests declare replicate IDs, seeds, parameters, and outputs as reviewable data. | +| `batch_iter.py` | hard-coded gamma sweep over eight values | A run manifest expresses the sweep; scheduler configuration remains outside the simulator. | +| `batchFile.py` | cluster-specific model defaults, timestamped pickle directories, and private physics setup calls | Explicit output paths, checkpoints, and provenance cover the portable parts of this workflow. | +| `CellModellerGUI.py` | starts the PyQt/OpenGL GUI | The independent viewer and live controller provide interactive inspection. | +| `LengthHistogram.py` | writes radial position and full capsule length to a global CSV | Typed cell tables expose both quantities, with `length + 2 * radius` named `capsule_length`. | +| `spatial_analyze.py` | computes counts and mean species channel 0 in 20 radial XY bins | The dataframe recipe accepts explicit bin edges, radius, and species channel. | +| `contactGraph.py` | reloads executable model text, reconstructs OpenCL contacts, collapses geometric rows into an undirected graph, and draws a PDF | Analysis export provides typed contact rows; a query derives unique neighbor edges without re-executing model source. | +| `Draw2DPDF.py` | renders 2D capsules and one signal slice from pickle fields | Scene v1 provides the required geometry and signals for a viewer or publication renderer. | +| `video.sh`, `contactVideo.sh` | shell loops through pickles, PDF conversion, and ffmpeg | Scene frames can feed an external rendering and video workflow without making ImageMagick conventions part of the engine. | +| `printTiming.py` | prints cell count beside checkpoint filesystem modification time | Checkpoints record simulation time; file modification time is not treated as physical time or measured runtime. | +| `gitPublish.sh` | merges a historical private repository into a public repository | Repository publication is outside the simulation and analysis interfaces. | +| `Analysis.ipynb` | demonstrates position/orientation plots, length and radial histograms, length-weighted 2D density, raw overlap inspection, lineage, and sister-neighbor counting | Documented recipes operate on versioned datasets rather than private solver buffers or local paths. | ## Data retained for analysis diff --git a/docs/compatibility/legacy-example-matrix.md b/docs/compatibility/legacy-example-matrix.md index fe67e1f..059681d 100644 --- a/docs/compatibility/legacy-example-matrix.md +++ b/docs/compatibility/legacy-example-matrix.md @@ -2,33 +2,33 @@ The executable matrix pins all 25 Python examples from CellModeller commit `4896f543c6250f053eea2312e628cc3a96bf7408`. Every legacy source and every migrated implementation is SHA-256 authenticated before model code executes. The runner exercises each runnable row on every device exposed by each requested backend and writes a complete JSON report, including failures. -| Legacy example | Classification | Execution path | -| --- | --- | --- | -| `ACS2012/EdgeDetectorChamber.py` | migrated | typed native model | -| `Conjugation.py` | runnable | callback adapter | -| `TimRudgeThesis/Meristem.py` | runnable | callback adapter | -| `Tutorial_1/Tutorial_1a.py` | runnable | callback adapter | -| `Tutorial_1/Tutorial_1b.py` | runnable | callback adapter | -| `Tutorial_1/Tutorial_1c.py` | runnable | callback adapter | -| `Tutorial_2/Tutorial_2a.py` | migrated | typed native model | -| `Tutorial_2/Tutorial_2b.py` | migrated | typed native model | -| `Tutorial_3/Tutorial_3.py` | migrated | typed native model | -| `colorWalk_planes_3d.py` | runnable | callback adapter | -| `ex1_simpleGrowth.py` | runnable | callback adapter | -| `ex1_simpleGrowth2D.py` | runnable | callback adapter | -| `ex1a_simpleGrowth2D.py` | runnable | callback adapter | -| `ex1a_simpleGrowth2Types.py` | runnable | callback adapter | -| `ex1b_simpleGrowth2D.py` | runnable | callback adapter | -| `ex1b_simpleGrowthRoundCell.py` | runnable | callback adapter | -| `ex2_constGene.py` | migrated | typed native model | -| `ex2a_dilution.py` | migrated | typed native model | -| `ex2b_diluteRepression.py` | migrated | typed native model | -| `ex3_simpleSignal.py` | migrated | typed native model | -| `ex4_simpleCellCellSignaling.py` | migrated | typed native model | -| `ex5_colonySector.py` | runnable | callback adapter | -| `ex5_colonySector_3d.py` | runnable | callback adapter | -| `load.py` | migration-only | `microsimulator import-legacy-pickle` | -| `sphere_constraints.py` | runnable | callback adapter | +| Legacy example | Classification | Execution path | +| -------------------------------- | -------------- | ------------------------------------- | +| `ACS2012/EdgeDetectorChamber.py` | migrated | typed native model | +| `Conjugation.py` | runnable | callback adapter | +| `TimRudgeThesis/Meristem.py` | runnable | callback adapter | +| `Tutorial_1/Tutorial_1a.py` | runnable | callback adapter | +| `Tutorial_1/Tutorial_1b.py` | runnable | callback adapter | +| `Tutorial_1/Tutorial_1c.py` | runnable | callback adapter | +| `Tutorial_2/Tutorial_2a.py` | migrated | typed native model | +| `Tutorial_2/Tutorial_2b.py` | migrated | typed native model | +| `Tutorial_3/Tutorial_3.py` | migrated | typed native model | +| `colorWalk_planes_3d.py` | runnable | callback adapter | +| `ex1_simpleGrowth.py` | runnable | callback adapter | +| `ex1_simpleGrowth2D.py` | runnable | callback adapter | +| `ex1a_simpleGrowth2D.py` | runnable | callback adapter | +| `ex1a_simpleGrowth2Types.py` | runnable | callback adapter | +| `ex1b_simpleGrowth2D.py` | runnable | callback adapter | +| `ex1b_simpleGrowthRoundCell.py` | runnable | callback adapter | +| `ex2_constGene.py` | migrated | typed native model | +| `ex2a_dilution.py` | migrated | typed native model | +| `ex2b_diluteRepression.py` | migrated | typed native model | +| `ex3_simpleSignal.py` | migrated | typed native model | +| `ex4_simpleCellCellSignaling.py` | migrated | typed native model | +| `ex5_colonySector.py` | runnable | callback adapter | +| `ex5_colonySector_3d.py` | runnable | callback adapter | +| `load.py` | migration-only | `microsimulator import-legacy-pickle` | +| `sphere_constraints.py` | runnable | callback adapter | No example is silently omitted or presently classified as deliberately retired. The 15 runnable sources are executed unchanged through the adapter, the 9 OpenCL equation models execute their typed MicroSimulator migrations, and `load.py` is represented by the separately tested one-way trusted-pickle migration workflow. diff --git a/docs/compatibility/legacy-example-migrations.md b/docs/compatibility/legacy-example-migrations.md index f07a4e1..0a8450e 100644 --- a/docs/compatibility/legacy-example-migrations.md +++ b/docs/compatibility/legacy-example-migrations.md @@ -2,16 +2,16 @@ Nine bundled CellModeller examples define their species or signaling equations as OpenCL source and therefore cannot run through the Python callback adapter. Each has a self-contained MicroSimulator translation with typed rate equations and restartable orchestration. The table maps source models to those translations; numerical comparisons are documented separately in the recorded trajectory reference. -| CellModeller example | Equation family | MicroSimulator model | -| --- | --- | --- | -| `ACS2012/EdgeDetectorChamber.py` | five species, one diffusive signal | `examples/legacy/ACS2012/EdgeDetectorChamber.py` | -| `Tutorial_2/Tutorial_2a.py` | one constitutively produced species | `examples/legacy/Tutorial_2/Tutorial_2a.py` | -| `Tutorial_2/Tutorial_2b.py` | two-species nonlinear feedback | `examples/legacy/Tutorial_2/Tutorial_2b.py` | -| `Tutorial_3/Tutorial_3.py` | two species coupled to two diffusive signals | `examples/legacy/Tutorial_3/Tutorial_3.py` | -| `ex2_constGene.py` | one constitutively produced species | `examples/legacy/ex2_constGene.py` | -| `ex2a_dilution.py` | one species with growth dilution only | `examples/legacy/ex2a_dilution.py` | -| `ex2b_diluteRepression.py` | dilution plus Hill repression | `examples/legacy/ex2b_diluteRepression.py` | -| `ex3_simpleSignal.py` | one species coupled to one diffusive signal | `examples/legacy/ex3_simpleSignal.py` | +| CellModeller example | Equation family | MicroSimulator model | +| -------------------------------- | --------------------------------------------- | ------------------------------------------------ | +| `ACS2012/EdgeDetectorChamber.py` | five species, one diffusive signal | `examples/legacy/ACS2012/EdgeDetectorChamber.py` | +| `Tutorial_2/Tutorial_2a.py` | one constitutively produced species | `examples/legacy/Tutorial_2/Tutorial_2a.py` | +| `Tutorial_2/Tutorial_2b.py` | two-species nonlinear feedback | `examples/legacy/Tutorial_2/Tutorial_2b.py` | +| `Tutorial_3/Tutorial_3.py` | two species coupled to two diffusive signals | `examples/legacy/Tutorial_3/Tutorial_3.py` | +| `ex2_constGene.py` | one constitutively produced species | `examples/legacy/ex2_constGene.py` | +| `ex2a_dilution.py` | one species with growth dilution only | `examples/legacy/ex2a_dilution.py` | +| `ex2b_diluteRepression.py` | dilution plus Hill repression | `examples/legacy/ex2b_diluteRepression.py` | +| `ex3_simpleSignal.py` | one species coupled to one diffusive signal | `examples/legacy/ex3_simpleSignal.py` | | `ex4_simpleCellCellSignaling.py` | three species coupled to one diffusive signal | `examples/legacy/ex4_simpleCellCellSignaling.py` | ## Common modeling choices diff --git a/docs/compatibility/legacy-viewer-audit.md b/docs/compatibility/legacy-viewer-audit.md index 6ed3fe5..1dd8a7c 100644 --- a/docs/compatibility/legacy-viewer-audit.md +++ b/docs/compatibility/legacy-viewer-audit.md @@ -39,15 +39,15 @@ The renderer file also contains sphere, plant, periodic-image, static-mesh, and The machine-readable contract is `compatibility/legacy-renderers-v1.json`. It authenticates `Renderers.py` at CellModeller commit `4896f543c6250f053eea2312e628cc3a96bf7408` and classifies all ten renderer classes. Repository-wide call-site search found that all 25 bundled examples select `GLBacteriumRenderer`, four also select `GLGridRenderer`, and none selects another renderer class. -| Family | CellModeller behavior | MicroSimulator support | -| --- | --- | --- | -| rod cells (`GLBacteriumRenderer`, `GLCelBacteriumRenderer`, `GL2DBacteriumRenderer`) | rods, stable cell IDs, selection, arbitrary color fields | Supported through scene rods, typed color mappings, and the independent viewer. | -| signal grid (`GLGridRenderer`) | direct reads from the signaling integrator | Supported through scene grids and viewer-owned channel and slice selection. | -| sphere cells (`GLSphereRenderer`) | a distinct spherical cell morphology; no bundled call site; picking references an undefined radius | Not supported as a cell morphology. Typed inside/outside sphere constraints remain available for rod cells. | -| plant cells (`GLPlantRenderer`, `GLPlantSignalRenderer`) | polygon `nodep`/`wallp` geometry and arbitrary signal attributes; no bundled call site or plant engine | Not supported; MicroSimulator has no corresponding plant-cell state model. | -| periodic cell images (`GLBacteriumRendererWithPeriodicImages`) | four visual copies offset by mutable collision-grid bounds; no declared periodic cell topology or bundled call site | Not supported for cells. Typed periodic signal boundaries remain available. | -| dynamic collision mesh (`GLWillsMeshRenderer`) | lines over mutable `CLBacterium` broad-phase bins; no bundled call site | Not part of scene data; it visualizes an implementation-specific debugging structure. | -| static triangle mesh (`GLStaticMeshRenderer`) | external mesh/regulator object graphs; no bundled call site or checkpoint representation | Not supported because there is no corresponding checkpoint or scene representation. | +| Family | CellModeller behavior | MicroSimulator support | +| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| rod cells (`GLBacteriumRenderer`, `GLCelBacteriumRenderer`, `GL2DBacteriumRenderer`) | rods, stable cell IDs, selection, arbitrary color fields | Supported through scene rods, typed color mappings, and the independent viewer. | +| signal grid (`GLGridRenderer`) | direct reads from the signaling integrator | Supported through scene grids and viewer-owned channel and slice selection. | +| sphere cells (`GLSphereRenderer`) | a distinct spherical cell morphology; no bundled call site; picking references an undefined radius | Not supported as a cell morphology. Typed inside/outside sphere constraints remain available for rod cells. | +| plant cells (`GLPlantRenderer`, `GLPlantSignalRenderer`) | polygon `nodep`/`wallp` geometry and arbitrary signal attributes; no bundled call site or plant engine | Not supported; MicroSimulator has no corresponding plant-cell state model. | +| periodic cell images (`GLBacteriumRendererWithPeriodicImages`) | four visual copies offset by mutable collision-grid bounds; no declared periodic cell topology or bundled call site | Not supported for cells. Typed periodic signal boundaries remain available. | +| dynamic collision mesh (`GLWillsMeshRenderer`) | lines over mutable `CLBacterium` broad-phase bins; no bundled call site | Not part of scene data; it visualizes an implementation-specific debugging structure. | +| static triangle mesh (`GLStaticMeshRenderer`) | external mesh/regulator object graphs; no bundled call site or checkpoint representation | Not supported because there is no corresponding checkpoint or scene representation. | Spherical-cell, plant-tissue, periodic-cell-domain, and mesh simulations require their own typed engine state, checkpoint representation, and scene semantics; they are not implied by the old renderer classes alone. diff --git a/docs/compatibility/tutorial-source-provenance.md b/docs/compatibility/tutorial-source-provenance.md index 0e10edc..0ea4aa5 100644 --- a/docs/compatibility/tutorial-source-provenance.md +++ b/docs/compatibility/tutorial-source-provenance.md @@ -10,24 +10,24 @@ The MicroSimulator tutorials draw from the maintained CellModeller wiki, bundled ## Tutorial relationships -| Source material | MicroSimulator model or guide | -| --- | --- | -| Wiki Tutorial 1a | `biophysics.py`, `basics` | -| Wiki Tutorial 1b | `biophysics.py`, `competition` | -| Wiki Tutorial 1c | `biophysics.py`, `box` | -| Wiki Tutorial 2a | `gene_expression.py`, `constitutive` | -| Wiki Tutorial 2b | `gene_expression.py`, `oscillator` | -| Wiki Tutorial 3 | `signaling.py`, `mutualism` | -| Old Example 1 and its exercises | `biophysics.py`, `basics`, `two_types`, `short_cells` | -| Old Example 2 | `gene_expression.py`, `legacy_constitutive`, `dilution`, `derepression` | -| Old Example 3 | `signaling.py`, `single_gene` | -| Old Example 4 | `signaling.py`, `communication` | -| Old Example 5 | `plasmid_segregation.py` | -| Contact graph and conjugation examples | `conjugation.py` and the analysis tutorial | -| Legacy analysis scripts | analysis tutorial and analysis recipes | -| SimBOL `CM_BBa_01`–`05`, `CM_BBa_I5200` | `simbol_circuits.py` | -| SimBOL `CM_Danino.py` | `danino_clock.py` | -| SimBOL CellModeller notebook | SimBOL tutorial workflow description | +| Source material | MicroSimulator model or guide | +| --------------------------------------- | ----------------------------------------------------------------------- | +| Wiki Tutorial 1a | `biophysics.py`, `basics` | +| Wiki Tutorial 1b | `biophysics.py`, `competition` | +| Wiki Tutorial 1c | `biophysics.py`, `box` | +| Wiki Tutorial 2a | `gene_expression.py`, `constitutive` | +| Wiki Tutorial 2b | `gene_expression.py`, `oscillator` | +| Wiki Tutorial 3 | `signaling.py`, `mutualism` | +| Old Example 1 and its exercises | `biophysics.py`, `basics`, `two_types`, `short_cells` | +| Old Example 2 | `gene_expression.py`, `legacy_constitutive`, `dilution`, `derepression` | +| Old Example 3 | `signaling.py`, `single_gene` | +| Old Example 4 | `signaling.py`, `communication` | +| Old Example 5 | `plasmid_segregation.py` | +| Contact graph and conjugation examples | `conjugation.py` and the analysis tutorial | +| Legacy analysis scripts | analysis tutorial and analysis recipes | +| SimBOL `CM_BBa_01`–`05`, `CM_BBa_I5200` | `simbol_circuits.py` | +| SimBOL `CM_Danino.py` | `danino_clock.py` | +| SimBOL CellModeller notebook | SimBOL tutorial workflow description | Exact equation translations used by the executable compatibility matrix remain under [`examples/legacy`](../../examples/legacy). The models under [`examples/tutorials`](../../examples/tutorials) are teaching versions: they consolidate related examples, expose parameters consistently, and support exact resume. diff --git a/docs/development/planar-mechanics-followup.md b/docs/development/planar-mechanics-followup.md new file mode 100644 index 0000000..8589de8 --- /dev/null +++ b/docs/development/planar-mechanics-followup.md @@ -0,0 +1,40 @@ +# Proposed follow-up: explicit planar cell mechanics + +Status: specification only; not implemented. The [planarity investigation](../tutorials/planarity.md) found expected three-dimensional responses, including Z-directed degenerate contacts and inherited tilt. `jitter_z=False` must retain its current orientation-perturbation meaning. A strict planar tutorial needs a separately selected native mechanical mode. + +## Public contract and state ownership + +Add a native mechanical dimensionality setting with `spatial_3d` as the backward-compatible default and `planar_xy` with an explicit finite `plane_z`. This is simulation state shared by Python, CPU, Metal, and CUDA, not a viewer option or controller-only callback. Preserve the existing three-coordinate geometry API and existing capsule length, radius, and volume conventions. + +For `planar_xy`, all mobile cell centers satisfy `position.z == plane_z` and all directions satisfy `direction.z == 0`, within a documented float32 representation tolerance after every public geometry mutation and completed native stage. The plane's stored value is its canonical float32 representation. Reject non-finite or unrepresentable heights. Initialization and explicit geometry edits reject appreciably off-plane centers, directions with nonzero Z beyond the declared input tolerance, and directions with no finite nonzero XY component; canonicalize accepted roundoff to exact stored plane Z and normalized XY direction. Do not silently flatten tilted checkpoint geometry or switch modes on an occupied simulation. + +## Translation, rotation, growth, and division + +Solve only the two in-plane translation components and rotation about Z. Assemble/project mechanical Jacobians, forces, increments, and residuals in those degrees of freedom; projecting a completed 3D solve afterwards is insufficient because its contact response was computed in a different space. Fixed cells obey the same planar geometry validation and remain stationary. + +Growth changes length without introducing Z. Division uses the planar parent axis to place daughters, keeps both centers on the configured plane, and preserves the existing volume and lineage contracts. A model requesting an XYZ geometry edit or incompatible division jitter must receive a clear validation error, so a mistakenly configured tutorial does not appear to work through silent filtering. XY-only or absent division jitter works unchanged. + +## Contacts and external boundaries + +Compute planar closest-segment contacts and normals in XY. Crossing rods and coincident parallel rods must receive deterministic in-plane separating directions instead of the 3D cross-product/fallback directions. Specify canonical axis signs, cell-ID ordering, tie breaks, and normal sign conventions centrally; test cell order reversal and both equivalent representations of a rod axis. The same overlapping input and solver parameters must produce matching contact identities and tolerance-equivalent corrections on all available backends. + +The first implementation should support Z-extruded lateral planes, axis-aligned boxes, and Z-aligned cylinders whose planar cross-sections are well-defined and whose finite-height caps fully clear a capsule on the chosen plane. Validate this compatibility when adding constraints and when adding or resizing cells. Reject tilted planes, spheres, or cap intersections until their planar mechanical semantics are explicitly implemented. A floor/ceiling touching a planar capsule must not create an unsatisfiable out-of-plane force. This mode models discs/capsules constrained to a plane within a compatible device; it does not replace finite-height 3D confinement. + +## Flow and chemical fields + +Project sampled drift velocity onto XY before applying mobile-cell translation. Deliberately ignore the normal component as a kinematic constraint and document that this is not a resolved reaction-force or momentum-conservation model. Preserve in-plane interpolation, obstacle handling, boundary behavior, and fixed-cell exclusions. Keep signal grids and chemistry independently three-dimensional: a 3D concentration field may be sampled at `plane_z`; a shallow grid does not automatically opt the cells into planar mode. + +## Checkpoint, resume, and diagnostics + +Version the native checkpoint schema to persist dimensionality and canonical plane height. Old checkpoints migrate explicitly to `spatial_3d`; missing or invalid fields in the new schema fail validation. Validate planar geometry and compatible constraints before exposing a restored simulation, without silently repairing incompatible data. Preserve mode and plane across CPU/Metal/CUDA restoration, controller restart, and clone/export paths. Reject a resume request whose requested mechanics mode conflicts with saved state. + +Expose the mode and plane in scene/analysis metadata so diagnostics can distinguish an intended invariant from a visual appearance. Coordinate that schema change with the existing metadata owner; do not infer dimensionality from channel labels, camera view, signal-grid depth, or jitter configuration. + +## Acceptance and validation + +- Shared native fixtures cover separated cells, overlapping parallel rods, crossing rods, order-reversed pairs, arbitrary in-plane orientations, division, long growth runs, and mixed mobile/fixed cells. Check both center Z and direction Z after each relevant stage, including zero-duration controller steps. +- Test all public construction and geometry-edit paths: accepted roundoff canonicalizes consistently; inherited tilt and invalid heights fail clearly; default 3D behavior and its existing conformance fixtures remain unchanged. +- Verify planar contacts separate overlap in XY with finite residuals and deterministic identities, including degenerate ties. Check force/rotation consistency and convergence rather than only final projection onto the plane. +- Exercise each supported boundary, each rejected incompatible boundary, cell growth approaching a cap, and a prescribed flow with nonzero Z velocity. In-plane drift is preserved and out-of-plane drift is suppressed only in planar mode. +- Round-trip checkpoints with each mode, migrate old data, reject malformed/inconsistent planar state, and compare resumed trajectories against uninterrupted execution. Run the same fixture contract on CPU, Metal, and CUDA; report unavailable accelerator hardware instead of substituting CPU. +- Add an opt-in tutorial using the new mode and a paired finite-height 3D example. Documentation explains the distinct mechanical assumptions and retains the current definition of `jitter_z`. diff --git a/docs/development/tutorial-command-validation.md b/docs/development/tutorial-command-validation.md new file mode 100644 index 0000000..b474c05 --- /dev/null +++ b/docs/development/tutorial-command-validation.md @@ -0,0 +1,34 @@ +# Tutorial command verification + +The [command guide](../tutorials/commands.md) separates shell argument handling, backend availability, and scientific backend validation. The executable examples are read directly from its marked Markdown blocks by [`test_tutorial_commands.py`](../../python/tests/test_tutorial_commands.py). + +## Coverage and limits + +The command tests exercise human-readable and JSON device discovery; the identical 100-step trap command on every available backend; explicit unavailable-backend failures; JSON string arguments and line continuation; repository, model, checkpoint, and output paths containing spaces; restored seed/parameters/source digest; and uninterrupted versus resumed controller/native state. They launch the documented trap, growth, and resumed live sessions in sequence on port 8765, save a checkpoint, send authenticated Stop, observe Stopped, and verify each process returns successfully before the next launch. A cleanup regression also verifies that a failed test terminates its own subprocess tree and drains inherited pipes. The CLI tests supply minimal static assets and do not claim browser rendering coverage. + +PowerShell tests set `$PSNativeCommandArgumentPassing = 'Standard'` exactly as documented. Windows uses `pwsh` 7.3+; Windows PowerShell 5.1 and `cmd.exe` are not covered. POSIX examples are executed in `sh`, Bash, and Zsh when those shells are installed. A missing shell is explicitly skipped. Reports include the exact script text, exit status, stdout/stderr, platform, Python version, shell version, and enumerated devices. + +The [Windows CLI and live-session workflow](../../.github/workflows/live-shutdown-windows.yml) builds a native CPU extension on `windows-2025`, executes these command tests and the existing live-session tests, and uploads reports. Its GPU backends are deliberately disabled. The shutdown tests additionally generate an actual Windows `CTRL_C_EVENT` in an isolated console; they do not simulate a human keyboard press in every terminal application. The viewer guide retains a [manual terminal procedure](../../viewer/README.md#stop-one-model-and-start-another). + +## Execution record + +The local command run on 2026-09-24 passed all ten tests with Python 3.12.8, Bash 3.2.57, Zsh 5.9, and PowerShell 7.6.4. Windows verification also passed on 2026-09-24 at commit `215e87dbf58c9964aef5a7821cdb3cb494c91490`: [workflow run 36068841367](https://github.com/DRAGGON-Lab/MicroSimulator/actions/runs/36068841367) recorded four command/cleanup tests passing in 17.934 seconds and all 28 live-session shutdown tests passing. Reports are retained as CI artifacts. + +| Platform and shell | Backend execution | Command coverage | +| ----------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| macOS, POSIX `sh`, Bash, Zsh | Native CPU and Apple M4 Max Metal | Passed: discovery, 100-step CPU/Metal trap, JSON/space paths, headless CPU resume, live CPU checkpoint/Stop/restart | +| macOS, PowerShell 7.6.4 with Standard arguments | Native CPU and Apple M4 Max Metal | Passed: same commands, PowerShell backtick continuation and string quoting | +| Windows Server 2025 build 26100, PowerShell 7.6.6, Python 3.12.10 | Native CPU | Passed: discovery, 100-step trap, PowerShell JSON/space paths, resume, three live Stop/restarts, cleanup regression; Metal/CUDA unavailable as expected | +| NVIDIA CUDA hardware | Not available in the command-verification campaign | Unavailable-backend error checked; no CUDA runtime or numerical claim | + +The test runner reports actual Metal availability for each macOS run. This run executed Metal headless trap commands; its JSON scenario, resume, and live-session examples selected CPU. A skipped/unavailable Metal result does not count as GPU execution. Backend support still requires the independent [native and application conformance gates](validation.md), even when a tutorial smoke command succeeds. + +## Reproduce + +After installing the development environment, run: + +```console +uv run --no-sync python -m pytest python/tests/test_tutorial_commands.py -v +``` + +Set `MICROSIMULATOR_COMMAND_REPORT` to a new output directory to retain per-shell JSON reports; the Windows workflow supplies this environment variable. Run from an environment with loopback networking enabled and port 8765 free. The tests use their own temporary working directories and never overwrite tutorial outputs in the source checkout. diff --git a/docs/formats/replay-v1.md b/docs/formats/replay-v1.md new file mode 100644 index 0000000..639363c --- /dev/null +++ b/docs/formats/replay-v1.md @@ -0,0 +1,46 @@ +# Replay bundle format v1 + +A replay bundle is a directory containing `manifest.json` and independent scene files. It contains presentation data; it cannot restart a model or execute simulation steps. `microsimulator export-replay CHECKPOINT... --output DIRECTORY` exports an explicitly ordered sequence using CPU checkpoint deserialization. It never imports the original model, invokes controller callbacks, or requires the source run's GPU. + +## Manifest + +The manifest envelope has exactly `format`, `version`, `integrity`, and `recording` fields. Format is `microsimulator-replay`, version is `1`, and `integrity` has `algorithm: "sha256"` and `recording`, the lowercase SHA-256 digest of the RFC 8785 canonical representation of the complete recording object. This detects corruption; it is not proof of publisher authenticity. + +`recording` contains exactly: + +- `export_backend`: backend identity of the CPU used to deserialize the portable checkpoints. This has the same `kind`, `name`, `device`, `device_index`, and `native` fields as a scene backend. +- `frames`: a nonempty ordered array of frame entries. + +Each entry contains exactly: + +| Field | Meaning | +| ------------------- | --------------------------------------------------------------------- | +| `ordinal` | Zero-based contiguous ordinal equal to the entry's array index | +| `time` | Finite nonnegative recorded simulation time | +| `file` | Safe relative path to a `.scene.json` document | +| `bytes` | Exact positive UTF-8 file byte length | +| `sha256` | Lowercase SHA-256 of the exact scene file bytes, including whitespace | +| `checkpoint_sha256` | Lowercase SHA-256 of the exact source checkpoint bytes consumed | +| `source_backend` | Backend identity recorded by that checkpoint's producer | + +The entry order is authoritative. Paths and checkpoint names are never sorted. Times must be nondecreasing. Equal times remain distinct frames and receive distinct ordinals; they are useful for topology events or separate observations at the same physical time. The exporter does not interpolate, merge, or drop snapshots. Supply checkpoints from the same run when stable cell identity across frames is required; the exporter cannot infer common ancestry from arbitrary checkpoint provenance. + +Paths may use ASCII letters, digits, underscores, hyphens and dots within nonempty segments. Segments start with a letter, digit, underscore or hyphen. Absolute paths, dot segments, backslashes, URI schemes and percent-encoded escapes are rejected. References must be unique. References identify files selected from the bundle folder; the browser never fetches URLs from a manifest. + +Readers verify the recording digest, strict schema, ordinals, timestamps and references before opening a frame. On demand, they verify the file size and exact-file digest, then use the shared scene reader to verify the scene's own digest/schema. The scene time and source backend must match the entry. Scene version 2 or 3 is supported through that reader; current exports use version 3 and preserve channel metadata. + +The scene's backend describes the source run, so the viewer does not present the exporter's CPU as the simulation device. The exporter identity is retained separately. Checkpoint source-backend values are provenance, not a request to allocate that device. Source paths are omitted; `checkpoint_sha256` identifies the source bytes without leaking machine-local paths. Each checkpoint is copied into a temporary snapshot before parsing, so a producer replacing its original path cannot make the parsed bytes differ from the recorded digest. + +## Limits and failure behavior + +Manifests are limited to 16 MiB and 100,000 entries. Each scene is bounded by the scene format's 1 GiB encoded limit. The exporter requires a new destination; it refuses existing files, folders and symlinks. It builds a temporary sibling and publishes the completed bundle only after all frames pass validation. Failures identify the zero-based ordinal and source checkpoint, remove the temporary export, and leave source checkpoints untouched. + +The viewer retains file handles and manifest metadata, but reads scene payloads only on demand. Its LRU cache holds at most three decoded frames and at most 64 MiB of conservative decoded-size accounting units. Objects too large for that budget are displayed without entering the cache. This is not a 64 MiB process-heap limit: the current displayed frame, renderer/GPU buffers, manifest, file handles and one active load/parse can exist outside the cache. Cache accounting includes cell arrays, species, signal levels, boundary values, constraints and labels. + +A single worker performs frame loading/decoding; repeated seeks replace one pending ordinal rather than creating a queue of decodes. Superseded reads are canceled where possible. Both successful and failed stale requests are ignored. Opening a different dataset cancels the previous reader and prevents its completion from changing the view. Failures leave the last successfully displayed frame in place, identify the affected ordinal/file, pause playback and allow seeking to another frame. + +## Playback and presentation + +Playback displays every recorded frame without interpolation. Configurable 1–120 frames/s defines a maximum presentation cadence; slow loading reduces achieved speed instead of skipping frames. Recorded simulation time remains visible independently of playback speed, including in the transport bar at narrow supported window widths. Playback stops at the last frame; pressing Play there restarts from the first when no seek is pending. Pressing Play during a pending seek waits for that requested frame, then starts timed playback from it. After a load failure, Play retries the failed requested frame and resumes only after it loads successfully; another failure leaves playback paused and preserves the last valid view. Manual seeking and previous/next stepping pause automatic playback. + +Opening a recording begins one viewer dataset. Subsequent frames, backward steps and seeks use the shared presentation-update path. Camera pose, reference-grid geometry and index-based channel preferences persist. Selected cells follow stable IDs across slot changes; selection clears if the ID is absent. Missing signal grids temporarily hide the controls, and smaller grids clamp displayed indices while retaining preferences for later compatible frames. Opening another recording or a static scene starts a new dataset. diff --git a/docs/formats/scene-v2.md b/docs/formats/scene-v2.md index 6530e27..6263adc 100644 --- a/docs/formats/scene-v2.md +++ b/docs/formats/scene-v2.md @@ -64,4 +64,4 @@ The scene preserves all channels. A viewer chooses a channel and slice as presen ## Compatibility -Writers always emit the current version. Readers accept version 2 exactly and fail closed on other versions until an explicit migration is defined. Backend conformance compares frame semantics while ignoring the expected backend identity fields. Pixel output is tested separately by the viewer. +Current writers emit [version 5](scene-v5.md). Current readers verify version-2 frames against this original schema and digest, enforce the presentation budget of 4096 species and 4096 signals independently, then supply unnamed channel metadata and absent culture state in memory. The budget applies even to empty colonies and prevents a tiny document's claimed count from causing unbounded label allocation. It does not change native simulation or checkpoint channel limits. Backend conformance compares frame semantics while ignoring the expected backend identity fields. Pixel output is tested separately by the viewer. diff --git a/docs/formats/scene-v3.md b/docs/formats/scene-v3.md new file mode 100644 index 0000000..56e462b --- /dev/null +++ b/docs/formats/scene-v3.md @@ -0,0 +1,20 @@ +# MicroSimulator scene format v3 + +Version 3 retains the [version 2 envelope, geometry, constraints and grid representation](scene-v2.md) and adds one required field inside the integrity-protected frame: + +```json +"channel_metadata": { + "species": ["Green reporter", "Red reporter"], + "signals": ["Nutrient", null] +} +``` + +Both groups are required arrays. `species` has exactly `species_count` entries; `signals` has exactly `signal_grid.signal_count` entries, or zero when the grid is null. Each entry is a Unicode scalar string or null. Null is the canonical serialized representation of an unspecified slot; unspecified groups are expanded to null-filled arrays. Empty and whitespace-only strings are retained verbatim and use the same display fallback as null. Duplicate names are valid. Unknown metadata fields and invalid lengths or types are errors. + +Scene presentation has a channel-count budget of **4096 species and 4096 signals independently**, inclusive (`MAX_SCENE_CHANNELS` in Python and TypeScript). This budget applies to both v2 and v3, including empty colonies. Readers check each claimed count before expanding missing labels, copying channel data, or constructing viewer controls; an oversized count raises a scene-format error identifying the count and budget. Python applies the same limit to `SceneFrame` construction, `capture_scene`, parsing/loading, and encoding/saving. The existing encoded-size and grid-shape checks remain separate. This presentation budget does not limit native simulation counts or checkpoint restoration, and exporters reject oversized scenes rather than silently truncating channels. + +The entire frame, including channel metadata, is hashed with RFC 8785 canonical JSON and SHA-256. Digests detect corruption; they do not authenticate a publisher. Labels must be rendered as text, never interpreted as HTML or executable code. + +Current readers accept versions 2, 3, and [4](scene-v4.md). They verify each frame's original digest and exact versioned keys first, then supply absent fields in memory. A version-2 file containing a `channel_metadata` field is invalid, even with a matching digest. Writers emit only version 4. + +Python `SceneFrame.channel_metadata` and TypeScript `SceneFrame.channelMetadata` expose the same ordered values. TypeScript `channelLabel(frame, "species" | "signals", index)` provides missing-label fallback and duplicate-name disambiguation. Indices identify channels; display names never identify settings or alter stored numerical values. See the [authoring guide](../models/channel-labels.md) for native, low-level and SBML examples. diff --git a/docs/formats/scene-v4.md b/docs/formats/scene-v4.md new file mode 100644 index 0000000..ec60f8c --- /dev/null +++ b/docs/formats/scene-v4.md @@ -0,0 +1,33 @@ +# MicroSimulator scene format v4 + +A scene is an immutable presentation snapshot with the `microsimulator-scene` format identifier. The root fields are `format`, `version` (4), `producer`, `integrity`, and `frame`. `integrity.frame` is the SHA-256 digest of the frame encoded with RFC 8785 canonical JSON. Readers reject unknown, duplicate, or missing fields, nonfinite numbers, invalid domain values, and documents larger than 1 GiB. A digest detects corruption; it does not authenticate a publisher. + +The frame contains `time`, `backend`, `species_count`, `cells`, `constraints`, `signal_grid`, `channel_metadata`, and `media`. The [geometry and grid definitions](scene-v2.md) specify the cell, constraint, and scalar-grid fields. [Channel metadata](scene-v3.md) supplies ordered species and signal labels. Each group is limited to 4096 channels independently, before labels or viewer controls are allocated. Cell IDs are canonical positive decimal strings representing unsigned 64-bit values. + +## Physical medium + +`media` is null for simulations without physical media flow. Otherwise it contains these fields: + +| Field | Meaning | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `length_unit_m`, `time_unit_s` | Positive SI conversion factors for model lengths and times | +| `viscosity_pa_s`, `density_kg_m3` | Positive medium properties in SI | +| `shape`, `origin`, `spacing` | Three grid dimensions, voxel-zero center, and positive isotropic model spacing | +| `obstacles` | Empty array or one 0/1 wall flag per voxel, with z varying fastest | +| `solutes` | Unique nonempty names in signal-channel order | +| `cells` | Stable `id`, unit `orientation` quaternion `(w,x,y,z)`, positive `biochemical_volume`, and nonnegative `species_amounts` in species-channel order | +| `fragments` | Voxel `site`, connected `component`, positive fluid `volume`, `centroid`, and nonnegative `amounts` in solute order | +| `reservoirs` | Port `name` and signed cumulative `amounts` transferred into the fluid | +| `max_speed_m_s` | Maximum speed from the last fluid solve | +| `flow_relative_residual` | Relative residual from that fluid solve | +| `maximum_volume_residual` | Largest local geometric-conservation residual, in model volume | + +Media numbers retain binary64 precision. Fragment order defines the presentation fragment index. Multiple disconnected fragments may share one voxel; a reader must preserve them separately. Media cell IDs match the frame's cell IDs in order. Scene cell geometry remains the float32 rendering projection; quantitative binary64 poses are available in checkpoints and analysis datasets. + +For a media scene with solutes, `signal_grid` contains fluid-volume-weighted voxel concentration means: the sum of fragment amounts in that voxel divided by the sum of their fluid volumes. Entirely solid voxels display zero. Its no-flux boundary fields are presentation placeholders; actual ports and boundary equations belong to checkpoint configuration. The viewer labels these concentrations as extracellular means. These arrays do not replace the fragment amounts for conservation calculations. + +The last fluid solve describes the initial geometry of its accepted substep. A scene is not a restart artifact and does not contain solver configuration, rate plans, or controller authority. See the [media modeling guide](../models/fluid-culture.md) for state ownership and units. + +## Readers and writers + +Current writers emit [version 5](scene-v5.md). Readers accept versions 2–5, verify each original frame against its declared schema and digest, and supply absent channel labels and media state in memory. A version-2 or version-3 frame containing `media` is invalid. Labels are rendered as text and never interpreted as HTML or executable content. The Python-generated media fixture in `viewer/tests/fixtures/media-v4.scene.json` is shared interchange evidence for the browser reader. diff --git a/docs/formats/scene-v5.md b/docs/formats/scene-v5.md new file mode 100644 index 0000000..5dc1a9a --- /dev/null +++ b/docs/formats/scene-v5.md @@ -0,0 +1,18 @@ +# Scene format version 5 + +Version 5 keeps the data-only scene envelope, SHA-256 frame digest, cell rendering geometry, grid projection, and channel metadata from [version 4](scene-v4.md). The optional `frame.culture` object contains the resolved fluid, chemical, and biological state. Its name reflects the coupled simulation; a biological medium recipe is not a fluid solver or a scene. + +The culture object retains the physical units, fluid properties, grid geometry, solute identities, fragments, cells, cumulative boundary transfers, and diagnostics defined for the version-4 `media` object. It adds `solute_amount_units`, an ordered array matching `solutes`, with the native amount basis for each chemical. + +Each culture cell additionally includes: + +| Field | Meaning | +| --------------------------------- | --------------------------------------------------------------------- | +| `dry_biomass_g` | Derived dry mass in grams for a kinetic growth model, otherwise null | +| `realized_specific_rate_per_hour` | Accepted specific biochemical-volume growth over the last public step | +| `biomass_produced_g` | Cumulative biomass produced by accepted nutrient uptake | +| `uptake_totals` | Cumulative consumed amounts in the declared solute order and units | + +The biochemical volume, intracellular amounts, fragment volumes, and extracellular amounts retain their existing meanings. Fluid-volume-weighted voxel concentrations are a display projection; conservation calculations use fragment amounts. Culture cells must match frame cells by identity and order. Solute and amount arrays must have matching dimensions, and nonfinite or negative amounts are rejected. + +Writers emit version 5. Readers accept versions 2–5, authenticate the original payload, and validate its original closed schema before constructing the current scene representation. Version 4 uses `media` and receives unknown amount units (`model`), null dry mass, and zero growth/uptake diagnostics. Version 2 and 3 have no culture state. Renaming an in-memory field does not alter an archived frame or its digest. diff --git a/docs/microfluidics.md b/docs/microfluidics.md index 833dc95..80e63b3 100644 --- a/docs/microfluidics.md +++ b/docs/microfluidics.md @@ -2,6 +2,8 @@ MicroSimulator connects the physical environment of a microfluidic device to the behavior of individual cells. A model can follow nutrient delivery into a trap, growth and division within its walls, signaling between cells, and the removal of cells carried into an outlet. Python defines the device and biological rules; the engine advances cell mechanics and chemistry using a shared transport grid and velocity field. +The device tutorials use stationary flow fields and kinematic cell motion. The opt-in [physical-media mode](models/fluid-culture.md) couples pressure/rate-driven flow to hydrodynamic body motion, cell growth, extracellular fragment amounts, and surface exchange. It has separate configuration and accuracy requirements, and currently requires cells to remain inside the device. + ## From device to experiment 1. **Describe the device.** Define the channels, cavities, and obstacles that shape the experiment. Device helpers produce mechanical constraints, solid transport masks, and inlet and outlet boundaries from one description. The [device tutorial](tutorials/microfluidics.md) covers traps and channels; the [pillar example](tutorials/flow-solvers.md) shows curved obstacles. @@ -10,16 +12,17 @@ MicroSimulator connects the physical environment of a microfluidic device to the 4. **Define attachment and exit rules.** Fixed cells represent an attached population. Free cells drift and rotate in the velocity field before contact relaxation. Model rules determine attachment, daughter release, and washout, while stable identifiers retain lineage history. 5. **Run and measure.** Use the [live viewer](../viewer/README.md) to inspect the model, [batch runs](formats/run-manifest-v1.md) to compare parameters and seeds, and [analysis datasets](analysis/recipes.md) to measure the resulting populations and fields. Checkpoints preserve the state needed to resume an experiment. -The [microfluidic trap](../examples/microfluidic_trap.py) is a compact starting model. The [Danino clock](../examples/tutorials/danino_clock.py) adds a quorum-sensing circuit; the [biopixel tutorial](tutorials/microfluidics.md#a-source-backed-prindle-biopixel-example) combines a single-trap model with published cavity dimensions and a supplied CAD layout whose provenance is documented. +Start with the [mother machine](tutorials/mother-machine.md) for single-file growth, retention by closed-ended channel geometry, and descendant washout. The [microfluidic trap](../examples/microfluidic_trap.py) models a wider growth cavity. The [Danino clock](../examples/tutorials/danino_clock.py) adds a quorum-sensing circuit; the [biopixel tutorial](tutorials/microfluidics.md#a-source-backed-prindle-biopixel-example) combines a single-trap model with published cavity dimensions and a supplied CAD layout whose provenance is documented. ## Choosing a flow model -Both solvers calculate steady, inertia-free device flow and normalize the result to a prescribed mean inlet speed. They expose the same velocity-field interface for transport, with native implementations on CPU, Metal, and CUDA. See [backend status](../README.md#backend-status) for current support. +The shallow and Stokes-Brinkman solvers calculate steady, inertia-free device flow and normalize the result to a prescribed mean inlet speed. Physical-media flow imposes pressure or total-rate patches directly in its coupled Stokes system. All expose staggered face velocities and have native CPU, Metal, and CUDA implementations. See [backend status](../README.md#backend-status) for current support. -| Model | Appropriate geometry and question | Numerical scope | -| --- | --- | --- | -| [Depth-averaged Hele-Shaw/Darcy flow](architecture/0022-brinkman-flow.md) | Shallow channels and traps; nutrient delivery and flux routing around obstacles | One pressure per contiguous depth column above a common floor. Gap height sets conductance, and an empirical mobility can represent stationary biomass. The model averages across the gap and omits in-plane viscous stresses. | -| [Resolved Stokes-Brinkman flow](architecture/0023-mac-stokes.md) | Velocity profiles and three-dimensional obstructions on meshes that resolve the passages | Face velocities and voxel pressures solve the coupled momentum and continuity equations with no-slip voxel walls and optional stationary porous drag. Accuracy depends on gap resolution and geometry refinement. | +| Model | Appropriate geometry and question | Numerical scope | +| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [Depth-averaged Hele-Shaw/Darcy flow](architecture/0022-brinkman-flow.md) | Shallow channels and traps; nutrient delivery and flux routing around obstacles | One pressure per contiguous depth column above a common floor. Gap height sets conductance, and an empirical mobility can represent stationary biomass. The model averages across the gap and omits in-plane viscous stresses. | +| [Resolved Stokes-Brinkman flow](architecture/0023-mac-stokes.md) | Velocity profiles and three-dimensional obstructions on meshes that resolve the passages | Face velocities and voxel pressures solve the coupled momentum and continuity equations with no-slip voxel walls and optional stationary porous drag. Accuracy depends on gap resolution and geometry refinement. | +| [Physical media flow](architecture/0026-resolved-fluid-culture.md) | General pressure/rate ports, force-balanced growing cells, and moving extracellular storage | Constrained Stokes flow, rigid capsule motion, bounded contacts, geometric fluid fragments, conservative amount transport, and surface exchange. Requires independent fluid-grid, surface, gap, and time refinement. | The shallow model lifts integrated fluxes conservatively onto the transport grid. Its reconstructed field does not resolve wall shear. The resolved solver reports momentum and block residuals, divergence, and minimum gap resolution; quantitative use also requires mesh convergence. The [flow tutorial](tutorials/flow-solvers.md) links these choices to analytic benchmarks. @@ -31,6 +34,12 @@ Attached biomass can change flow resistance through a conservatively smoothed de Free-cell motion uses the local velocity and a finite-aspect Jeffery orientation approximation, followed by contact relaxation. This kinematic coupling approximates rods as equivalent spheroids for rotation. Cell-scale hydrodynamic forces, lubrication, and predictive adhesion or detachment are outside its scope. The [flow-drift design](architecture/0021-flow-drift.md) specifies the approximation and integration limits. +## Cell-occupied extracellular volume + +The legacy signal-grid mode stores concentration per full non-wall voxel. The smoothed biochemical biomass density used for stationary resistance is neither bounded geometric occupancy nor a resolved fluid fraction. [ADR 0025](architecture/0025-cell-occupied-volume.md) describes separate coarse geometric-porosity primitives through `microsimulator.occupancy.OccupancySolver`; these remain standalone operations. + +Physical-media simulation excludes cell volume using geometric clipping and retains separate connected fluid fragments within a voxel. Stored solute amounts move through actual open areas; a volume-flux projection enforces geometric conservation as cells move and grow. Surface exchange uses membrane area and paired intracellular/extracellular ledgers. Division preserves geometric and biochemical amounts through an explicit remap without a septation-flow transient. See the [modeling guide](models/fluid-culture.md) for units, checkpoints, analysis, and unsupported lifecycle operations. + ## Interpreting results The [analytic flow benchmarks](tutorials/flow-solvers.md#numerical-evidence) test profile convergence, flux routing, and agreement between the solvers in a shared thin-gap regime. The [controlled nutrient study](tutorials/nutrient-validation.md) measures spatial growth, nutrient balance, and sensitivity to grid spacing, timestep, and flow-refresh interval. It isolates attached-population growth and transport; the interactive tutorials exercise division, mechanics, and washout separately. diff --git a/docs/models/channel-labels.md b/docs/models/channel-labels.md new file mode 100644 index 0000000..5b2fbe1 --- /dev/null +++ b/docs/models/channel-labels.md @@ -0,0 +1,82 @@ +# Species and signal labels + +Numerical channel indices determine rate-plan inputs, storage order, and viewer preferences. Labels only describe those indices. They are never inferred from Python variable names. + +## Native models + +Pass immutable `ChannelMetadata` to `NativeController` after configuring the simulation's species count and signal grid: + +```python +from microsimulator import ChannelMetadata, NativeController + +return NativeController( + simulation, + model_id="my-model", + model_version=1, + rng=context.rng, + channel_metadata=ChannelMetadata( + species=("Green reporter", "Red reporter"), + signals=("Nutrient", "Extracellular cue"), + ), +) +``` + +Each supplied tuple must contain exactly one entry per corresponding numerical channel. Use `None` for an unnamed entry, or omit a whole group to leave all its channels unnamed. The constructor validates counts immediately; the runner checks again before the first step, and exporters validate against the current state. Empty strings and whitespace-only strings display the same fallback as `None`, while retaining their exact supplied text in files. Labels must be Unicode scalar strings; unpaired surrogates are rejected. Presentation collapses and trims ASCII whitespace like an HTML option label before checking for duplicates; serialized metadata retains the original text. Duplicate names are valid and display their numerical indices for disambiguation. If a supplied name imitates one of those generated labels (for example, `GFP`, `GFP`, and `GFP [0]`), all labels in that species or signal group receive their indices so every displayed name remains distinct. Labels, including HTML-like strings, render as text. + +The complete [named-channel model](../../examples/named_channels.py) declares two species and two signals: + +```sh +uv run microsimulator run --model examples/named_channels.py --backend cpu --seed 17 --steps 2 --dt 0.01 --output named.json +uv run microsimulator view --model examples/named_channels.py --resume named.json --backend cpu --dt 0.01 +``` + +`NativeController.from_checkpoint` restores the persisted labels automatically. A custom controller may optionally expose a typed `channel_metadata: ChannelMetadata` attribute; this is not a required member of `SimulationController`. Its `resume` function must restore `checkpoint.channel_metadata`. The native model runner rejects a resumed model that changes the saved labels. Unnamed native models and legacy adapters need no changes. + +## Data-only export and low-level APIs + +Labels are stored in checkpoints independently of the controller payload, so recovering them never requires running model code. Scenes carry the same ordered arrays. Use the bundle's labels explicitly when exporting or saving native state directly: + +```python +from microsimulator import capture_scene, load_checkpoint_bundle, save_checkpoint, save_scene + +bundle = load_checkpoint_bundle("named.json") +frame = capture_scene(bundle.simulation, channel_metadata=bundle.channel_metadata) +save_scene(frame, "named.scene.json") +save_checkpoint( + bundle.simulation, + "copy.json", + provenance=bundle.provenance, + controller=bundle.controller, + channel_metadata=bundle.channel_metadata, +) +``` + +`capture_scene` and `save_checkpoint` also accept `channel_metadata` for a bare `Simulation` when no controller is needed. A bare native simulation does not own Python presentation metadata. Therefore `load_checkpoint` refuses a file with non-null labels, just as it refuses a non-null controller payload: use `load_checkpoint_bundle` to avoid silently losing labels. Such a named, bare-native checkpoint is exported or continued through the bundle API; `run --resume` without a model retains its existing unnamed-only contract. Standard named models use the controller resume command above. + +Scenes support at most 4096 species and 4096 signals per frame, independently, including unnamed channels and empty colonies. `MAX_SCENE_CHANNELS` exposes this presentation budget; oversized export fails with `SceneError` before copying native state or expanding labels. Native simulation and checkpoint counts retain their existing semantics. When loading checkpoints predating v9, `CheckpointBundle.channel_metadata` keeps both unspecified groups as `None`, without allocating labels from native counts. Bounded scene export supplies the null-filled arrays; callers that explicitly need resolved metadata can use `.resolved(species_count, signal_count)`. + +Within a live dataset, channel choices stay keyed by kind and index. Renaming a channel does not change its concentration or select another channel. Frames, reset, and replay retain the same labels through the shared scene parser. Opening another dataset establishes a new presentation identity. + +## SBML labels + +`SBMLRateModel.channel_metadata` explicitly maps nonempty species names to labels and falls back to SBML species identifiers when names are missing. It preserves the imported species order: + +```python +from microsimulator import CellInit, NativeController, load_sbml + +rates = load_sbml("model.xml") +simulation = context.simulation(species_count=rates.species_count) +simulation.set_species_rate_plan(rates.rate_plan) +cell = CellInit() +cell.species = list(rates.initial_levels) +simulation.add_cell(cell) +return NativeController( + simulation, + model_id="my-sbml-model", + model_version=1, + rng=context.rng, + channel_metadata=rates.channel_metadata, +) +``` + +SBML species identifiers remain authoritative for compilation. If the simulation also has extracellular signals, declare those explicitly with `ChannelMetadata(species=rates.channel_metadata.species, signals=(...))`. The SBML importer does not infer extracellular signal identities. diff --git a/docs/models/fluid-culture.md b/docs/models/fluid-culture.md new file mode 100644 index 0000000..7676ed2 --- /dev/null +++ b/docs/models/fluid-culture.md @@ -0,0 +1,55 @@ +# Fluid mechanics, solute transport, and cell growth + +`Simulation.configure_culture` composes physical Stokes flow, hydrodynamic cell motion, geometric extracellular transport, and surface exchange on CPU, Metal, or CUDA. Seed cells first, then configure this mode before the first step. `examples/growing_media.py` is a complete perfusion-driven growth example; `examples/physical_flow.py` exercises the standalone hydraulic solver. + +```sh +uv run python examples/growing_media.py --backend cpu --output /tmp/culture-example +``` + +Select `metal` or `cuda` explicitly to use that backend. An unavailable device, failed solve, unresolved geometry, or exhausted resource limit raises an error. There is no backend fallback. The current implementation is suitable for numerical evaluation and refinement studies; large-colony throughput and quantitative cell-drag accuracy have not been established. + +## Units and device geometry + +`FluidGridSpec` stores a three-dimensional uniform grid. `origin` is the center of voxel zero, `spacing` is a model length, and obstacle entries are Boolean wall voxels in x-major/z-minor order. `length_unit_m` and `time_unit_s` convert model lengths and times to SI. Hydrodynamic viscosity, density, pressure, applied force, torque, and total port rate use Pa s, kg/m³, Pa, N, N m, and m³/s. Solute diffusivity uses model length²/model time. Concentrations use an explicitly chosen amount unit per model volume; the same amount unit applies to a solute and its bound intracellular species. + +Each `FlowPort` selects a patch on one outer grid boundary. Empty `sites` selects all fluid faces on that boundary. A pressure port specifies reservoir pressure; a rate port specifies total signed volume flow, positive outward. Remaining boundary faces and obstacles are no-slip walls. Each disconnected fluid component has its own pressure reference. A component with only rate ports must balance prescribed net outflow against cell growth. A sealed incompressible chamber cannot accommodate net cell growth. + +Cells are analytic capsules with a cylindrical length and hemispherical caps. Their hydrodynamic pose uses a persistent quaternion. Closed-wall gaps are bounded by `stepping.minimum_gap_m`; singular lubrication is not resolved. A radius of two grid spacings is only a representation minimum. Quantitative results require independent fluid-grid, surface, gap, and time refinement. Cells must remain inside the device, and their immersed-boundary support must not reach an open port. Cell passage through outlets is not implemented. + +## Amounts, biomass, and growth + +A `Medium` is an immutable composition of named solutes, with explicit concentration units and optional provenance. Each `Solute` declares a stable identity, amount unit, and diffusivity. Nutritional requirements belong to the cell's growth model. The same transported chemical can be a nutrient for one organism and inert for another. + +Each port has one hydraulic drive and one chemical boundary. `AdvectiveFeed` supplies its composition with inflowing fluid and supplies nothing at zero volume rate. `ReservoirContact` also permits diffusion to an external bath. `ConvectiveOutflow` uses interior concentration with no diffusive flux; backflow requires an explicit external composition or rejects the step. `PiecewiseConstant` schedules can change hydraulic drives and compositions independently. Stepping splits exactly at scheduled event times, and restart preserves the event cursor. + +`MonodGrowth` consumes one limiting substrate. `EssentialNutrientGrowth` requires all declared nutrients, using the minimum limitation and a shared stoichiometric growth extent. `BiomassConversion` connects dry biomass in grams to biochemical volume and geometric capsule volume. The coupled solver iterates geometry, surface concentration, uptake, and growth until they agree. Only accepted nutrient uptake can produce biomass. Exported growth rates describe the realized volume change over the accepted public step. + +Reserve-budget growth remains available through `ReserveBudgetGrowth` and the native `ReserveRequirement`/`CellSurfaceExchange` records. Reserve growth consumes intracellular material present at the beginning of a substep; imported material becomes available on the next substep. It cannot be combined with kinetic growth in the same configuration. Intracellular reaction plans use actual biochemical volume and its change rate. Native rate-plan arithmetic retains its existing float32 precision; zero reaction plans preserve stored amounts through dilution. + +`culture_checkpoint` returns a copy containing persistent poses, biochemical volumes, intracellular amounts, extracellular fragment amounts, cumulative reservoir transfers, and the last solve reports. `fluid_fragments` describes the matching extracellular volumes. Amount arrays are fragment-major and solute-minor. `cell_surface_concentrations(id)` returns an area-weighted membrane concentration. Legacy signal-grid reaction plans and geometric mechanics are separate simulation modes and cannot be combined with the coupled culture model. + +## Geometry and integration + +The extracellular mesh uses convex capsule surfaces whose cross-sectional area and total volume equal the analytic capsule values. The reported surface approximation error decreases with `geometry.surface_resolution`. Actual polyhedral clipping determines fluid volumes and open face areas. Face-connected pieces within a voxel are combined; disconnected pieces remain separate. A gap smaller than the surface error is rejected and requires refinement. Thin positive-volume pieces are retained during clipping. + +Old and new fragments are joined within a voxel only through positive geometric overlap. The resulting temporal control volumes retain closing and opening fragments. Endpoint face areas define the time-averaged apertures, and a constrained flux projection enforces the change in extracellular volume while retaining prescribed total port rates. Residual refinement controls local volume equations in addition to the global Krylov norm. Conservative backward-Euler advection/diffusion and implicit uptake advance amounts. Reports expose geometric-conservation and total-amount residuals. This is a first-order time scheme; a small solver residual does not establish spatial or temporal accuracy. + +`Simulation.step(dt)` stages the complete interval. Body proposals bound surface displacement to at most one quarter of a spacing. Bounded retries reduce failed numerical substeps, and any unrecoverable failure restores cells, chemistry, amounts, ledgers, and time. A culture-enabled `NativeController` also restores its explicit JSON state, RNG, lifecycle changes, and step counters if regulation or integration fails. Arbitrary external effects performed by user callbacks are outside that transaction. + +Division splits analytic capsule volume, biochemical volume, and intracellular amounts according to the requested fraction. The daughter cylinders have lengths `f*(L + 4*r/3) - 4*r/3` and `(1-f)*(L + 4*r/3) - 4*r/3`. Negative lengths or daughters that do not fit are rejected. An amount-preserving geometric remap updates extracellular fluid without a septation-flow transient or reservoir transfer. Daughter placement can require more axial room than the parent. Instantaneous cell removal and manual pose edits are rejected because they do not provide a conservative displacement model. + +## Storage and inspection + +Checkpoint version 11 stores fluid and chemical configuration, kinetic models, schedules, poses, biochemical and extracellular amounts, uptake and boundary ledgers, recipe provenance, and diagnostics. Versions 1–10 continue to load; version 10 migrates to explicit reserve-budget semantics. The polyhedral mesh is reconstructed and checked on restore. Restart tests compare continued and restored trajectories. + +Scene version 5 includes physical units, biochemical volumes, dry biomass, realized growth rates, uptake totals, fragment volumes and amounts, and boundary ledgers. Versions 2–4 remain readable. The viewer displays fluid-volume-weighted voxel concentration means and labels them accordingly. Those means do not merge the authoritative fragments. Flow diagnostics describe the last fluid solve, whose geometry is the beginning of its substep. + +Analysis version 5 writes `culture_frames.parquet`, `culture_cells.parquet`, `fluid_fragments.parquet`, and `chemical_transfers.parquet`. These preserve binary64 amounts, poses, volumes, units, cumulative transfers, and biological growth diagnostics. Version-4 tables remain readable by the canonical table names. `signals.zarr` contains the presentation concentration means. Use fragment amounts and volumes for conservation calculations. + +## Backend and validation boundaries + +Geometry, sparse coefficient assembly, and factorization setup run on the host. CPU linear algebra uses binary64. Metal and CUDA use native binary32 Krylov vectors, sparse operators, preconditioner application, and reductions, with host scalar control and checks against the original operator. Scalar defect correction retains a double-precision authoritative solution; correction solves execute on the chosen device. Coupled-body preconditioning currently applies triangular factors serially on the selected device; it is a performance limitation. Resource estimates are checked before the principal allocations. + +Conformance tests cover pressure/rate ports, disconnected pressure references, a square-duct refinement limit, immersed-boundary identities, force balance, no-slip residuals, contact, growth displacement, polyhedral volume/area closure, diffusion eigenmodes, moving-volume uniform concentration, surface-transfer budgets, restart, and rollback. Actual Metal execution requires `metal_runtime_gate`; CUDA compilation is distinct from execution on an NVIDIA device. Dense contact networks, large-colony scaling, and quantitative drag/lubrication refinement remain scientific validation work. + +See [code ownership and interfaces](../architecture/0027-fluid-transport-biology-code.md) for the numerical, fluid, chemical, and biological layer boundaries. diff --git a/docs/protocols/live-viewer-v1.md b/docs/protocols/live-viewer-v1.md index fbbf780..98da8c8 100644 --- a/docs/protocols/live-viewer-v1.md +++ b/docs/protocols/live-viewer-v1.md @@ -37,6 +37,15 @@ Rejected commands and model failures return a data-only error: { "type": "error", "message": "reason" } ``` +Intentional shutdown sends lifecycle notifications before closing the WebSocket with code 1000: + +```json +{"type":"session","state":"stopping"} +{"type":"session","state":"stopped"} +``` + +`stopping` means admission of new work has ended. `stopped` means the active operation has finished and the simulation worker has terminated. Clients should process previously received messages (including asynchronous scene verification) before interpreting the subsequent socket close. A close without `stopped` is still an unexpected disconnect. These messages extend the v1 vocabulary; use a viewer built from the same release as the server. + ## Client commands The vocabulary is closed. Unknown fields are rejected. @@ -48,8 +57,19 @@ The vocabulary is closed. Unknown fields are rejected. {"type":"pause"} {"type":"reset"} {"type":"checkpoint"} +{"type":"stop"} ``` `steps` defaults to one and is bounded to 1 through 10,000. Playback advances the configured number of steps per published frame. A step or reset first pauses playback. Disconnecting the final client pauses the simulation. Reset calls the original server-side model factory again with its original backend, device, seed, parameters, and resume source. Checkpoint writes only to the destination configured when the server starts and atomically replaces that file. It preserves controller state for any runnable model implementing the `SimulationController` protocol, including the legacy compatibility adapter. + +## Stop and restart + +Stop ends this server process and releases its listening port. It is authenticated through the same token and exact-origin WebSocket upgrade as every other command. The reader handles Stop immediately, including while an earlier command on that same socket is executing. Ordinary commands retain per-client ordering in a bounded queue of 32; additional queued commands receive an error rather than blocking Stop. + +Shutdown is cooperative: an in-progress individual simulation step finishes, then the rest of its batch is skipped. An already-running reset, scene capture, or atomic checkpoint write also finishes. There is no timeout that kills the worker in the middle of model state mutation or file replacement. A model operation that never returns will therefore keep the session in `stopping`. Queued operations and newly received Frame, Play, Step, Pause, Reset, and Checkpoint commands are rejected once stopping begins; repeated Stop requests are idempotent. Closing the final browser connection still pauses the session and allows reconnection. + +Browser Stop and terminal Ctrl+C share the same worker/socket cleanup path. After `stopped`, the server closes client sockets and its application runner, exits, and releases the port. Launch the next `microsimulator view` command from the terminal and open its newly printed URL; each process has a new token. The old browser keeps its last rendered frame and displays Stopped. + +Network delivery has separate deadlines from cooperative model work. Initial frame writes, broadcasts, and lifecycle notifications allow one second per receiver; independent lifecycle deliveries run concurrently. The complete WebSocket close operation, including writing and draining its close frame, also has a one-second deadline. An unresponsive connection is then aborted, discarding queued network bytes so neither its initial-send handler nor the application runner waits indefinitely for a reader. Responsive clients still receive `stopping`, `stopped`, and a normal code-1000 close. A stalled client may miss these notifications and observe an unexpected disconnect; this does not cancel an active simulation operation or interrupt an atomic checkpoint write. diff --git a/docs/tutorials/README.md b/docs/tutorials/README.md index e55816a..70f8e0b 100644 --- a/docs/tutorials/README.md +++ b/docs/tutorials/README.md @@ -2,6 +2,8 @@ These tutorials explore cells in microfluidic devices through runnable models: geometry and flow supply the environment, while growth, mechanics, and circuits determine how populations respond. The [modeling guide](../microfluidics.md) introduces the full workflow and its assumptions. Each example can also be used independently. +For backend selection, PowerShell syntax, quoted JSON parameters, and paths with spaces, see [tutorial commands by backend and shell](commands.md#choose-a-shell). Multiline commands on this page use POSIX shell backslashes; the guide provides the PowerShell equivalents and [explicit CPU, Metal, and CUDA trap launches](commands.md#run-the-same-trap-on-cpu-metal-or-cuda). + ## Start here Follow [getting started](getting-started.md) to run a nutrient-fed trap, configure the viewer, and resume a checkpoint. Then choose a path below. @@ -10,9 +12,10 @@ The examples use `uv`, the `microsimulator` command, data-only checkpoints, and ## Devices, flow, and transport -1. [Microfluidic devices: walls, flow, and washout](microfluidics.md) connects geometry, nutrient delivery, cell growth, and outlet removal, with trap, clock, and biopixel examples. -2. [Solved flow in a pillar channel](flow-solvers.md) introduces attached founders, released daughters, stationary resistance, solver selection, and analytic benchmarks. -3. [Nutrient penetration and attached-population growth](nutrient-validation.md) measures spatial growth, conservation, and sensitivity to spatial and temporal resolution. +1. [Mother-machine growth and washout](mother-machine.md) starts with single-file growth channels, retention by confinement, old-pole lineage tracking, and a live viewer example. +2. [Microfluidic devices: walls, flow, and washout](microfluidics.md) connects geometry, nutrient delivery, cell growth, and outlet removal, with trap, clock, and biopixel examples. +3. [Solved flow in a pillar channel](flow-solvers.md) introduces attached founders, released daughters, stationary resistance, solver selection, and analytic benchmarks. +4. [Nutrient penetration and attached-population growth](nutrient-validation.md) measures spatial growth, conservation, and sensitivity to spatial and temporal resolution. ## Cell biology and circuits @@ -28,6 +31,8 @@ These lessons develop the biological rules used within devices and in standalone The [analysis tutorial](analysis.md) covers checkpoints, contact graphs, and quantitative output. Continue with [analysis recipes](../analysis/recipes.md) for reproducible Parquet/Zarr datasets and Polars queries. +The [dimensionality audit and planarity diagnostic](planarity.md) explain XY-only division jitter, finite-height confinement, and reproducible causes of out-of-plane cell motion. + ## Working with the examples Teaching models are under [`examples/tutorials`](../../examples/tutorials). Scenario parameters are JSON values passed with `--parameter`; every command in the tutorials can be run from the repository root. diff --git a/docs/tutorials/analysis.md b/docs/tutorials/analysis.md index 120bdc5..4fc6564 100644 --- a/docs/tutorials/analysis.md +++ b/docs/tutorials/analysis.md @@ -2,6 +2,8 @@ MicroSimulator separates simulation output into three artifacts: +For backend selection, PowerShell syntax, quoted JSON parameters, and paths with spaces, see [tutorial commands by backend and shell](commands.md#choose-a-shell). Multiline commands on this page use POSIX shell backslashes; the guide provides the PowerShell equivalents and [explicit CPU, Metal, and CUDA trap launches](commands.md#run-the-same-trap-on-cpu-metal-or-cuda). + - a checkpoint is an exact, integrity-checked restart artifact; - a scene is an immutable presentation snapshot; and - an analysis dataset is an immutable Parquet/Zarr projection with schemas and provenance. @@ -13,6 +15,7 @@ Use checkpoints for resuming, scenes for viewing, and datasets for statistics. ```console uv run microsimulator run \ --model examples/tutorials/biophysics.py \ + --backend cpu \ --parameter scenario='"basics"' \ --seed 42 \ --steps 200 \ diff --git a/docs/tutorials/biophysics-and-growth.md b/docs/tutorials/biophysics-and-growth.md index dcea61b..129b0c6 100644 --- a/docs/tutorials/biophysics-and-growth.md +++ b/docs/tutorials/biophysics-and-growth.md @@ -2,6 +2,10 @@ This tutorial introduces cell geometry, growth, division, lineage, cell types, mechanical constraints, and competition. Its five runnable scenarios are defined in `examples/tutorials/biophysics.py`. +The `basics`, `two_types`, and `competition` scenarios add XY-only division jitter but retain unrestricted 3D mechanics. `short_cells` adds XYZ jitter. `box` also adds XYZ jitter and has a floor and four lateral walls, with no ceiling. None guarantees a planar colony; see [division jitter and out-of-plane motion](planarity.md) for the full contract and reproducible diagnostics. + +For backend selection, PowerShell syntax, quoted JSON parameters, and paths with spaces, see [tutorial commands by backend and shell](commands.md#choose-a-shell). Multiline commands on this page use POSIX shell backslashes; the guide provides the PowerShell equivalents and [explicit CPU, Metal, and CUDA trap launches](commands.md#run-the-same-trap-on-cpu-metal-or-cuda). + ## 1. A founder that grows and divides Run the basic model: @@ -9,6 +13,7 @@ Run the basic model: ```console uv run microsimulator view \ --model examples/tutorials/biophysics.py \ + --backend cpu \ --parameter scenario='"basics"' \ --seed 42 \ --dt 0.05 \ @@ -32,19 +37,24 @@ The controller stores one stochastic division target per stable cell ID. On each ### Length and volume -The tutorial uses centerline length as its division threshold. MicroSimulator uses the effective capsule volume +The tutorial uses centerline length as its division threshold. MicroSimulator uses the conserved biochemical biomass volume ```text -V = pi r^2 (length + 2r) +B = pi r^2 (length + 2r) ``` -for concentration dilution and cell-grid exchange. If an experiment requires a volume-based division rule, compute that threshold explicitly in the regulation callback. +for concentration dilution and cell-grid exchange. This differs from geometric capsule volume, `V_geom = pi r^2 length + (4/3) pi r^3`. A length threshold is neither of these volumes. If an experiment requires a volume-based division rule, compute that threshold explicitly in the regulation callback. + +During new tutorial construction, each founder target is sampled exactly once from the model random stream. The requested founder centerline length is preserved when valid and otherwise capped at that target. Native single-precision lengths are rounded downward when needed to stay at or below the sampled value; the target itself is unchanged. No threshold rejection sampling is used. Regulation retains the strict `length > target` comparison: a zero-time step does not divide a newly initialized founder, while later growth can. + +`UniformLengthDivision.initialize_founders(simulation, state, rng, founders)` applies this opt-in policy before adding cells. Custom policies use `capped_founder_length(requested, target)` after sampling. The culture-dish founders use the same policy, preserving each requested `3.0 + 0.2 * index` length when valid. No ordinary tutorial intentionally starts above its target. The lower-level `growth_and_division.py` demonstrates explicit division without a stochastic threshold, and `native_controller.py` permits explicit `initial_length` parameters for model experiments; its ordinary default 3.0 is below its 4.0 threshold. Existing `initialize(state, rng, cell_ids)` and raw `Simulation.add_cell()` remain available for intentionally oversized cells. Resume restores saved geometry and targets without calling a founder initializer. The existing source-digest guard still requires the exact model file recorded in a checkpoint; retain that file when continuing a run made with an older tutorial version. Conjugation uses its original Gaussian target distribution; an invalid negative target raises an error instead of being resampled or silently changed. ## 2. Two founder types ```console uv run microsimulator view \ --model examples/tutorials/biophysics.py \ + --backend cpu \ --parameter scenario='"two_types"' \ --seed 42 \ --dt 0.02 \ @@ -58,19 +68,21 @@ The model places type 0 at `x = -10` and type 1 at `x = 10`. Both use the same g ```console uv run microsimulator view \ --model examples/tutorials/biophysics.py \ + --backend cpu \ --parameter scenario='"short_cells"' \ --seed 42 \ --dt 0.01 \ --open ``` -This scenario lowers the post-founder division length to produce short spherocylinders. It does not simulate a distinct spherical cell morphology. Sphere *constraints* are available for bounding rod cells, but they do not change cell shape. +This scenario lowers the post-founder division length to produce short spherocylinders. It does not simulate a distinct spherical cell morphology. Sphere _constraints_ are available for bounding rod cells, but they do not change cell shape. ## 4. Type-dependent competition in a growth zone ```console uv run microsimulator view \ --model examples/tutorials/biophysics.py \ + --backend cpu \ --parameter scenario='"competition"' \ --seed 7 \ --dt 0.01 \ @@ -94,6 +106,7 @@ Use `Growth rate` coloring to see the active zone and `Cell type` coloring to se ```console uv run microsimulator view \ --model examples/tutorials/biophysics.py \ + --backend cpu \ --parameter scenario='"box"' \ --seed 42 \ --dt 0.01 \ diff --git a/docs/tutorials/commands.md b/docs/tutorials/commands.md new file mode 100644 index 0000000..ffb7fea --- /dev/null +++ b/docs/tutorials/commands.md @@ -0,0 +1,194 @@ +# Tutorial commands by backend and shell + +Run these commands from the repository root. They use the same Python models on every backend. `--backend` selects `cpu`, `metal`, or `cuda`; `--device-index` selects the zero-based device within that backend. Model paths, JSON parameters, seeds, and timesteps do not acquire backend-specific syntax. + +## Prepare and discover devices + +Install Python 3.12, `uv`, CMake, Ninja, and a C++23 compiler. Windows CPU builds need the Visual Studio C++ build tools and Windows SDK available to the build process; the tested Windows route uses PowerShell 7 on `windows-2025`. Metal needs macOS and an accessible Apple GPU. CUDA additionally needs a CUDA-enabled build, an NVIDIA GPU, and a compatible toolkit/driver; see the [Metal](../../environments/metal/README.md) and [CUDA](../../environments/cuda/README.md) environment guides. + +```console +uv sync --locked --group dev +``` + +The commands below use `uv run --no-sync` after this installation so running a tutorial does not change the environment. Run `uv sync --locked --group dev` again after changing dependencies or checking out another version. No virtual-environment activation is required. + + + +```console +uv run --no-sync microsimulator devices +``` + + + +```console +uv run --no-sync microsimulator devices --json +``` + +An entry such as `metal:0 ...` identifies backend `metal`, device index `0`. CPU also uses index `0`. For a second enumerated GPU, change only `--device-index 0` to `--device-index 1`. `devices` reports what this installation can construct; it does not certify scientific conformance. CPU is the reference backend, Metal is the supported Apple backend, and CUDA remains under development pending the required NVIDIA runtime and application gates. A CUDA compile check alone does not establish runtime support. See the [validation policy](../development/validation.md). + +Requesting an unavailable backend/device fails with an error such as `backend cuda device 0 is unavailable (0 device(s) found)` and a nonzero exit status. There is no automatic CPU fallback. Choose an available backend explicitly or install/configure the requested backend. Do not change a backend flag merely to label a CPU result as a GPU result. + +## Choose a shell + +Single-line `console` commands on this page work in POSIX `sh`, Bash, Zsh, and PowerShell 7.3 or newer configured as below. Multiline `sh` blocks use a trailing backslash; multiline `powershell` blocks use a trailing backtick. The continuation character must be the last character on the line, with no trailing spaces or comment. Do not paste the backslashes from a `sh` block into PowerShell; use the PowerShell block or join the command onto one line. + +For PowerShell, open `pwsh` and set its native argument-passing mode once in that session: + + + +```powershell +$PSNativeCommandArgumentPassing = 'Standard' +``` + +The tested Windows route is PowerShell 7.3+ (`pwsh`), not Windows PowerShell 5.1 (`powershell.exe`) or Command Prompt (`cmd.exe`). Their native JSON quoting differs; install/use `pwsh` for these examples. A shell passing the arguments correctly does not establish GPU availability on that operating system. CUDA's current hardware conformance scripts target Linux. + +## Run the same trap on CPU, Metal, or CUDA + +Choose one available backend. These commands all run [`examples/microfluidic_trap.py`](../../examples/microfluidic_trap.py), with seed `42`, no model parameter overrides, 100 steps, `dt=0.02`, device index `0`, and checkpoints every 20 steps. Only the backend and output filename differ. Keeping separate output names prevents one backend's result from replacing another's. + +CPU: + + + +```console +uv run --no-sync microsimulator run --model examples/microfluidic_trap.py --backend cpu --device-index 0 --seed 42 --steps 100 --dt 0.02 --checkpoint-every 20 --output "results/tutorial runs/trap-cpu.json" +``` + +Metal: + + + +```console +uv run --no-sync microsimulator run --model examples/microfluidic_trap.py --backend metal --device-index 0 --seed 42 --steps 100 --dt 0.02 --checkpoint-every 20 --output "results/tutorial runs/trap-metal.json" +``` + +CUDA: + + + +```console +uv run --no-sync microsimulator run --model examples/microfluidic_trap.py --backend cuda --device-index 0 --seed 42 --steps 100 --dt 0.02 --checkpoint-every 20 --output "results/tutorial runs/trap-cuda.json" +``` + +The quoted output paths contain spaces. Parent directories are created automatically. Final and periodic outputs must be new: choose another output name on a second run. `--overwrite` is an explicit replacement option, not a prerequisite for running a tutorial. Identical seeds define the same experiment; floating-point results across backends must be compared using the project's numerical tolerances, not an assumption of byte-identical checkpoints. + +## JSON parameters and model paths with spaces + +`--parameter` takes one `NAME=JSON` argument. For a JSON string, the shell must preserve the inner double quotes: `'scenario="basics"'` reaches Python as `scenario="basics"`. A bare `scenario=basics` is invalid JSON. Numeric and Boolean examples are `--parameter copies_per_cell=6` and `--parameter enabled=true`, when the selected model defines those parameters. Repeat `--parameter` for additional names. + +This example copies the self-contained growth model to a path containing spaces, then runs its `basics` scenario. Choose the block for your shell. Both blocks describe the same run and output, so run only one. + +POSIX `sh`, Bash, or Zsh: + + + +```sh +mkdir -p "results/tutorial models" +cp examples/tutorials/biophysics.py "results/tutorial models/biophysics.py" +``` + + + +```sh +uv run --no-sync microsimulator run \ + --model "results/tutorial models/biophysics.py" \ + --parameter 'scenario="basics"' \ + --backend cpu --device-index 0 --seed 42 \ + --steps 10 --dt 0.02 \ + --output "results/tutorial runs/basics.json" +``` + +PowerShell 7.3+ with `Standard` argument passing: + + + +```powershell +New-Item -ItemType Directory -Force "results/tutorial models" | Out-Null +Copy-Item examples/tutorials/biophysics.py "results/tutorial models/biophysics.py" +``` + + + +```powershell +uv run --no-sync microsimulator run ` + --model "results/tutorial models/biophysics.py" ` + --parameter 'scenario="basics"' ` + --backend cpu --device-index 0 --seed 42 ` + --steps 10 --dt 0.02 ` + --output "results/tutorial runs/basics.json" +``` + +To run this scenario on a GPU, replace `--backend cpu` with an enumerated `metal` or `cuda` backend and choose a new output filename. Keep the model, `scenario`, seed, step count, and timestep unchanged for a comparison. Quotes also protect an absolute repository or model path containing spaces; on Windows, forward slashes in these Python CLI paths are accepted. + +## Resume saved parameters and state + +Resume the preceding scenario for ten additional steps: + + + +```console +uv run --no-sync microsimulator run --model "results/tutorial models/biophysics.py" --resume "results/tutorial runs/basics.json" --backend cpu --device-index 0 --steps 10 --dt 0.02 --output "results/tutorial runs/basics-resumed.json" +``` + +For native `--model ... --resume ...`, the CLI obtains the seed and parameters from the checkpoint, restores controller/random/native state, and checks the model file's SHA-256 before executing it. Do not pass `--parameter` on resume: the CLI rejects it. `--seed` is a construction option and does not override the saved resume seed; omit it here. The model file may move, but its bytes must match the saved digest. Keep the original model source when updating a checkout. `--steps` is an additional step count, and `--dt` remains an explicit choice; retain the original timestep when continuing the same experiment. An available backend/device can be selected explicitly for resume; that does not guarantee bitwise equality across backends. + +To continue the CPU trap from above: + + + +```console +uv run --no-sync microsimulator run --model examples/microfluidic_trap.py --resume "results/tutorial runs/trap-cpu.json" --backend cpu --device-index 0 --steps 100 --dt 0.02 --output "results/tutorial runs/trap-cpu-resumed.json" +``` + +For a Metal or CUDA trap checkpoint, change both `--resume` and `--output` to that run's filenames and select the intended available backend. Controller-backed checkpoints need their original `--model`; a checkpoint is data, not a substitute for model behavior. + +## Live view, Stop, and restart + +Build the browser assets once from the repository root: + +```console +pnpm --dir viewer install +pnpm --dir viewer build +``` + +Start a CPU trap session. Open the tokenized loopback URL printed in the terminal, or append `--open` to open it automatically. The explicit `--viewer-dist` resolves from the current directory. + + + +```console +uv run --no-sync microsimulator view --model examples/microfluidic_trap.py --backend cpu --device-index 0 --seed 42 --dt 0.02 --viewer-dist viewer/dist --checkpoint-output "results/tutorial runs/live-trap.json" +``` + +To use a GPU, the only simulation selection changes are `--backend metal` or `--backend cuda` and, if needed, `--device-index`. `--port` defaults to `8765`; choose another free port explicitly if it is occupied. Use the new tokenized URL after each launch. + +| Control or action | Effect | +| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Pause | Stops continuous playback; keeps the model process and current state available. | +| Reset | Rebuilds this session's original model. For a resumed session, reloads its starting checkpoint. Does not select a different model. | +| Close the browser | Disconnects and pauses the session; the server remains available for reconnection. | +| Checkpoint | Saves to `--checkpoint-output`; use this before Stop when restartable state is needed. | +| Stop session or one terminal Ctrl+C | Finishes the current individual step or checkpoint write, drains the worker, releases the port, and returns to the prompt. No automatic checkpoint is written. | + +Wait for **Stopped** and the terminal prompt before starting another command. A long `--frame-steps` batch is interrupted between individual steps; Stop does not forcibly interrupt a single model callback or solver. After stopping the trap, launch another model on the same default port: + + + +```console +uv run --no-sync microsimulator view --model examples/tutorials/biophysics.py --parameter 'scenario="basics"' --backend cpu --device-index 0 --seed 42 --dt 0.02 --viewer-dist viewer/dist +``` + +After stopping that session, the saved headless scenario can also be opened live: + + + +```console +uv run --no-sync microsimulator view --model "results/tutorial models/biophysics.py" --resume "results/tutorial runs/basics.json" --backend cpu --device-index 0 --dt 0.02 --viewer-dist viewer/dist +``` + +The [viewer guide](../../viewer/README.md#stop-one-model-and-start-another) describes shutdown checks, including the distinction between a generated Windows console Ctrl+C event and a human keyboard press in a particular terminal application. + +## Executed-command coverage + +[`test_tutorial_commands.py`](../../python/tests/test_tutorial_commands.py) executes the marked command blocks above through the actual shell, in a temporary repository path containing spaces. It checks device discovery, each available backend's trap checkpoint, unavailable-backend errors, JSON string parameters, quoted model/output/resume paths, saved provenance, continuation syntax, resume equivalence, and real live-session Stop/restart on the same port. Live CLI tests use minimal static assets; browser control behavior is covered separately by the [viewer shutdown tests](../../python/tests/test_viewer_shutdown.py). + +The [Windows CLI and live-session workflow](../../.github/workflows/live-shutdown-windows.yml) runs PowerShell commands against a freshly built native CPU extension and uploads shell/platform/backend/command evidence. The [command verification record](../development/tutorial-command-validation.md) states which platforms, shells, and hardware were actually exercised. Missing GPU hardware is recorded as unavailable, never counted as a passing GPU execution. These smoke checks establish command behavior, not complete backend conformance. diff --git a/docs/tutorials/discrete-state-and-contacts.md b/docs/tutorials/discrete-state-and-contacts.md index cc9ff72..6552d18 100644 --- a/docs/tutorials/discrete-state-and-contacts.md +++ b/docs/tutorials/discrete-state-and-contacts.md @@ -2,11 +2,18 @@ This tutorial uses plasmid segregation and conjugation to show how discrete biological state, stochastic events, and contact-dependent behavior fit into a MicroSimulator model. +Both models start with centers at Z=0 and axes in XY, and division adds no orientation jitter. Neither has mechanical Z confinement: daughters inherit the parent axis and contact relaxation remains three-dimensional. See [division jitter and out-of-plane motion](planarity.md). + +For backend selection, PowerShell syntax, quoted JSON parameters, and paths with spaces, see [tutorial commands by backend and shell](commands.md#choose-a-shell). Multiline commands on this page use POSIX shell backslashes; the guide provides the PowerShell equivalents and [explicit CPU, Metal, and CUDA trap launches](commands.md#run-the-same-trap-on-cpu-metal-or-cuda). + +New founders preserve their requested length unless it exceeds the single sampled division target. This also handles the rare short Gaussian target in the conjugation model without rejection sampling. Checkpoint restoration keeps stored lengths and targets. See [founder initialization](biophysics-and-growth.md#length-and-volume). + ## 1. Incompatible plasmid segregation ```console uv run microsimulator view \ --model examples/tutorials/plasmid_segregation.py \ + --backend cpu \ --parameter copies_per_cell=10 \ --seed 42 \ --dt 0.02 \ @@ -69,6 +76,7 @@ Contact graphs are derived on demand and have no fixed scientific contact cap; a ```console uv run microsimulator view \ --model examples/tutorials/conjugation.py \ + --backend cpu \ --parameter transfer_probability=0.1 \ --seed 42 \ --dt 0.02 \ @@ -77,7 +85,7 @@ uv run microsimulator view \ The founders are an acceptor (type 0) and donor (type 1). At each regulation step, an acceptor independently tests every donor or transconjugant neighbor. A successful event changes it to a transconjugant (type 2), which can transmit on later steps. -The default `0.1` is a probability *per simulation step*, so changing `dt` changes the implied physical hazard. For a time-calibrated rate `lambda`, replace it with +The default `0.1` is a probability _per simulation step_, so changing `dt` changes the implied physical hazard. For a time-calibrated rate `lambda`, replace it with ```text p(dt) = 1 - exp(-lambda dt) diff --git a/docs/tutorials/flow-solvers.md b/docs/tutorials/flow-solvers.md index 27ec792..c800b0b 100644 --- a/docs/tutorials/flow-solvers.md +++ b/docs/tutorials/flow-solvers.md @@ -2,8 +2,12 @@ The [pillar-channel model](../../examples/tutorials/pillar_channel.py) combines cylindrical walls, a depth-integrated flow calculation, attached founder lineages, and released daughters: +Cells retain three-dimensional mechanics inside the channel walls at Z=±3. XY-only division jitter and depth-integrated flow do not impose a planar cell constraint; fixed founders remain attached while released daughters can move and tilt within the finite-height chamber. See the [dimensionality audit](planarity.md). + +For backend selection, PowerShell syntax, quoted JSON parameters, and paths with spaces, see [tutorial commands by backend and shell](commands.md#choose-a-shell). Multiline commands on this page use POSIX shell backslashes; the guide provides the PowerShell equivalents and [explicit CPU, Metal, and CUDA trap launches](commands.md#run-the-same-trap-on-cpu-metal-or-cuda). + ```console -uv run microsimulator view --model examples/tutorials/pillar_channel.py --seed 7 --dt 0.01 --backend metal --open +uv run microsimulator view --model examples/tutorials/pillar_channel.py --seed 7 --dt 0.01 --backend cpu --open ``` ## Geometry and flow diff --git a/docs/tutorials/getting-started.md b/docs/tutorials/getting-started.md index b475f7f..f057599 100644 --- a/docs/tutorials/getting-started.md +++ b/docs/tutorials/getting-started.md @@ -2,6 +2,8 @@ Start with a cell trap supplied by a flowing nutrient channel. This model combines device walls, a steady flow solve, solute transport, nutrient-dependent growth, and cell motion. The [microfluidics tutorial](microfluidics.md) explains the model, and the [modeling guide](../microfluidics.md) introduces the broader workflow. +For backend selection, PowerShell syntax, quoted JSON parameters, and paths with spaces, see [tutorial commands by backend and shell](commands.md#choose-a-shell). Multiline commands on this page use POSIX shell backslashes; the guide provides the PowerShell equivalents and [explicit CPU, Metal, and CUDA trap launches](commands.md#run-the-same-trap-on-cpu-metal-or-cuda). + ## Prepare the workspace MicroSimulator requires Python 3.12, CMake, Ninja, a C++23 compiler, and `uv`. From the repository root: @@ -66,20 +68,19 @@ uv run microsimulator view \ --open ``` -The viewer can play, pause, step, reset, and request a checkpoint. For the trap, enable a nutrient signal slice and choose `Growth rate` coloring to inspect the population alongside its environment. Other models can use `Species` coloring for intracellular channels or `Cell type` for strain or discrete-state categories. Selecting a cell shows its stable ID, lineage parent, geometry, type, growth rate, and ordered species values. +The viewer can play, pause, step, reset, request a checkpoint, and stop the session. Pause retains the current process/state; Reset rebuilds this session's starting model; closing the browser pauses for reconnection. Stop session or terminal Ctrl+C releases the server after current work completes, without automatically saving. See [Stop and restart](commands.md#live-view-stop-and-restart) before launching another model. For the trap, enable a nutrient signal slice and choose `Growth rate` coloring to inspect the population alongside its environment. Other models can use `Species` coloring for intracellular channels or `Cell type` for strain or discrete-state categories. Selecting a cell shows its stable ID, lineage parent, geometry, type, growth rate, and ordered species values. The browser owns only presentation state. Python owns the clock, model, backend, checkpoint path, and random state. ## Resume exactly -Controller-backed checkpoints must be resumed with the same model source, seed, and parameters. MicroSimulator verifies the source digest before running the file: +Controller-backed checkpoints require the same model source bytes. The CLI restores the saved seed and parameters automatically and verifies the source digest before running the file. Do not pass new `--parameter` values; they are rejected. Omit `--seed` because it does not override the saved seed during resume. The step count below is additional, and the timestep is retained explicitly: ```console uv run microsimulator run \ --model examples/microfluidic_trap.py \ --resume results/tutorial-trap.json \ --backend cpu \ - --seed 42 \ --steps 100 \ --dt 0.02 \ --output results/trap-resumed.json diff --git a/docs/tutorials/intracellular-dynamics.md b/docs/tutorials/intracellular-dynamics.md index 9998c6e..48b84cd 100644 --- a/docs/tutorials/intracellular-dynamics.md +++ b/docs/tutorials/intracellular-dynamics.md @@ -2,6 +2,12 @@ This tutorial introduces intracellular concentrations, growth dilution, typed rate equations, gene-circuit feedback, and quantitative time-course analysis. The runnable scenarios are collected in `examples/tutorials/gene_expression.py`. +All five scenarios use XY-only division jitter and start with centers at Z=0, but add no mechanical walls. Their cells retain three-dimensional translations and rotations. See [division jitter and out-of-plane motion](planarity.md) before treating a planar-looking trajectory as a strict 2D model. + +For backend selection, PowerShell syntax, quoted JSON parameters, and paths with spaces, see [tutorial commands by backend and shell](commands.md#choose-a-shell). Multiline commands on this page use POSIX shell backslashes; the guide provides the PowerShell equivalents and [explicit CPU, Metal, and CUDA trap launches](commands.md#run-the-same-trap-on-cpu-metal-or-cuda). + +All five gene-expression scenarios request a founder centerline length of 3.5 and cap it at the one sampled division target. Initial concentrations are unchanged; the smaller biomass can change total initial amount. See [founder initialization and volume conventions](biophysics-and-growth.md#length-and-volume). + ## The native species contract A simulation declares one immutable species count. Each cell contains exactly that many finite single-precision concentrations. A typed rate plan returns one concentration-per-time derivative for each channel. @@ -22,6 +28,7 @@ Equal division copies concentrations to both daughters. Since their effective vo ```console uv run microsimulator view \ --model examples/tutorials/gene_expression.py \ + --backend cpu \ --parameter scenario='"constitutive"' \ --seed 42 \ --dt 0.01 \ @@ -52,6 +59,7 @@ The `legacy_constitutive` scenario provides an alternative parameterization with ```console uv run microsimulator view \ --model examples/tutorials/gene_expression.py \ + --backend cpu \ --parameter scenario='"dilution"' \ --seed 42 \ --dt 0.01 \ @@ -65,6 +73,7 @@ The founder starts at `x = 10` and the explicit chemical rate is zero. Any decli ```console uv run microsimulator view \ --model examples/tutorials/gene_expression.py \ + --backend cpu \ --parameter scenario='"derepression"' \ --seed 42 \ --dt 0.01 \ @@ -85,6 +94,7 @@ As growth dilutes `x0`, reporter production approaches one. Inspect both channel ```console uv run microsimulator view \ --model examples/tutorials/gene_expression.py \ + --backend cpu \ --parameter scenario='"oscillator"' \ --seed 42 \ --dt 0.005 \ @@ -109,6 +119,7 @@ Create periodic checkpoints, export them, and plot or inspect one stable cell li ```console uv run microsimulator run \ --model examples/tutorials/gene_expression.py \ + --backend cpu \ --parameter scenario='"oscillator"' \ --seed 42 \ --steps 400 \ diff --git a/docs/tutorials/microfluidics.md b/docs/tutorials/microfluidics.md index a294b6e..51b5ba9 100644 --- a/docs/tutorials/microfluidics.md +++ b/docs/tutorials/microfluidics.md @@ -2,17 +2,23 @@ This tutorial connects device geometry, flowing media, and cell biology in runnable MicroSimulator models. The [modeling guide](../microfluidics.md) introduces the workflow and the choice of flow solver. Four examples cover the range: -| Model | Device | Demonstrates | -| --- | --- | --- | -| [`examples/culture_dish.py`](../../examples/culture_dish.py) | round dish | one inside-cylinder constraint as a dish | -| [`examples/microfluidic_trap.py`](../../examples/microfluidic_trap.py) | trap + channel | flow, obstacles, drift, washout | -| [`examples/tutorials/danino_clock.py`](../../examples/tutorials/danino_clock.py) | trap + channel | the full quorum clock in a device | +These models use XY-only division jitter and finite-height 3D confinement. A thin cavity can encourage a monolayer, but it does not force a common center Z or eliminate tilt; walls are soft constraints whose residual depends on relaxation tolerance and passes. The [dimensionality audit](planarity.md) lists each device and reproduces these distinctions. + +For backend selection, PowerShell syntax, quoted JSON parameters, and paths with spaces, see [tutorial commands by backend and shell](commands.md#choose-a-shell). Multiline commands on this page use POSIX shell backslashes; the guide provides the PowerShell equivalents and [explicit CPU, Metal, and CUDA trap launches](commands.md#run-the-same-trap-on-cpu-metal-or-cuda). + +The microfluidic-trap, Danino, biopixel, and pillar tutorial founders request centerline length 3.5, capped at their single sampled target in [3.2, 3.8]. Attachment, position, radius, and concentrations are preserved. This affects new construction only; saved geometry is restored unchanged. See [founder initialization and volume conventions](biophysics-and-growth.md#length-and-volume). + +| Model | Device | Demonstrates | +| ---------------------------------------------------------------------------------- | ------------------- | -------------------------------------------- | +| [`examples/culture_dish.py`](../../examples/culture_dish.py) | round dish | one inside-cylinder constraint as a dish | +| [`examples/microfluidic_trap.py`](../../examples/microfluidic_trap.py) | trap + channel | flow, obstacles, drift, washout | +| [`examples/tutorials/danino_clock.py`](../../examples/tutorials/danino_clock.py) | trap + channel | the full quorum clock in a device | | [`examples/tutorials/biopixel_trap.py`](../../examples/tutorials/biopixel_trap.py) | biopixel array trap | reported cavity, CAD layout, monolayer model | Run any of them live: ```console -uv run microsimulator view --model examples/microfluidic_trap.py --seed 42 --dt 0.02 --backend metal --open +uv run microsimulator view --model examples/microfluidic_trap.py --seed 42 --dt 0.02 --backend cpu --open ``` ## Walls that cells and chemistry both respect @@ -103,11 +109,11 @@ The [`prindle.dwg` and `prindle.dxf` files](devices) supplied with this tutorial The example deliberately separates three kinds of information: -| Basis | Values used or observed | Role in the example | -| --- | --- | --- | -| Published methods | trapping region 100 x 85 x 1.65 micrometers; 25-micrometer trap spacing; nominal arrays of 500 and 12,000 biopixels | source of the modeled cavity dimensions and context for the array scale | -| Supplied CAD | 496 matching model-space `Layer-2` outlines in a 16 x 31 layout; raw outline size 0.110 x 0.100 drawing units; raw row pitch 0.125 | validates the supplied layout and its source-specific scale, but does not define cavity walls or layer thicknesses | -| Model choices | one 100 x 85 x 1.65 cavity beside a 100 x 10 x 300 micrometer channel; 10-micrometer numerical walls; mean inlet speed 20 micrometers per model time unit; chosen nutrient, drag, and re-solve parameters | defines a qualitative single-trap simulation, not a calibrated reconstruction of the experimental device | +| Basis | Values used or observed | Role in the example | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| Published methods | trapping region 100 x 85 x 1.65 micrometers; 25-micrometer trap spacing; nominal arrays of 500 and 12,000 biopixels | source of the modeled cavity dimensions and context for the array scale | +| Supplied CAD | 496 matching model-space `Layer-2` outlines in a 16 x 31 layout; raw outline size 0.110 x 0.100 drawing units; raw row pitch 0.125 | validates the supplied layout and its source-specific scale, but does not define cavity walls or layer thicknesses | +| Model choices | one 100 x 85 x 1.65 cavity beside a 100 x 10 x 300 micrometer channel; 10-micrometer numerical walls; mean inlet speed 20 micrometers per model time unit; chosen nutrient, drag, and re-solve parameters | defines a qualitative single-trap simulation, not a calibrated reconstruction of the experimental device | The trapping-region dimensions and spacing come from the [published supplementary methods](https://media.springernature.com/original/springer-static/esm/art%3A10.1038%2Fnature10722/MediaObjects/41586_2012_BFnature10722_MOESM313_ESM.pdf), not from subtracting a guessed wall inset from the CAD. `BiopixelTrapDevice` therefore defaults to a 100 x 85 x 1.65 micrometer cavity. Its channel dimensions, wall thickness, and flow speed remain ordinary constructor parameters: @@ -141,7 +147,7 @@ With `include_blocks=True`, the reader also exposes geometry in unplaced block d The executable example loads and checks this layout, then simulates one cavity using the independently published dimensions. That single-trap reduction assumes one selected local inlet condition; it does not assert uniform flow across the array, reproduce the array manifold, or include inter-trap coupling. Run it live: ```console -uv run microsimulator view --model examples/tutorials/biopixel_trap.py --seed 5 --dt 0.02 --backend metal --open +uv run microsimulator view --model examples/tutorials/biopixel_trap.py --seed 5 --dt 0.02 --backend cpu --open ``` ## Units and timescales diff --git a/docs/tutorials/mother-machine.md b/docs/tutorials/mother-machine.md new file mode 100644 index 0000000..4c26284 --- /dev/null +++ b/docs/tutorials/mother-machine.md @@ -0,0 +1,47 @@ +# Mother-machine growth and washout + +The [mother-machine example](../../examples/mother_machine.py) models six single-file growth channels connected to a perfusion channel. All cells can translate and rotate. A closed end and narrow side walls confine each cell row; elongation and division push descendants toward the opening. Cells entering the perfusion channel are carried downstream and removed near its outlet. There is no attachment flag, tether, position reset, or division-triggered release. + +## Experimental basis and model geometry + +Wang et al., [Robust Growth of Escherichia coli](https://doi.org/10.1016/j.cub.2010.04.045) (2010), describe growth channels approximately 25 µm long, 1.5 µm wide, and 1.4 µm deep in their [supplemental experimental procedures](https://dev.jun.ucsd.edu/files/publications/RobustGrowth_complete_CurrBiol2010.pdf). Their device retains the closed-end lineage while descendants leave through the open end. The authors passivated the device to prevent cell adhesion; this example therefore uses geometric confinement rather than permanent attachment. + +`microsimulator.microfluidics.MotherMachineDevice` uses those growth-channel dimensions for both mechanical walls and the transport mask. Growth channels run from x=0 to x=25 µm. Their openings face the perfusion channel at negative x; medium flows along positive y. The floor is z=0. Seven rods initially occupy each growth channel, with a diameter of 1 µm and centerline lengths sampled between 2 and 2.4 µm. This represents an already loaded device; loading is not simulated. + +The six-channel count, 7.5 µm pitch, 12 × 45 × 7 µm perfusion segment, 40 µm/s mean inlet speed, and cell parameters are illustrative choices. They do not reconstruct or calibrate the complete experimental chip. The perfusion segment is deliberately compact enough to inspect the growth channels and washout together. + +## Run in the viewer + +Build the viewer with `pnpm --dir viewer build`, then run: + +```console +uv run --no-sync microsimulator view --model examples/mother_machine.py --backend cpu --seed 42 --dt 0.025 --parameter growth_rate=0.3 --frame-steps 2 --fps 20 --viewer-dist viewer/dist --checkpoint-output results/mother-machine-live.json --open +``` + +Click **Play**. Use **Cell type** coloring: type 0 marks the tracked closed-end lineage, and type 1 marks other cells. The horizontal branches contain the growing rows, while the vertical channel carries medium and escaped descendants. Select **XY plane (Z)** and signal slice **1**, corresponding to z=0.7 µm, to see dye or nutrient at cell height. Dye enters for three seconds, followed by three seconds of clear medium. Its penetration into the branches is predominantly diffusive. + +The `growth_rate=0.3` override accelerates biology for this demonstration. It changes the relative timescales of growth, flow, and diffusion; measurements from that run must not be interpreted as experimental predictions. Omit the override to use the default maximum centerline elongation rate ln(2)/1800 s⁻¹. That is a nominal 30-minute doubling timescale at saturating nutrient, not a guarantee of 30-minute division cycles: nutrient limitation, finite end caps, and division thresholds also matter. The viewer clock is in seconds. + +For a headless run at the default growth rate: + +```console +uv run --no-sync microsimulator run --model examples/mother_machine.py --backend cpu --seed 42 --steps 100 --dt 0.025 --output results/mother-machine.json +``` + +This short run checks startup and transport; several biological generations require a much longer simulated interval. `--parameter growth_rate=0` disables elongation, and `--parameter cells=false` runs the same device without cells. + +## Growth, lineage, and nutrient + +The local nutrient concentration sets the centerline growth rate through `maximum × nutrient / (5 + nutrient)`. Nutrient uptake is proportional to actual cell volume increase with an illustrative yield of 0.5. A cell divides when its centerline length exceeds its sampled 3–3.6 µm target. Both daughters remain movable and inherit the parent's direction without artificial division jitter. Contact mechanics handles their interactions with neighboring cells and the device. + +Each initial closed-end cell has a tracked pole facing the back wall. Division transfers that identity to the daughter inheriting that pole, regardless of its position. The other daughter becomes type 1. This bookkeeping does not hold either daughter in place or select a replacement mother by proximity. If a tracked lineage washes out, its recorded ID becomes null. `mother_generations` counts divisions since initialization, rather than absolute biological pole age. + +Checkpoints include `mother_ids`, `mother_generations`, total `division_count`, `washed_out`, per-cell division thresholds, and random state. Together with the native lineage records, these support retention and washout checks without relying on the animation alone. + +## Numerical scope and validation + +The example uses steady, depth-averaged device flow followed by kinematic rod drift and contact relaxation. It does not solve cell-induced fluid displacement, cell-dependent hydraulic resistance, near-contact lubrication, or wall friction. Solute concentrations use the full non-wall voxel volume, without excluding cell-occupied volume. These approximations matter in tightly packed growth channels; geometry-based retention alone does not establish quantitative agreement with an experiment. + +The transport lattice has 1 × 0.375 × 1.4 µm spacing: four sites across each growth channel and one through its depth. Native backward Euler transport uses an absolute residual tolerance of 1e-5 concentration units to accommodate float32 roundoff on this narrow geometry. Nutrient and dye diffusion coefficients are illustrative (40 and 10 µm²/s), and concentrations are relative. The dye inlet is a prescribed three-row reservoir. Nutrient is supplied at both external reservoir boundaries; downstream cells are removed before reaching the outlet wall. + +The regression tests in [test_mother_machine.py](../../python/tests/test_mother_machine.py) check movable-cell confinement, old-pole retention through repeated divisions, descendants entering flow, outlet loss, population accounting, nutrient-dependent growth and uptake, transport through channel mouths, and exact checkpoint restart. They repeat the retention test at two timesteps. Quantitative use still requires grid and timestep convergence, measured biological and transport parameters, and comparison with experimental trajectories and residence times. diff --git a/docs/tutorials/nutrient-validation.md b/docs/tutorials/nutrient-validation.md index bc0b197..838bcc5 100644 --- a/docs/tutorials/nutrient-validation.md +++ b/docs/tutorials/nutrient-validation.md @@ -2,6 +2,8 @@ Run the controlled numerical study with: +For backend selection, PowerShell syntax, quoted JSON parameters, and paths with spaces, see [tutorial commands by backend and shell](commands.md#choose-a-shell). Multiline commands on this page use POSIX shell backslashes; the guide provides the PowerShell equivalents and [explicit CPU, Metal, and CUDA trap launches](commands.md#run-the-same-trap-on-cpu-metal-or-cuda). + ```console uv run python scripts/run_nutrient_benchmarks.py --backend cpu --output build/nutrient-cpu.json uv run python scripts/run_nutrient_benchmarks.py --backend metal --output build/nutrient-metal.json diff --git a/docs/tutorials/planarity.md b/docs/tutorials/planarity.md new file mode 100644 index 0000000..27da869 --- /dev/null +++ b/docs/tutorials/planarity.md @@ -0,0 +1,80 @@ +# Division jitter, confinement, and out-of-plane motion + +MicroSimulator's native cell mechanics is three-dimensional. An initially planar colony and XY-only division jitter can remain planar in a particular run, but neither imposes a planar constraint. A shallow flow calculation or a one-layer signal grid does not change the mechanical degrees of freedom. + +`UniformLengthDivision(jitter_z=False)` draws the usual three random perturbations and replaces the new Z perturbation with zero. It adds the resulting vector to each daughter's inherited direction, then the engine normalizes that direction. An inherited nonzero Z component therefore survives, and its normalized value can change when X or Y changes. Daughter centers are placed along the parent's three-dimensional axis before jitter is applied. `jitter_z=None` disables orientation jitter entirely; `jitter_z=True` includes the new Z perturbation. All three settings leave subsequent contact relaxation and flow drift three-dimensional. + +## Tutorial audit + +The following contracts describe the checked-in models after the founder-initialization correction in issue #22. “XY-only” refers to the added division perturbation, not a mechanical constraint. No model below implements strict 2D mechanics. + +| Model or scenario | Initial plane and division jitter | Mechanical confinement and dimensional contract | +| ----------------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `biophysics.py`: `basics`, `two_types`, `competition` | Centers Z=0, directions in XY; XY-only jitter | No walls. All translations and rotations remain 3D. | +| `biophysics.py`: `short_cells` | Centers Z=0; XYZ jitter | No walls. Orientation leaves XY at division by design. | +| `biophysics.py`: `box` | Centers Z=0.5; XYZ jitter | Floor at Z=0 and four lateral walls; no ceiling. This is an open 3D box. | +| `gene_expression.py`: all five scenarios | Centers Z=0, directions in XY; XY-only jitter | No mechanical walls; unrestricted 3D. | +| `signaling.py`: `single_gene`, `communication` | Centers Z=0, directions in XY; XY-only jitter | Only lateral Y walls at ±16; no Z confinement. | +| `signaling.py`: `mutualism` | Centers Z=0, directions in XY; XY-only jitter | No mechanical walls; unrestricted 3D. | +| `simbol_circuits.py`: all six circuits | Centers Z=0, directions in XY; XY-only jitter | No mechanical walls; unrestricted 3D. | +| `plasmid_segregation.py`, `conjugation.py` | Centers Z=0, directions in XY; no orientation jitter | Division inherits the parent axis; contact mechanics remains unrestricted 3D. | +| `pillar_channel.py` | Centers Z=0, directions in XY; XY-only jitter | Box walls at Z=±3 and cylindrical pillars. Attached cells are fixed; released daughters move in a finite-height 3D chamber. | +| `danino_clock.py`, `examples/microfluidic_trap.py` | Centers Z=0, directions in XY; XY-only jitter | Trap walls at Z=±3. These permit nonzero Z and tilt within a finite-height 3D device. | +| `biopixel_trap.py` | Centers Z=0.825, directions in XY; XY-only jitter | Trap floor Z=0 and roof Z=1.65; the channel is taller. The thin trap encourages a monolayer but does not force identical center Z or zero tilt. | +| `examples/culture_dish.py` | Centers Z=0, directions in XY; XY-only jitter | Inside cylinder with caps at Z=±1. Finite-height confinement permits nonzero Z and tilt. | + +“Monolayer” in the device tutorials describes a physical modeling intention supported by a thin cavity. Walls are soft numerical constraints, and residual overlap depends on the contact solver tolerance and number of relaxation passes. Their presence does not guarantee exact wall bounds after one pass or eliminate out-of-plane degrees of freedom. + +## Reproduce and locate a departure + +For backend discovery and shell-specific JSON quoting, see the [shared command guide](commands.md#choose-a-shell). The commands below run the dedicated diagnostic script; its diagnostic flags are described here. + +Run the diagnostic from the repository root after installing the development environment: + +```console +uv run python scripts/diagnose_planarity.py --backend cpu --seed 17 --dt 0.02 --output results/planarity-fixtures.json +uv run python scripts/diagnose_planarity.py --backend cpu --seed 17 --dt 0.02 --model examples/tutorials/biophysics.py --scenario basics --steps 1000 --max-cells 128 --output results/planarity-basics.json +uv run python scripts/diagnose_planarity.py --backend metal --seed 17 --dt 0.02 --model examples/tutorials/biopixel_trap.py --plane-z 0.825 --steps 1000 --max-cells 128 --output results/planarity-biopixel.json +``` + +Use `--plane-z 0.5` for the box scenario. Other model parameters accept JSON, for example `--parameter circuit='"bba_0001"'`. The script rejects unavailable backends instead of falling back. A division batch can exceed `--max-cells`; that limit stops the next biological step rather than truncating the model's division requests. + +The JSON records the backend, seed, timestep, model path and supplied parameters, model/controller state, source provenance, mechanics settings, initial and final geometry, and constraints. Every stage records maximum absolute center Z, displacement from the selected reference plane, absolute direction Z, and the change in each Z component for surviving cell IDs. The first event exceeding `1e-6` includes before/after geometry. This is a diagnostic detection tolerance, not a global engine guarantee. For an initially tilted cell, the first event is initialization; later stages still report their changes. + +The probe wraps native Python entry points for the selected simulation in a dedicated process and restores them in `finally`. It observes division, geometry edits, removal, growth/chemistry, flow drift, and contact/constraint relaxation without duplicating the controller or drawing random numbers. Contact and wall relaxation use the same native entry point: the isolated contact-only and wall-only fixtures distinguish their causes. Arbitrary native work performed internally by a custom extension is outside these Python stage boundaries. Use this as a headless diagnostic, not inside a multithreaded application. + +## Findings and classification + +The seven fixtures in `scripts/diagnose_planarity.py` and their shared-backend assertions in `python/tests/test_planarity.py` establish the following causes independently of any long colony trajectory. The measurements below were reproduced on CPU and Apple Metal with seed 17; NVIDIA CUDA hardware was unavailable for this investigation. The same tests select CUDA when its runtime is available. + +| Fixture | Observation | Classification | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Separated planar rods | Two nonoverlapping X-oriented rods retain center Z=0 and direction Z=0 through relaxation. | Planar control case; no departure. | +| Crossing rods | Two rods at the same center, directions X and Y, centerline length 4 and radius 0.5, produce a Z-directed normal. One relaxation moves their centers to approximately ±0.4. | Expected 3D contact response. Crossing rods at zero centerline separation are an overlapping initialization if intended as a nonoverlapping planar colony. | +| Coincident parallel rods | Two identical X-oriented rods at one center use the deterministic degenerate-contact fallback; the normal points in Z and relaxation separates centers to approximately ±0.4. | Expected 3D degeneracy handling, also an overlapping initialization. | +| Planar division | An intentionally oversized planar parent divides with XY-only jitter without creating a Z component. | Planar division control; no departure. The fixture intentionally bypasses tutorial founder capping. | +| Inherited tilt | A parent initialized with direction `(1, 0, 0.2)` has normalized Z≈0.196116. Division places daughters at Z≈±0.245145. Both geometry-edit calls request exactly zero added Z, although native normalization changes the daughters' direction Z. | Expected inherited geometry and normalization; an initially tilted setup cannot test preservation of a planar state. | +| Finite-height walls | A horizontal radius-0.5 rod at Z=0.8 intersects the ceiling at Z=1. One relaxation moves its center to approximately 0.6. Repeated default solves stop with approximately 0.003704 penetration; tightening the residual tolerance to `1e-8` reduces penetration below `1e-6`. The center remains near Z=0.5. | Expected soft-wall convergence within a finite-height 3D space. Default residual tolerance is `0.005`; physical confinement neither implies exact wall projection nor the plane Z=0. | +| Vertical flow | Prescribed Z velocity 0.2 over dt=0.02 moves a center from Z=1 to approximately 1.004, with no division. | Expected 3D advection. XY-only jitter does not filter flow. | + +For zero closest-point separation, the CPU, Metal, and CUDA contact implementations first try the cross product of rod axes, then transverse center separation, then a deterministic perpendicular fallback. Two crossing XY axes have a Z-directed cross product; coincident X axes use a fallback that can also point in Z. Choosing these directions is consistent with the existing 3D contact contract. Suppressing them globally would change three-dimensional mechanics. + +Representative tutorial runs used seed 17, dt=0.02, a maximum of 1000 steps, and a stop threshold of 128 cells. CPU and Metal reached the same step/count endpoints below; agreement here is not a claim of bitwise trajectory equivalence. Geometry was checked after each instrumented stage, including both center displacement and direction Z. + +| Model/scenario | Reference Z | Completed steps / final cells | First departure | +| ----------------------------- | ----------: | ----------------------------: | ------------------------------------------------------------ | +| Biophysics: basics | 0 | 312 / 128 | None above diagnostic tolerance | +| Biophysics: two_types | 0 | 149 / 128 | None above diagnostic tolerance | +| Biophysics: competition | 0 | 351 / 130 | None above diagnostic tolerance | +| Biophysics: short_cells | 0 | 325 / 128 | Division geometry edit at time≈0.02: direction Z≈0.000531725 | +| Biophysics: box | 0.5 | 327 / 128 | Division geometry edit at time≈0.02: direction Z≈0.000531725 | +| Gene expression: constitutive | 0 | 327 / 128 | None above diagnostic tolerance | +| Signaling: single_gene | 0 | 179 / 128 | None above diagnostic tolerance | +| Pillar channel | 0 | 394 / 131 | None above diagnostic tolerance | +| Biopixel trap | 0.825 | 478 / 128 | None above diagnostic tolerance | + +The two departures in this matrix are the tutorials' intentional XYZ jitter. The source audit covers additional scenarios without claiming that they all received these trajectory runs. The unchanged plane in the other runs provides reproducibility evidence for those particular initial states and durations, not proof of planar invariance. + +No solver defect was demonstrated. Issue #22 separately corrected oversized tutorial founders, which could previously divide immediately; the diagnostics here use that corrected initialization. Luiza's older-version observation cannot be assigned to one particular mechanism without its original model, geometry, seed, and trajectory. The fixtures nevertheless show several reproducible paths to Z motion even with `jitter_z=False`. + +If a lesson requires every center to remain at a specified Z and every axis to remain in XY, it requires an explicit planar mechanics feature. The [proposed planar-mechanics contract](../development/planar-mechanics-followup.md) defines that follow-up without changing the meaning of division jitter. diff --git a/docs/tutorials/signaling.md b/docs/tutorials/signaling.md index bf4733b..e54171c 100644 --- a/docs/tutorials/signaling.md +++ b/docs/tutorials/signaling.md @@ -2,6 +2,12 @@ This tutorial introduces extracellular grids, diffusion, cell-grid exchange, sender-receiver communication, and two-strain mutualism. Run the scenarios in `examples/tutorials/signaling.py` with a small time step such as `0.01`. +All scenarios use XY-only division jitter with three-dimensional mechanics. `single_gene` and `communication` have lateral Y walls only; `mutualism` has no mechanical walls. Signal-grid depth does not constrain cell Z or tilt. See [division jitter and out-of-plane motion](planarity.md). + +For backend selection, PowerShell syntax, quoted JSON parameters, and paths with spaces, see [tutorial commands by backend and shell](commands.md#choose-a-shell). Multiline commands on this page use POSIX shell backslashes; the guide provides the PowerShell equivalents and [explicit CPU, Metal, and CUDA trap launches](commands.md#run-the-same-trap-on-cpu-metal-or-cuda). + +Each new founder requests centerline length 3.5 and is capped at its sampled division threshold. Its radius, position, cell type, and initial concentrations are preserved. See [founder initialization](biophysics-and-growth.md#length-and-volume). + ## Grid geometry and units A `SignalGridSpec` declares channel count, lattice shape, physical origin, spacing, diffusion coefficients, advection velocities, integration method, and six boundary conditions. Grid levels are concentrations. A coupled rate plan returns: @@ -28,6 +34,7 @@ Both coefficient arrays must be finite and non-negative. A relaxation toward a n ```console uv run microsimulator view \ --model examples/tutorials/signaling.py \ + --backend cpu \ --parameter scenario='"single_gene"' \ --seed 42 \ --dt 0.01 \ @@ -56,6 +63,7 @@ Two inward-facing planes at `y = -16` and `y = 16` confine cells. The signal gri ```console uv run microsimulator view \ --model examples/tutorials/signaling.py \ + --backend cpu \ --parameter scenario='"communication"' \ --seed 42 \ --dt 0.01 \ @@ -84,6 +92,7 @@ Use cell-type coloring to identify sender and receiver lineages, species channel ```console uv run microsimulator view \ --model examples/tutorials/signaling.py \ + --backend cpu \ --parameter scenario='"mutualism"' \ --seed 42 \ --dt 0.01 \ diff --git a/docs/tutorials/simbol.md b/docs/tutorials/simbol.md index 9b3908d..9b4159d 100644 --- a/docs/tutorials/simbol.md +++ b/docs/tutorials/simbol.md @@ -2,6 +2,10 @@ SimBOL connects an SBOL 3 design to simulator-specific code through a summarized JSON representation. This tutorial presents typed MicroSimulator versions of six BioBrick circuit examples and a spatial quorum-sensing clock. +The six circuit models start in XY and use XY-only division jitter without mechanical walls. The Danino clock uses a finite-height trap. Both retain three-dimensional mechanics; see [division jitter, confinement, and out-of-plane motion](planarity.md). + +For backend selection, PowerShell syntax, quoted JSON parameters, and paths with spaces, see [tutorial commands by backend and shell](commands.md#choose-a-shell). Multiline commands on this page use POSIX shell backslashes; the guide provides the PowerShell equivalents and [explicit CPU, Metal, and CUDA trap launches](commands.md#run-the-same-trap-on-cpu-metal-or-cuda). + These are explicit example models, not a general SBOL-to-rate-plan import path. The [source reference](../compatibility/tutorial-source-provenance.md#simbol-source-workflow) describes how they relate to the SimBOL notebook, generated Python, and JSON fixtures. ## Run the six circuits @@ -11,6 +15,7 @@ Use one model and select a circuit: ```console uv run microsimulator view \ --model examples/tutorials/simbol_circuits.py \ + --backend cpu \ --parameter circuit='"bba_0001"' \ --seed 42 \ --dt 0.01 \ @@ -19,20 +24,21 @@ uv run microsimulator view \ Allowed names are `bba_0001`, `bba_0002`, `bba_0003`, `bba_0004`, `bba_0005`, and `bba_i5200`. Choose `Species` coloring and the reporter channel listed below. -| Circuit | Ordered species channels | Reporter channel | Additional input | -| --- | --- | --: | --- | -| BBa_0001 | GFP | 0 | none | -| BBa_0002 | RFP, TetR | 0 | `inducer_concentration`, default 0 aTc | -| BBa_0003 | LuxR, GFP, LuxI, LuxR–AHL pool | 1 | `precursor_concentration`, default 5 | -| BBa_0004 | LacI, GFP | 1 | `inducer_concentration`, default 1 IPTG | -| BBa_0005 | TetR, GFP, cI, LacI | 1 | `inducer_concentration`, default 1 aTc | -| BBa_I5200 | cI, GFP, LacI, TetR | 1 | none | +| Circuit | Ordered species channels | Reporter channel | Additional input | +| --------- | ------------------------------ | ---------------: | --------------------------------------- | +| BBa_0001 | GFP | 0 | none | +| BBa_0002 | RFP, TetR | 0 | `inducer_concentration`, default 0 aTc | +| BBa_0003 | LuxR, GFP, LuxI, LuxR–AHL pool | 1 | `precursor_concentration`, default 5 | +| BBa_0004 | LacI, GFP | 1 | `inducer_concentration`, default 1 IPTG | +| BBa_0005 | TetR, GFP, cI, LacI | 1 | `inducer_concentration`, default 1 aTc | +| BBa_I5200 | cI, GFP, LacI, TetR | 1 | none | Parameters are JSON numbers: ```console uv run microsimulator run \ --model examples/tutorials/simbol_circuits.py \ + --backend cpu \ --parameter circuit='"bba_0004"' \ --parameter inducer_concentration=4.0 \ --seed 42 \ @@ -115,6 +121,7 @@ These choices change trajectories relative to the generated callback scripts. A ```console uv run microsimulator view \ --model examples/tutorials/danino_clock.py \ + --backend cpu \ --seed 42 \ --dt 0.005 \ --open diff --git a/environments/cuda/README.md b/environments/cuda/README.md index cb16efe..603dbb5 100644 --- a/environments/cuda/README.md +++ b/environments/cuda/README.md @@ -2,6 +2,8 @@ CUDA is the NVIDIA backend under active development. It is implemented directly in CUDA C++ with the CUDA Runtime API: no portability layer, translated Metal source, or CPU computational fallback is used. +For application commands, use the [shared CPU, Metal, and CUDA tutorial examples](../../docs/tutorials/commands.md#run-the-same-trap-on-cpu-metal-or-cuda), [device discovery](../../docs/tutorials/commands.md#prepare-and-discover-devices), and [shell quoting guide](../../docs/tutorials/commands.md#choose-a-shell). Select this backend with `--backend cuda` and an enumerated `--device-index`; the model syntax is unchanged. + `CM_ENABLE_CUDA` is off by default so ordinary CPU builds do not acquire a CUDA toolchain dependency. ## Compile check diff --git a/environments/metal/README.md b/environments/metal/README.md index 53500ea..d5214f7 100644 --- a/environments/metal/README.md +++ b/environments/metal/README.md @@ -2,6 +2,8 @@ Metal is the feature-complete Apple GPU backend. It is implemented directly with the Metal API and independent Metal Shading Language kernels, and it is validated against both the CPU reference and recorded behavior from the original CellModeller OpenCL runtime. +For application commands, use the [shared CPU, Metal, and CUDA tutorial examples](../../docs/tutorials/commands.md#run-the-same-trap-on-cpu-metal-or-cuda), [device discovery](../../docs/tutorials/commands.md#prepare-and-discover-devices), and [shell quoting guide](../../docs/tutorials/commands.md#choose-a-shell). Select this backend with `--backend metal` and an enumerated `--device-index`; the model syntax is unchanged. + Metal is enabled by default on Apple platforms. It compiles embedded MSL source at runtime through `MTLDevice`, making kernel compilation part of device construction and validation. ## Native conformance diff --git a/examples/batch_model.py b/examples/batch_model.py index c3208c0..7ef49be 100644 --- a/examples/batch_model.py +++ b/examples/batch_model.py @@ -11,4 +11,5 @@ def build(context): cell.radius = 0.5 cell.growth_rate = float(context.parameters.get("growth_rate", 0.2)) simulation.add_cell(cell) + return simulation diff --git a/examples/culture_dish.py b/examples/culture_dish.py index b4c50a2..7f0fcc8 100644 --- a/examples/culture_dish.py +++ b/examples/culture_dish.py @@ -58,7 +58,8 @@ def build(context: ModelContext) -> NativeController: simulation = context.simulation(reserved_capacity=20_000) _add_dish(simulation) - founder_ids = [] + founders: list[CellInit] = [] + for index in range(FOUNDER_COUNT): placement = context.rng.uniform(0.0, 2.0 * math.pi) # The square root spreads founders uniformly over the seeded area @@ -75,10 +76,11 @@ def build(context: ModelContext) -> NativeController: founder.length = 3.0 + 0.2 * index founder.radius = CELL_RADIUS founder.growth_rate = 1.0 - founder_ids.append(simulation.add_cell(founder)) + founders.append(founder) state: dict[str, JSONValue] = {"scope": "culture-dish"} - DIVISION.initialize(state, context.rng, tuple(founder_ids)) + DIVISION.initialize_founders(simulation, state, context.rng, tuple(founders)) + return NativeController( simulation, model_id=MODEL_ID, @@ -93,6 +95,7 @@ def build(context: ModelContext) -> NativeController: def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeController: del context + return NativeController.from_checkpoint( checkpoint, model_id=MODEL_ID, diff --git a/examples/growing_media.py b/examples/growing_media.py new file mode 100644 index 0000000..de316ae --- /dev/null +++ b/examples/growing_media.py @@ -0,0 +1,144 @@ +"""Perfuse an attached cell and measure nutrient consumption and biomass growth. + +Parameters are illustrative, not organism calibration. Geometry and timestep +must be refined before quantitative interpretation. The default run starts +without extracellular nutrients or intracellular nutrient reserves. +""" + +# ruff: noqa: N803 -- unit symbols retain the distinction between mM and mm. + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import microsimulator as cm +from microsimulator.flow import FluidDomain, FluidProperties, Pressure, VolumeFlow +from microsimulator.growth import ( + BiomassConversion, + CellGrowth, + EssentialNutrientGrowth, + MonodGrowth, + NutrientRequirement, +) +from microsimulator.media import Medium, PiecewiseConstant +from microsimulator.scene import capture_scene, save_scene +from microsimulator.stokes import StokesFlow +from microsimulator.transport import AdvectiveFeed, ConvectiveOutflow, Solute, SoluteTransport + + +def build_culture( + backend: cm.BackendKind = cm.BackendKind.CPU, + *, + flow_ul_per_min: float = 1e-6, + glucose_mM: float = 0.2, + ammonium_mM: float = 0.5, + essential: bool = False, + spacing_um: float = 1.0, + switch_at_seconds: float | None = None, +) -> cm.Simulation: + simulation = cm.Simulation(backend) + founder = cm.CellInit() + founder.position = cm.Vec3(6, 6, 6) + founder.radius, founder.length = 2, 1 + founder.fixed, founder.growth_rate = True, 0 + cell_id = simulation.add_cell(founder) + empty = Medium.millimolar("nutrient-free", {}) + medium = Medium.millimolar( + "feed", + {"glucose": glucose_mM, "ammonium": ammonium_mM}, + provenance="Illustrative effective composition; not calibrated.", + ) + feed = ( + PiecewiseConstant(medium, ((switch_at_seconds, empty),)) + if switch_at_seconds is not None + else medium + ) + model = ( + EssentialNutrientGrowth.molar( + mu_max_per_hour=0.8, + requirements={ + "glucose": NutrientRequirement(0.1, 90), + "ammonium": NutrientRequirement(0.02, 100), + }, + ) + if essential + else MonodGrowth.molar( + substrate="glucose", mu_max_per_hour=0.8, half_saturation_mM=0.1, yield_g_per_mol=90 + ) + ) + simulation.configure_culture( + fluid=StokesFlow( + domain=FluidDomain.rectangular_channel(size_um=(12, 12, 12), spacing_um=spacing_um), + properties=FluidProperties(), + boundaries={"inlet": VolumeFlow(flow_ul_per_min), "outlet": Pressure(0)}, + ), + transport=SoluteTransport( + solutes=(Solute("glucose", 600), Solute("ammonium", 1500)), + initial_medium=empty, + boundaries={"inlet": AdvectiveFeed(feed), "outlet": ConvectiveOutflow(backflow=empty)}, + ), + cell_growth={cell_id: CellGrowth(model, BiomassConversion(density_g_per_um3=3e-13))}, + ) + + return simulation + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--backend", choices=("cpu", "metal", "cuda"), default="cpu") + parser.add_argument("--flow-ul-per-min", type=float, default=1e-6) + parser.add_argument("--glucose-mm", type=float, default=0.2) + parser.add_argument("--ammonium-mm", type=float, default=0.5) + parser.add_argument("--essential", action="store_true") + parser.add_argument("--steps", type=int, default=3) + parser.add_argument("--dt", type=float, default=1) + parser.add_argument("--spacing-um", type=float, default=1) + parser.add_argument("--switch-at-seconds", type=float) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + sim = build_culture( + {"cpu": cm.BackendKind.CPU, "metal": cm.BackendKind.METAL, "cuda": cm.BackendKind.CUDA}[ + args.backend + ], + flow_ul_per_min=args.flow_ul_per_min, + glucose_mM=args.glucose_mm, + ammonium_mM=args.ammonium_mm, + essential=args.essential, + spacing_um=args.spacing_um, + switch_at_seconds=args.switch_at_seconds, + ) + + for _ in range(args.steps): + sim.step(args.dt) + state = sim.culture_state + assert state is not None + cell = next(iter(state.cells.values())) + print( + json.dumps( + { + "time_s": state.time_seconds, + "dry_biomass_g": cell.dry_biomass_g, + "biomass_produced_g": cell.biomass_produced_g, + "growth_per_hour": cell.realized_specific_growth_rate_per_hour, + "surface_mM": dict(cell.surface_concentrations_mM), + "consumed_mol": dict(cell.uptake), + "boundary_mol": { + port: dict(values) for port, values in state.boundary_transfer.items() + }, + "budget_residual_mol": dict(state.budget_residual), + }, + sort_keys=True, + ), + flush=True, + ) + + if args.output: + args.output.mkdir(parents=True, exist_ok=True) + cm.save_checkpoint(sim, args.output / "culture.checkpoint.json") + save_scene(capture_scene(sim), args.output / "culture.scene.json") + + +if __name__ == "__main__": + main() diff --git a/examples/legacy/ACS2012/EdgeDetectorChamber.py b/examples/legacy/ACS2012/EdgeDetectorChamber.py index ce8ecf4..ca5a88c 100644 --- a/examples/legacy/ACS2012/EdgeDetectorChamber.py +++ b/examples/legacy/ACS2012/EdgeDetectorChamber.py @@ -39,6 +39,7 @@ def _grid() -> SignalGridSpec: grid.advection = [Vec3()] grid.integration = SignalIntegrationKind.CRANK_NICOLSON grid.solver.absolute_tolerance = 1.0e-12 + return grid @@ -57,6 +58,7 @@ def _rates() -> CoupledRatePlan: ahl_i_squared = ahl_i * ahl_i ahl_production = lux_i / (1.0 + lux_i) reporter = 1.0e-5 + ahl_i_squared / (5.0e-5 + ahl_i_squared) + return rates.coupled_plan( 5, 1, @@ -97,6 +99,7 @@ def build(context: ModelContext) -> NativeController: simulation.set_coupled_rate_plan(_rates()) _add_channel(simulation) founders: list[int] = [] + for cell_type, x in ((1, -20.0), (0, 20.0)): founder = CellInit() founder.position = Vec3(x, 0.0, 0.0) @@ -106,8 +109,10 @@ def build(context: ModelContext) -> NativeController: founder.cell_type = cell_type founder.species = [0.0] * 5 founders.append(simulation.add_cell(founder)) + state: dict[str, JSONValue] = {} DIVISION.initialize(state, context.rng, tuple(founders)) + return NativeController( simulation, model_id=MODEL_ID, @@ -122,6 +127,7 @@ def build(context: ModelContext) -> NativeController: def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeController: del context + return NativeController.from_checkpoint( checkpoint, model_id=MODEL_ID, diff --git a/examples/legacy/Tutorial_2/Tutorial_2a.py b/examples/legacy/Tutorial_2/Tutorial_2a.py index f9d2fbd..72b0d9b 100644 --- a/examples/legacy/Tutorial_2/Tutorial_2a.py +++ b/examples/legacy/Tutorial_2/Tutorial_2a.py @@ -39,6 +39,7 @@ def build(context: ModelContext) -> NativeController: founder_id = simulation.add_cell(founder) state: dict[str, JSONValue] = {} DIVISION.initialize(state, context.rng, (founder_id,)) + return NativeController( simulation, model_id=MODEL_ID, @@ -53,6 +54,7 @@ def build(context: ModelContext) -> NativeController: def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeController: del context + return NativeController.from_checkpoint( checkpoint, model_id=MODEL_ID, diff --git a/examples/legacy/Tutorial_2/Tutorial_2b.py b/examples/legacy/Tutorial_2/Tutorial_2b.py index 7422cf9..f04f9c1 100644 --- a/examples/legacy/Tutorial_2/Tutorial_2b.py +++ b/examples/legacy/Tutorial_2/Tutorial_2b.py @@ -35,6 +35,7 @@ def _rate_plan() -> SpeciesRatePlan: x_squared = x * x dx = 2.0 * (1.0 + x_squared) / (1.0 + x_squared + y * y) - x dy = 2.0 * (1.0 + x_squared) / (1.0 + x_squared) - y + return rates.species_plan(2, (dx, dy)) @@ -49,6 +50,7 @@ def build(context: ModelContext) -> NativeController: founder_id = simulation.add_cell(founder) state: dict[str, JSONValue] = {} DIVISION.initialize(state, context.rng, (founder_id,)) + return NativeController( simulation, model_id=MODEL_ID, @@ -63,6 +65,7 @@ def build(context: ModelContext) -> NativeController: def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeController: del context + return NativeController.from_checkpoint( checkpoint, model_id=MODEL_ID, diff --git a/examples/legacy/Tutorial_3/Tutorial_3.py b/examples/legacy/Tutorial_3/Tutorial_3.py index 7fe4189..d38c4c1 100644 --- a/examples/legacy/Tutorial_3/Tutorial_3.py +++ b/examples/legacy/Tutorial_3/Tutorial_3.py @@ -38,6 +38,7 @@ def _grid() -> SignalGridSpec: grid.advection = [Vec3(), Vec3()] grid.integration = SignalIntegrationKind.CRANK_NICOLSON grid.solver.absolute_tolerance = 1.0e-12 + return grid @@ -51,6 +52,7 @@ def _rates() -> CoupledRatePlan: alpha_exchange = (alpha - alpha_in) * area beta_exchange = (beta - beta_in) * area type_zero = rates.equal(rates.cell_type(), 0) + return rates.coupled_plan( 2, 2, @@ -68,10 +70,12 @@ def _rates() -> CoupledRatePlan: def _regulate(step: ControllerStep) -> StepPlan: updates: list[CellUpdate] = [] + for cell in step.cells: partner = cell.species[1] if cell.cell_type == 0 else cell.species[0] growth_rate = 0.1 + 0.9 * partner / (0.1 + partner) updates.append(CellUpdate(cell.id, growth_rate=growth_rate)) + return StepPlan(updates=tuple(updates), divisions=DIVISION.requests(step)) @@ -80,6 +84,7 @@ def build(context: ModelContext) -> NativeController: simulation.configure_signal_grid(_grid()) simulation.set_coupled_rate_plan(_rates()) founders: list[int] = [] + for cell_type, x in ((0, -3.0), (1, 3.0)): founder = CellInit() founder.position = Vec3(x, 0.0, 0.0) @@ -89,8 +94,10 @@ def build(context: ModelContext) -> NativeController: founder.cell_type = cell_type founder.species = [0.0, 0.0] founders.append(simulation.add_cell(founder)) + state: dict[str, JSONValue] = {} DIVISION.initialize(state, context.rng, tuple(founders)) + return NativeController( simulation, model_id=MODEL_ID, @@ -105,6 +112,7 @@ def build(context: ModelContext) -> NativeController: def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeController: del context + return NativeController.from_checkpoint( checkpoint, model_id=MODEL_ID, diff --git a/examples/legacy/ex2_constGene.py b/examples/legacy/ex2_constGene.py index e0baf19..76c2ce9 100644 --- a/examples/legacy/ex2_constGene.py +++ b/examples/legacy/ex2_constGene.py @@ -39,6 +39,7 @@ def build(context: ModelContext) -> NativeController: founder_id = simulation.add_cell(founder) state: dict[str, JSONValue] = {} DIVISION.initialize(state, context.rng, (founder_id,)) + return NativeController( simulation, model_id=MODEL_ID, @@ -53,6 +54,7 @@ def build(context: ModelContext) -> NativeController: def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeController: del context + return NativeController.from_checkpoint( checkpoint, model_id=MODEL_ID, diff --git a/examples/legacy/ex2a_dilution.py b/examples/legacy/ex2a_dilution.py index 709734a..2daec04 100644 --- a/examples/legacy/ex2a_dilution.py +++ b/examples/legacy/ex2a_dilution.py @@ -39,6 +39,7 @@ def build(context: ModelContext) -> NativeController: founder_id = simulation.add_cell(founder) state: dict[str, JSONValue] = {} DIVISION.initialize(state, context.rng, (founder_id,)) + return NativeController( simulation, model_id=MODEL_ID, @@ -53,6 +54,7 @@ def build(context: ModelContext) -> NativeController: def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeController: del context + return NativeController.from_checkpoint( checkpoint, model_id=MODEL_ID, diff --git a/examples/legacy/ex2b_diluteRepression.py b/examples/legacy/ex2b_diluteRepression.py index 5365672..2dad77f 100644 --- a/examples/legacy/ex2b_diluteRepression.py +++ b/examples/legacy/ex2b_diluteRepression.py @@ -32,6 +32,7 @@ def _rate_plan() -> SpeciesRatePlan: rates = RatePlanBuilder() x0 = rates.species(0) repression = 4.0 / (4.0 + x0 * x0) + return rates.species_plan(2, (rates.constant(0.0), repression)) @@ -46,6 +47,7 @@ def build(context: ModelContext) -> NativeController: founder_id = simulation.add_cell(founder) state: dict[str, JSONValue] = {} DIVISION.initialize(state, context.rng, (founder_id,)) + return NativeController( simulation, model_id=MODEL_ID, @@ -60,6 +62,7 @@ def build(context: ModelContext) -> NativeController: def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeController: del context + return NativeController.from_checkpoint( checkpoint, model_id=MODEL_ID, diff --git a/examples/legacy/ex3_simpleSignal.py b/examples/legacy/ex3_simpleSignal.py index 8af72df..93b4788 100644 --- a/examples/legacy/ex3_simpleSignal.py +++ b/examples/legacy/ex3_simpleSignal.py @@ -37,6 +37,7 @@ def _grid() -> SignalGridSpec: grid.spacing = Vec3(4.0, 4.0, 4.0) grid.diffusion = [10.0] grid.advection = [Vec3()] + return grid @@ -45,6 +46,7 @@ def _rates() -> CoupledRatePlan: intracellular = rates.species(0) extracellular = rates.signal(0) exchange_amount = 0.1 * (extracellular - intracellular) * rates.cell_surface_area() + return rates.coupled_plan( 1, 1, @@ -82,6 +84,7 @@ def build(context: ModelContext) -> NativeController: founder_id = simulation.add_cell(founder) state: dict[str, JSONValue] = {} DIVISION.initialize(state, context.rng, (founder_id,)) + return NativeController( simulation, model_id=MODEL_ID, @@ -96,6 +99,7 @@ def build(context: ModelContext) -> NativeController: def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeController: del context + return NativeController.from_checkpoint( checkpoint, model_id=MODEL_ID, diff --git a/examples/legacy/ex4_simpleCellCellSignaling.py b/examples/legacy/ex4_simpleCellCellSignaling.py index 72d2cc4..801ff4d 100644 --- a/examples/legacy/ex4_simpleCellCellSignaling.py +++ b/examples/legacy/ex4_simpleCellCellSignaling.py @@ -40,6 +40,7 @@ def _grid() -> SignalGridSpec: grid.advection = [Vec3()] grid.integration = SignalIntegrationKind.CRANK_NICOLSON grid.solver.absolute_tolerance = 1.0e-12 + return grid @@ -51,6 +52,7 @@ def _rates() -> CoupledRatePlan: exchange_concentration = exchange_amount / VOXEL_VOLUME type_zero = rates.equal(rates.cell_type(), 0) x0_squared = x0 * x0 + return rates.coupled_plan( 3, 1, @@ -85,6 +87,7 @@ def build(context: ModelContext) -> NativeController: simulation.set_coupled_rate_plan(_rates()) _add_channel(simulation) founders: list[int] = [] + for cell_type, x in ((0, -10.0), (1, 10.0)): founder = CellInit() founder.position = Vec3(x, 0.0, 0.0) @@ -94,8 +97,10 @@ def build(context: ModelContext) -> NativeController: founder.cell_type = cell_type founder.species = [0.0, 0.0, 0.0] founders.append(simulation.add_cell(founder)) + state: dict[str, JSONValue] = {} DIVISION.initialize(state, context.rng, tuple(founders)) + return NativeController( simulation, model_id=MODEL_ID, @@ -110,6 +115,7 @@ def build(context: ModelContext) -> NativeController: def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeController: del context + return NativeController.from_checkpoint( checkpoint, model_id=MODEL_ID, diff --git a/examples/microfluidic_flow.py b/examples/microfluidic_flow.py new file mode 100644 index 0000000..4078ff7 --- /dev/null +++ b/examples/microfluidic_flow.py @@ -0,0 +1,254 @@ +"""Cells growing, dividing, and shedding into flow through a pillar array. + +Lengths are micrometers and time is seconds. A shallow, depth-integrated flow +solve routes medium around five pillars. Dye is supplied in a thin inlet +reservoir strip; the native advection-diffusion solver carries it downstream. +The dye is passive; a separate nutrient supports growth and is consumed by +cells. Growth is deliberately accelerated for an interactive demonstration, +not calibrated to an organism. Set the model parameter cells=false for dye only. +""" + +import math + +from microsimulator import ( + BoxConstraintInit, + CellInit, + CellUpdate, + ChannelMetadata, + CheckpointBundle, + ConstraintRegion, + ControllerStep, + CoupledRatePlan, + CylinderConstraintInit, + DivisionEvent, + GridBoundaryKind, + GridShape, + MechanicsConfig, + ModelContext, + NativeController, + RatePlanBuilder, + SignalGridSpec, + SignalIntegrationKind, + Simulation, + StepPlan, + UniformLengthDivision, + Vec3, +) +from microsimulator.checkpoint import JSONValue +from microsimulator.flow import gap_mobility, solve_flow_field + +MODEL_ID = "examples.microfluidic-flow" +MODEL_VERSION = 2 +PILLARS = ((-20.0, -60.0), (20.0, -60.0), (0.0, 0.0), (-20.0, 60.0), (20.0, 60.0)) +PILLAR_RADIUS = 10.0 +SHAPE = (42, 120, 4) +ORIGIN = (-41.0, -119.0, -4.5) +SPACING = (2.0, 2.0, 3.0) +FLOW_SPEED = 40.0 +PULSE_PERIOD = 3.0 +NUTRIENT_INLET = 10.0 +NUTRIENT_YIELD = 0.5 +GROWTH_RATE = 0.4 +DIVISION = UniformLengthDivision(3.8, 4.8, jitter_z=False) +ANCHORS = tuple((x + offset, y + 14) for x, y in PILLARS for offset in (-6, -2, 2, 6)) + + +def _solid(x: float, y: float, z: float) -> bool: + return ( + abs(x) >= 40.0 + or abs(z) >= 3.0 + or any((x - px) ** 2 + (y - py) ** 2 < PILLAR_RADIUS**2 for px, py in PILLARS) + ) + + +def _grid(simulation: Simulation) -> SignalGridSpec: + shape = GridShape() + shape.x, shape.y, shape.z = SHAPE + grid = SignalGridSpec() + grid.signal_count = 2 + grid.shape = shape + grid.origin = Vec3(*ORIGIN) + grid.spacing = Vec3(*SPACING) + grid.diffusion = [0.5, 40.0] + grid.advection = [Vec3(), Vec3()] + grid.integration = SignalIntegrationKind.BACKWARD_EULER + + for name in ("y_lower", "y_upper"): + boundary = getattr(grid, name) + boundary.kind = GridBoundaryKind.FIXED + boundary.values = [0.0, NUTRIENT_INLET if name == "y_lower" else 0.0] + setattr(grid, name, boundary) + + grid.obstacles = [ + int( + _solid( + ORIGIN[0] + x * SPACING[0], ORIGIN[1] + y * SPACING[1], ORIGIN[2] + z * SPACING[2] + ) + ) + for x in range(shape.x) + for y in range(shape.y) + for z in range(shape.z) + ] + field, _ = solve_flow_field( + grid, + mean_inlet_speed=FLOW_SPEED, + mobility=gap_mobility(grid), + simulation=simulation, + ) + grid.velocity_field = field + + return grid + + +def _walls(simulation: Simulation) -> None: + channel = BoxConstraintInit() + channel.half_extents = Vec3(40, 120, 3) + channel.allowed_region = ConstraintRegion.INSIDE + simulation.add_box_constraint(channel) + + for x, y in PILLARS: + pillar = CylinderConstraintInit() + pillar.center = Vec3(x, y, 0) + pillar.radius = PILLAR_RADIUS + pillar.half_height = 4 + pillar.allowed_region = ConstraintRegion.OUTSIDE + simulation.add_cylinder_constraint(pillar) + + +def _inlet_indices(grid: SignalGridSpec) -> list[int]: + nx, ny, nz = SHAPE + obstacles = grid.obstacles + + return [ + (x * ny + y) * nz + z + for x in range(nx) + for y in range(3) + for z in range(nz) + if not obstacles[(x * ny + y) * nz + z] + ] + + +def _feed(step: ControllerStep) -> StepPlan: + # Prescribed inlet reservoir concentration alternates between dyed and + # clear medium. Only this inlet strip is reset; interior transport is solved. + concentration = float(step.time % PULSE_PERIOD < PULSE_PERIOD / 2) + levels = step.simulation.signal_levels + indices = step.state["inlet_indices"] + assert isinstance(indices, list) + + for index in indices: + assert isinstance(index, int) + levels[index] = concentration + + step.simulation.set_signal_levels(levels) + + divisions = DIVISION.requests(step) + washed = tuple(cell.id for cell in step.cells if abs(cell.position.y) > 108) + + if washed: + DIVISION.forget(step, washed) + divisions = tuple(request for request in divisions if request.parent_id not in washed) + + return StepPlan( + updates=tuple( + CellUpdate(cell.id, growth_rate=_growth(step.simulation, cell.position)) + for cell in step.cells + if cell.id not in washed + ), + divisions=divisions, + removals=washed, + ) + + +def _growth(simulation: Simulation, position: Vec3) -> float: + nutrient = max(0.0, simulation.sample_signals(position)[1]) + + return GROWTH_RATE * nutrient / (5.0 + nutrient) + + +def _rates() -> CoupledRatePlan: + rates = RatePlanBuilder() + uptake = -rates.cell_volume_change_rate() / NUTRIENT_YIELD + + return rates.coupled_plan(0, 2, (), (rates.constant(0.0), uptake)) + + +def _anchor_distance(position: Vec3) -> float: + return min(math.hypot(position.x - x, position.y - y) for x, y in ANCHORS) + + +def _divided(step: ControllerStep, event: DivisionEvent) -> None: + DIVISION.on_division(step, event) + + if event.parent.fixed: + released = ( + event.second + if _anchor_distance(event.first.position) <= _anchor_distance(event.second.position) + else event.first + ) + step.simulation.set_cell_fixed(released.id, False) + + +def _seed_cells(simulation: Simulation, context: ModelContext, state: dict[str, JSONValue]) -> None: + founders: list[CellInit] = [] + enabled = context.parameters.get("cells", True) + + if not isinstance(enabled, bool): + raise ValueError("cells must be a Boolean") + + sites = (*ANCHORS, *((x, y) for y in (-96, -24, 36) for x in (-32, -8, 8, 32))) + + for index, (x, y) in enumerate(sites if enabled else ()): + cell = CellInit() + cell.position = Vec3(x, y, 0) + cell.direction = Vec3(0, 1, 0) + cell.radius = 0.65 + cell.length = context.rng.uniform(3.2, 4.6) + cell.fixed = index < len(ANCHORS) + cell.cell_type = index // 4 if cell.fixed else 5 + cell.growth_rate = _growth(simulation, cell.position) + founders.append(cell) + + DIVISION.initialize_founders(simulation, state, context.rng, tuple(founders)) + + +def build(context: ModelContext) -> NativeController: + simulation = context.simulation() + grid = _grid(simulation) + inlet = _inlet_indices(grid) + levels = [0.0] * grid.site_count + [ + 0.0 if solid else NUTRIENT_INLET for solid in grid.obstacles + ] + + for index in inlet: + levels[index] = 1.0 + + simulation.configure_signal_grid(grid, levels) + simulation.set_coupled_rate_plan(_rates()) + _walls(simulation) + state: dict[str, JSONValue] = {"inlet_indices": [*inlet]} + _seed_cells(simulation, context, state) + + return NativeController( + simulation, + model_id=MODEL_ID, + model_version=MODEL_VERSION, + rng=context.rng, + regulate=_feed, + on_division=_divided, + mechanics=MechanicsConfig(flow_drift=True), + state=state, + channel_metadata=ChannelMetadata( + signals=("Flow tracer (relative concentration)", "Nutrient (relative concentration)") + ), + ) + + +def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeController: + return NativeController.from_checkpoint( + checkpoint, + model_id=MODEL_ID, + model_version=MODEL_VERSION, + regulate=_feed, + on_division=_divided, + ) diff --git a/examples/microfluidic_trap.py b/examples/microfluidic_trap.py index 2d812b7..d71ab45 100644 --- a/examples/microfluidic_trap.py +++ b/examples/microfluidic_trap.py @@ -81,6 +81,7 @@ def _grid(simulation: Simulation | None = None) -> SignalGridSpec: outlet_values=[0.0], simulation=simulation, ) + return grid @@ -91,27 +92,28 @@ def _grid(simulation: Simulation | None = None) -> SignalGridSpec: def _rate_plan() -> CoupledRatePlan: rates = RatePlanBuilder() uptake = -rates.cell_volume_change_rate() / NUTRIENT_YIELD + return rates.coupled_plan(0, 1, (), (uptake,)) def _primed_levels(grid: SignalGridSpec) -> list[float]: # The device is loaded flooded with fresh media before flow starts. - return [ - NUTRIENT_INLET if solid == 0 else 0.0 - for solid in grid.obstacles - ] + return [NUTRIENT_INLET if solid == 0 else 0.0 for solid in grid.obstacles] def _nutrient_growth(simulation: Simulation, position: Vec3) -> float: nutrient = max(0.0, simulation.sample_signals(position)[0]) + return BASE_GROWTH_RATE * nutrient / (NUTRIENT_K + nutrient) def _regulate(step: ControllerStep) -> StepPlan: if step.completed_steps and step.completed_steps % RESOLVE_INTERVAL == 0: mobility = colony_mobility( - GRID, (cell for cell in step.cells if cell.fixed), - base=GAP_MOBILITY, drag_coefficient=DRAG_COEFFICIENT + GRID, + (cell for cell in step.cells if cell.fixed), + base=GAP_MOBILITY, + drag_coefficient=DRAG_COEFFICIENT, ) field, _ = solve_flow_field( GRID, @@ -120,11 +122,14 @@ def _regulate(step: ControllerStep) -> StepPlan: simulation=step.simulation, ) step.simulation.set_velocity_field(field) + divisions = DIVISION.requests(step) washed = tuple(cell.id for cell in step.cells if abs(cell.position.y) > WASHOUT_Y) + if washed: DIVISION.forget(step, washed) divisions = tuple(request for request in divisions if request.parent_id not in washed) + return StepPlan( updates=tuple( CellUpdate(cell.id, growth_rate=_nutrient_growth(step.simulation, cell.position)) @@ -152,9 +157,9 @@ def build(context: ModelContext) -> NativeController: founder.length = 3.5 founder.radius = CELL_RADIUS founder.growth_rate = 1.0 - founder_id = simulation.add_cell(founder) state: dict[str, JSONValue] = {"scope": "microfluidic-trap"} - DIVISION.initialize(state, context.rng, (founder_id,)) + DIVISION.initialize_founders(simulation, state, context.rng, (founder,)) + return NativeController( simulation, model_id=MODEL_ID, @@ -169,6 +174,7 @@ def build(context: ModelContext) -> NativeController: def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeController: del context + return NativeController.from_checkpoint( checkpoint, model_id=MODEL_ID, diff --git a/examples/mother_machine.py b/examples/mother_machine.py new file mode 100644 index 0000000..28b8b41 --- /dev/null +++ b/examples/mother_machine.py @@ -0,0 +1,251 @@ +"""Mother-machine growth, mechanical retention, nutrient delivery, and washout. + +Lengths are micrometers and time is seconds. The growth-channel dimensions +follow Wang et al. (2010); the six-channel array and perfusion channel are a +compact model, not a reconstruction of the complete experimental chip. +All cells are free to move. Walls and cell contacts retain the closed-end +lineage; growth pushes descendants into the perfusion channel. + +The default maximum growth rate is ln(2)/1800 per second. For visible growth +during a short viewer session, explicitly use --parameter growth_rate=0.3. +This accelerates biology only, and is not an experimentally calibrated run. +""" + +import math + +from microsimulator import ( + CellInit, + CellUpdate, + ChannelMetadata, + CheckpointBundle, + ControllerStep, + CoupledRatePlan, + DivisionEvent, + GridShape, + MechanicsConfig, + ModelContext, + NativeController, + RatePlanBuilder, + SignalGridSpec, + SignalIntegrationKind, + Simulation, + StepPlan, + UniformLengthDivision, + Vec3, +) +from microsimulator.checkpoint import JSONValue +from microsimulator.microfluidics import MotherMachineDevice + +MODEL_ID = "examples.mother-machine" +MODEL_VERSION = 1 +DEVICE = MotherMachineDevice() +SHAPE = (40, 120, 7) +ORIGIN = (-12.5, -22.3125, -0.7) +SPACING = (1.0, 0.375, 1.4) +PULSE_PERIOD = 6.0 +NUTRIENT_INLET = 10.0 +NUTRIENT_YIELD = 0.5 +CELL_RADIUS = 0.5 +CELLS_PER_CHANNEL = 7 +DIVISION = UniformLengthDivision(3.0, 3.6) + + +def _grid(simulation: Simulation) -> SignalGridSpec: + shape = GridShape() + shape.x, shape.y, shape.z = SHAPE + grid = SignalGridSpec() + grid.signal_count = 2 + grid.shape = shape + grid.origin = Vec3(*ORIGIN) + grid.spacing = Vec3(*SPACING) + grid.diffusion = [10.0, 40.0] + grid.integration = SignalIntegrationKind.BACKWARD_EULER + # Float32 diffusion on the narrow cross section cannot reliably reach the + # generic 1e-7 absolute residual. This is 1e-6 of the supplied nutrient. + solver = grid.solver + solver.absolute_tolerance = 1.0e-5 + grid.solver = solver + DEVICE.apply_to_grid( + grid, + inlet_values=[0.0, NUTRIENT_INLET], + outlet_values=[0.0, NUTRIENT_INLET], + simulation=simulation, + ) + + return grid + + +def _inlet_indices(grid: SignalGridSpec) -> list[int]: + nx, ny, nz = SHAPE + obstacles = grid.obstacles + + return [ + (x * ny + y) * nz + z + for x in range(nx) + for y in range(3) + for z in range(nz) + if not obstacles[(x * ny + y) * nz + z] + ] + + +def _regulate(step: ControllerStep) -> StepPlan: + # Only the inlet reservoir is prescribed. Transport supplies the interior + # and the closed ends through the channel mouths. + levels = step.simulation.signal_levels + indices = step.state["inlet_indices"] + assert isinstance(indices, list) + concentration = float(step.time % PULSE_PERIOD < PULSE_PERIOD / 2) + + for index in indices: + assert isinstance(index, int) + levels[index] = concentration + + step.simulation.set_signal_levels(levels) + maximum = step.state["growth_rate"] + assert isinstance(maximum, float) + divisions = DIVISION.requests(step) + washed = tuple(cell.id for cell in step.cells if cell.position.x < 0 and cell.position.y > 19.0) + + if washed: + DIVISION.forget(step, washed) + divisions = tuple(request for request in divisions if request.parent_id not in washed) + count = step.state["washed_out"] + assert isinstance(count, int) + step.state["washed_out"] = count + len(washed) + mothers = step.state["mother_ids"] + assert isinstance(mothers, list) + step.state["mother_ids"] = [None if cell_id in washed else cell_id for cell_id in mothers] + + return StepPlan( + updates=tuple( + CellUpdate(cell.id, growth_rate=_growth(step.simulation, cell.position, maximum)) + for cell in step.cells + if cell.id not in washed + ), + divisions=divisions, + removals=washed, + ) + + +def _growth(simulation: Simulation, position: Vec3, maximum: float) -> float: + nutrient = max(0.0, simulation.sample_signals(position)[1]) + + return maximum * nutrient / (5.0 + nutrient) + + +def _rates() -> CoupledRatePlan: + rates = RatePlanBuilder() + uptake = -rates.cell_volume_change_rate() / NUTRIENT_YIELD + + return rates.coupled_plan(0, 2, (), (rates.constant(0.0), uptake)) + + +def _divided(step: ControllerStep, event: DivisionEvent) -> None: + DIVISION.on_division(step, event) + count = step.state["division_count"] + assert isinstance(count, int) + step.state["division_count"] = count + 1 + mothers = step.state["mother_ids"] + generations = step.state["mother_generations"] + assert isinstance(mothers, list) and isinstance(generations, list) + + if event.parent.id in mothers: + # Founder directions point toward the opening. The first daughter + # inherits the pole facing the closed end. This is lineage bookkeeping: + # it changes neither daughter's position, orientation, nor mobility. + index = mothers.index(event.parent.id) + mothers[index] = event.first.id + generation = generations[index] + assert isinstance(generation, int) + generations[index] = generation + 1 + step.simulation.set_cell_attributes(event.second.id, event.second.growth_rate, 1) + + +def _seed_cells(simulation: Simulation, context: ModelContext, state: dict[str, JSONValue]) -> None: + enabled = context.parameters.get("cells", True) + + if not isinstance(enabled, bool): + raise ValueError("cells must be a Boolean") + + founders: list[CellInit] = [] + maximum = state["growth_rate"] + assert isinstance(maximum, float) + + for center in DEVICE.growth_centers if enabled else (): + pole = DEVICE.growth_length - 0.05 + + for index in range(CELLS_PER_CHANNEL): + cell = CellInit() + cell.length = context.rng.uniform(2.0, 2.4) + cell.radius = CELL_RADIUS + cell.position = Vec3( + pole - cell.length / 2 - cell.radius, center, DEVICE.growth_height / 2 + ) + cell.direction = Vec3(-1, 0, 0) + cell.cell_type = 0 if index == 0 else 1 + cell.growth_rate = _growth(simulation, cell.position, maximum) + founders.append(cell) + pole -= cell.length + 2 * cell.radius + 0.03 + + ids = DIVISION.initialize_founders(simulation, state, context.rng, tuple(founders)) + state["mother_ids"] = [*ids[::CELLS_PER_CHANNEL]] + state["mother_generations"] = [0 for _ in ids[::CELLS_PER_CHANNEL]] + + +def build(context: ModelContext) -> NativeController: + growth = context.parameters.get("growth_rate", math.log(2) / 1800) + + if ( + isinstance(growth, bool) + or not isinstance(growth, int | float) + or not math.isfinite(growth) + or growth < 0 + ): + raise ValueError("growth_rate must be finite and nonnegative (per second)") + + simulation = context.simulation() + grid = _grid(simulation) + inlet = _inlet_indices(grid) + levels = [0.0] * grid.site_count + [ + 0.0 if solid else NUTRIENT_INLET for solid in grid.obstacles + ] + + for index in inlet: + levels[index] = 1.0 + + simulation.configure_signal_grid(grid, levels) + simulation.set_coupled_rate_plan(_rates()) + DEVICE.add_constraints(simulation) + state: dict[str, JSONValue] = { + "inlet_indices": [*inlet], + "growth_rate": float(growth), + "washed_out": 0, + "division_count": 0, + } + _seed_cells(simulation, context, state) + + return NativeController( + simulation, + model_id=MODEL_ID, + model_version=MODEL_VERSION, + rng=context.rng, + regulate=_regulate, + on_division=_divided, + mechanics=MechanicsConfig( + passes=20, gamma=100.0, residual_rms_tolerance=1.0e-4, flow_drift=True + ), + state=state, + channel_metadata=ChannelMetadata( + signals=("Flow tracer (relative concentration)", "Nutrient (relative concentration)") + ), + ) + + +def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeController: + return NativeController.from_checkpoint( + checkpoint, + model_id=MODEL_ID, + model_version=MODEL_VERSION, + regulate=_regulate, + on_division=_divided, + ) diff --git a/examples/named_channels.py b/examples/named_channels.py new file mode 100644 index 0000000..b0b25fe --- /dev/null +++ b/examples/named_channels.py @@ -0,0 +1,44 @@ +"""Two intracellular reporters and two extracellular signals with explicit labels.""" + +from microsimulator import ( + CellInit, + ChannelMetadata, + CheckpointBundle, + GridShape, + ModelContext, + NativeController, + SignalGridSpec, + Vec3, +) + + +def build(context: ModelContext) -> NativeController: + simulation = context.simulation(species_count=2) + grid = SignalGridSpec() + grid.signal_count = 2 + shape = GridShape() + shape.x, shape.y, shape.z = 4, 4, 1 + grid.shape = shape + grid.spacing = Vec3(1.0, 1.0, 1.0) + grid.diffusion = [0.1, 0.2] + grid.advection = [Vec3(), Vec3()] + simulation.configure_signal_grid(grid, [0.25] * 16 + [0.75] * 16) + cell = CellInit() + cell.length = 2.0 + cell.species = [0.25, 0.75] + simulation.add_cell(cell) + + return NativeController( + simulation, + model_id="named-channels", + model_version=1, + rng=context.rng, + channel_metadata=ChannelMetadata( + species=("Green reporter", "Red reporter"), + signals=("Nutrient", "Extracellular cue"), + ), + ) + + +def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeController: + return NativeController.from_checkpoint(checkpoint, model_id="named-channels", model_version=1) diff --git a/examples/native_controller.py b/examples/native_controller.py index 122498d..5f2fe5d 100644 --- a/examples/native_controller.py +++ b/examples/native_controller.py @@ -26,8 +26,10 @@ def _number(parameters: Mapping[str, JSONValue], name: str, default: float) -> float: value = parameters.get(name, default) + if not isinstance(value, int | float) or isinstance(value, bool) or not math.isfinite(value): raise ValueError(f"parameter {name!r} must be finite") + return float(value) @@ -37,6 +39,7 @@ def regulate(step: ControllerStep) -> StepPlan: divisions = tuple( DivisionRequest(cell.id) for cell in step.cells if cell.length >= division_length ) + return StepPlan(updates=updates, divisions=divisions) return regulate @@ -44,9 +47,12 @@ def regulate(step: ControllerStep) -> StepPlan: def _division(step: ControllerStep, event: DivisionEvent) -> None: count = step.state.get("division_count", 0) + if not isinstance(count, int) or isinstance(count, bool): raise ValueError("division_count controller state is invalid") + step.state["division_count"] = count + 1 + for daughter in (event.first, event.second): jitter = step.rng.uniform(-1.0e-3, 1.0e-3) direction = Vec3(daughter.direction.x, daughter.direction.y + jitter, daughter.direction.z) @@ -66,6 +72,7 @@ def build(context: ModelContext) -> NativeController: founder.length = _number(context.parameters, "initial_length", 3.0) founder.radius = 0.5 simulation.add_cell(founder) + return NativeController( simulation, model_id=MODEL_ID, @@ -80,6 +87,7 @@ def build(context: ModelContext) -> NativeController: def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeController: division_length = _number(context.parameters, "division_length", 4.0) + return NativeController.from_checkpoint( checkpoint, model_id=MODEL_ID, diff --git a/examples/physical_flow.py b/examples/physical_flow.py new file mode 100644 index 0000000..3222a04 --- /dev/null +++ b/examples/physical_flow.py @@ -0,0 +1,37 @@ +"""Solve pressure-driven medium flow without creating a biochemical signal grid.""" + +import argparse + +import microsimulator as ms + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--backend", choices=("cpu", "metal", "cuda"), default="cpu") + args = parser.parse_args() + grid = ms.FluidGridSpec() + grid.shape.x, grid.shape.y, grid.shape.z = 4, 16, 4 + grid.spacing = 1.0 + grid.length_unit_m = 1e-6 + grid.time_unit_s = 1.0 + feed, drain = ms.FlowPort(), ms.FlowPort() + feed.name, feed.value = "feed", 0.01 # Pa + drain.name, drain.upper = "drain", True + backend = { + "cpu": ms.BackendKind.CPU, + "metal": ms.BackendKind.METAL, + "cuda": ms.BackendKind.CUDA, + }[args.backend] + solver = ms.StokesFlowSolver(backend) + result = solver.solve(grid, ms.FluidProperties(), [feed, drain]) + + for port in result.ports: + print( + f"{port.name}: p={port.pressure_pa:.6g} Pa, Q={port.flow_rate_m3_s:.6g} m^3/s (outward)" + ) + + print(f"residual={result.report.relative_residual:.3g}, Re={result.report.reynolds_number:.3g}") + + +if __name__ == "__main__": + main() diff --git a/examples/replay_demo.py b/examples/replay_demo.py new file mode 100644 index 0000000..3272080 --- /dev/null +++ b/examples/replay_demo.py @@ -0,0 +1,46 @@ +"""Short deterministic growth/division/removal recording for the replay tutorial.""" + +from microsimulator import ( + CellInit, + ChannelMetadata, + CheckpointBundle, + ControllerStep, + DivisionRequest, + ModelContext, + NativeController, + StepPlan, +) + + +def regulate(step: ControllerStep) -> StepPlan: + if step.completed_steps == 1: + return StepPlan(divisions=(DivisionRequest(step.cells[0].id),)) + + if step.completed_steps == 2: + return StepPlan(removals=(step.cells[0].id,)) + + return StepPlan() + + +def build(context: ModelContext) -> NativeController: + simulation = context.simulation(species_count=1) + cell = CellInit() + cell.length = 4.0 + cell.growth_rate = 0.5 + cell.species = [0.25] + simulation.add_cell(cell) + + return NativeController( + simulation, + model_id="replay-demo", + model_version=1, + rng=context.rng, + regulate=regulate, + channel_metadata=ChannelMetadata(species=("Reporter",)), + ) + + +def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeController: + return NativeController.from_checkpoint( + checkpoint, model_id="replay-demo", model_version=1, regulate=regulate + ) diff --git a/examples/tutorials/biophysics.py b/examples/tutorials/biophysics.py index e5edb13..2382e6a 100644 --- a/examples/tutorials/biophysics.py +++ b/examples/tutorials/biophysics.py @@ -23,6 +23,7 @@ Simulation, StepPlan, Vec3, + capped_founder_length, ) from microsimulator.checkpoint import CheckpointBundle, JSONValue @@ -33,57 +34,76 @@ def _scenario(parameters: Mapping[str, JSONValue]) -> str: value = parameters.get("scenario", "basics") + if not isinstance(value, str) or value not in _SCENARIOS: raise ValueError(f"scenario must be one of {sorted(_SCENARIOS)}") + return value def _target_range(scenario: str, cell_type: int, *, founder: bool = False) -> tuple[float, float]: if scenario == "competition": lower = {0: 1.0, 1: 2.0, 2: 3.5}[cell_type] + return lower, lower + 0.5 + if scenario == "two_types": return 2.5, 3.0 + if scenario == "short_cells": return (2.5, 3.0) if founder else (1.0, 1.5) + if scenario == "box": return 3.0, 3.5 + return 3.5, 4.0 def _growth_rate(scenario: str, cell_type: int) -> float: if scenario == "competition": return {0: 2.0, 1: 1.1, 2: 0.8}[cell_type] + if scenario in {"two_types", "short_cells"}: return 2.0 + return 1.0 def _targets(step: ControllerStep) -> dict[str, JSONValue]: value = step.state.get("division_targets") + if not isinstance(value, dict): raise ValueError("division target state is invalid") + return value def _callbacks(scenario: str): def regulate(step: ControllerStep) -> StepPlan: targets = _targets(step) + if set(targets) != {str(cell.id) for cell in step.cells}: raise ValueError("division targets do not match active cells") + max_y = max((cell.position.y for cell in step.cells), default=0.0) updates: list[CellUpdate] = [] divisions: list[DivisionRequest] = [] + for cell in step.cells: growth_rate = _growth_rate(scenario, cell.cell_type) + if scenario == "competition" and max_y - cell.position.y >= 5.0: growth_rate = 0.0 + updates.append(CellUpdate(cell.id, growth_rate=growth_rate)) target = targets[str(cell.id)] + if not isinstance(target, int | float) or isinstance(target, bool): raise ValueError("division target state is invalid") + if cell.length > float(target): divisions.append(DivisionRequest(cell.id)) + return StepPlan(updates=tuple(updates), divisions=tuple(divisions)) def divided(step: ControllerStep, event: DivisionEvent) -> None: @@ -91,11 +111,14 @@ def divided(step: ControllerStep, event: DivisionEvent) -> None: del targets[str(event.parent.id)] lower, upper = _target_range(scenario, event.parent.cell_type) jitter_z = scenario in {"short_cells", "box"} + for daughter in (event.first, event.second): targets[str(daughter.id)] = step.rng.uniform(lower, upper) jitter = [step.rng.uniform(-1.0e-3, 1.0e-3) for _ in range(3)] + if not jitter_z: jitter[2] = 0.0 + step.simulation.set_cell_geometry( daughter.id, daughter.position, @@ -118,6 +141,7 @@ def _add_box(simulation: Simulation) -> None: ((0.0, 10.0, 0.0), (0.0, -1.0, 0.0)), ((0.0, -10.0, 0.0), (0.0, 1.0, 0.0)), ) + for point, normal in planes: plane = PlaneConstraintInit() plane.point = Vec3(*point) @@ -129,24 +153,29 @@ def _add_box(simulation: Simulation) -> None: def _founder_specs(scenario: str) -> tuple[tuple[int, Vec3], ...]: if scenario == "two_types": return ((0, Vec3(-10.0, 0.0, 0.0)), (1, Vec3(10.0, 0.0, 0.0))) + if scenario == "competition": return ( (0, Vec3(0.0, 0.0, 0.0)), (1, Vec3(6.0, 0.0, 0.0)), (2, Vec3(-6.0, 0.0, 0.0)), ) + if scenario == "box": return ((0, Vec3(0.0, 0.0, 0.5)),) + return ((0, Vec3()),) def build(context: ModelContext) -> NativeController: scenario = _scenario(context.parameters) simulation = context.simulation(reserved_capacity=10_000) + if scenario == "box": _add_box(simulation) targets: dict[str, JSONValue] = {} + for cell_type, position in _founder_specs(scenario): founder = CellInit() founder.position = position @@ -155,12 +184,15 @@ def build(context: ModelContext) -> NativeController: founder.radius = 0.5 founder.growth_rate = _growth_rate(scenario, cell_type) founder.cell_type = cell_type - founder_id = simulation.add_cell(founder) lower, upper = _target_range(scenario, cell_type, founder=True) - targets[str(founder_id)] = context.rng.uniform(lower, upper) + target = context.rng.uniform(lower, upper) + founder.length = capped_founder_length(founder.length, target) + founder_id = simulation.add_cell(founder) + targets[str(founder_id)] = target regulate, divided = _callbacks(scenario) mechanics = MechanicsConfig(gamma=20.0 if scenario == "box" else 10.0) + return NativeController( simulation, model_id=MODEL_ID, @@ -176,8 +208,10 @@ def build(context: ModelContext) -> NativeController: def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeController: scenario = _scenario(context.parameters) state = checkpoint.controller + if not isinstance(state, dict): raise ValueError("checkpoint controller state is invalid") + regulate, divided = _callbacks(scenario) controller = NativeController.from_checkpoint( checkpoint, @@ -186,6 +220,8 @@ def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeControl regulate=regulate, on_division=divided, ) + if cast(object, controller.state.get("scenario")) != scenario: raise ValueError("checkpoint scenario does not match model parameters") + return controller diff --git a/examples/tutorials/biopixel_trap.py b/examples/tutorials/biopixel_trap.py index 900c089..8c29e94 100644 --- a/examples/tutorials/biopixel_trap.py +++ b/examples/tutorials/biopixel_trap.py @@ -64,8 +64,10 @@ def _load_prindle_layout() -> tuple[MaskRectangle, ...]: unit_scale=_MASK_UNIT_SCALE, ) traps = match_rectangles(rectangles, *_MASK_OUTLINE, tolerance=1.0) + if len(traps) != 496: raise MaskError(f"expected 496 Prindle layout outlines, found {len(traps)}") + return traps @@ -114,6 +116,7 @@ def _grid(simulation: Simulation | None = None) -> SignalGridSpec: outlet_values=[0.0], simulation=simulation, ) + return grid @@ -124,6 +127,7 @@ def _grid(simulation: Simulation | None = None) -> SignalGridSpec: def _rate_plan() -> CoupledRatePlan: rates = RatePlanBuilder() uptake = -rates.cell_volume_change_rate() / NUTRIENT_YIELD + return rates.coupled_plan(0, 1, (), (uptake,)) @@ -134,14 +138,17 @@ def _primed_levels(grid: SignalGridSpec) -> list[float]: def _nutrient_growth(simulation: Simulation, position: Vec3) -> float: nutrient = max(0.0, simulation.sample_signals(position)[0]) + return BASE_GROWTH_RATE * nutrient / (NUTRIENT_K + nutrient) def _regulate(step: ControllerStep) -> StepPlan: if step.completed_steps and step.completed_steps % RESOLVE_INTERVAL == 0: mobility = colony_mobility( - GRID, (cell for cell in step.cells if cell.fixed), - base=GAP_MOBILITY, drag_coefficient=DRAG_COEFFICIENT + GRID, + (cell for cell in step.cells if cell.fixed), + base=GAP_MOBILITY, + drag_coefficient=DRAG_COEFFICIENT, ) field, _ = solve_flow_field( GRID, @@ -150,11 +157,14 @@ def _regulate(step: ControllerStep) -> StepPlan: simulation=step.simulation, ) step.simulation.set_velocity_field(field) + divisions = DIVISION.requests(step) washed = tuple(cell.id for cell in step.cells if abs(cell.position.y) > WASHOUT_Y) + if washed: DIVISION.forget(step, washed) divisions = tuple(request for request in divisions if request.parent_id not in washed) + return StepPlan( updates=tuple( CellUpdate(cell.id, growth_rate=_nutrient_growth(step.simulation, cell.position)) @@ -182,9 +192,9 @@ def build(context: ModelContext) -> NativeController: founder.length = 3.5 founder.radius = CELL_RADIUS founder.growth_rate = 1.0 - founder_id = simulation.add_cell(founder) state: dict[str, JSONValue] = {"scope": "biopixel-trap"} - DIVISION.initialize(state, context.rng, (founder_id,)) + DIVISION.initialize_founders(simulation, state, context.rng, (founder,)) + return NativeController( simulation, model_id=MODEL_ID, @@ -199,6 +209,7 @@ def build(context: ModelContext) -> NativeController: def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeController: del context + return NativeController.from_checkpoint( checkpoint, model_id=MODEL_ID, diff --git a/examples/tutorials/conjugation.py b/examples/tutorials/conjugation.py index 20c7371..8d6acaf 100644 --- a/examples/tutorials/conjugation.py +++ b/examples/tutorials/conjugation.py @@ -16,6 +16,7 @@ NativeController, StepPlan, Vec3, + capped_founder_length, ) from microsimulator.checkpoint import CheckpointBundle, JSONValue @@ -25,6 +26,7 @@ def _probability(parameters: Mapping[str, JSONValue]) -> float: value = parameters.get("transfer_probability", 0.1) + if ( not isinstance(value, int | float) or isinstance(value, bool) @@ -33,46 +35,59 @@ def _probability(parameters: Mapping[str, JSONValue]) -> float: or value > 1.0 ): raise ValueError("transfer_probability must be finite and in [0, 1]") + return float(value) def _targets(step: ControllerStep) -> dict[str, JSONValue]: value = step.state.get("division_targets") + if not isinstance(value, dict): raise ValueError("conjugation division state is invalid") + return value def _callbacks(transfer_probability: float): def regulate(step: ControllerStep) -> StepPlan: targets = _targets(step) + if set(targets) != {str(cell.id) for cell in step.cells}: raise ValueError("conjugation division state does not match active cells") + graph = step.simulation.find_cell_contacts() cell_types = {cell.id: cell.cell_type for cell in step.cells} updates: list[CellUpdate] = [] divisions: list[DivisionRequest] = [] + for cell in step.cells: next_type = cell.cell_type + if cell.cell_type == 0: infectious_neighbors = ( neighbor for neighbor in graph.neighbor_ids(cell.slot) if cell_types[neighbor] != 0 ) + if any(step.rng.random() < transfer_probability for _ in infectious_neighbors): next_type = 2 + updates.append(CellUpdate(cell.id, growth_rate=1.0, cell_type=next_type)) target = targets[str(cell.id)] + if not isinstance(target, int | float) or isinstance(target, bool): raise ValueError("conjugation division target is invalid") + if cell.length > float(target): divisions.append(DivisionRequest(cell.id)) + return StepPlan(updates=tuple(updates), divisions=tuple(divisions)) def divided(step: ControllerStep, event: DivisionEvent) -> None: targets = _targets(step) del targets[str(event.parent.id)] + for daughter in (event.first, event.second): sampled = daughter.length + step.rng.gauss(1.9, 0.45) targets[str(daughter.id)] = max(daughter.length + 0.1, sampled) @@ -84,6 +99,7 @@ def build(context: ModelContext) -> NativeController: transfer_probability = _probability(context.parameters) simulation = context.simulation(reserved_capacity=50_000) targets: dict[str, JSONValue] = {} + for cell_type, x in ((0, -5.0), (1, 5.0)): founder = CellInit() founder.position = Vec3(x, 0.0, 0.0) @@ -92,10 +108,13 @@ def build(context: ModelContext) -> NativeController: founder.radius = 0.4 founder.growth_rate = 1.0 founder.cell_type = cell_type + target = founder.length + context.rng.gauss(1.9, 0.45) + founder.length = capped_founder_length(founder.length, target) founder_id = simulation.add_cell(founder) - targets[str(founder_id)] = founder.length + context.rng.gauss(1.9, 0.45) + targets[str(founder_id)] = target regulate, divided = _callbacks(transfer_probability) + return NativeController( simulation, model_id=MODEL_ID, @@ -121,6 +140,8 @@ def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeControl regulate=regulate, on_division=divided, ) + if controller.state.get("transfer_probability") != transfer_probability: raise ValueError("checkpoint transfer probability does not match model parameters") + return controller diff --git a/examples/tutorials/danino_clock.py b/examples/tutorials/danino_clock.py index 31c88bc..326d1b2 100644 --- a/examples/tutorials/danino_clock.py +++ b/examples/tutorials/danino_clock.py @@ -120,6 +120,7 @@ def _grid(simulation: Simulation | None = None) -> SignalGridSpec: outlet_values=[0.0, 0.0], simulation=simulation, ) + return grid @@ -132,9 +133,11 @@ def _primed_levels(grid: SignalGridSpec) -> list[float]: # starts at zero everywhere. site_count = grid.shape.x * grid.shape.y * grid.shape.z levels = [0.0] * (2 * site_count) + for site, solid in enumerate(grid.obstacles): if solid == 0: levels[site_count + site] = NUTRIENT_INLET + return levels @@ -147,6 +150,7 @@ def _rate_plan() -> CoupledRatePlan: ahl_cubed = ahl**3.0 hill = ahl_cubed / (AHL_THRESHOLD**3.0 + ahl_cubed) activated = CLOCK_RATE * (0.02 + 8.0 * hill) + return rates.coupled_plan( 3, 2, @@ -183,21 +187,26 @@ def _ahl_removal_field(cells: Sequence[CellSnapshot]) -> SignalGridAffineReactio reaction = SignalGridAffineReaction() reaction.source_rates = _NO_SOURCES reaction.loss_rates = loss.tolist() + return reaction def _nutrient_growth(simulation: Simulation, position: Vec3) -> float: nutrient = max(0.0, simulation.sample_signals(position)[1]) + return BASE_GROWTH_RATE * nutrient / (NUTRIENT_K + nutrient) def _regulate(step: ControllerStep) -> StepPlan: if step.completed_steps % REMOVAL_INTERVAL == 0: step.simulation.set_signal_reaction(_ahl_removal_field(step.cells)) + if step.completed_steps and step.completed_steps % RESOLVE_INTERVAL == 0: mobility = colony_mobility( - GRID, (cell for cell in step.cells if cell.fixed), - base=GAP_MOBILITY, drag_coefficient=DRAG_COEFFICIENT + GRID, + (cell for cell in step.cells if cell.fixed), + base=GAP_MOBILITY, + drag_coefficient=DRAG_COEFFICIENT, ) field, _ = solve_flow_field( GRID, @@ -206,11 +215,14 @@ def _regulate(step: ControllerStep) -> StepPlan: simulation=step.simulation, ) step.simulation.set_velocity_field(field) + divisions = DIVISION.requests(step) washed = tuple(cell.id for cell in step.cells if abs(cell.position.y) > WASHOUT_Y) + if washed: DIVISION.forget(step, washed) divisions = tuple(request for request in divisions if request.parent_id not in washed) + return StepPlan( updates=tuple( CellUpdate(cell.id, growth_rate=_nutrient_growth(step.simulation, cell.position)) @@ -224,6 +236,7 @@ def _regulate(step: ControllerStep) -> StepPlan: def _divided(step: ControllerStep, event: DivisionEvent) -> None: DIVISION.on_division(step, event) + for daughter in (event.first, event.second): step.simulation.set_species( daughter.id, @@ -244,9 +257,9 @@ def build(context: ModelContext) -> NativeController: founder.radius = CELL_RADIUS founder.growth_rate = 1.0 founder.species = [context.rng.uniform(0.0, 0.2), context.rng.uniform(0.0, 0.2), 0.0] - founder_id = simulation.add_cell(founder) state: dict[str, JSONValue] = {"scope": "clock-nutrient-field-and-trap"} - DIVISION.initialize(state, context.rng, (founder_id,)) + DIVISION.initialize_founders(simulation, state, context.rng, (founder,)) + return NativeController( simulation, model_id=MODEL_ID, @@ -261,6 +274,7 @@ def build(context: ModelContext) -> NativeController: def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeController: del context + return NativeController.from_checkpoint( checkpoint, model_id=MODEL_ID, diff --git a/examples/tutorials/gene_expression.py b/examples/tutorials/gene_expression.py index 0dea834..85d3507 100644 --- a/examples/tutorials/gene_expression.py +++ b/examples/tutorials/gene_expression.py @@ -32,14 +32,17 @@ def _scenario(parameters: Mapping[str, JSONValue]) -> str: value = parameters.get("scenario", "constitutive") + if not isinstance(value, str) or value not in _SCENARIOS: raise ValueError(f"scenario must be one of {sorted(_SCENARIOS)}") + return value def _division(scenario: str) -> UniformLengthDivision: if scenario in {"constitutive", "oscillator"}: return UniformLengthDivision(3.0, 3.5, jitter_z=False) + return UniformLengthDivision(2.5, 3.0, jitter_z=False) @@ -50,23 +53,31 @@ def _growth_rate(scenario: str) -> float: def _initial_species(scenario: str) -> list[float]: if scenario == "dilution": return [10.0] + if scenario == "derepression": return [10.0, 0.0] + if scenario == "oscillator": return [0.0, 0.0] + return [0.0] def _rate_plan(scenario: str) -> SpeciesRatePlan: rates = RatePlanBuilder() + if scenario == "constitutive": return rates.species_plan(1, (rates.constant(2.0),)) + if scenario == "legacy_constitutive": return rates.species_plan(1, (rates.constant(1.0),)) + if scenario == "dilution": return rates.species_plan(1, (rates.constant(0.0),)) + if scenario == "derepression": x0 = rates.species(0) + return rates.species_plan( 2, (rates.constant(0.0), 4.0 / (4.0 + x0 * x0)), @@ -76,11 +87,11 @@ def _rate_plan(scenario: str) -> SpeciesRatePlan: inhibitor = rates.species(1) activator_squared = activator * activator activator_rate = ( - 2.0 * (1.0 + activator_squared) - / (1.0 + activator_squared + inhibitor * inhibitor) + 2.0 * (1.0 + activator_squared) / (1.0 + activator_squared + inhibitor * inhibitor) - activator ) inhibitor_rate = 2.0 * (1.0 + activator_squared) / (1.0 + activator_squared) - inhibitor + return rates.species_plan(2, (activator_rate, inhibitor_rate)) @@ -112,11 +123,11 @@ def build(context: ModelContext) -> NativeController: founder.radius = 0.5 founder.growth_rate = _growth_rate(scenario) founder.species = initial_species - founder_id = simulation.add_cell(founder) division, regulate = _callbacks(scenario) state: dict[str, JSONValue] = {"scenario": scenario} - division.initialize(state, context.rng, (founder_id,)) + division.initialize_founders(simulation, state, context.rng, (founder,)) + return NativeController( simulation, model_id=MODEL_ID, @@ -139,6 +150,8 @@ def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeControl regulate=regulate, on_division=division.on_division, ) + if controller.state.get("scenario") != scenario: raise ValueError("checkpoint scenario does not match model parameters") + return controller diff --git a/examples/tutorials/pillar_channel.py b/examples/tutorials/pillar_channel.py index b3e7340..dc72d0d 100644 --- a/examples/tutorials/pillar_channel.py +++ b/examples/tutorials/pillar_channel.py @@ -89,10 +89,13 @@ def _grid(simulation: Simulation | None = None) -> SignalGridSpec: grid.advection = [Vec3()] grid.integration = SignalIntegrationKind.BACKWARD_EULER obstacles = [0] * grid.site_count + for x in range(shape.x): px = grid.origin.x + grid.spacing.x * x + for y in range(shape.y): py = grid.origin.y + grid.spacing.y * y + for z in range(shape.z): pz = grid.origin.z + grid.spacing.z * z solid = ( @@ -100,14 +103,18 @@ def _grid(simulation: Simulation | None = None) -> SignalGridSpec: or abs(pz) >= CHANNEL_HALF_HEIGHT or _in_pillar_core(px, py) ) + if solid: obstacles[(x * shape.y + y) * shape.z + z] = 1 + grid.obstacles = obstacles + for name in ("y_lower", "y_upper"): boundary = getattr(grid, name) boundary.kind = GridBoundaryKind.FIXED boundary.values = [NUTRIENT_INLET if name == "y_lower" else 0.0] setattr(grid, name, boundary) + if simulation is not None: field, _ = solve_flow_field( grid, @@ -116,6 +123,7 @@ def _grid(simulation: Simulation | None = None) -> SignalGridSpec: simulation=simulation, ) grid.velocity_field = field + return grid @@ -126,12 +134,11 @@ def _grid(simulation: Simulation | None = None) -> SignalGridSpec: def _add_walls(simulation: Simulation) -> None: chamber = BoxConstraintInit() chamber.center = Vec3(0.0, 0.0, 0.0) - chamber.half_extents = Vec3( - CHANNEL_HALF_WIDTH, CHANNEL_HALF_LENGTH, CHANNEL_HALF_HEIGHT - ) + chamber.half_extents = Vec3(CHANNEL_HALF_WIDTH, CHANNEL_HALF_LENGTH, CHANNEL_HALF_HEIGHT) chamber.coefficient = 1.0 chamber.allowed_region = ConstraintRegion.INSIDE simulation.add_box_constraint(chamber) + for x, y in PILLARS: pillar = CylinderConstraintInit() pillar.center = Vec3(x, y, 0.0) @@ -145,6 +152,7 @@ def _add_walls(simulation: Simulation) -> None: def _rate_plan() -> CoupledRatePlan: rates = RatePlanBuilder() uptake = -rates.cell_volume_change_rate() / NUTRIENT_YIELD + return rates.coupled_plan(0, 1, (), (uptake,)) @@ -155,14 +163,17 @@ def _primed_levels(grid: SignalGridSpec) -> list[float]: def _nutrient_growth(simulation: Simulation, position: Vec3) -> float: nutrient = max(0.0, simulation.sample_signals(position)[0]) + return BASE_GROWTH_RATE * nutrient / (NUTRIENT_K + nutrient) def _regulate(step: ControllerStep) -> StepPlan: if step.completed_steps and step.completed_steps % RESOLVE_INTERVAL == 0: mobility = colony_mobility( - GRID, (cell for cell in step.cells if cell.fixed), - base=GAP_MOBILITY, drag_coefficient=DRAG_COEFFICIENT + GRID, + (cell for cell in step.cells if cell.fixed), + base=GAP_MOBILITY, + drag_coefficient=DRAG_COEFFICIENT, ) field, _ = solve_flow_field( GRID, @@ -171,11 +182,14 @@ def _regulate(step: ControllerStep) -> StepPlan: simulation=step.simulation, ) step.simulation.set_velocity_field(field) + divisions = DIVISION.requests(step) washed = tuple(cell.id for cell in step.cells if abs(cell.position.y) > WASHOUT_Y) + if washed: DIVISION.forget(step, washed) divisions = tuple(request for request in divisions if request.parent_id not in washed) + return StepPlan( updates=tuple( CellUpdate(cell.id, growth_rate=_nutrient_growth(step.simulation, cell.position)) @@ -193,6 +207,7 @@ def _site_distance(position: Vec3) -> float: def _divided(step: ControllerStep, event: DivisionEvent) -> None: DIVISION.on_division(step, event) + # Daughters inherit adhesion. The daughter nearer the adhesion site stays # attached and the other is released into the stream; anchoring by site, # not by daughter order, keeps the attached lineage at its wake instead of @@ -214,7 +229,8 @@ def build(context: ModelContext) -> NativeController: simulation.set_coupled_rate_plan(_rate_plan()) _add_walls(simulation) - founder_ids: list[int] = [] + founder_ids: list[CellInit] = [] + for x, y in FOUNDER_SITES: founder = CellInit() founder.position = Vec3(x, y, 0.0) @@ -223,9 +239,11 @@ def build(context: ModelContext) -> NativeController: founder.radius = CELL_RADIUS founder.growth_rate = 1.0 founder.fixed = True - founder_ids.append(simulation.add_cell(founder)) + founder_ids.append(founder) + state: dict[str, JSONValue] = {"scope": "pillar-channel"} - DIVISION.initialize(state, context.rng, tuple(founder_ids)) + DIVISION.initialize_founders(simulation, state, context.rng, tuple(founder_ids)) + return NativeController( simulation, model_id=MODEL_ID, @@ -240,6 +258,7 @@ def build(context: ModelContext) -> NativeController: def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeController: del context + return NativeController.from_checkpoint( checkpoint, model_id=MODEL_ID, diff --git a/examples/tutorials/plasmid_segregation.py b/examples/tutorials/plasmid_segregation.py index df37491..5d4d12a 100644 --- a/examples/tutorials/plasmid_segregation.py +++ b/examples/tutorials/plasmid_segregation.py @@ -11,6 +11,7 @@ CellInit, ModelContext, Simulation, + capped_founder_length, capture_random_state, restore_random_state, ) @@ -22,24 +23,31 @@ def _copies(parameters: Mapping[str, JSONValue]) -> int: value = parameters.get("copies_per_cell", 10) + if not isinstance(value, int) or isinstance(value, bool) or value < 2 or value > 1_000: raise ValueError("copies_per_cell must be an integer in [2, 1000]") + return value def _records(state: dict[str, JSONValue], name: str) -> dict[str, JSONValue]: value = state.get(name) + if not isinstance(value, dict): raise ValueError(f"plasmid controller {name} state is invalid") + return value def _count(record: JSONValue, name: str) -> int: if not isinstance(record, dict) or set(record) != {"a", "b"}: raise ValueError("plasmid count state is invalid") + value = record[name] + if not isinstance(value, int) or isinstance(value, bool) or value < 0: raise ValueError("plasmid count state is invalid") + return value @@ -66,14 +74,19 @@ def _validate(self) -> None: cells = {str(cell.id) for cell in self.simulation.cells()} plasmids = _records(self._state, "plasmids") targets = _records(self._state, "division_targets") + if set(plasmids) != cells or set(targets) != cells: raise ValueError("plasmid controller state does not match active cells") + for cell_id in cells: a_count = _count(plasmids[cell_id], "a") b_count = _count(plasmids[cell_id], "b") + if a_count + b_count != self._copies_per_cell: raise ValueError("plasmid copy-number total is invalid") + target = targets[cell_id] + if ( not isinstance(target, int | float) or isinstance(target, bool) @@ -88,6 +101,7 @@ def _partition(self, a_count: int, b_count: int) -> tuple[tuple[int, int], tuple split = self._copies_per_cell first = plasmids[:split] second = plasmids[split:] + return ( (first.count(0), first.count(1)), (second.count(0), second.count(1)), @@ -96,6 +110,7 @@ def _partition(self, a_count: int, b_count: int) -> tuple[tuple[int, int], tuple def _present_state(self) -> None: plasmids = _records(self._state, "plasmids") total = float(self._copies_per_cell) + for cell in self.simulation.cells(): record = plasmids[str(cell.id)] a_count = _count(record, "a") @@ -107,6 +122,7 @@ def _present_state(self) -> None: def step(self, dt: float) -> None: if not math.isfinite(dt) or dt < 0.0: raise ValueError("time step must be finite and non-negative") + self._validate() plasmids = _records(self._state, "plasmids") targets = _records(self._state, "division_targets") @@ -115,6 +131,7 @@ def step(self, dt: float) -> None: for cell in self.simulation.cells() if cell.length > float(cast(int | float, targets[str(cell.id)])) ] + for parent in dividing: parent_key = str(parent.id) record = plasmids[parent_key] @@ -124,26 +141,32 @@ def step(self, dt: float) -> None: first_id, second_id = self.simulation.divide_equal(parent.id) del plasmids[parent_key] del targets[parent_key] + for daughter_id, (daughter_a, daughter_b) in ( (first_id, first_counts), (second_id, second_counts), ): plasmids[str(daughter_id)] = {"a": daughter_a, "b": daughter_b} targets[str(daughter_id)] = self._rng.uniform(3.5, 4.0) + if first_counts[0] + second_counts[0] != 2 * a_count: raise AssertionError("plasmid A copies were not conserved") + if first_counts[1] + second_counts[1] != 2 * b_count: raise AssertionError("plasmid B copies were not conserved") self.simulation.step(dt) + if self.simulation.cell_count: self.simulation.relax_cell_mechanics() + self._present_state() self._completed_steps += 1 self._validate() def controller_state(self) -> dict[str, JSONValue]: self._validate() + return { "kind": MODEL_ID, "version": MODEL_VERSION, @@ -164,11 +187,14 @@ def build(context: ModelContext) -> PlasmidController: first_count = copies_per_cell // 2 second_count = copies_per_cell - first_count founder.species = [first_count / copies_per_cell, second_count / copies_per_cell] + target = context.rng.uniform(3.5, 4.0) + founder.length = capped_founder_length(founder.length, target) founder_id = simulation.add_cell(founder) state: dict[str, JSONValue] = { "plasmids": {str(founder_id): {"a": first_count, "b": second_count}}, - "division_targets": {str(founder_id): context.rng.uniform(3.5, 4.0)}, + "division_targets": {str(founder_id): target}, } + return PlasmidController( simulation, context.rng, @@ -180,6 +206,7 @@ def build(context: ModelContext) -> PlasmidController: def resume(context: ModelContext, checkpoint: CheckpointBundle) -> PlasmidController: copies_per_cell = _copies(context.parameters) value = checkpoint.controller + if not isinstance(value, dict) or set(value) != { "kind", "version", @@ -189,22 +216,27 @@ def resume(context: ModelContext, checkpoint: CheckpointBundle) -> PlasmidContro "state", }: raise ValueError("plasmid checkpoint controller state is invalid") + if ( value["kind"] != MODEL_ID or value["version"] != MODEL_VERSION or value["copies_per_cell"] != copies_per_cell ): raise ValueError("plasmid checkpoint model identity does not match") + completed_steps = value["completed_steps"] state = value["state"] + if ( not isinstance(completed_steps, int) or isinstance(completed_steps, bool) or completed_steps < 0 ): raise ValueError("plasmid checkpoint step count is invalid") + if not isinstance(state, dict): raise ValueError("plasmid checkpoint model state is invalid") + return PlasmidController( checkpoint.simulation, restore_random_state(value["random"]), diff --git a/examples/tutorials/signaling.py b/examples/tutorials/signaling.py index 1a6cbb9..c3de58b 100644 --- a/examples/tutorials/signaling.py +++ b/examples/tutorials/signaling.py @@ -35,14 +35,17 @@ def _scenario(parameters: Mapping[str, JSONValue]) -> str: value = parameters.get("scenario", "single_gene") + if not isinstance(value, str) or value not in _SCENARIOS: raise ValueError(f"scenario must be one of {sorted(_SCENARIOS)}") + return value def _grid(scenario: str) -> SignalGridSpec: shape = GridShape() grid = SignalGridSpec() + if scenario == "mutualism": shape.x, shape.y, shape.z = 80, 80, 8 grid.signal_count = 2 @@ -55,10 +58,12 @@ def _grid(scenario: str) -> SignalGridSpec: grid.origin = Vec3(-128.0, -14.0, -8.0) grid.diffusion = [10.0] grid.advection = [Vec3()] + grid.shape = shape grid.spacing = Vec3(4.0, 4.0, 4.0) grid.integration = SignalIntegrationKind.CRANK_NICOLSON grid.solver.absolute_tolerance = 1.0e-12 + return grid @@ -66,16 +71,19 @@ def _rate_plan(scenario: str) -> CoupledRatePlan: rates = RatePlanBuilder() voxel_volume = 64.0 area = rates.cell_surface_area() + if scenario == "single_gene": intracellular = rates.species(0) extracellular = rates.signal(0) exchange_amount = 0.1 * (extracellular - intracellular) * area + return rates.coupled_plan( 1, 1, (1.0 + exchange_amount / voxel_volume,), (-exchange_amount,), ) + if scenario == "communication": x0 = rates.species(0) extracellular = rates.signal(0) @@ -83,6 +91,7 @@ def _rate_plan(scenario: str) -> CoupledRatePlan: exchange_concentration = exchange_amount / voxel_volume type_zero = rates.equal(rates.cell_type(), 0) x0_squared = x0 * x0 + return rates.coupled_plan( 3, 1, @@ -101,6 +110,7 @@ def _rate_plan(scenario: str) -> CoupledRatePlan: alpha_exchange = (alpha - alpha_in) * area beta_exchange = (beta - beta_in) * area type_zero = rates.equal(rates.cell_type(), 0) + return rates.coupled_plan( 2, 2, @@ -133,13 +143,16 @@ def _callbacks(scenario: str): def regulate(step: ControllerStep) -> StepPlan: updates: list[CellUpdate] = [] + for cell in step.cells: if scenario == "mutualism": partner = cell.species[1] if cell.cell_type == 0 else cell.species[0] growth_rate = 0.1 + 0.9 * partner / (0.1 + partner) else: growth_rate = 2.0 + updates.append(CellUpdate(cell.id, growth_rate=growth_rate)) + return StepPlan(updates=tuple(updates), divisions=division.requests(step)) return division, regulate @@ -160,6 +173,7 @@ def build(context: ModelContext) -> NativeController: simulation = context.simulation(reserved_capacity=10_000, species_count=species_count) simulation.configure_signal_grid(_grid(scenario)) simulation.set_coupled_rate_plan(_rate_plan(scenario)) + if scenario != "mutualism": _add_channel(simulation) @@ -170,7 +184,8 @@ def build(context: ModelContext) -> NativeController: if scenario == "communication" else ((0, 0.0),) ) - founders: list[int] = [] + founders: list[CellInit] = [] + for cell_type, x in founder_specs: founder = CellInit() founder.position = Vec3(x, 0.0, 0.0) @@ -179,11 +194,12 @@ def build(context: ModelContext) -> NativeController: founder.growth_rate = 1.0 if scenario == "mutualism" else 2.0 founder.cell_type = cell_type founder.species = [0.0] * species_count - founders.append(simulation.add_cell(founder)) + founders.append(founder) division, regulate = _callbacks(scenario) state: dict[str, JSONValue] = {"scenario": scenario} - division.initialize(state, context.rng, tuple(founders)) + division.initialize_founders(simulation, state, context.rng, tuple(founders)) + return NativeController( simulation, model_id=MODEL_ID, @@ -206,6 +222,8 @@ def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeControl regulate=regulate, on_division=division.on_division, ) + if controller.state.get("scenario") != scenario: raise ValueError("checkpoint scenario does not match model parameters") + return controller diff --git a/examples/tutorials/simbol_circuits.py b/examples/tutorials/simbol_circuits.py index 63bec1b..69d9be9 100644 --- a/examples/tutorials/simbol_circuits.py +++ b/examples/tutorials/simbol_circuits.py @@ -33,20 +33,21 @@ MODEL_ID = "tutorials.simbol-circuits" MODEL_VERSION = 1 DIVISION = UniformLengthDivision(3.5, 3.505, jitter_z=False) -_CIRCUITS = frozenset( - {"bba_0001", "bba_0002", "bba_0003", "bba_0004", "bba_0005", "bba_i5200"} -) +_CIRCUITS = frozenset({"bba_0001", "bba_0002", "bba_0003", "bba_0004", "bba_0005", "bba_i5200"}) def _circuit(parameters: Mapping[str, JSONValue]) -> str: value = parameters.get("circuit", "bba_0001") + if not isinstance(value, str) or value not in _CIRCUITS: raise ValueError(f"circuit must be one of {sorted(_CIRCUITS)}") + return value def _number(parameters: Mapping[str, JSONValue], name: str, default: float) -> float: value = parameters.get(name, default) + if ( not isinstance(value, int | float) or isinstance(value, bool) @@ -54,6 +55,7 @@ def _number(parameters: Mapping[str, JSONValue], name: str, default: float) -> f or value < 0.0 ): raise ValueError(f"{name} must be a finite non-negative number") + return float(value) @@ -73,8 +75,10 @@ def _species_model( parameters: Mapping[str, JSONValue], ) -> tuple[list[float], SpeciesRatePlan]: rates = RatePlanBuilder() + if circuit == "bba_0001": gfp = rates.species(0) + return [1.0], rates.species_plan(1, (1.0 - 0.05 * gfp,)) if circuit == "bba_0002": @@ -84,6 +88,7 @@ def _species_model( rates, _number(parameters, "inducer_concentration", 0.0), ) + return [2.0, 1.0], rates.species_plan( 2, (_repression(rates, active_tetr) - 0.05 * rfp, 1.0 - 0.05 * tetr), @@ -96,6 +101,7 @@ def _species_model( rates, _number(parameters, "inducer_concentration", 1.0), ) + return [0.0, 0.0], rates.species_plan( 2, (2.0 - 0.1 * laci, 2.0 * _repression(rates, active_laci) - 0.1 * gfp), @@ -111,6 +117,7 @@ def _species_model( _number(parameters, "inducer_concentration", 1.0), ) k909012 = _repression(rates, ci) * _repression(rates, laci) + return [1.0, 0.0, 0.0, 0.0], rates.species_plan( 4, ( @@ -126,6 +133,7 @@ def _species_model( gfp = rates.species(1) laci = rates.species(2) tetr = rates.species(3) + return [1.0, 0.0, 0.0, 0.0], rates.species_plan( 4, ( @@ -135,6 +143,7 @@ def _species_model( 2.0 * _repression(rates, laci) - 0.1 * tetr, ), ) + raise AssertionError("signaling circuit must use the coupled model") @@ -150,6 +159,7 @@ def _signal_grid() -> SignalGridSpec: grid.advection = [Vec3()] grid.integration = SignalIntegrationKind.CRANK_NICOLSON grid.solver.absolute_tolerance = 1.0e-12 + return grid @@ -178,6 +188,7 @@ def _signaling_model( ), (exchange_amount,), ) + return [0.0, 0.0, 0.0, 0.0], plan @@ -190,6 +201,7 @@ def _regulate(step: ControllerStep) -> StepPlan: def build(context: ModelContext) -> NativeController: circuit = _circuit(context.parameters) + if circuit == "bba_0003": initial_species, coupled_plan = _signaling_model(context.parameters) simulation = context.simulation(reserved_capacity=10_000, species_count=4) @@ -208,9 +220,9 @@ def build(context: ModelContext) -> NativeController: founder.radius = 0.5 founder.growth_rate = 1.0 founder.species = initial_species - founder_id = simulation.add_cell(founder) state: dict[str, JSONValue] = {"circuit": circuit} - DIVISION.initialize(state, context.rng, (founder_id,)) + DIVISION.initialize_founders(simulation, state, context.rng, (founder,)) + return NativeController( simulation, model_id=MODEL_ID, @@ -232,6 +244,8 @@ def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeControl regulate=_regulate, on_division=DIVISION.on_division, ) + if controller.state.get("circuit") != circuit: raise ValueError("checkpoint circuit does not match model parameters") + return controller diff --git a/examples/viewer_scene.py b/examples/viewer_scene.py index 14f6d9b..9ae9077 100644 --- a/examples/viewer_scene.py +++ b/examples/viewer_scene.py @@ -35,12 +35,15 @@ def build_scene() -> Simulation: grid.advection = [Vec3(), Vec3()] first_signal: list[float] = [] second_signal: list[float] = [] + for x_index in range(shape.x): x = grid.origin.x + x_index * grid.spacing.x + for y_index in range(shape.y): y = grid.origin.y + y_index * grid.spacing.y first_signal.append(math.exp(-((x + 3.0) ** 2 + (y - 1.5) ** 2) / 28.0)) second_signal.append(math.exp(-((x - 4.0) ** 2 + (y + 2.0) ** 2) / 20.0)) + simulation.configure_signal_grid(grid, first_signal + second_signal) dish = CylinderConstraintInit() @@ -61,6 +64,7 @@ def build_scene() -> Simulation: for column in range(-5, 6): if (column / 5.5) ** 2 + (row / 4.5) ** 2 > 1.0: continue + x = column * 1.62 + (0.81 if row % 2 else 0.0) y = row * 1.43 angle = 0.16 * x - 0.11 * y @@ -77,6 +81,7 @@ def build_scene() -> Simulation: 0.5 + 0.5 * math.cos(y * 0.41), ] simulation.add_cell(cell) + return simulation diff --git a/pyproject.toml b/pyproject.toml index 61757ca..6c42841 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,5 @@ [build-system] -requires = [ - "scikit-build-core>=0.10,<0.12", - "nanobind>=2.7,<3", -] +requires = ["scikit-build-core>=0.10,<0.12", "nanobind>=2.7,<3"] build-backend = "scikit_build_core.build" [project] @@ -20,27 +17,16 @@ classifiers = [ "Programming Language :: C++", "Programming Language :: Python :: 3.12", ] -dependencies = [ - "numpy>=2.1,<3", - "rfc8785>=0.1.4,<0.2", -] +dependencies = ["numpy>=2.1,<3", "rfc8785>=0.1.4,<0.2"] [project.urls] Repository = "https://github.com/DRAGGON-Lab/MicroSimulator" Issues = "https://github.com/DRAGGON-Lab/MicroSimulator/issues" [project.optional-dependencies] -analysis = [ - "polars>=1.43,<2", - "pyarrow>=25,<26", - "zarr>=3.2.1,<4", -] -sbml = [ - "python-libsbml>=5.21.1,<6", -] -viewer = [ - "aiohttp>=3.14.3,<4", -] +analysis = ["polars>=1.43,<2", "pyarrow>=25,<26", "zarr>=3.2.1,<4"] +sbml = ["python-libsbml>=5.21.1,<6"] +viewer = ["aiohttp>=3.14.3,<4"] [project.scripts] microsimulator = "microsimulator.cli:main" @@ -53,9 +39,23 @@ dev = [ "pytest>=8.3,<9", "pyright>=1.1.390,<2", "python-libsbml>=5.21.1,<6", - "ruff>=0.9,<1", + "ruff==0.16.3", "zarr>=3.2.1,<4", ] +quality = [ + "clang-format==19.1.7", + "cmakelang==0.6.13", + "lizard==1.24.0", + "pre-commit==4.1.0", + "ruff==0.16.3", + "shfmt-py==4.2.0", + "taplo==0.9.3", + "tree-sitter==0.25.2", + "tree-sitter-cpp==0.23.4", + "tree-sitter-javascript==0.25.0", + "tree-sitter-python==0.25.0", + "tree-sitter-typescript==0.23.2", +] [tool.scikit-build] cmake.build-type = "Release" @@ -67,6 +67,7 @@ addopts = "-ra --strict-config --strict-markers" testpaths = ["python/tests"] [tool.ruff] +required-version = "==0.16.3" target-version = "py312" line-length = 100 @@ -74,6 +75,8 @@ line-length = 100 select = ["B", "E", "F", "I", "N", "RUF", "SIM", "UP"] [tool.pyright] +venvPath = "." +venv = ".venv" pythonVersion = "3.12" typeCheckingMode = "strict" include = ["python/src", "python/tests"] diff --git a/python/src/microsimulator/__init__.py b/python/src/microsimulator/__init__.py index 156f409..11d9142 100644 --- a/python/src/microsimulator/__init__.py +++ b/python/src/microsimulator/__init__.py @@ -5,15 +5,26 @@ BackendInfo, BackendKind, BoxConstraintInit, + CapsuleBody, CellContact, CellCorrection, + CellGrowthModel, CellInit, CellSnapshot, + CellSurfaceExchange, + ChemicalBoundary, + ChemicalBoundaryKind, + ChemicalTransfer, ConstraintContactParameters, ConstraintRegion, ContactGraph, ContactParameters, CoupledRatePlan, + CultureCellState, + CultureCheckpoint, + CultureConfiguration, + CultureEvent, + CultureReport, CylinderConstraintInit, DepthAveragedFlowParameters, DepthAveragedFlowReport, @@ -22,15 +33,41 @@ ExternalContact, ExternalContactGraph, FlowAxis, + FlowPort, + FlowPortKind, + FlowPortResult, + FluidBodyResult, + FluidBodyStep, + FluidBodyStepParameters, + FluidContactResult, + FluidFace, + FluidFlowResult, + FluidFragment, + FluidGeometry, + FluidGeometryParameters, + FluidGeometryReport, + FluidGridSpec, + FluidOverlap, + FluidProperties, + FluidSolveReport, + GeometricFluxReport, GridBoundary, GridBoundaryKind, GridShape, + GrowthEvaluation, + GrowthExecutor, + GrowthInput, + GrowthKind, + GrowthRequirement, + LinearSolveParameters, + MacVelocityField, MechanicsIntegrationParameters, MechanicsParameters, MechanicsSolveResult, PlaneConstraintInit, RateInstruction, RateOp, + ReserveRequirement, ResolvedFlowParameters, ResolvedFlowReport, ResolvedFlowResult, @@ -43,16 +80,25 @@ SignalSolveParameters, SignalSolveReport, Simulation, + Solute, + SoluteTransportReport, + SoluteTransportResult, + SoluteTransportSolver, SolverBreakdown, SolverReport, SolverStatus, SpeciesRatePlan, SphereConstraintInit, SphereRegion, + StokesFlowSolver, + SurfaceEnvironment, + SurfaceTransferLaw, + TransportGeometry, Vec3, backend_available, backend_device_count, ) +from .channels import ChannelMetadata, ChannelMetadataError from .checkpoint import ( CHECKPOINT_FORMAT, CHECKPOINT_VERSION, @@ -92,7 +138,7 @@ capture_random_state, restore_random_state, ) -from .division import UniformLengthDivision +from .division import UniformLengthDivision, capped_founder_length from .legacy import LegacyCell, LegacyCompatibilityError, LegacyModelAdapter from .legacy_loader import build_legacy_model, resume_legacy_model from .legacy_pickle import LegacyPickleError, LegacyPickleImport, import_legacy_pickle @@ -120,14 +166,19 @@ from .sbml import SBMLImportError, SBMLRateModel, load_sbml, parse_sbml from .scene import ( MAX_SCENE_BYTES, + MAX_SCENE_CHANNELS, SCENE_FORMAT, SCENE_VERSION, SceneBackend, SceneBoxConstraint, SceneCell, + SceneChemicalTransfer, SceneConstraints, + SceneCulture, + SceneCultureCell, SceneCylinderConstraint, SceneError, + SceneFluidFragment, SceneFrame, SceneGridBoundary, ScenePlaneConstraint, @@ -148,6 +199,7 @@ "MAX_LEGACY_EXAMPLE_MATRIX_BYTES", "MAX_RUN_MANIFEST_BYTES", "MAX_SCENE_BYTES", + "MAX_SCENE_CHANNELS", "RUN_MANIFEST_FORMAT", "RUN_MANIFEST_VERSION", "SCENE_FORMAT", @@ -158,14 +210,22 @@ "BackendTarget", "BatchError", "BoxConstraintInit", + "CapsuleBody", "CellContact", "CellCorrection", + "CellGrowthModel", "CellInit", "CellSnapshot", + "CellSurfaceExchange", "CellUpdate", + "ChannelMetadata", + "ChannelMetadataError", "CheckpointBundle", "CheckpointError", "CheckpointSourceBackend", + "ChemicalBoundary", + "ChemicalBoundaryKind", + "ChemicalTransfer", "ConstraintContactParameters", "ConstraintRegion", "ContactGraph", @@ -174,6 +234,11 @@ "ControllerStateError", "ControllerStep", "CoupledRatePlan", + "CultureCellState", + "CultureCheckpoint", + "CultureConfiguration", + "CultureEvent", + "CultureReport", "CylinderConstraintInit", "DepthAveragedFlowParameters", "DepthAveragedFlowReport", @@ -185,9 +250,32 @@ "ExternalContact", "ExternalContactGraph", "FlowAxis", + "FlowPort", + "FlowPortKind", + "FlowPortResult", + "FluidBodyResult", + "FluidBodyStep", + "FluidBodyStepParameters", + "FluidContactResult", + "FluidFace", + "FluidFlowResult", + "FluidFragment", + "FluidGeometry", + "FluidGeometryParameters", + "FluidGeometryReport", + "FluidGridSpec", + "FluidOverlap", + "FluidProperties", + "FluidSolveReport", + "GeometricFluxReport", "GridBoundary", "GridBoundaryKind", "GridShape", + "GrowthEvaluation", + "GrowthExecutor", + "GrowthInput", + "GrowthKind", + "GrowthRequirement", "LegacyCell", "LegacyCompatibilityError", "LegacyExample", @@ -197,6 +285,8 @@ "LegacyModelAdapter", "LegacyPickleError", "LegacyPickleImport", + "LinearSolveParameters", + "MacVelocityField", "MechanicsConfig", "MechanicsIntegrationParameters", "MechanicsParameters", @@ -210,6 +300,7 @@ "RatePlanBuilder", "RatePlanError", "RegulationCallback", + "ReserveRequirement", "ResolvedFlowParameters", "ResolvedFlowReport", "ResolvedFlowResult", @@ -227,9 +318,13 @@ "SceneBackend", "SceneBoxConstraint", "SceneCell", + "SceneChemicalTransfer", "SceneConstraints", + "SceneCulture", + "SceneCultureCell", "SceneCylinderConstraint", "SceneError", + "SceneFluidFragment", "SceneFrame", "SceneGridBoundary", "ScenePlaneConstraint", @@ -243,6 +338,10 @@ "SignalSolveReport", "Simulation", "SimulationController", + "Solute", + "SoluteTransportReport", + "SoluteTransportResult", + "SoluteTransportSolver", "SolverBreakdown", "SolverReport", "SolverStatus", @@ -250,12 +349,17 @@ "SphereConstraintInit", "SphereRegion", "StepPlan", + "StokesFlowSolver", + "SurfaceEnvironment", + "SurfaceTransferLaw", + "TransportGeometry", "UniformLengthDivision", "Vec3", "backend_available", "backend_device_count", "build_legacy_model", "build_model", + "capped_founder_length", "capture_random_state", "capture_scene", "dumps_scene", diff --git a/python/src/microsimulator/_artifact_paths.py b/python/src/microsimulator/_artifact_paths.py index 734430a..8ca5e7f 100644 --- a/python/src/microsimulator/_artifact_paths.py +++ b/python/src/microsimulator/_artifact_paths.py @@ -18,6 +18,7 @@ def periodic_checkpoint_parts(output: Path) -> tuple[Path, str, str]: else _CHECKPOINT_SUFFIX ) stem = name[: -len(suffix)] if name.endswith(suffix) else name + return output.parent, stem, suffix @@ -25,4 +26,5 @@ def periodic_checkpoint_path(output: Path, step: int) -> Path: """Derive one periodic checkpoint path while preserving legacy suffixes.""" parent, stem, suffix = periodic_checkpoint_parts(output) + return parent / f"{stem}.step-{step:08d}{suffix}" diff --git a/python/src/microsimulator/_core.pyi b/python/src/microsimulator/_core.pyi index b2ca381..06bc047 100644 --- a/python/src/microsimulator/_core.pyi +++ b/python/src/microsimulator/_core.pyi @@ -1,6 +1,90 @@ +from collections.abc import Mapping from enum import Enum from typing import overload +from .culture import CultureState +from .growth import CellGrowth +from .stokes import StokesFlow +from .transport import SoluteTransport + +class OccupancyCapsule: + def __init__( + self, + center: tuple[float, float, float], + direction: tuple[float, float, float], + length: float, + radius: float, + ) -> None: ... + +class OccupancyFace: + first: int + second: int + conductance: float + volume_flux: float + def __init__(self, first: int, second: int, conductance: float, volume_flux: float) -> None: ... + +class OccupancyReservoir: + def __init__( + self, site: int, concentration: float, conductance: float, volume_flux: float + ) -> None: ... + +class OccupancyBalance: + before: float + after: float + source: float + reaction: float + boundary: float + +class OccupancyStep: + amount: list[float] + balance: OccupancyBalance + iterations: int + relative_residual: float + +class OccupancySolver: + def __init__(self, backend: BackendKind, device_index: int, epsilon_cutoff: float) -> None: ... + def geometric_porosity( + self, + centers: list[tuple[float, float, float]], + spacing: tuple[float, float, float], + cells: list[OccupancyCapsule], + subdivisions: int, + walls: list[int], + ) -> list[float]: ... + def accessible_volumes(self, porosity: list[float], voxel_volume: float) -> list[float]: ... + def concentration(self, amount: list[float], volume: list[float]) -> list[float]: ... + def porosity_face( + self, + first: int, + second: int, + epsilon_first: float, + epsilon_second: float, + diffusion: float, + area: float, + distance: float, + intrinsic_velocity: float, + ) -> OccupancyFace: ... + def remap_amounts( + self, + amount: list[float], + old_volume: list[float], + new_volume: list[float], + neighbors: list[tuple[int, int]], + ) -> list[float]: ... + def exchange_weights(self, kernel: list[float], volume: list[float]) -> list[float]: ... + def backward_euler( + self, + amount: list[float], + volume: list[float], + faces: list[OccupancyFace], + dt: float, + source: list[float], + loss: list[float], + reservoirs: list[OccupancyReservoir], + max_iterations: int, + relative_tolerance: float, + ) -> OccupancyStep: ... + class BackendKind(Enum): CPU: BackendKind METAL: BackendKind @@ -16,6 +100,7 @@ class BackendFeature(Enum): COUPLED_RATES: BackendFeature DEPTH_AVERAGED_FLOW: BackendFeature RESOLVED_FLOW: BackendFeature + CULTURE: BackendFeature class FlowAxis(Enum): X: FlowAxis @@ -468,6 +553,7 @@ class _SimulationCheckpoint: species_rate_plan: SpeciesRatePlan signal_grid: _SignalGridCheckpoint | None coupled_rate_plan: CoupledRatePlan | None + culture: CultureCheckpoint | None def __init__(self) -> None: ... def validate(self) -> None: ... @@ -583,6 +669,35 @@ class Simulation: def last_signal_solve_report(self) -> SignalSolveReport | None: ... @property def has_coupled_rate_plan(self) -> bool: ... + @property + def has_culture(self) -> bool: ... + @property + def culture_checkpoint(self) -> CultureCheckpoint | None: ... + @property + def fluid_fragments(self) -> list[FluidFragment]: ... + def configure_culture( + self, + *, + fluid: StokesFlow, + transport: SoluteTransport, + cell_growth: Mapping[int, CellGrowth] = ..., + ) -> None: ... + @property + def culture_state(self) -> CultureState | None: ... + def _configure_culture( + self, + configuration: CultureConfiguration, + concentrations: list[float] = ..., + biochemical_volumes: list[float] = ..., + ) -> None: ... + def set_cell_force( + self, + id: int, + force_n: tuple[float, float, float], + torque_nm: tuple[float, float, float] = ..., + ) -> None: ... + def _restore_checkpoint(self, checkpoint: _SimulationCheckpoint) -> None: ... + def cell_surface_concentrations(self, id: int) -> list[float]: ... def add_cell(self, cell: CellInit) -> int: ... def remove_cell(self, id: int) -> None: ... def apply_flow_drift( @@ -645,3 +760,478 @@ class Simulation: def sample_signals(self, position: Vec3) -> list[float]: ... def _checkpoint(self) -> _SimulationCheckpoint: ... def validate(self) -> None: ... + +class CapsuleBody: + length_rate: float + id: int + position: tuple[float, float, float] + orientation: tuple[float, float, float, float] + length: float + radius: float + fixed: bool + force_n: tuple[float, float, float] + torque_nm: tuple[float, float, float] + def __init__(self) -> None: ... + def validate(self) -> None: ... + @property + def geometric_volume(self) -> float: ... + +class FluidBodyResult: + @property + def id(self) -> int: ... + @property + def velocity(self) -> tuple[float, float, float]: ... + @property + def angular_velocity(self) -> tuple[float, float, float]: ... + @property + def hydrodynamic_force_n(self) -> tuple[float, float, float]: ... + @property + def hydrodynamic_torque_nm(self) -> tuple[float, float, float]: ... + @property + def no_slip_rms_m_s(self) -> float: ... + @property + def volume_change_rate_m3_s(self) -> float: ... + @property + def marker_count(self) -> int: ... + +class FluidBodyStepParameters: + minimum_gap_m: float + maximum_displacement_fraction: float + max_halvings: int + max_contact_iterations: int + def __init__(self) -> None: ... + def validate(self) -> None: ... + +class FluidContactResult: + @property + def first_id(self) -> int: ... + @property + def second_id(self) -> int: ... + @property + def normal(self) -> tuple[float, float, float]: ... + @property + def point_on_first(self) -> tuple[float, float, float]: ... + @property + def initial_gap_m(self) -> float: ... + @property + def normal_force_n(self) -> float: ... + +class FluidBodyStep: + @property + def accepted_dt(self) -> float: ... + @property + def bodies(self) -> list[CapsuleBody]: ... + @property + def flow(self) -> FluidFlowResult: ... + @property + def contacts(self) -> list[FluidContactResult]: ... + @property + def halvings(self) -> int: ... + @property + def contact_iterations(self) -> int: ... + +class FluidGridSpec: + shape: GridShape + origin: Vec3 + spacing: float + length_unit_m: float + time_unit_s: float + obstacles: list[int] + def __init__(self) -> None: ... + def validate(self) -> None: ... + @property + def site_count(self) -> int: ... + +class FluidProperties: + viscosity_pa_s: float + density_kg_m3: float + def __init__(self) -> None: ... + def validate(self) -> None: ... + +class FlowPortKind(Enum): + PRESSURE: FlowPortKind + FLOW_RATE: FlowPortKind + +class FlowPort: + name: str + axis: FlowAxis + upper: bool + kind: FlowPortKind + value: float + sites: list[int] + def __init__(self) -> None: ... + +class LinearSolveParameters: + relative_tolerance: float + absolute_tolerance: float + max_iterations: int + memory_limit_bytes: int + def __init__(self) -> None: ... + def validate(self) -> None: ... + +class FlowPortResult: + @property + def name(self) -> str: ... + @property + def pressure_pa(self) -> float: ... + @property + def flow_rate_m3_s(self) -> float: ... + @property + def area_m2(self) -> float: ... + +class FluidSolveReport: + def __init__(self) -> None: ... + iterations: int + relative_residual: float + absolute_residual: float + divergence_rms_per_s: float + continuity_rms_per_s: float + source_volume_rate_m3_s: float + max_speed_m_s: float + reynolds_number: float + viscous_relaxation_time_s: float + net_flow_rate_m3_s: float + estimated_memory_bytes: int + +class FluidFlowResult: + @property + def field(self) -> SignalGridVelocityField: ... + @property + def pressure_pa(self) -> list[float]: ... + @property + def ports(self) -> list[FlowPortResult]: ... + @property + def bodies(self) -> list[FluidBodyResult]: ... + @property + def report(self) -> FluidSolveReport: ... + +class StokesFlowSolver: + def __init__(self, backend: BackendKind = ..., device_index: int = 0) -> None: ... + def solve( + self, + grid: FluidGridSpec, + fluid: FluidProperties, + ports: list[FlowPort], + parameters: LinearSolveParameters = ..., + ) -> FluidFlowResult: ... + def solve_bodies( + self, + grid: FluidGridSpec, + fluid: FluidProperties, + ports: list[FlowPort], + bodies: list[CapsuleBody], + parameters: LinearSolveParameters = ..., + ) -> FluidFlowResult: ... + def propose_body_step( + self, + grid: FluidGridSpec, + fluid: FluidProperties, + ports: list[FlowPort], + bodies: list[CapsuleBody], + maximum_dt: float, + solve_parameters: LinearSolveParameters = ..., + step_parameters: FluidBodyStepParameters = ..., + ) -> FluidBodyStep: ... + +class FluidGeometryParameters: + def __init__(self) -> None: ... + surface_resolution: int + maximum_surface_error_fraction: float + memory_limit_bytes: int + def validate(self) -> None: ... + +class FluidFragment: + @property + def site(self) -> int: ... + @property + def component(self) -> int: ... + @property + def volume(self) -> float: ... + @property + def centroid(self) -> tuple[float, float, float]: ... + +class FluidFace: + @property + def first(self) -> int: ... + @property + def second(self) -> int: ... + @property + def area(self) -> float: ... + @property + def centroid(self) -> tuple[float, float, float]: ... + @property + def normal(self) -> tuple[float, float, float]: ... + @property + def body_id(self) -> int: ... + @property + def axis(self) -> FlowAxis: ... + @property + def grid_face(self) -> int: ... + +class FluidGeometryReport: + @property + def fluid_volume(self) -> float: ... + @property + def expected_fluid_volume(self) -> float: ... + @property + def volume_error(self) -> float: ... + @property + def maximum_surface_error(self) -> float: ... + @property + def component_count(self) -> int: ... + @property + def estimated_memory_bytes(self) -> int: ... + +class FluidOverlap: + @property + def first(self) -> int: ... + @property + def second(self) -> int: ... + @property + def volume(self) -> float: ... + +class Solute: + amount_unit: str + + def __init__(self) -> None: ... + name: str + diffusion: float + +class ChemicalBoundary: + kind: ChemicalBoundaryKind + allow_backflow: bool + + def __init__(self) -> None: ... + port: str + concentrations: list[float] + +class SurfaceTransferLaw: + def __init__(self) -> None: ... + body_id: int + solute: int + uptake_velocity: float + secretion_rate: float + +class ChemicalTransfer: + def __init__(self) -> None: ... + port: str + body_id: int + amounts: list[float] + +class SoluteTransportReport: + def __init__(self) -> None: ... + projection_iterations: int + transport_iterations: int + maximum_volume_residual: float + mass_balance_error: list[float] + +class SoluteTransportResult: + @property + def surfaces(self) -> list[SurfaceEnvironment]: ... + @property + def amounts(self) -> list[float]: ... + @property + def concentrations(self) -> list[float]: ... + @property + def reservoirs(self) -> list[ChemicalTransfer]: ... + @property + def cells(self) -> list[ChemicalTransfer]: ... + @property + def report(self) -> SoluteTransportReport: ... + +class CellSurfaceExchange: + def __init__(self) -> None: ... + body_id: int + solute: int + species: int + uptake_velocity: float + secretion_rate: float + +class ReserveRequirement: + def __init__(self) -> None: ... + species: int + amount_per_biomass: float + +class CultureConfiguration: + growth: list[CellGrowthModel] + events: list[CultureEvent] + authoring_json: str + coupling_tolerance: float + maximum_coupling_iterations: int + + def __init__(self) -> None: ... + grid: FluidGridSpec + fluid: FluidProperties + ports: list[FlowPort] + solutes: list[Solute] + reservoirs: list[ChemicalBoundary] + exchange: list[CellSurfaceExchange] + biomass_requirements: list[ReserveRequirement] + biomass_per_geometric_volume: float + solver: LinearSolveParameters + stepping: FluidBodyStepParameters + geometry: FluidGeometryParameters + maximum_substeps: int + maximum_retries: int + def validate(self, species_count: int) -> None: ... + +class CultureCellState: + uptake_totals: list[float] + realized_specific_rate: float + biomass_produced: float + + def __init__(self) -> None: ... + body: CapsuleBody + biochemical_volume: float + species_amounts: list[float] + +class CultureReport: + def __init__(self) -> None: ... + substeps: int + retries: int + flow: FluidSolveReport + transport: SoluteTransportReport + +class CultureCheckpoint: + time: float + event_index: int + + def __init__(self) -> None: ... + configuration: CultureConfiguration + cells: list[CultureCellState] + extracellular_amounts: list[float] + reservoir_totals: list[ChemicalTransfer] + last_report: CultureReport + def validate(self, world: _WorldStateCheckpoint) -> None: ... + +class FluidGeometry: + def __init__( + self, + grid: FluidGridSpec, + bodies: list[CapsuleBody], + parameters: FluidGeometryParameters = ..., + ) -> None: ... + @property + def grid(self) -> FluidGridSpec: ... + @property + def bodies(self) -> list[CapsuleBody]: ... + @property + def fragments(self) -> list[FluidFragment]: ... + @property + def faces(self) -> list[FluidFace]: ... + @property + def report(self) -> FluidGeometryReport: ... + def overlaps(self, other: FluidGeometry) -> list[FluidOverlap]: ... + +class SoluteTransportSolver: + def propose( + self, + geometry: TransportGeometry, + solutes: list[Solute], + boundaries: list[ChemicalBoundary], + amounts: list[float], + exchange: list[SurfaceTransferLaw] = ..., + parameters: LinearSolveParameters = ..., + ) -> SoluteTransportResult: ... + def __init__(self, backend: BackendKind = ..., device_index: int = 0) -> None: ... + def step( + self, + before: FluidGeometry, + after: FluidGeometry, + velocity: SignalGridVelocityField, + ports: list[FlowPort], + solutes: list[Solute], + reservoirs: list[ChemicalBoundary], + amounts: list[float], + dt: float, + exchange: list[SurfaceTransferLaw] = ..., + parameters: LinearSolveParameters = ..., + ) -> SoluteTransportResult: ... + +class ChemicalBoundaryKind(Enum): + RESERVOIR_CONTACT = 0 + ADVECTIVE = 1 + OUTFLOW = 2 + +class GrowthKind(Enum): + MONOD = 0 + ESSENTIAL = 1 + +class GrowthRequirement: + def __init__(self) -> None: ... + solute: int + half_saturation: float + biomass_yield: float + +class CellGrowthModel: + def __init__(self) -> None: ... + cell_id: int + kind: GrowthKind + mu_max: float + biomass_density: float + volume_ratio: float + requirements: list[GrowthRequirement] + +class CultureEvent: + def __init__(self) -> None: ... + time: float + ports: list[FlowPort] + reservoirs: list[ChemicalBoundary] + +MacVelocityField = SignalGridVelocityField + +class GeometricFluxReport: + @property + def projection_iterations(self) -> int: ... + @property + def maximum_volume_residual(self) -> float: ... + +class TransportGeometry: + def __init__( + self, + before: FluidGeometry, + after: FluidGeometry, + velocity: MacVelocityField, + ports: list[FlowPort], + dt: float, + backend: BackendKind = ..., + device_index: int = 0, + parameters: LinearSolveParameters = ..., + ) -> None: ... + @property + def report(self) -> GeometricFluxReport: ... + +class SurfaceEnvironment: + @property + def body_id(self) -> int: ... + @property + def area(self) -> float: ... + @property + def concentrations(self) -> list[float]: ... + +class GrowthInput: + def __init__(self) -> None: ... + biochemical_volume: float + surface_area: float + concentrations: list[float] + uptake: list[float] + +class GrowthEvaluation: + @property + def uptake_velocities(self) -> list[float]: ... + @property + def biomass_gain(self) -> float: ... + @property + def biochemical_volume_gain(self) -> float: ... + @property + def geometric_volume_gain(self) -> float: ... + @property + def specific_rate(self) -> float: ... + @property + def stoichiometric_residual(self) -> float: ... + +class GrowthExecutor: + def __init__(self, backend: BackendKind = ..., device_index: int = 0) -> None: ... + def evaluate( + self, models: list[CellGrowthModel], inputs: list[GrowthInput], dt: float + ) -> list[GrowthEvaluation]: ... diff --git a/python/src/microsimulator/_culture_checkpoint.py b/python/src/microsimulator/_culture_checkpoint.py new file mode 100644 index 0000000..a69eb1c --- /dev/null +++ b/python/src/microsimulator/_culture_checkpoint.py @@ -0,0 +1,413 @@ +"""Closed, data-only schema for the native culture state.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Literal, cast + +from . import _core as core # pyright: ignore[reportMissingModuleSource, reportPrivateUsage] + +type JSONValue = str | int | float | bool | list[JSONValue] | dict[str, JSONValue] | None + + +@dataclass(frozen=True) +class _Record: + factory: type[object] + fields: dict[str, Codec] + + +@dataclass(frozen=True) +class _Array: + element: Codec + length: int | None = None + + +@dataclass(frozen=True) +class _Enum: + values: dict[str, object] + + +type Codec = Literal["float", "u32", "u64", "bool", "str"] | _Record | _Array | _Enum + + +def _encode(value: object, codec: Codec) -> JSONValue: + if isinstance(codec, _Record): + return { + key: _encode(cast(object, getattr(value, key)), item) + for key, item in codec.fields.items() + } + + if isinstance(codec, _Array): + return [_encode(item, codec.element) for item in cast(list[object], value)] + + if isinstance(codec, _Enum): + for name, item in codec.values.items(): + if value == item: + return name + + raise ValueError("unknown native culture enum value") + + return cast(JSONValue, value) + + +def _decode_record(value: object, codec: _Record, path: str, *, validate_only: bool) -> object: + if not isinstance(value, dict) or set(cast(dict[object, object], value)) != set(codec.fields): + raise ValueError(f"{path}: culture object fields do not match the schema") + + data = cast(dict[str, object], value) + result = None if validate_only else codec.factory() + + for key, item in codec.fields.items(): + decoded = _decode(data[key], item, f"{path}.{key}", validate_only=validate_only) + + if result is not None: + setattr(result, key, decoded) + + return result + + +def _decode_array(value: object, codec: _Array, path: str, *, validate_only: bool) -> list[object]: + if not isinstance(value, list) or ( + codec.length is not None and len(cast(list[object], value)) != codec.length + ): + raise ValueError(f"{path}: invalid culture array") + + return [ + _decode(item, codec.element, f"{path}[{i}]", validate_only=validate_only) + for i, item in enumerate(cast(list[object], value)) + ] + + +def _decode_scalar( + value: object, codec: Literal["float", "u32", "u64", "bool", "str"], path: str +) -> object: + if codec == "bool": + if type(value) is not bool: + raise ValueError(f"{path}: expected a Boolean") + elif codec == "str": + if not isinstance(value, str): + raise ValueError(f"{path}: expected a string") + elif codec in ("u32", "u64"): + maximum = (1 << (32 if codec == "u32" else 64)) - 1 + + if type(value) is not int or not 0 <= value <= maximum: + raise ValueError(f"{path}: unsigned integer out of range") + elif codec == "float": + if type(value) not in (int, float) or not math.isfinite(cast(float, value)): + raise ValueError(f"{path}: expected a finite number") + + return float(cast(float, value)) + + return value + + +def _decode(value: object, codec: Codec, path: str, *, validate_only: bool = False) -> object: + if isinstance(codec, _Record): + return _decode_record(value, codec, path, validate_only=validate_only) + + if isinstance(codec, _Array): + return _decode_array(value, codec, path, validate_only=validate_only) + + if isinstance(codec, _Enum): + if not isinstance(value, str) or value not in codec.values: + raise ValueError(f"{path}: invalid culture enum") + + return codec.values[value] + + return _decode_scalar(value, codec, path) + + +_axis = _Enum({"x": core.FlowAxis.X, "y": core.FlowAxis.Y, "z": core.FlowAxis.Z}) +_port_kind = _Enum( + {"pressure": core.FlowPortKind.PRESSURE, "flow_rate": core.FlowPortKind.FLOW_RATE} +) +_shape = _Record(core.GridShape, {"x": "u32", "y": "u32", "z": "u32"}) +_vector = _Record(core.Vec3, {"x": "float", "y": "float", "z": "float"}) +_grid = _Record( + core.FluidGridSpec, + { + "shape": _shape, + "origin": _vector, + "spacing": "float", + "length_unit_m": "float", + "time_unit_s": "float", + "obstacles": _Array("u32"), + }, +) +_fluid = _Record(core.FluidProperties, {"viscosity_pa_s": "float", "density_kg_m3": "float"}) +_port = _Record( + core.FlowPort, + { + "name": "str", + "axis": _axis, + "upper": "bool", + "kind": _port_kind, + "value": "float", + "sites": _Array("u32"), + }, +) +_solver = _Record( + core.LinearSolveParameters, + { + "relative_tolerance": "float", + "absolute_tolerance": "float", + "max_iterations": "u32", + "memory_limit_bytes": "u64", + }, +) +_stepping = _Record( + core.FluidBodyStepParameters, + { + "minimum_gap_m": "float", + "maximum_displacement_fraction": "float", + "max_halvings": "u32", + "max_contact_iterations": "u32", + }, +) +_geometry = _Record( + core.FluidGeometryParameters, + { + "surface_resolution": "u32", + "maximum_surface_error_fraction": "float", + "memory_limit_bytes": "u64", + }, +) +_solute = _Record(core.Solute, {"name": "str", "diffusion": "float", "amount_unit": "str"}) +_reservoir = _Record( + core.ChemicalBoundary, + { + "port": "str", + "concentrations": _Array("float"), + "kind": _Enum( + { + "reservoir_contact": core.ChemicalBoundaryKind.RESERVOIR_CONTACT, + "advective": core.ChemicalBoundaryKind.ADVECTIVE, + "outflow": core.ChemicalBoundaryKind.OUTFLOW, + } + ), + "allow_backflow": "bool", + }, +) +_exchange = _Record( + core.CellSurfaceExchange, + { + "body_id": "u64", + "solute": "u32", + "species": "u32", + "uptake_velocity": "float", + "secretion_rate": "float", + }, +) +_requirement = _Record(core.ReserveRequirement, {"species": "u32", "amount_per_biomass": "float"}) +_growth_requirement = _Record( + core.GrowthRequirement, + { + "solute": "u32", + "half_saturation": "float", + "biomass_yield": "float", + }, +) +_growth_model = _Record( + core.CellGrowthModel, + { + "cell_id": "u64", + "kind": _Enum({"monod": core.GrowthKind.MONOD, "essential": core.GrowthKind.ESSENTIAL}), + "mu_max": "float", + "biomass_density": "float", + "volume_ratio": "float", + "requirements": _Array(_growth_requirement), + }, +) +_event = _Record( + core.CultureEvent, + { + "time": "float", + "ports": _Array(_port), + "reservoirs": _Array(_reservoir), + }, +) +_configuration = _Record( + core.CultureConfiguration, + { + "grid": _grid, + "fluid": _fluid, + "ports": _Array(_port), + "solutes": _Array(_solute), + "reservoirs": _Array(_reservoir), + "exchange": _Array(_exchange), + "biomass_requirements": _Array(_requirement), + "biomass_per_geometric_volume": "float", + "solver": _solver, + "stepping": _stepping, + "geometry": _geometry, + "maximum_substeps": "u32", + "maximum_retries": "u32", + "growth": _Array(_growth_model), + "events": _Array(_event), + "authoring_json": "str", + "coupling_tolerance": "float", + "maximum_coupling_iterations": "u32", + }, +) +_body = _Record( + core.CapsuleBody, + { + "id": "u64", + "position": _Array("float", 3), + "orientation": _Array("float", 4), + "length": "float", + "radius": "float", + "length_rate": "float", + "fixed": "bool", + "force_n": _Array("float", 3), + "torque_nm": _Array("float", 3), + }, +) +_cell = _Record( + core.CultureCellState, + { + "body": _body, + "biochemical_volume": "float", + "species_amounts": _Array("float"), + "uptake_totals": _Array("float"), + "realized_specific_rate": "float", + "biomass_produced": "float", + }, +) +_transfer = _Record( + core.ChemicalTransfer, {"port": "str", "body_id": "u64", "amounts": _Array("float")} +) +_flow_report = _Record( + core.FluidSolveReport, + { + "iterations": "u32", + "relative_residual": "float", + "absolute_residual": "float", + "divergence_rms_per_s": "float", + "continuity_rms_per_s": "float", + "source_volume_rate_m3_s": "float", + "max_speed_m_s": "float", + "reynolds_number": "float", + "viscous_relaxation_time_s": "float", + "net_flow_rate_m3_s": "float", + "estimated_memory_bytes": "u64", + }, +) +_transport_report = _Record( + core.SoluteTransportReport, + { + "projection_iterations": "u32", + "transport_iterations": "u32", + "maximum_volume_residual": "float", + "mass_balance_error": _Array("float"), + }, +) +_report = _Record( + core.CultureReport, + {"substeps": "u32", "retries": "u32", "flow": _flow_report, "transport": _transport_report}, +) +_checkpoint = _Record( + core.CultureCheckpoint, + { + "configuration": _configuration, + "cells": _Array(_cell), + "extracellular_amounts": _Array("float"), + "reservoir_totals": _Array(_transfer), + "last_report": _report, + "time": "float", + "event_index": "u32", + }, +) + + +# The original version-10 shape is checked before migration. Unknown new fields +# must not be silently overwritten by defaults or bypass the old closed schema. +_v10_solute = _Record(core.Solute, {k: v for k, v in _solute.fields.items() if k != "amount_unit"}) +_v10_boundary = _Record( + core.ChemicalBoundary, + {k: v for k, v in _reservoir.fields.items() if k not in {"kind", "allow_backflow"}}, +) +_v10_configuration = _Record( + core.CultureConfiguration, + { + **{ + k: v + for k, v in _configuration.fields.items() + if k + not in { + "fluid", + "growth", + "events", + "authoring_json", + "coupling_tolerance", + "maximum_coupling_iterations", + } + }, + "medium": _fluid, + "solutes": _Array(_v10_solute), + "reservoirs": _Array(_v10_boundary), + }, +) +_v10_cell = _Record( + core.CultureCellState, + { + k: v + for k, v in _cell.fields.items() + if k not in {"uptake_totals", "realized_specific_rate", "biomass_produced"} + }, +) +_v10_checkpoint = _Record( + core.CultureCheckpoint, + { + **{k: v for k, v in _checkpoint.fields.items() if k not in {"time", "event_index"}}, + "configuration": _v10_configuration, + "cells": _Array(_v10_cell), + }, +) + + +def encode_culture_checkpoint(value: core.CultureCheckpoint | None) -> JSONValue: + return None if value is None else _encode(value, _checkpoint) + + +def decode_culture_checkpoint( + value: object, schema_version: int = 11, time: float = 0.0 +) -> core.CultureCheckpoint | None: + if value is not None and schema_version == 10: + import copy + + _decode(value, _v10_checkpoint, "$.simulation.media_flow", validate_only=True) + data = copy.deepcopy(cast(dict[str, JSONValue], value)) + config = cast(dict[str, JSONValue], data["configuration"]) + config["fluid"] = config.pop("medium") + config.update( + growth=[], + events=[], + authoring_json="", + coupling_tolerance=2e-6, + maximum_coupling_iterations=64, + ) + solutes = cast(list[dict[str, JSONValue]], config["solutes"]) + + for solute in solutes: + solute["amount_unit"] = "model" + + for boundary in cast(list[dict[str, JSONValue]], config["reservoirs"]): + boundary.update(kind="reservoir_contact", allow_backflow=True) + + for cell in cast(list[dict[str, JSONValue]], data["cells"]): + cell.update( + uptake_totals=cast(JSONValue, [0.0] * len(solutes)), + realized_specific_rate=0.0, + biomass_produced=0.0, + ) + + data.update(time=time, event_index=0) + value = data + + return ( + None + if value is None + else cast(core.CultureCheckpoint, _decode(value, _checkpoint, "$.simulation.culture")) + ) diff --git a/python/src/microsimulator/analysis.py b/python/src/microsimulator/analysis.py index bfa0808..4d94373 100644 --- a/python/src/microsimulator/analysis.py +++ b/python/src/microsimulator/analysis.py @@ -34,9 +34,9 @@ from .scene import SceneFrame, SceneGridBoundary, SceneSignalGrid, capture_scene ANALYSIS_FORMAT = "microsimulator-analysis" -ANALYSIS_VERSION = 3 +ANALYSIS_VERSION = 5 MAX_ANALYSIS_MANIFEST_BYTES = 1 << 26 -_SUPPORTED_ANALYSIS_VERSIONS = frozenset({1, 2, ANALYSIS_VERSION}) +_SUPPORTED_ANALYSIS_VERSIONS = frozenset({1, 2, 3, 4, ANALYSIS_VERSION}) _TABLE_NAMES = frozenset( { @@ -45,8 +45,20 @@ "species.parquet", "contacts.parquet", "external_contacts.parquet", + "culture_frames.parquet", + "culture_cells.parquet", + "fluid_fragments.parquet", + "chemical_transfers.parquet", } ) +# Version 4 files keep their original identities and digests. Public readers +# resolve canonical table names to those legacy paths without rewriting data. +_LEGACY_TABLE_NAMES = { + "culture_frames.parquet": "media_frames.parquet", + "culture_cells.parquet": "media_cells.parquet", + "fluid_fragments.parquet": "media_fragments.parquet", + "chemical_transfers.parquet": "media_reservoirs.parquet", +} _REQUIRED_TABLE_NAMES = frozenset({"frames.parquet", "cells.parquet", "species.parquet"}) _BACKEND_NAMES = { @@ -98,15 +110,24 @@ def has_table(self, name: str) -> bool: if name not in _TABLE_NAMES: raise AnalysisError(f"unknown analysis table {name!r}") + tables = cast(dict[str, object], self.manifest["tables"]) - return name in tables + + return self._table_path(name) in tables + + def _table_path(self, name: str) -> str: + if self.manifest["version"] == 4: + return _LEGACY_TABLE_NAMES.get(name, name) + + return name def scan_table(self, name: str) -> pl.LazyFrame: """Lazily scan a named Parquet table after manifest validation.""" if not self.has_table(name): raise AnalysisError(f"analysis dataset does not contain {name}") - return pl.scan_parquet(self.root / name) + + return pl.scan_parquet(self.root / self._table_path(name)) @dataclass(frozen=True, slots=True) @@ -126,12 +147,14 @@ class _SignalEpoch: def _sha256(path: Path) -> str: digest = hashlib.sha256() + try: with path.open("rb") as stream: while chunk := stream.read(1 << 20): digest.update(chunk) except OSError as error: raise AnalysisError(f"could not read {path}") from error + return digest.hexdigest() @@ -151,27 +174,35 @@ def _reject_constant(value: str) -> None: def _reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]: result: dict[str, object] = {} + for key, value in pairs: if key in result: raise AnalysisError(f"analysis manifest contains duplicate key {key!r}") + result[key] = value + return result def _manifest_object(value: object, path: str) -> dict[str, object]: if not isinstance(value, dict): raise AnalysisError(f"{path}: expected an object") + result = cast(dict[object, object], value) + if not all(isinstance(key, str) for key in result): raise AnalysisError(f"{path}: expected string object keys") + return cast(dict[str, object], result) def _manifest_keys(value: dict[str, object], path: str, required: set[str]) -> None: missing = required - value.keys() unknown = value.keys() - required + if missing: raise AnalysisError(f"{path}: missing keys {sorted(missing)}") + if unknown: raise AnalysisError(f"{path}: unknown keys {sorted(unknown)}") @@ -179,12 +210,15 @@ def _manifest_keys(value: dict[str, object], path: str, required: set[str]) -> N def _digest_value(value: object, path: str) -> str: if not isinstance(value, str) or len(value) != 64: raise AnalysisError(f"{path}: expected a SHA-256 digest") + try: bytes.fromhex(value) except ValueError as error: raise AnalysisError(f"{path}: expected a SHA-256 digest") from error + if value != value.lower(): raise AnalysisError(f"{path}: expected a lowercase SHA-256 digest") + return value @@ -238,16 +272,22 @@ def _boundary_record(boundary: SceneGridBoundary) -> dict[str, JSONValue]: def _signal_epochs(frames: Sequence[_SourceFrame]) -> list[_SignalEpoch]: epochs: list[_SignalEpoch] = [] previous_had_grid = False + for frame in frames: grid = frame.scene.signal_grid + if grid is None: previous_had_grid = False continue + signature = _grid_signature(grid) + if not previous_had_grid or not epochs or epochs[-1].signature != signature: epochs.append(_SignalEpoch(signature, [])) + epochs[-1].frames.append(frame) previous_had_grid = True + return epochs @@ -260,18 +300,24 @@ def _write_signals(path: Path, epochs: Sequence[_SignalEpoch]) -> list[dict[str, ) records: list[dict[str, JSONValue]] = [] compressor = ZstdCodec(level=7, checksum=True) + for epoch_index, epoch in enumerate(epochs): first_grid = epoch.frames[0].scene.signal_grid + if first_grid is None: # pragma: no cover - guaranteed by _signal_epochs raise AssertionError("signal epoch has no grid") + shape = first_grid.shape array_shape = (len(epoch.frames), first_grid.signal_count, *shape) chunks = (1, 1, min(shape[0], 64), min(shape[1], 64), min(shape[2], 16)) levels = np.empty(array_shape, dtype=np.float32) + for local_index, frame in enumerate(epoch.frames): grid = frame.scene.signal_grid + if grid is None: # pragma: no cover - guaranteed by _signal_epochs raise AssertionError("signal epoch contains an empty grid") + levels[local_index] = np.asarray(grid.levels, dtype=np.float32).reshape( (grid.signal_count, *grid.shape) ) @@ -329,46 +375,46 @@ def _write_signals(path: Path, epochs: Sequence[_SignalEpoch]) -> list[dict[str, "compression": {"codec": "zstd", "level": 7, "checksum": True}, } ) + return records def _directory_digest(path: Path) -> str: digest = hashlib.sha256() children = sorted(path.rglob("*")) + if any(child.is_symlink() for child in children): raise AnalysisError(f"analysis data contains a symbolic link under {path}") + for child in (item for item in children if item.is_file()): relative = child.relative_to(path).as_posix().encode("utf-8") digest.update(len(relative).to_bytes(8, "big")) digest.update(relative) digest.update(bytes.fromhex(_sha256(child))) - return digest.hexdigest() + return digest.hexdigest() -def open_dataset( - path: str | os.PathLike[str], - *, - verify: bool = True, -) -> AnalysisDataset: - """Open a supported analysis dataset and optionally verify every data digest.""" - root = Path(path) - if not root.is_dir() or root.is_symlink(): - raise AnalysisError(f"analysis dataset is not a directory: {root}") +def _read_dataset_manifest(root: Path) -> dict[str, object]: manifest_path = root / "manifest.json" + if manifest_path.is_symlink(): raise AnalysisError("analysis manifest must not be a symbolic link") + try: with manifest_path.open("rb") as stream: encoded = stream.read(MAX_ANALYSIS_MANIFEST_BYTES + 1) except OSError as error: raise AnalysisError(f"could not read analysis manifest {manifest_path}") from error + if not encoded: raise AnalysisError("analysis manifest is empty") + if len(encoded) > MAX_ANALYSIS_MANIFEST_BYTES: raise AnalysisError( f"analysis manifest exceeds the {MAX_ANALYSIS_MANIFEST_BYTES}-byte limit" ) + try: decoded = json.loads( encoded, @@ -381,22 +427,33 @@ def open_dataset( raise AnalysisError(f"analysis manifest is not valid UTF-8 JSON: {error}") from error manifest = _manifest_object(cast(object, decoded), "$") + + return manifest + + +def _validate_dataset_identity(manifest: dict[str, object]) -> int: top_level_keys = {"format", "version", "dataset_id", "sources", "options", "tables", "signals"} _manifest_keys(manifest, "$", top_level_keys) + if manifest["format"] not in (ANALYSIS_FORMAT, "cellmodeller2-analysis"): raise AnalysisError("$.format: not a MicroSimulator analysis dataset") + schema_version = manifest["version"] + if ( isinstance(schema_version, bool) or not isinstance(schema_version, int) or schema_version not in _SUPPORTED_ANALYSIS_VERSIONS ): raise AnalysisError(f"$.version: unsupported analysis version {manifest['version']!r}") + dataset_id = _digest_value(manifest["dataset_id"], "$.dataset_id") sources = manifest["sources"] options = manifest["options"] + if not isinstance(sources, list): raise AnalysisError("$.sources: expected an array") + _manifest_object(options, "$.options") identity: dict[str, object] = { "format": manifest["format"], @@ -404,51 +461,100 @@ def open_dataset( "sources": sources, "options": options, } + if schema_version >= 2: identity["tables"] = manifest["tables"] identity["signals"] = manifest["signals"] + expected_dataset_id = hashlib.sha256(_canonical_json(identity)).hexdigest() + if not hmac.compare_digest(dataset_id, expected_dataset_id): raise AnalysisError("$.dataset_id: dataset identity digest does not match") + return schema_version + + +def _validate_dataset_tables( + root: Path, manifest: dict[str, object], schema_version: int, verify: bool +) -> None: tables = _manifest_object(manifest["tables"], "$.tables") table_names = set(tables) missing_tables = _REQUIRED_TABLE_NAMES - table_names - unknown_tables = table_names - _TABLE_NAMES + allowed_tables = ( + (_TABLE_NAMES - _LEGACY_TABLE_NAMES.keys()) | set(_LEGACY_TABLE_NAMES.values()) + if schema_version == 4 + else _TABLE_NAMES + ) + unknown_tables = table_names - allowed_tables + if missing_tables: raise AnalysisError(f"$.tables: missing tables {sorted(missing_tables)}") + if unknown_tables: raise AnalysisError(f"$.tables: unknown tables {sorted(unknown_tables)}") + for name, value in tables.items(): record = _manifest_object(value, f"$.tables.{name}") _manifest_keys(record, f"$.tables.{name}", {"rows", "schema", "sha256"}) rows = record["rows"] + if isinstance(rows, bool) or not isinstance(rows, int) or rows < 0: raise AnalysisError(f"$.tables.{name}.rows: expected a non-negative integer") + if not isinstance(record["schema"], list): raise AnalysisError(f"$.tables.{name}.schema: expected an array") + expected_digest = _digest_value(record["sha256"], f"$.tables.{name}.sha256") table_path = root / name + if not table_path.is_file() or table_path.is_symlink(): raise AnalysisError(f"analysis table is missing or unsafe: {name}") + if verify and not hmac.compare_digest(_sha256(table_path), expected_digest): raise AnalysisError(f"analysis table digest does not match: {name}") + +def _validate_dataset_signals(root: Path, manifest: dict[str, object], verify: bool) -> None: signals = manifest["signals"] + if signals is not None: signal_record = _manifest_object(signals, "$.signals") _manifest_keys(signal_record, "$.signals", {"path", "sha256_tree", "epochs"}) + if signal_record["path"] != "signals.zarr": raise AnalysisError("$.signals.path: expected 'signals.zarr'") + expected_tree = _digest_value(signal_record["sha256_tree"], "$.signals.sha256_tree") + if not isinstance(signal_record["epochs"], list): raise AnalysisError("$.signals.epochs: expected an array") + signals_path = root / "signals.zarr" + if not signals_path.is_dir() or signals_path.is_symlink(): raise AnalysisError("analysis signal store is missing or unsafe") + if verify and not hmac.compare_digest(_directory_digest(signals_path), expected_tree): raise AnalysisError("analysis signal store digest does not match") + +def open_dataset( + path: str | os.PathLike[str], + *, + verify: bool = True, +) -> AnalysisDataset: + """Open a supported analysis dataset and optionally verify every data digest.""" + + root = Path(path) + + if not root.is_dir() or root.is_symlink(): + raise AnalysisError(f"analysis dataset is not a directory: {root}") + + manifest = _read_dataset_manifest(root) + schema_version = _validate_dataset_identity(manifest) + _validate_dataset_tables(root, manifest, schema_version, verify) + _validate_dataset_signals(root, manifest, verify) + return AnalysisDataset( root=root.resolve(), manifest=cast(dict[str, JSONValue], manifest), @@ -464,17 +570,21 @@ def _load_sources( ) -> list[_SourceFrame]: if not checkpoints: raise AnalysisError("at least one checkpoint is required") + frames: list[_SourceFrame] = [] previous_time: float | None = None + for index, value in enumerate(checkpoints): path = Path(value) bundle = load_checkpoint_bundle(path, backend=backend, device_index=device_index) - scene = capture_scene(bundle.simulation) + scene = capture_scene(bundle.simulation, channel_metadata=bundle.channel_metadata) + if previous_time is not None and scene.time < previous_time: raise AnalysisError( f"checkpoint {path} has time {scene.time:.9g}, before prior time " f"{previous_time:.9g}" ) + previous_time = scene.time frames.append( _SourceFrame( @@ -485,6 +595,7 @@ def _load_sources( scene=scene, ) ) + return frames @@ -534,6 +645,69 @@ def _load_sources( ] ) +_CULTURE_SCHEMAS = { + "culture_frames.parquet": pa.schema( + [ + pa.field("frame_index", pa.uint32(), nullable=False), + *[ + pa.field(name, pa.float64(), nullable=False) + for name in ( + "length_unit_m", + "time_unit_s", + "viscosity_pa_s", + "density_kg_m3", + "spacing", + "max_speed_m_s", + "flow_relative_residual", + "maximum_volume_residual", + ) + ], + pa.field("shape", pa.list_(pa.uint32()), nullable=False), + pa.field("origin", pa.list_(pa.float64()), nullable=False), + pa.field("obstacles", pa.list_(pa.uint8()), nullable=False), + pa.field("solute_names", pa.list_(pa.string()), nullable=False), + pa.field("solute_amount_units", pa.list_(pa.string()), nullable=False), + pa.field("culture_authoring_json", pa.string(), nullable=False), + ] + ), + "culture_cells.parquet": pa.schema( + [ + pa.field("frame_index", pa.uint32(), nullable=False), + pa.field("cell_id", pa.uint64(), nullable=False), + *[ + pa.field(name, pa.float64(), nullable=False) + for name in ("biochemical_volume", "geometric_volume", "cylinder_length", "radius") + ], + pa.field("position", pa.list_(pa.float64()), nullable=False), + pa.field("orientation", pa.list_(pa.float64()), nullable=False), + pa.field("species_amounts", pa.list_(pa.float64()), nullable=False), + pa.field("dry_biomass_g", pa.float64()), + pa.field("realized_specific_rate_per_hour", pa.float64(), nullable=False), + pa.field("biomass_produced_g", pa.float64(), nullable=False), + pa.field("uptake_totals", pa.list_(pa.float64()), nullable=False), + pa.field("surface_concentrations", pa.list_(pa.float64()), nullable=False), + ] + ), + "fluid_fragments.parquet": pa.schema( + [ + pa.field("frame_index", pa.uint32(), nullable=False), + pa.field("fragment_index", pa.uint32(), nullable=False), + pa.field("site", pa.uint32(), nullable=False), + pa.field("component", pa.uint32(), nullable=False), + pa.field("volume", pa.float64(), nullable=False), + pa.field("centroid", pa.list_(pa.float64()), nullable=False), + pa.field("solute_amounts", pa.list_(pa.float64()), nullable=False), + ] + ), + "chemical_transfers.parquet": pa.schema( + [ + pa.field("frame_index", pa.uint32(), nullable=False), + pa.field("port", pa.string(), nullable=False), + pa.field("net_amounts_into_fluid", pa.list_(pa.float64()), nullable=False), + ] + ), +} + _SPECIES_SCHEMA = pa.schema( [ pa.field("frame_index", pa.uint32(), nullable=False), @@ -591,19 +765,24 @@ def _parameter_record(parameters: object, names: Sequence[str]) -> dict[str, JSO def _publish(temporary: Path, destination: Path, *, replace: bool) -> None: if destination.exists() and not replace: raise AnalysisError(f"output already exists: {destination}") + backup: Path | None = None + if destination.exists(): backup = Path( tempfile.mkdtemp(prefix=f".{destination.name}.backup-", dir=destination.parent) ) backup.rmdir() destination.rename(backup) + try: temporary.rename(destination) except OSError: if backup is not None: backup.rename(destination) + raise + if backup is not None: if backup.is_dir(): shutil.rmtree(backup) @@ -611,6 +790,380 @@ def _publish(temporary: Path, destination: Path, *, replace: bool) -> None: backup.unlink() +def _append_culture_rows( + source: _SourceFrame, culture_rows: dict[str, list[dict[str, object]]] +) -> None: + scene = source.scene + + if scene.culture is not None: + culture = scene.culture + state = source.bundle.simulation.culture_checkpoint + + if state is None: + raise AnalysisError("culture scene and native state disagree") + + culture_rows["culture_frames.parquet"].append( + { + "frame_index": source.index, + "length_unit_m": culture.length_unit_m, + "time_unit_s": culture.time_unit_s, + "viscosity_pa_s": culture.viscosity_pa_s, + "density_kg_m3": culture.density_kg_m3, + "spacing": culture.spacing, + "max_speed_m_s": culture.max_speed_m_s, + "flow_relative_residual": culture.flow_relative_residual, + "maximum_volume_residual": culture.maximum_volume_residual, + "shape": list(culture.shape), + "origin": list(culture.origin), + "obstacles": list(culture.obstacles), + "solute_names": list(culture.solutes), + "solute_amount_units": [s.amount_unit for s in state.configuration.solutes], + "culture_authoring_json": state.configuration.authoring_json, + } + ) + models = {m.cell_id: m for m in state.configuration.growth} + + for cell in state.cells: + concentrations = source.bundle.simulation.cell_surface_concentrations(cell.body.id) + culture_rows["culture_cells.parquet"].append( + { + "frame_index": source.index, + "cell_id": cell.body.id, + "biochemical_volume": cell.biochemical_volume, + "geometric_volume": cell.body.geometric_volume, + "cylinder_length": cell.body.length, + "radius": cell.body.radius, + "position": list(cell.body.position), + "orientation": list(cell.body.orientation), + "species_amounts": cell.species_amounts, + "dry_biomass_g": cell.biochemical_volume * models[cell.body.id].biomass_density + if cell.body.id in models + else None, + "realized_specific_rate_per_hour": cell.realized_specific_rate + * 3600 + / state.configuration.grid.time_unit_s, + "biomass_produced_g": cell.biomass_produced, + "uptake_totals": cell.uptake_totals, + "surface_concentrations": concentrations, + } + ) + + for i, fragment in enumerate(culture.fragments): + culture_rows["fluid_fragments.parquet"].append( + { + "frame_index": source.index, + "fragment_index": i, + "site": fragment.site, + "component": fragment.component, + "volume": fragment.volume, + "centroid": list(fragment.centroid), + "solute_amounts": list(fragment.amounts), + } + ) + + for reservoir in culture.reservoirs: + culture_rows["chemical_transfers.parquet"].append( + { + "frame_index": source.index, + "port": reservoir.name, + "net_amounts_into_fluid": list(reservoir.amounts), + } + ) + + +def _append_cell_rows( + source: _SourceFrame, cell_rows: list[dict[str, object]], species_rows: list[dict[str, object]] +) -> None: + scene = source.scene + + for cell in scene.cells: + cell_rows.append( + { + "frame_index": source.index, + "id": cell.id, + "parent_id": cell.parent_id, + "slot": cell.slot, + "position_x": cell.position[0], + "position_y": cell.position[1], + "position_z": cell.position[2], + "direction_x": cell.direction[0], + "direction_y": cell.direction[1], + "direction_z": cell.direction[2], + "cylinder_length": cell.length, + "radius": cell.radius, + "capsule_length": cell.length + (2.0 * cell.radius), + "growth_rate": cell.growth_rate, + "cell_type": cell.cell_type, + "fixed": cell.fixed, + } + ) + + for channel, level in enumerate(cell.species): + species_rows.append( + { + "frame_index": source.index, + "cell_id": cell.id, + "channel": channel, + "level": level, + } + ) + + +def _append_contact_rows( + source: _SourceFrame, contact_values: ContactParameters, contact_rows: list[dict[str, object]] +) -> int: + graph = source.bundle.simulation.find_cell_contacts(contact_values) + + for contact in graph.contacts: + contact_rows.append( + { + "frame_index": source.index, + "first_id": contact.first_id, + "second_id": contact.second_id, + "first_slot": contact.first_slot, + "second_slot": contact.second_slot, + "ordinal": contact.ordinal, + "point_x": contact.point_on_first.x, + "point_y": contact.point_on_first.y, + "point_z": contact.point_on_first.z, + "normal_x": contact.normal.x, + "normal_y": contact.normal.y, + "normal_z": contact.normal.z, + "signed_separation": contact.signed_separation, + "overlap": max(0.0, -contact.signed_separation), + "weight": contact.weight, + } + ) + + frame_contact_count = len(graph) + + return frame_contact_count + + +def _append_external_rows( + source: _SourceFrame, + constraint_values: ConstraintContactParameters, + external_rows: list[dict[str, object]], +) -> int: + external_graph = source.bundle.simulation.find_external_contacts(constraint_values) + + for contact in external_graph.contacts: + external_rows.append( + { + "frame_index": source.index, + "cell_id": contact.cell_id, + "cell_slot": contact.cell_slot, + "constraint_id": contact.constraint_id, + "constraint_kind": _CONSTRAINT_NAMES[contact.constraint_kind], + "location": _CONTACT_LOCATION_NAMES[contact.location], + "point_x": contact.point_on_cell.x, + "point_y": contact.point_on_cell.y, + "point_z": contact.point_on_cell.z, + "normal_x": contact.normal.x, + "normal_y": contact.normal.y, + "normal_z": contact.normal.z, + "signed_separation": contact.signed_separation, + "overlap": max(0.0, -contact.signed_separation), + "weight": contact.weight, + } + ) + + frame_external_count = len(external_graph) + + return frame_external_count + + +def _append_frame_row( + source: _SourceFrame, + frame_rows: list[dict[str, object]], + frame_contact_count: int, + frame_external_count: int, +) -> None: + scene = source.scene + source_backend = source.bundle.source_backend + reconstructed = scene.backend + frame_rows.append( + { + "frame_index": source.index, + "time": scene.time, + "source": source.label, + "source_sha256": source.digest, + "checkpoint_version": source.bundle.schema_version, + "source_backend_kind": source_backend.kind, + "source_backend_name": source_backend.name, + "source_backend_device": source_backend.device, + "source_backend_device_index": source_backend.device_index, + "source_backend_native": source_backend.native, + "reconstruction_backend_kind": reconstructed.kind, + "reconstruction_backend_name": reconstructed.name, + "reconstruction_backend_device": reconstructed.device, + "reconstruction_backend_device_index": reconstructed.device_index, + "reconstruction_backend_native": reconstructed.native, + "cell_count": len(scene.cells), + "species_count": scene.species_count, + "signal_count": scene.signal_grid.signal_count if scene.signal_grid else 0, + "contact_count": frame_contact_count, + "external_contact_count": frame_external_count, + } + ) + + +def _dataset_options( + backend: BackendKind, + device_index: int, + include_contacts: bool, + include_external_contacts: bool, + path_provenance: bool, + contact_values: ContactParameters, + constraint_values: ConstraintContactParameters, +) -> dict[str, JSONValue]: + options: dict[str, JSONValue] = { + "backend": _BACKEND_NAMES[backend], + "device_index": device_index, + "include_contacts": include_contacts, + "include_external_contacts": include_external_contacts, + "path_provenance": path_provenance, + "contact_parameters": ( + _parameter_record( + contact_values, + ("activation_margin", "parallel_sine_threshold", "degeneracy_epsilon"), + ) + if include_contacts + else None + ), + "constraint_contact_parameters": ( + _parameter_record(constraint_values, ("activation_margin", "degeneracy_epsilon")) + if include_external_contacts + else None + ), + "contact_conformance": ( + "cpu_reference" if backend == BackendKind.CPU else "hardware_conformant" + ) + if include_contacts or include_external_contacts + else None, + } + + return options + + +def _write_dataset_manifest( + temporary: Path, + frames: Sequence[_SourceFrame], + options: dict[str, JSONValue], + table_manifest: dict[str, JSONValue], + signal_manifest: dict[str, JSONValue] | None, +) -> str: + source_manifest: list[JSONValue] = [ + { + "frame_index": source.index, + "path": source.label, + "sha256": source.digest, + "checkpoint_version": source.bundle.schema_version, + "provenance": source.bundle.provenance, + } + for source in frames + ] + dataset_id = hashlib.sha256( + _canonical_json( + { + "format": ANALYSIS_FORMAT, + "version": ANALYSIS_VERSION, + "sources": source_manifest, + "options": options, + "tables": table_manifest, + "signals": signal_manifest, + } + ) + ).hexdigest() + manifest: dict[str, JSONValue] = { + "format": ANALYSIS_FORMAT, + "version": ANALYSIS_VERSION, + "dataset_id": dataset_id, + "sources": source_manifest, + "options": options, + "tables": table_manifest, + "signals": signal_manifest, + } + (temporary / "manifest.json").write_bytes( + json.dumps( + manifest, + allow_nan=False, + ensure_ascii=False, + indent=2, + sort_keys=True, + ).encode("utf-8") + + b"\n" + ) + + return dataset_id + + +def _write_dataset_data( + temporary: Path, + tables: list[tuple[str, list[dict[str, object]], Any]], + epoch_values: list[_SignalEpoch], +) -> tuple[dict[str, JSONValue], dict[str, JSONValue] | None]: + table_manifest: dict[str, JSONValue] = {} + + for filename, rows, schema in tables: + path = temporary / filename + _write_table(path, rows, schema) + table_manifest[filename] = cast( + JSONValue, + { + "rows": len(rows), + "schema": _schema_record(schema), + "sha256": _sha256(path), + }, + ) + + signal_manifest: dict[str, JSONValue] | None = None + + if epoch_values: + signals_path = temporary / "signals.zarr" + epoch_records = _write_signals(signals_path, epoch_values) + signal_manifest = cast( + dict[str, JSONValue], + { + "path": "signals.zarr", + "sha256_tree": _directory_digest(signals_path), + "epochs": epoch_records, + }, + ) + + return table_manifest, signal_manifest + + +def _analysis_tables( + frame_rows: list[dict[str, object]], + cell_rows: list[dict[str, object]], + species_rows: list[dict[str, object]], + contact_rows: list[dict[str, object]], + external_rows: list[dict[str, object]], + culture_rows: dict[str, list[dict[str, object]]], + include_contacts: bool, + include_external_contacts: bool, +) -> list[tuple[str, list[dict[str, object]], Any]]: + tables: list[tuple[str, list[dict[str, object]], Any]] = [ + ("frames.parquet", frame_rows, _FRAMES_SCHEMA), + ("cells.parquet", cell_rows, _CELLS_SCHEMA), + ("species.parquet", species_rows, _SPECIES_SCHEMA), + ] + + if include_contacts: + tables.append(("contacts.parquet", contact_rows, _CONTACTS_SCHEMA)) + + if include_external_contacts: + tables.append(("external_contacts.parquet", external_rows, _EXTERNAL_CONTACTS_SCHEMA)) + + if culture_rows["culture_frames.parquet"]: + tables.extend( + (name, culture_rows[name], schema) for name, schema in _CULTURE_SCHEMAS.items() + ) + + return tables + + def export_dataset( checkpoints: Sequence[str | os.PathLike[str]], output: str | os.PathLike[str], @@ -628,9 +1181,12 @@ def export_dataset( if device_index < 0: raise AnalysisError("device index must be non-negative") + destination = Path(output) + if destination.exists() and not replace: raise AnalysisError(f"output already exists: {destination}") + destination.parent.mkdir(parents=True, exist_ok=True) frames = _load_sources(checkpoints, backend, device_index, path_provenance) contact_values = contact_parameters or ContactParameters() @@ -641,224 +1197,58 @@ def export_dataset( species_rows: list[dict[str, object]] = [] contact_rows: list[dict[str, object]] = [] external_rows: list[dict[str, object]] = [] + culture_rows: dict[str, list[dict[str, object]]] = {name: [] for name in _CULTURE_SCHEMAS} + for source in frames: - scene = source.scene - for cell in scene.cells: - cell_rows.append( - { - "frame_index": source.index, - "id": cell.id, - "parent_id": cell.parent_id, - "slot": cell.slot, - "position_x": cell.position[0], - "position_y": cell.position[1], - "position_z": cell.position[2], - "direction_x": cell.direction[0], - "direction_y": cell.direction[1], - "direction_z": cell.direction[2], - "cylinder_length": cell.length, - "radius": cell.radius, - "capsule_length": cell.length + (2.0 * cell.radius), - "growth_rate": cell.growth_rate, - "cell_type": cell.cell_type, - "fixed": cell.fixed, - } - ) - for channel, level in enumerate(cell.species): - species_rows.append( - { - "frame_index": source.index, - "cell_id": cell.id, - "channel": channel, - "level": level, - } - ) + _append_culture_rows(source, culture_rows) + + _append_cell_rows(source, cell_rows, species_rows) frame_contact_count = 0 + if include_contacts: - graph = source.bundle.simulation.find_cell_contacts(contact_values) - for contact in graph.contacts: - contact_rows.append( - { - "frame_index": source.index, - "first_id": contact.first_id, - "second_id": contact.second_id, - "first_slot": contact.first_slot, - "second_slot": contact.second_slot, - "ordinal": contact.ordinal, - "point_x": contact.point_on_first.x, - "point_y": contact.point_on_first.y, - "point_z": contact.point_on_first.z, - "normal_x": contact.normal.x, - "normal_y": contact.normal.y, - "normal_z": contact.normal.z, - "signed_separation": contact.signed_separation, - "overlap": max(0.0, -contact.signed_separation), - "weight": contact.weight, - } - ) - frame_contact_count = len(graph) + frame_contact_count = _append_contact_rows(source, contact_values, contact_rows) frame_external_count = 0 + if include_external_contacts: - external_graph = source.bundle.simulation.find_external_contacts(constraint_values) - for contact in external_graph.contacts: - external_rows.append( - { - "frame_index": source.index, - "cell_id": contact.cell_id, - "cell_slot": contact.cell_slot, - "constraint_id": contact.constraint_id, - "constraint_kind": _CONSTRAINT_NAMES[contact.constraint_kind], - "location": _CONTACT_LOCATION_NAMES[contact.location], - "point_x": contact.point_on_cell.x, - "point_y": contact.point_on_cell.y, - "point_z": contact.point_on_cell.z, - "normal_x": contact.normal.x, - "normal_y": contact.normal.y, - "normal_z": contact.normal.z, - "signed_separation": contact.signed_separation, - "overlap": max(0.0, -contact.signed_separation), - "weight": contact.weight, - } - ) - frame_external_count = len(external_graph) + frame_external_count = _append_external_rows(source, constraint_values, external_rows) - source_backend = source.bundle.source_backend - reconstructed = scene.backend - frame_rows.append( - { - "frame_index": source.index, - "time": scene.time, - "source": source.label, - "source_sha256": source.digest, - "checkpoint_version": source.bundle.schema_version, - "source_backend_kind": source_backend.kind, - "source_backend_name": source_backend.name, - "source_backend_device": source_backend.device, - "source_backend_device_index": source_backend.device_index, - "source_backend_native": source_backend.native, - "reconstruction_backend_kind": reconstructed.kind, - "reconstruction_backend_name": reconstructed.name, - "reconstruction_backend_device": reconstructed.device, - "reconstruction_backend_device_index": reconstructed.device_index, - "reconstruction_backend_native": reconstructed.native, - "cell_count": len(scene.cells), - "species_count": scene.species_count, - "signal_count": scene.signal_grid.signal_count if scene.signal_grid else 0, - "contact_count": frame_contact_count, - "external_contact_count": frame_external_count, - } - ) + _append_frame_row(source, frame_rows, frame_contact_count, frame_external_count) epoch_values = _signal_epochs(frames) temporary = Path(tempfile.mkdtemp(prefix=f".{destination.name}.tmp-", dir=destination.parent)) - try: - tables: list[tuple[str, list[dict[str, object]], Any]] = [ - ("frames.parquet", frame_rows, _FRAMES_SCHEMA), - ("cells.parquet", cell_rows, _CELLS_SCHEMA), - ("species.parquet", species_rows, _SPECIES_SCHEMA), - ] - if include_contacts: - tables.append(("contacts.parquet", contact_rows, _CONTACTS_SCHEMA)) - if include_external_contacts: - tables.append(("external_contacts.parquet", external_rows, _EXTERNAL_CONTACTS_SCHEMA)) - table_manifest: dict[str, JSONValue] = {} - for filename, rows, schema in tables: - path = temporary / filename - _write_table(path, rows, schema) - table_manifest[filename] = cast( - JSONValue, - { - "rows": len(rows), - "schema": _schema_record(schema), - "sha256": _sha256(path), - }, - ) - signal_manifest: dict[str, JSONValue] | None = None - if epoch_values: - signals_path = temporary / "signals.zarr" - epoch_records = _write_signals(signals_path, epoch_values) - signal_manifest = cast( - dict[str, JSONValue], - { - "path": "signals.zarr", - "sha256_tree": _directory_digest(signals_path), - "epochs": epoch_records, - }, - ) + try: + tables = _analysis_tables( + frame_rows, + cell_rows, + species_rows, + contact_rows, + external_rows, + culture_rows, + include_contacts, + include_external_contacts, + ) - options: dict[str, JSONValue] = { - "backend": _BACKEND_NAMES[backend], - "device_index": device_index, - "include_contacts": include_contacts, - "include_external_contacts": include_external_contacts, - "path_provenance": path_provenance, - "contact_parameters": ( - _parameter_record( - contact_values, - ("activation_margin", "parallel_sine_threshold", "degeneracy_epsilon"), - ) - if include_contacts - else None - ), - "constraint_contact_parameters": ( - _parameter_record(constraint_values, ("activation_margin", "degeneracy_epsilon")) - if include_external_contacts - else None - ), - "contact_conformance": ( - "cpu_reference" if backend == BackendKind.CPU else "hardware_conformant" - ) - if include_contacts or include_external_contacts - else None, - } - source_manifest: list[JSONValue] = [ - { - "frame_index": source.index, - "path": source.label, - "sha256": source.digest, - "checkpoint_version": source.bundle.schema_version, - "provenance": source.bundle.provenance, - } - for source in frames - ] - dataset_id = hashlib.sha256( - _canonical_json( - { - "format": ANALYSIS_FORMAT, - "version": ANALYSIS_VERSION, - "sources": source_manifest, - "options": options, - "tables": table_manifest, - "signals": signal_manifest, - } - ) - ).hexdigest() - manifest: dict[str, JSONValue] = { - "format": ANALYSIS_FORMAT, - "version": ANALYSIS_VERSION, - "dataset_id": dataset_id, - "sources": source_manifest, - "options": options, - "tables": table_manifest, - "signals": signal_manifest, - } - (temporary / "manifest.json").write_bytes( - json.dumps( - manifest, - allow_nan=False, - ensure_ascii=False, - indent=2, - sort_keys=True, - ).encode("utf-8") - + b"\n" + table_manifest, signal_manifest = _write_dataset_data(temporary, tables, epoch_values) + options = _dataset_options( + backend, + device_index, + include_contacts, + include_external_contacts, + path_provenance, + contact_values, + constraint_values, + ) + dataset_id = _write_dataset_manifest( + temporary, frames, options, table_manifest, signal_manifest ) _publish(temporary, destination, replace=replace) except Exception: if temporary.exists(): shutil.rmtree(temporary) + raise return AnalysisSummary( diff --git a/python/src/microsimulator/analysis_recipes.py b/python/src/microsimulator/analysis_recipes.py index a1d2557..5a03e0c 100644 --- a/python/src/microsimulator/analysis_recipes.py +++ b/python/src/microsimulator/analysis_recipes.py @@ -44,14 +44,19 @@ def _dataset(source: DatasetSource) -> AnalysisDataset: def _edges(values: Sequence[float], name: str) -> tuple[float, ...]: result = tuple(float(value) for value in values) + if len(result) < 2: raise AnalysisError(f"{name} requires at least two edges") + if not all(math.isfinite(value) for value in result): raise AnalysisError(f"{name} edges must be finite") + if any(right <= left for left, right in pairwise(result)): raise AnalysisError(f"{name} edges must be strictly increasing") + if len(result) - 1 > _UINT32_MAX: raise AnalysisError(f"{name} has too many bins") + return result @@ -69,6 +74,7 @@ def _bins(values: Sequence[float], prefix: str) -> tuple[pl.LazyFrame, int]: ), } ) + return frame.lazy(), count - 1 @@ -89,6 +95,7 @@ def cells_with_radial_position(source: DatasetSource) -> pl.LazyFrame: """Add radial XY position without discarding any typed cell column.""" cells = _dataset(source).scan_table("cells.parquet") + return cells.with_columns( ( pl.col("position_x").cast(pl.Float64).pow(2) @@ -117,6 +124,7 @@ def radial_counts(source: DatasetSource, edges: Sequence[float]) -> pl.LazyFrame counts = assigned.group_by("frame_index", "radial_bin").agg( pl.len().cast(pl.UInt64).alias("cell_count") ) + return ( _frame_grid(dataset, bins) .join(counts, on=["frame_index", "radial_bin"], how="left") @@ -138,6 +146,7 @@ def radial_species_mean( if isinstance(channel, bool) or channel < 0 or channel > _UINT32_MAX: raise AnalysisError("species channel must be a uint32 value") + dataset = _dataset(source) bins, last_bin = _bins(edges, "radial") positions = cells_with_radial_position(dataset).select( @@ -154,6 +163,7 @@ def radial_species_mean( pl.len().cast(pl.UInt64).alias("cell_count"), pl.col("level").cast(pl.Float64).mean().alias("species_mean"), ) + return ( _frame_grid(dataset, bins) .join(means, on=["frame_index", "radial_bin"], how="left") @@ -172,6 +182,7 @@ def length_histogram( if length not in {"cylinder_length", "capsule_length"}: raise AnalysisError(f"unknown length field {length!r}") + dataset = _dataset(source) bins, last_bin = _bins(edges, "length") assigned = ( @@ -183,6 +194,7 @@ def length_histogram( counts = assigned.group_by("frame_index", "length_bin").agg( pl.len().cast(pl.UInt64).alias("cell_count") ) + return ( _frame_grid(dataset, bins) .join(counts, on=["frame_index", "length_bin"], how="left") @@ -224,6 +236,7 @@ def line_density_xy( pl.len().cast(pl.UInt64).alias("cell_count"), pl.col("capsule_length").sum().alias("line_density_proxy"), ) + return ( _frame_grid(dataset, bin_grid) .join(density, on=["frame_index", "x_bin", "y_bin"], how="left") @@ -248,6 +261,7 @@ def unique_neighbor_edges(source: DatasetSource) -> pl.LazyFrame: "overlap", "weight", ) + return ( normalized.group_by("frame_index", "first_id", "second_id") .agg( @@ -288,6 +302,7 @@ def sister_neighbor_counts(source: DatasetSource) -> pl.LazyFrame: pl.len().cast(pl.UInt32).alias("sister_neighbor_count") ) frames = dataset.scan_table("frames.parquet").select("frame_index", "time") + return ( cells.join(frames, on="frame_index", how="left") .join(counts, on=["frame_index", "cell_id"], how="left") @@ -308,28 +323,39 @@ def _signal_epoch( ) -> tuple[AnalysisDataset, dict[str, object], Any]: if isinstance(epoch_index, bool) or epoch_index < 0: raise AnalysisError("signal epoch index must be non-negative") + dataset = _dataset(source) signal_value = dataset.manifest["signals"] + if not isinstance(signal_value, dict): raise AnalysisError("analysis dataset does not contain signals") + signal_record = cast(dict[str, object], signal_value) epochs = signal_record.get("epochs") + if not isinstance(epochs, list) or epoch_index >= len(epochs): raise AnalysisError(f"signal epoch {epoch_index} is unavailable") + epoch_value = epochs[epoch_index] + if not isinstance(epoch_value, dict): raise AnalysisError(f"signal epoch {epoch_index} metadata is invalid") + epoch = cast(dict[str, object], epoch_value) expected_name = f"epoch-{epoch_index:04d}" + if epoch.get("name") != expected_name: raise AnalysisError(f"signal epoch {epoch_index} name is invalid") + root = zarr.open_group(dataset.root / "signals.zarr", mode="r") + return dataset, epoch, root[expected_name] def _signal_shape(epoch: dict[str, object]) -> tuple[int, int, int, int]: signal_count = epoch.get("signal_count") shape = epoch.get("shape") + if ( isinstance(signal_count, bool) or not isinstance(signal_count, int) @@ -341,7 +367,9 @@ def _signal_shape(epoch: dict[str, object]) -> tuple[int, int, int, int]: ) ): raise AnalysisError("signal epoch shape metadata is invalid") + dimensions = cast(list[int], shape) + return signal_count, dimensions[0], dimensions[1], dimensions[2] @@ -362,19 +390,27 @@ def signal_slice( if axis not in {"x", "y", "z"}: raise AnalysisError(f"unknown signal slice axis {axis!r}") + _, metadata, group = _signal_epoch(source, epoch) signal_count, x_size, y_size, z_size = _signal_shape(metadata) frame_indices = metadata.get("frame_indices") + if not isinstance(frame_indices, list): raise AnalysisError("signal epoch frame metadata is invalid") + if isinstance(local_frame, bool) or local_frame < 0 or local_frame >= len(frame_indices): raise AnalysisError("signal local frame index is out of range") + if isinstance(channel, bool) or channel < 0 or channel >= signal_count: raise AnalysisError("signal channel is out of range") + axis_sizes = {"x": x_size, "y": y_size, "z": z_size} + if isinstance(index, bool) or index < 0 or index >= axis_sizes[axis]: raise AnalysisError(f"signal {axis} index is out of range") + levels = group["levels"] + if axis == "x": values = levels[local_frame, channel, index, :, :] dimensions: tuple[SignalAxis, SignalAxis] = ("y", "z") @@ -384,6 +420,7 @@ def signal_slice( else: values = levels[local_frame, channel, :, :, index] dimensions = ("x", "y") + return SignalSlice( frame_index=cast(int, frame_indices[local_frame]), time=float(group["time"][local_frame]), @@ -410,14 +447,18 @@ def signal_time_course( signal_count, x_size, y_size, z_size = _signal_shape(metadata) coordinates = {"x": x, "y": y, "z": z} limits = {"x": x_size, "y": y_size, "z": z_size} + if isinstance(channel, bool) or channel < 0 or channel >= signal_count: raise AnalysisError("signal channel is out of range") + for name, value in coordinates.items(): if isinstance(value, bool) or value < 0 or value >= limits[name]: raise AnalysisError(f"signal {name} index is out of range") + frame_indices = np.asarray(group["frame_index"][:], dtype=np.uint32) times = np.asarray(group["time"][:], dtype=np.float64) frame_count = frame_indices.shape[0] + return pl.DataFrame( { "frame_index": pl.Series(frame_indices, dtype=pl.UInt32), diff --git a/python/src/microsimulator/biomass.py b/python/src/microsimulator/biomass.py index 523d055..5f271c1 100644 --- a/python/src/microsimulator/biomass.py +++ b/python/src/microsimulator/biomass.py @@ -9,11 +9,14 @@ def biomass_volume(length: float, radius: float) -> float: At constant biomass density, multiplying this measure by density gives biomass mass. It is the same volume used by native concentration dilution. """ + if not math.isfinite(length) or length < 0 or not math.isfinite(radius) or radius <= 0: raise ValueError("length must be finite and nonnegative; radius finite and positive") + return math.pi * radius * radius * (length + 2 * radius) def capsule_volume(length: float, radius: float) -> float: """Return the geometric capsule volume, which is not the biomass measure.""" + return biomass_volume(length, radius) - (2 / 3) * math.pi * radius**3 diff --git a/python/src/microsimulator/channels.py b/python/src/microsimulator/channels.py new file mode 100644 index 0000000..c1643bb --- /dev/null +++ b/python/src/microsimulator/channels.py @@ -0,0 +1,98 @@ +"""Optional presentation labels; numerical channel indices remain authoritative.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, cast + +if TYPE_CHECKING: + from .checkpoint import JSONValue + + +class ChannelMetadataError(ValueError): + """Raised when labels cannot describe the declared numerical channels.""" + + +@dataclass(frozen=True, slots=True) +class ChannelMetadata: + """Ordered labels, with ``None`` for an unnamed group or individual channel. + + Explicit tuples must match channel counts exactly. Empty/whitespace-only + strings are treated as missing labels for display, but preserved on disk. + """ + + species: tuple[str | None, ...] | None = None + signals: tuple[str | None, ...] | None = None + + def __post_init__(self) -> None: + for kind in ("species", "signals"): + labels = cast(object, getattr(self, kind)) + + if labels is None: + continue + + if not isinstance(labels, tuple): + raise ChannelMetadataError(f"channel_metadata.{kind}: expected a tuple or None") + + for index, label in enumerate(cast(tuple[object, ...], labels)): + if label is not None and not isinstance(label, str): + raise ChannelMetadataError( + f"channel_metadata.{kind}[{index}]: expected a string or None" + ) + + if isinstance(label, str): + try: + label.encode("utf-8") + except UnicodeEncodeError as error: + raise ChannelMetadataError( + f"channel_metadata.{kind}[{index}]: invalid Unicode scalar value" + ) from error + + def resolved(self, species_count: int, signal_count: int) -> ChannelMetadata: + """Validate explicit counts and expand unspecified groups to null labels.""" + + groups: list[tuple[str | None, ...]] = [] + + for kind, labels, count in ( + ("species", self.species, species_count), + ("signals", self.signals, signal_count), + ): + if labels is not None and len(labels) != count: + raise ChannelMetadataError( + f"channel_metadata.{kind}: expected {count} labels, got {len(labels)}" + ) + + groups.append((None,) * count if labels is None else labels) + + return ChannelMetadata(species=groups[0], signals=groups[1]) + + def to_json(self, species_count: int, signal_count: int) -> dict[str, JSONValue]: + labels = self.resolved(species_count, signal_count) + + return {"species": list(labels.species or ()), "signals": list(labels.signals or ())} + + @classmethod + def from_json(cls, value: object, species_count: int, signal_count: int) -> ChannelMetadata: + """Decode the closed data-only representation shared by scenes/checkpoints.""" + + if not isinstance(value, dict) or set(cast(dict[object, object], value)) != { + "species", + "signals", + }: + raise ChannelMetadataError("channel_metadata: expected exactly species and signals") + + data = cast(dict[str, object], value) + groups: list[tuple[str | None, ...]] = [] + + for kind in ("species", "signals"): + values = data[kind] + + if not isinstance(values, list): + raise ChannelMetadataError(f"channel_metadata.{kind}: expected an array") + + groups.append(cast(tuple[str | None, ...], tuple(cast(list[object], values)))) + + return cls(species=groups[0], signals=groups[1]).resolved(species_count, signal_count) + + +UNNAMED_CHANNELS = ChannelMetadata() diff --git a/python/src/microsimulator/checkpoint.py b/python/src/microsimulator/checkpoint.py index fa87c6c..77efe4c 100644 --- a/python/src/microsimulator/checkpoint.py +++ b/python/src/microsimulator/checkpoint.py @@ -43,11 +43,13 @@ _SphereConstraint, _WorldStateCheckpoint, ) +from ._culture_checkpoint import decode_culture_checkpoint, encode_culture_checkpoint +from .channels import UNNAMED_CHANNELS, ChannelMetadata, ChannelMetadataError CHECKPOINT_FORMAT = "microsimulator-checkpoint" -CHECKPOINT_VERSION = 8 +CHECKPOINT_VERSION = 11 MAX_CHECKPOINT_BYTES = 1 << 30 -_NATIVE_CHECKPOINT_VERSION = 4 +_NATIVE_CHECKPOINT_VERSION = 5 _UINT32_MAX = (1 << 32) - 1 _UINT64_MAX = (1 << 64) - 1 @@ -83,6 +85,7 @@ class CheckpointBundle: provenance: dict[str, JSONValue] schema_version: int source_backend: CheckpointSourceBackend + channel_metadata: ChannelMetadata = UNNAMED_CHANNELS _RATE_OP_NAMES = { @@ -162,7 +165,9 @@ def _boundary_to_json(boundary: GridBoundary) -> dict[str, JSONValue]: def _signal_grid_to_json(checkpoint: _SignalGridCheckpoint | None) -> JSONValue: if checkpoint is None: return None + spec = checkpoint.spec + return { "spec": { "signal_count": spec.signal_count, @@ -224,6 +229,7 @@ def _instructions_to_json(instructions: list[RateInstruction]) -> list[JSONValue def _coupled_rate_plan_to_json(plan: CoupledRatePlan | None) -> JSONValue: if plan is None: return None + return { "species_count": plan.species_count, "signal_count": plan.signal_count, @@ -235,6 +241,7 @@ def _coupled_rate_plan_to_json(plan: CoupledRatePlan | None) -> JSONValue: def _simulation_to_json(checkpoint: _SimulationCheckpoint) -> dict[str, JSONValue]: cells: list[JSONValue] = [] + for cell in checkpoint.world.cells: cells.append( { @@ -295,6 +302,7 @@ def _simulation_to_json(checkpoint: _SimulationCheckpoint) -> dict[str, JSONValu for cylinder in checkpoint.constraints.cylinders ] instructions = _instructions_to_json(checkpoint.species_rate_plan.instructions) + return { "time": checkpoint.time, "world": { @@ -317,6 +325,7 @@ def _simulation_to_json(checkpoint: _SimulationCheckpoint) -> dict[str, JSONValu }, "signal_grid": _signal_grid_to_json(checkpoint.signal_grid), "coupled_rate_plan": _coupled_rate_plan_to_json(checkpoint.coupled_rate_plan), + "culture": encode_culture_checkpoint(checkpoint.culture), } @@ -336,12 +345,19 @@ def save_checkpoint( *, provenance: Mapping[str, JSONValue] | None = None, controller: JSONValue = None, + channel_metadata: ChannelMetadata = UNNAMED_CHANNELS, ) -> None: """Atomically save a complete simulation checkpoint as validated JSON.""" checkpoint = simulation._checkpoint() checkpoint.validate() state = _simulation_to_json(checkpoint) + + try: + labels = channel_metadata.to_json(simulation.species_count, simulation.signal_count) + except ChannelMetadataError as error: + raise CheckpointError(str(error)) from error + digest = hashlib.sha256(_canonical_json(state)).hexdigest() controller_digest = hashlib.sha256(_canonical_json(controller)).hexdigest() backend = simulation.backend_info @@ -361,10 +377,13 @@ def save_checkpoint( "algorithm": "sha256", "simulation": digest, "controller": controller_digest, + "channel_metadata": hashlib.sha256(_canonical_json(labels)).hexdigest(), }, "simulation": state, "controller": controller, + "channel_metadata": labels, } + try: encoded = ( json.dumps( @@ -381,6 +400,7 @@ def save_checkpoint( destination = Path(path) temporary: Path | None = None + try: with tempfile.NamedTemporaryFile( mode="wb", @@ -393,6 +413,7 @@ def save_checkpoint( stream.write(encoded) stream.flush() os.fsync(stream.fileno()) + os.replace(temporary, destination) temporary = None except OSError as error: @@ -412,25 +433,32 @@ def _reject_constant(value: str) -> NoReturn: def _reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]: result: dict[str, object] = {} + for key, value in pairs: if key in result: raise CheckpointError(f"checkpoint contains duplicate key {key!r}") + result[key] = value + return result def _object(value: object, path: str) -> dict[str, object]: if not isinstance(value, dict): _fail(path, "expected an object") + mapping = cast(dict[object, object], value) + if not all(isinstance(key, str) for key in mapping): _fail(path, "expected string object keys") + return cast(dict[str, object], mapping) def _array(value: object, path: str) -> list[object]: if not isinstance(value, list): _fail(path, "expected an array") + return cast(list[object], value) @@ -440,8 +468,10 @@ def _keys( allowed = required | (optional or set()) missing = required - value.keys() unknown = value.keys() - allowed + if missing: _fail(path, f"missing keys {sorted(missing)}") + if unknown: _fail(path, f"unknown keys {sorted(unknown)}") @@ -449,41 +479,51 @@ def _keys( def _string(value: object, path: str) -> str: if not isinstance(value, str): _fail(path, "expected a string") + return value def _integer(value: object, path: str, minimum: int, maximum: int) -> int: if isinstance(value, bool) or not isinstance(value, int): _fail(path, "expected an integer") + if value < minimum or value > maximum: _fail(path, f"integer is outside [{minimum}, {maximum}]") + return value def _boolean(value: object, path: str) -> bool: if not isinstance(value, bool): _fail(path, "expected a boolean") + return value def _number(value: object, path: str, *, float32: bool = False) -> float: if isinstance(value, bool) or not isinstance(value, int | float): _fail(path, "expected a number") + try: result = float(value) except (OverflowError, ValueError): _fail(path, "number is outside the finite float64 range") + if not math.isfinite(result): _fail(path, "number must be finite") + if float32 and abs(result) > _FLOAT32_MAX: _fail(path, "number is outside the finite float32 range") + return result def _vec3(value: object, path: str) -> Vec3: values = _array(value, path) + if len(values) != 3: _fail(path, "expected exactly three coordinates") + return Vec3( _number(values[0], f"{path}[0]", float32=True), _number(values[1], f"{path}[1]", float32=True), @@ -504,8 +544,10 @@ def _cell(value: object, path: str, schema_version: int) -> CellSnapshot: "cell_type", "species", } + if schema_version >= 6: required.add("fixed") + _keys( data, path, @@ -525,6 +567,7 @@ def _cell(value: object, path: str, schema_version: int) -> CellSnapshot: _number(item, f"{path}.species[{index}]", float32=True) for index, item in enumerate(_array(data["species"], f"{path}.species")) ] + return cell @@ -534,6 +577,7 @@ def _lineage_entry(value: object, path: str) -> _LineageEntry: entry = _LineageEntry() entry.child = _integer(data["child"], f"{path}.child", 1, _UINT64_MAX) entry.parent = _integer(data["parent"], f"{path}.parent", 1, _UINT64_MAX) + return entry @@ -545,6 +589,7 @@ def _plane(value: object, path: str) -> _PlaneConstraint: plane.point = _vec3(data["point"], f"{path}.point") plane.inward_normal = _vec3(data["inward_normal"], f"{path}.inward_normal") plane.coefficient = _number(data["coefficient"], f"{path}.coefficient", float32=True) + return plane @@ -557,10 +602,12 @@ def _sphere(value: object, path: str) -> _SphereConstraint: sphere.radius = _number(data["radius"], f"{path}.radius", float32=True) sphere.coefficient = _number(data["coefficient"], f"{path}.coefficient", float32=True) region_name = _string(data["allowed_region"], f"{path}.allowed_region") + try: sphere.allowed_region = _CONSTRAINT_REGIONS[region_name] except KeyError: _fail(f"{path}.allowed_region", f"unknown sphere region {region_name!r}") + return sphere @@ -573,10 +620,12 @@ def _box(value: object, path: str) -> _BoxConstraint: box.half_extents = _vec3(data["half_extents"], f"{path}.half_extents") box.coefficient = _number(data["coefficient"], f"{path}.coefficient", float32=True) region_name = _string(data["allowed_region"], f"{path}.allowed_region") + try: box.allowed_region = _CONSTRAINT_REGIONS[region_name] except KeyError: _fail(f"{path}.allowed_region", f"unknown box region {region_name!r}") + return box @@ -590,10 +639,12 @@ def _cylinder(value: object, path: str) -> _CylinderConstraint: cylinder.half_height = _number(data["half_height"], f"{path}.half_height", float32=True) cylinder.coefficient = _number(data["coefficient"], f"{path}.coefficient", float32=True) region_name = _string(data["allowed_region"], f"{path}.allowed_region") + try: cylinder.allowed_region = _CONSTRAINT_REGIONS[region_name] except KeyError: _fail(f"{path}.allowed_region", f"unknown cylinder region {region_name!r}") + return cylinder @@ -602,14 +653,17 @@ def _instruction(value: object, path: str) -> RateInstruction: _keys(data, path, {"operation", "first", "second", "third", "value"}) operation_name = _string(data["operation"], f"{path}.operation") instruction = RateInstruction() + try: instruction.operation = _RATE_OPS[operation_name] except KeyError: _fail(f"{path}.operation", f"unknown rate operation {operation_name!r}") + instruction.first = _integer(data["first"], f"{path}.first", 0, _UINT32_MAX) instruction.second = _integer(data["second"], f"{path}.second", 0, _UINT32_MAX) instruction.third = _integer(data["third"], f"{path}.third", 0, _UINT32_MAX) instruction.value = _number(data["value"], f"{path}.value", float32=True) + return instruction @@ -618,20 +672,24 @@ def _boundary(value: object, path: str) -> GridBoundary: _keys(data, path, {"kind", "values"}) kind_name = _string(data["kind"], f"{path}.kind") boundary = GridBoundary() + try: boundary.kind = _GRID_BOUNDARIES[kind_name] except KeyError: _fail(f"{path}.kind", f"unknown grid boundary kind {kind_name!r}") + boundary.values = [ _number(item, f"{path}.values[{index}]", float32=True) for index, item in enumerate(_array(data["values"], f"{path}.values")) ] + return boundary def _affine_reaction(value: object, path: str) -> SignalGridAffineReaction | None: if value is None: return None + data = _object(value, path) _keys(data, path, {"source_rates", "loss_rates"}) reaction = SignalGridAffineReaction() @@ -643,188 +701,77 @@ def _affine_reaction(value: object, path: str) -> SignalGridAffineReaction | Non _number(item, f"{path}.loss_rates[{index}]", float32=True) for index, item in enumerate(_array(data["loss_rates"], f"{path}.loss_rates")) ] - return reaction + return reaction -def _signal_grid(value: object, path: str, schema_version: int) -> _SignalGridCheckpoint | None: - if value is None: - return None - data = _object(value, path) - _keys(data, path, {"spec", "levels"}) - spec_data = _object(data["spec"], f"{path}.spec") - spec_keys = { - "signal_count", - "shape", - "origin", - "spacing", - "diffusion", - "advection", - "boundaries", - } - if schema_version >= 5: - spec_keys.update({"integration", "solver"}) - if schema_version >= 7: - spec_keys.add("reaction") - if schema_version >= 8: - spec_keys.update({"obstacles", "velocity_field"}) - _keys( - spec_data, - f"{path}.spec", - spec_keys, - ) - shape_values = _array(spec_data["shape"], f"{path}.spec.shape") - if len(shape_values) != 3: - _fail(f"{path}.spec.shape", "expected exactly three dimensions") - shape = GridShape() - shape.x = _integer(shape_values[0], f"{path}.spec.shape[0]", 1, _UINT32_MAX) - shape.y = _integer(shape_values[1], f"{path}.spec.shape[1]", 1, _UINT32_MAX) - shape.z = _integer(shape_values[2], f"{path}.spec.shape[2]", 1, _UINT32_MAX) - boundaries = _object(spec_data["boundaries"], f"{path}.spec.boundaries") - boundary_names = {"x_lower", "x_upper", "y_lower", "y_upper", "z_lower", "z_upper"} - _keys(boundaries, f"{path}.spec.boundaries", boundary_names) +def _checkpoint_velocity_field( + spec_data: dict[str, object], path: str +) -> SignalGridVelocityField | None: + field_value = spec_data["velocity_field"] - spec = SignalGridSpec() - spec.signal_count = _integer( - spec_data["signal_count"], f"{path}.spec.signal_count", 1, _UINT32_MAX - ) - spec.shape = shape - spec.origin = _vec3(spec_data["origin"], f"{path}.spec.origin") - spec.spacing = _vec3(spec_data["spacing"], f"{path}.spec.spacing") - spec.diffusion = [ - _number(item, f"{path}.spec.diffusion[{index}]", float32=True) - for index, item in enumerate(_array(spec_data["diffusion"], f"{path}.spec.diffusion")) - ] - spec.advection = [ - _vec3(item, f"{path}.spec.advection[{index}]") - for index, item in enumerate(_array(spec_data["advection"], f"{path}.spec.advection")) - ] - if schema_version >= 7: - spec.reaction = _affine_reaction(spec_data["reaction"], f"{path}.spec.reaction") - if schema_version >= 8: - spec.obstacles = [ - _integer(item, f"{path}.spec.obstacles[{index}]", 0, 1) + if field_value is not None: + field_data = _object(field_value, f"{path}.spec.velocity_field") + _keys(field_data, f"{path}.spec.velocity_field", {"x_faces", "y_faces", "z_faces"}) + field = SignalGridVelocityField() + field.x_faces = [ + _number(item, f"{path}.spec.velocity_field.x_faces[{index}]", float32=True) for index, item in enumerate( - _array(spec_data["obstacles"], f"{path}.spec.obstacles") + _array(field_data["x_faces"], f"{path}.spec.velocity_field.x_faces") + ) + ] + field.y_faces = [ + _number(item, f"{path}.spec.velocity_field.y_faces[{index}]", float32=True) + for index, item in enumerate( + _array(field_data["y_faces"], f"{path}.spec.velocity_field.y_faces") + ) + ] + field.z_faces = [ + _number(item, f"{path}.spec.velocity_field.z_faces[{index}]", float32=True) + for index, item in enumerate( + _array(field_data["z_faces"], f"{path}.spec.velocity_field.z_faces") ) ] - field_value = spec_data["velocity_field"] - if field_value is not None: - field_data = _object(field_value, f"{path}.spec.velocity_field") - _keys(field_data, f"{path}.spec.velocity_field", {"x_faces", "y_faces", "z_faces"}) - field = SignalGridVelocityField() - field.x_faces = [ - _number(item, f"{path}.spec.velocity_field.x_faces[{index}]", float32=True) - for index, item in enumerate( - _array(field_data["x_faces"], f"{path}.spec.velocity_field.x_faces") - ) - ] - field.y_faces = [ - _number(item, f"{path}.spec.velocity_field.y_faces[{index}]", float32=True) - for index, item in enumerate( - _array(field_data["y_faces"], f"{path}.spec.velocity_field.y_faces") - ) - ] - field.z_faces = [ - _number(item, f"{path}.spec.velocity_field.z_faces[{index}]", float32=True) - for index, item in enumerate( - _array(field_data["z_faces"], f"{path}.spec.velocity_field.z_faces") - ) - ] - spec.velocity_field = field - if schema_version >= 5: - integration_name = _string(spec_data["integration"], f"{path}.spec.integration") - if integration_name not in _SIGNAL_INTEGRATIONS: - _fail(f"{path}.spec.integration", f"unknown integration {integration_name!r}") - spec.integration = _SIGNAL_INTEGRATIONS[integration_name] - solver_data = _object(spec_data["solver"], f"{path}.spec.solver") - _keys( - solver_data, - f"{path}.spec.solver", - {"max_iterations", "absolute_tolerance", "relative_tolerance"}, - ) - spec.solver.max_iterations = _integer( - solver_data["max_iterations"], - f"{path}.spec.solver.max_iterations", - 1, - _UINT32_MAX, - ) - spec.solver.absolute_tolerance = _number( - solver_data["absolute_tolerance"], - f"{path}.spec.solver.absolute_tolerance", - float32=True, - ) - spec.solver.relative_tolerance = _number( - solver_data["relative_tolerance"], - f"{path}.spec.solver.relative_tolerance", - float32=True, - ) - spec.x_lower = _boundary(boundaries["x_lower"], f"{path}.spec.boundaries.x_lower") - spec.x_upper = _boundary(boundaries["x_upper"], f"{path}.spec.boundaries.x_upper") - spec.y_lower = _boundary(boundaries["y_lower"], f"{path}.spec.boundaries.y_lower") - spec.y_upper = _boundary(boundaries["y_upper"], f"{path}.spec.boundaries.y_upper") - spec.z_lower = _boundary(boundaries["z_lower"], f"{path}.spec.boundaries.z_lower") - spec.z_upper = _boundary(boundaries["z_upper"], f"{path}.spec.boundaries.z_upper") - checkpoint = _SignalGridCheckpoint() - checkpoint.spec = spec - checkpoint.levels = [ - _number(item, f"{path}.levels[{index}]", float32=True) - for index, item in enumerate(_array(data["levels"], f"{path}.levels")) - ] - return checkpoint + return field + return None -def _coupled_rate_plan(value: object, path: str) -> CoupledRatePlan | None: - if value is None: - return None - data = _object(value, path) + +def _checkpoint_signal_solver( + spec: SignalGridSpec, spec_data: dict[str, object], path: str +) -> None: + integration_name = _string(spec_data["integration"], f"{path}.spec.integration") + + if integration_name not in _SIGNAL_INTEGRATIONS: + _fail(f"{path}.spec.integration", f"unknown integration {integration_name!r}") + + spec.integration = _SIGNAL_INTEGRATIONS[integration_name] + solver_data = _object(spec_data["solver"], f"{path}.spec.solver") _keys( - data, - path, - { - "species_count", - "signal_count", - "instructions", - "species_outputs", - "signal_outputs", - }, + solver_data, + f"{path}.spec.solver", + {"max_iterations", "absolute_tolerance", "relative_tolerance"}, + ) + spec.solver.max_iterations = _integer( + solver_data["max_iterations"], + f"{path}.spec.solver.max_iterations", + 1, + _UINT32_MAX, + ) + spec.solver.absolute_tolerance = _number( + solver_data["absolute_tolerance"], + f"{path}.spec.solver.absolute_tolerance", + float32=True, + ) + spec.solver.relative_tolerance = _number( + solver_data["relative_tolerance"], + f"{path}.spec.solver.relative_tolerance", + float32=True, ) - species_count = _integer(data["species_count"], f"{path}.species_count", 0, _UINT64_MAX) - signal_count = _integer(data["signal_count"], f"{path}.signal_count", 1, _UINT64_MAX) - instructions = [ - _instruction(item, f"{path}.instructions[{index}]") - for index, item in enumerate(_array(data["instructions"], f"{path}.instructions")) - ] - species_outputs = [ - _integer(item, f"{path}.species_outputs[{index}]", 0, _UINT32_MAX) - for index, item in enumerate(_array(data["species_outputs"], f"{path}.species_outputs")) - ] - signal_outputs = [ - _integer(item, f"{path}.signal_outputs[{index}]", 0, _UINT32_MAX) - for index, item in enumerate(_array(data["signal_outputs"], f"{path}.signal_outputs")) - ] - try: - return CoupledRatePlan( - species_count, - signal_count, - instructions, - species_outputs, - signal_outputs, - ) - except (ValueError, OverflowError) as error: - raise CheckpointError(f"{path}: invalid coupled rate plan: {error}") from error - -def _native_checkpoint(value: object, schema_version: int) -> _SimulationCheckpoint: - data = _object(value, "$.simulation") - required = {"time", "world", "constraints", "species_rate_plan"} - if schema_version >= 2: - required.add("signal_grid") - if schema_version >= 3: - required.add("coupled_rate_plan") - _keys(data, "$.simulation", required) +def _checkpoint_world(data: dict[str, object], schema_version: int) -> _WorldStateCheckpoint: world_data = _object(data["world"], "$.simulation.world") _keys(world_data, "$.simulation.world", {"species_count", "next_id", "cells", "lineage"}) world = _WorldStateCheckpoint() @@ -841,10 +788,18 @@ def _native_checkpoint(value: object, schema_version: int) -> _SimulationCheckpo for index, item in enumerate(_array(world_data["lineage"], "$.simulation.world.lineage")) ] + return world + + +def _checkpoint_constraints( + data: dict[str, object], schema_version: int +) -> _ConstraintSetCheckpoint: constraint_data = _object(data["constraints"], "$.simulation.constraints") constraint_keys = {"next_id", "planes", "spheres"} + if schema_version >= 8: constraint_keys.update({"boxes", "cylinders"}) + _keys(constraint_data, "$.simulation.constraints", constraint_keys) constraints = _ConstraintSetCheckpoint() constraints.next_id = _integer( @@ -862,6 +817,7 @@ def _native_checkpoint(value: object, schema_version: int) -> _SimulationCheckpo _array(constraint_data["spheres"], "$.simulation.constraints.spheres") ) ] + if schema_version >= 8: constraints.boxes = [ _box(item, f"$.simulation.constraints.boxes[{index}]") @@ -876,6 +832,10 @@ def _native_checkpoint(value: object, schema_version: int) -> _SimulationCheckpo ) ] + return constraints + + +def _checkpoint_species_plan(data: dict[str, object]) -> SpeciesRatePlan: plan_data = _object(data["species_rate_plan"], "$.simulation.species_rate_plan") _keys( plan_data, @@ -901,43 +861,17 @@ def _native_checkpoint(value: object, schema_version: int) -> _SimulationCheckpo ) ] - checkpoint = _SimulationCheckpoint() - checkpoint.schema_version = _NATIVE_CHECKPOINT_VERSION - checkpoint.time = _number(data["time"], "$.simulation.time") - checkpoint.world = world - checkpoint.constraints = constraints - checkpoint.species_rate_plan = SpeciesRatePlan(plan_species_count, instructions, outputs) - checkpoint.signal_grid = ( - _signal_grid(data["signal_grid"], "$.simulation.signal_grid", schema_version) - if schema_version >= 2 - else None - ) - checkpoint.coupled_rate_plan = ( - _coupled_rate_plan(data["coupled_rate_plan"], "$.simulation.coupled_rate_plan") - if schema_version >= 3 - else None - ) - try: - checkpoint.validate() - except (ValueError, OverflowError) as error: - raise CheckpointError(f"checkpoint state is invalid: {error}") from error - return checkpoint - + return SpeciesRatePlan(plan_species_count, instructions, outputs) -def load_checkpoint_bundle( - path: str | os.PathLike[str], - *, - backend: BackendKind = BackendKind.CPU, - device_index: int = 0, -) -> CheckpointBundle: - """Load native and optional controller state without evaluating executable content.""" - source = Path(path) +def _read_checkpoint(source: Path) -> dict[str, object]: try: with source.open("rb") as stream: encoded = stream.read(MAX_CHECKPOINT_BYTES + 1) + if not encoded: raise CheckpointError("checkpoint is empty") + if len(encoded) > MAX_CHECKPOINT_BYTES: raise CheckpointError(f"checkpoint exceeds the {MAX_CHECKPOINT_BYTES}-byte limit") except OSError as error: @@ -955,12 +889,20 @@ def load_checkpoint_bundle( raise CheckpointError(f"checkpoint is not valid UTF-8 JSON: {error}") from error root = _object(cast(object, decoded), "$") + + return root + + +def _checkpoint_version(root: dict[str, object]) -> int: if "version" not in root: _fail("$", "missing keys ['version']") + schema_version = _integer(root["version"], "$.version", 0, _UINT32_MAX) - supported_versions = {1, 2, 3, 4, 5, 6, 7, CHECKPOINT_VERSION} + supported_versions = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, CHECKPOINT_VERSION} + if schema_version not in supported_versions: _fail("$.version", f"unsupported checkpoint version {schema_version}") + required = { "format", "version", @@ -970,16 +912,26 @@ def load_checkpoint_bundle( "integrity", "simulation", } + if schema_version >= 4: required.add("controller") + + if schema_version >= 9: + required.add("channel_metadata") + _keys( root, "$", required, ) + if _string(root["format"], "$.format") not in (CHECKPOINT_FORMAT, "cellmodeller2-checkpoint"): _fail("$.format", "not a MicroSimulator checkpoint") - _object(root["producer"], "$.producer") + + return schema_version + + +def _checkpoint_source_backend(root: dict[str, object]) -> CheckpointSourceBackend: source_backend_data = _object(root["source_backend"], "$.source_backend") _keys( source_backend_data, @@ -987,8 +939,10 @@ def load_checkpoint_bundle( {"kind", "name", "device", "device_index", "native"}, ) source_backend_kind = _string(source_backend_data["kind"], "$.source_backend.kind") + if source_backend_kind not in _BACKEND_NAMES.values(): _fail("$.source_backend.kind", f"unknown backend kind {source_backend_kind!r}") + source_backend = CheckpointSourceBackend( kind=source_backend_kind, name=_string(source_backend_data["name"], "$.source_backend.name"), @@ -1001,36 +955,286 @@ def load_checkpoint_bundle( ), native=_boolean(source_backend_data["native"], "$.source_backend.native"), ) - provenance = cast(dict[str, JSONValue], _object(root["provenance"], "$.provenance")) + return source_backend + + +def _validate_checkpoint_integrity(root: dict[str, object], schema_version: int) -> JSONValue: integrity = _object(root["integrity"], "$.integrity") integrity_keys = {"algorithm", "simulation"} + if schema_version >= 4: integrity_keys.add("controller") + + if schema_version >= 9: + integrity_keys.add("channel_metadata") + _keys(integrity, "$.integrity", integrity_keys) + if _string(integrity["algorithm"], "$.integrity.algorithm") != "sha256": _fail("$.integrity.algorithm", "unsupported integrity algorithm") + expected_digest = _string(integrity["simulation"], "$.integrity.simulation") actual_digest = hashlib.sha256(_canonical_json(root["simulation"])).hexdigest() + if not hmac.compare_digest(actual_digest, expected_digest): _fail("$.integrity.simulation", "state digest does not match") controller = cast(JSONValue, root["controller"]) if schema_version >= 4 else None + if schema_version >= 4: - expected_controller_digest = _string( - integrity["controller"], "$.integrity.controller" - ) + expected_controller_digest = _string(integrity["controller"], "$.integrity.controller") actual_controller_digest = hashlib.sha256(_canonical_json(controller)).hexdigest() + if not hmac.compare_digest(actual_controller_digest, expected_controller_digest): _fail("$.integrity.controller", "controller digest does not match") + if schema_version >= 9: + expected_labels_digest = _string( + integrity["channel_metadata"], "$.integrity.channel_metadata" + ) + actual_labels_digest = hashlib.sha256(_canonical_json(root["channel_metadata"])).hexdigest() + + if not hmac.compare_digest(actual_labels_digest, expected_labels_digest): + _fail("$.integrity.channel_metadata", "channel metadata digest does not match") + + return controller + + +def _signal_grid(value: object, path: str, schema_version: int) -> _SignalGridCheckpoint | None: + if value is None: + return None + + data = _object(value, path) + _keys(data, path, {"spec", "levels"}) + spec_data = _object(data["spec"], f"{path}.spec") + spec_keys = { + "signal_count", + "shape", + "origin", + "spacing", + "diffusion", + "advection", + "boundaries", + } + + if schema_version >= 5: + spec_keys.update({"integration", "solver"}) + + if schema_version >= 7: + spec_keys.add("reaction") + + if schema_version >= 8: + spec_keys.update({"obstacles", "velocity_field"}) + + _keys( + spec_data, + f"{path}.spec", + spec_keys, + ) + shape_values = _array(spec_data["shape"], f"{path}.spec.shape") + + if len(shape_values) != 3: + _fail(f"{path}.spec.shape", "expected exactly three dimensions") + + shape = GridShape() + shape.x = _integer(shape_values[0], f"{path}.spec.shape[0]", 1, _UINT32_MAX) + shape.y = _integer(shape_values[1], f"{path}.spec.shape[1]", 1, _UINT32_MAX) + shape.z = _integer(shape_values[2], f"{path}.spec.shape[2]", 1, _UINT32_MAX) + + boundaries = _object(spec_data["boundaries"], f"{path}.spec.boundaries") + boundary_names = {"x_lower", "x_upper", "y_lower", "y_upper", "z_lower", "z_upper"} + _keys(boundaries, f"{path}.spec.boundaries", boundary_names) + + spec = SignalGridSpec() + spec.signal_count = _integer( + spec_data["signal_count"], f"{path}.spec.signal_count", 1, _UINT32_MAX + ) + spec.shape = shape + spec.origin = _vec3(spec_data["origin"], f"{path}.spec.origin") + spec.spacing = _vec3(spec_data["spacing"], f"{path}.spec.spacing") + spec.diffusion = [ + _number(item, f"{path}.spec.diffusion[{index}]", float32=True) + for index, item in enumerate(_array(spec_data["diffusion"], f"{path}.spec.diffusion")) + ] + spec.advection = [ + _vec3(item, f"{path}.spec.advection[{index}]") + for index, item in enumerate(_array(spec_data["advection"], f"{path}.spec.advection")) + ] + + if schema_version >= 7: + spec.reaction = _affine_reaction(spec_data["reaction"], f"{path}.spec.reaction") + + if schema_version >= 8: + spec.obstacles = [ + _integer(item, f"{path}.spec.obstacles[{index}]", 0, 1) + for index, item in enumerate(_array(spec_data["obstacles"], f"{path}.spec.obstacles")) + ] + spec.velocity_field = _checkpoint_velocity_field(spec_data, path) + + if schema_version >= 5: + _checkpoint_signal_solver(spec, spec_data, path) + + spec.x_lower = _boundary(boundaries["x_lower"], f"{path}.spec.boundaries.x_lower") + spec.x_upper = _boundary(boundaries["x_upper"], f"{path}.spec.boundaries.x_upper") + spec.y_lower = _boundary(boundaries["y_lower"], f"{path}.spec.boundaries.y_lower") + spec.y_upper = _boundary(boundaries["y_upper"], f"{path}.spec.boundaries.y_upper") + spec.z_lower = _boundary(boundaries["z_lower"], f"{path}.spec.boundaries.z_lower") + spec.z_upper = _boundary(boundaries["z_upper"], f"{path}.spec.boundaries.z_upper") + + checkpoint = _SignalGridCheckpoint() + checkpoint.spec = spec + checkpoint.levels = [ + _number(item, f"{path}.levels[{index}]", float32=True) + for index, item in enumerate(_array(data["levels"], f"{path}.levels")) + ] + + return checkpoint + + +def _coupled_rate_plan(value: object, path: str) -> CoupledRatePlan | None: + if value is None: + return None + + data = _object(value, path) + _keys( + data, + path, + { + "species_count", + "signal_count", + "instructions", + "species_outputs", + "signal_outputs", + }, + ) + species_count = _integer(data["species_count"], f"{path}.species_count", 0, _UINT64_MAX) + signal_count = _integer(data["signal_count"], f"{path}.signal_count", 1, _UINT64_MAX) + instructions = [ + _instruction(item, f"{path}.instructions[{index}]") + for index, item in enumerate(_array(data["instructions"], f"{path}.instructions")) + ] + species_outputs = [ + _integer(item, f"{path}.species_outputs[{index}]", 0, _UINT32_MAX) + for index, item in enumerate(_array(data["species_outputs"], f"{path}.species_outputs")) + ] + signal_outputs = [ + _integer(item, f"{path}.signal_outputs[{index}]", 0, _UINT32_MAX) + for index, item in enumerate(_array(data["signal_outputs"], f"{path}.signal_outputs")) + ] + + try: + return CoupledRatePlan( + species_count, + signal_count, + instructions, + species_outputs, + signal_outputs, + ) + except (ValueError, OverflowError) as error: + raise CheckpointError(f"{path}: invalid coupled rate plan: {error}") from error + + +def _native_checkpoint(value: object, schema_version: int) -> _SimulationCheckpoint: + data = _object(value, "$.simulation") + required = {"time", "world", "constraints", "species_rate_plan"} + + if schema_version >= 2: + required.add("signal_grid") + + if schema_version >= 3: + required.add("coupled_rate_plan") + + if schema_version >= 10: + required.add("media_flow" if schema_version == 10 else "culture") + + _keys(data, "$.simulation", required) + + world = _checkpoint_world(data, schema_version) + + constraints = _checkpoint_constraints(data, schema_version) + + species_plan = _checkpoint_species_plan(data) + + checkpoint = _SimulationCheckpoint() + checkpoint.schema_version = _NATIVE_CHECKPOINT_VERSION + checkpoint.time = _number(data["time"], "$.simulation.time") + checkpoint.world = world + checkpoint.constraints = constraints + checkpoint.species_rate_plan = species_plan + checkpoint.signal_grid = ( + _signal_grid(data["signal_grid"], "$.simulation.signal_grid", schema_version) + if schema_version >= 2 + else None + ) + checkpoint.coupled_rate_plan = ( + _coupled_rate_plan(data["coupled_rate_plan"], "$.simulation.coupled_rate_plan") + if schema_version >= 3 + else None + ) + + try: + checkpoint.culture = ( + decode_culture_checkpoint( + data["media_flow" if schema_version == 10 else "culture"], + schema_version, + checkpoint.time, + ) + if schema_version >= 10 + else None + ) + checkpoint.validate() + except (ValueError, OverflowError, TypeError) as error: + raise CheckpointError(f"checkpoint state is invalid: {error}") from error + + return checkpoint + + +def load_checkpoint_bundle( + path: str | os.PathLike[str], + *, + backend: BackendKind = BackendKind.CPU, + device_index: int = 0, +) -> CheckpointBundle: + """Load native and optional controller state without evaluating executable content.""" + + source = Path(path) + + root = _read_checkpoint(source) + + schema_version = _checkpoint_version(root) + + _object(root["producer"], "$.producer") + source_backend = _checkpoint_source_backend(root) + provenance = cast(dict[str, JSONValue], _object(root["provenance"], "$.provenance")) + + controller = _validate_checkpoint_integrity(root, schema_version) + checkpoint = _native_checkpoint(root["simulation"], schema_version) + species_count = checkpoint.world.species_count + signal_count = checkpoint.signal_grid.spec.signal_count if checkpoint.signal_grid else 0 + + if checkpoint.culture is not None: + signal_count = len(checkpoint.culture.configuration.solutes) + + try: + labels = ( + ChannelMetadata.from_json(root["channel_metadata"], species_count, signal_count) + if schema_version >= 9 + # Legacy files contain no labels. Preserve that omission compactly: + # claimed native counts must not allocate new presentation arrays. + else UNNAMED_CHANNELS + ) + except ChannelMetadataError as error: + raise CheckpointError(str(error)) from error + return CheckpointBundle( simulation=Simulation(backend, checkpoint, device_index), controller=controller, provenance=provenance, schema_version=schema_version, source_backend=source_backend, + channel_metadata=labels, ) @@ -1043,8 +1247,19 @@ def load_checkpoint( """Load a native checkpoint, rejecting controller state that would be discarded.""" bundle = load_checkpoint_bundle(path, backend=backend, device_index=device_index) + if bundle.controller is not None: raise CheckpointError( "checkpoint contains controller state; load it with load_checkpoint_bundle" ) + + if any( + label is not None + for group in (bundle.channel_metadata.species, bundle.channel_metadata.signals) + for label in (group or ()) + ): + raise CheckpointError( + "checkpoint contains channel metadata; load it with load_checkpoint_bundle" + ) + return bundle.simulation diff --git a/python/src/microsimulator/cli.py b/python/src/microsimulator/cli.py index 540473f..a2cc588 100644 --- a/python/src/microsimulator/cli.py +++ b/python/src/microsimulator/cli.py @@ -107,6 +107,12 @@ def _parser() -> argparse.ArgumentParser: ) analysis.add_argument("--overwrite", action="store_true") + replay = commands.add_parser( + "export-replay", help="export explicitly ordered checkpoints for offline replay" + ) + replay.add_argument("checkpoints", nargs="+", type=Path) + replay.add_argument("--output", type=Path, required=True, help="new replay bundle directory") + manifest = commands.add_parser( "run-manifest", help="execute one named job from a data-only run manifest" ) @@ -115,6 +121,7 @@ def _parser() -> argparse.ArgumentParser: manifest.add_argument("--progress-every", type=int, default=100) manifest.add_argument("--overwrite", action="store_true") manifest.add_argument("--quiet", action="store_true") + return parser @@ -145,28 +152,39 @@ def _add_source_arguments(parser: argparse.ArgumentParser) -> None: def _json_value(value: object, path: str) -> JSONValue: if value is None or isinstance(value, str | bool): return value + if isinstance(value, int): return value + if isinstance(value, float): if not math.isfinite(value): raise BatchError(f"{path} must be finite JSON") + return value + if isinstance(value, list): return [_json_value(item, f"{path}[]") for item in cast(list[object], value)] + if isinstance(value, dict): mapping = cast(dict[object, object], value) + if not all(isinstance(key, str) for key in mapping): raise BatchError(f"{path} must use string object keys") + return {cast(str, key): _json_value(item, f"{path}.{key}") for key, item in mapping.items()} + raise BatchError(f"{path} is not JSON data") def _parameters(values: Sequence[str]) -> dict[str, JSONValue]: result: dict[str, JSONValue] = {} + for value in values: name, separator, encoded = value.partition("=") + if not separator or not name: raise BatchError(f"invalid parameter {value!r}; expected NAME=JSON") + if name in result: raise BatchError(f"duplicate parameter {name!r}") @@ -177,54 +195,71 @@ def reject_constant(constant: str, parameter_name: str = name) -> NoReturn: decoded = json.loads(encoded, parse_constant=reject_constant) except json.JSONDecodeError as error: raise BatchError(f"parameter {name!r} is not valid JSON: {error.msg}") from error + result[name] = _json_value(cast(object, decoded), f"parameter {name!r}") + return result def _device_records() -> list[dict[str, JSONValue]]: records: list[dict[str, JSONValue]] = [] + for name, backend in _BACKENDS.items(): count = backend_device_count(backend) + if count == 0: records.append({"backend": name, "available": False, "devices": []}) continue + devices: list[JSONValue] = [] + for device_index in range(count): info = Simulation(backend, device_index=device_index).backend_info devices.append({"index": info.device_index, "name": info.device}) + records.append({"backend": name, "available": True, "devices": devices}) + return records def _devices(json_output: bool) -> int: records = _device_records() + if json_output: print(json.dumps(records, indent=2, sort_keys=True)) + return 0 + for record in records: backend = cast(str, record["backend"]) devices = cast(list[JSONValue], record["devices"]) + if not devices: print(f"{backend}: unavailable") continue + for device in devices: device_record = cast(dict[str, JSONValue], device) print(f"{backend}:{device_record['index']} {device_record['name']}") + return 0 def _resume_provenance(path: Path) -> dict[str, JSONValue]: source = path.resolve() + try: digest = hashlib.sha256(source.read_bytes()).hexdigest() except OSError as error: raise BatchError(f"could not read checkpoint {source}") from error + return {"resume": {"path": str(source), "sha256": digest}} def _progress_printer(interval: int, quiet: bool): if quiet: return None + if interval <= 0: raise BatchError("progress interval must be positive unless --quiet is used") @@ -246,25 +281,30 @@ def _model_factory( ) -> Callable[[], tuple[RunnableModel, dict[str, JSONValue]]]: backend = _BACKENDS[cast(str, arguments.backend)] device_index = cast(int, arguments.device_index) + if not backend_available(backend, device_index): count = backend_device_count(backend) raise BatchError( f"backend {arguments.backend} device {device_index} is unavailable " f"({count} device(s) found)" ) + parameters = _parameters(cast(list[str], arguments.parameter)) model_path = cast(Path | None, arguments.model) legacy_model_path = cast(Path | None, arguments.legacy_model) resume_path = cast(Path | None, arguments.resume) + if model_path is not None: if resume_path is None: seed = cast(int, arguments.seed) def build_native_model() -> tuple[RunnableModel, dict[str, JSONValue]]: context = ModelContext(backend, device_index, seed, parameters) + return build_model(model_path, context) return build_native_model + if parameters: raise BatchError( "native resume uses the checkpoint parameters; do not pass --parameter" @@ -277,16 +317,20 @@ def resume_native_model() -> tuple[RunnableModel, dict[str, JSONValue]]: device_index=device_index, ) model_value = bundle.provenance.get("model") + if not isinstance(model_value, dict): raise BatchError("native checkpoint is missing model provenance") + seed_value = model_value.get("seed") saved_parameters = model_value.get("parameters") + if ( not isinstance(seed_value, int) or isinstance(seed_value, bool) or not isinstance(saved_parameters, dict) ): raise BatchError("native checkpoint model provenance is invalid") + context = ModelContext( backend=backend, device_index=device_index, @@ -300,6 +344,7 @@ def resume_native_model() -> tuple[RunnableModel, dict[str, JSONValue]]: ) provenance = dict(model_provenance) provenance.update(_resume_provenance(resume_path)) + return model, provenance return resume_native_model @@ -309,9 +354,11 @@ def resume_native_model() -> tuple[RunnableModel, dict[str, JSONValue]]: def build_legacy() -> tuple[RunnableModel, dict[str, JSONValue]]: context = ModelContext(backend, device_index, seed, parameters) + return build_legacy_model(legacy_model_path, context) return build_legacy + if parameters: raise BatchError( "legacy resume uses the checkpoint parameters; do not pass --parameter" @@ -324,16 +371,20 @@ def resume_legacy() -> tuple[RunnableModel, dict[str, JSONValue]]: device_index=device_index, ) model_value = bundle.provenance.get("model") + if not isinstance(model_value, dict): raise BatchError("legacy checkpoint is missing model provenance") + seed_value = model_value.get("seed") saved_parameters = model_value.get("parameters") + if ( not isinstance(seed_value, int) or isinstance(seed_value, bool) or not isinstance(saved_parameters, dict) ): raise BatchError("legacy checkpoint model provenance is invalid") + context = ModelContext( backend=backend, device_index=device_index, @@ -347,12 +398,14 @@ def resume_legacy() -> tuple[RunnableModel, dict[str, JSONValue]]: ) provenance = dict(model_provenance) provenance.update(_resume_provenance(resume_path)) + return simulation, provenance return resume_legacy else: if resume_path is None: raise BatchError("a model or checkpoint is required") + if parameters: raise BatchError("--parameter is only valid with --model") @@ -362,6 +415,7 @@ def resume_native() -> tuple[RunnableModel, dict[str, JSONValue]]: backend=backend, device_index=device_index, ) + return simulation, _resume_provenance(resume_path) return resume_native @@ -387,15 +441,19 @@ def _run(arguments: argparse.Namespace) -> int: f"wrote {summary.output} steps={summary.completed_steps} " f"time={summary.time:.9g} cells={summary.cell_count} stop={summary.stop_reason}" ) + return 0 def _viewer_distribution(value: Path | None) -> Path: if value is not None: return value.resolve() + source_distribution = Path(__file__).resolve().parents[3] / "viewer" / "dist" + if source_distribution.is_dir(): return source_distribution + raise BatchError("viewer build not found; run `pnpm --dir viewer build` or pass --viewer-dist") @@ -405,6 +463,7 @@ def _view(arguments: argparse.Namespace) -> int: except ModuleNotFoundError as error: if error.name == "aiohttp": raise BatchError("live viewer requires `microsimulator[viewer]`") from error + raise viewer_dist = _viewer_distribution(cast(Path | None, arguments.viewer_dist)) @@ -422,13 +481,16 @@ def _view(arguments: argparse.Namespace) -> int: fps=cast(float, arguments.fps), open_browser=cast(bool, arguments.open), ) + return 0 def _import_legacy_pickle(arguments: argparse.Namespace) -> int: output = cast(Path, arguments.output) + if output.exists() and not cast(bool, arguments.overwrite): raise BatchError(f"output already exists: {output}") + imported = import_legacy_pickle( cast(Path, arguments.input), time=cast(float | None, arguments.time), @@ -442,6 +504,7 @@ def _import_legacy_pickle(arguments: argparse.Namespace) -> int: f"wrote {output} cells={imported.simulation.cell_count} " f"dropped_fields={len(imported.dropped_cell_fields)}" ) + return 0 @@ -451,17 +514,19 @@ def _export_analysis(arguments: argparse.Namespace) -> int: except ModuleNotFoundError as error: if error.name in {"pyarrow", "zarr"}: raise BatchError("analysis export requires `microsimulator[analysis]`") from error + raise backend_name = cast(str, arguments.backend) backend = _BACKENDS[backend_name] device_index = cast(int, arguments.device_index) + if not backend_available(backend, device_index): count = backend_device_count(backend) raise BatchError( - f"backend {backend_name} device {device_index} is unavailable " - f"({count} device(s) found)" + f"backend {backend_name} device {device_index} is unavailable ({count} device(s) found)" ) + summary = export_dataset( cast(list[Path], arguments.checkpoints), cast(Path, arguments.output), @@ -476,6 +541,7 @@ def _export_analysis(arguments: argparse.Namespace) -> int: f"wrote {summary.output} frames={summary.frame_count} " f"cells={summary.cell_rows} signal_epochs={summary.signal_epochs}" ) + return 0 @@ -495,6 +561,7 @@ def _run_manifest(arguments: argparse.Namespace) -> int: f"wrote {summary.output} steps={summary.completed_steps} " f"time={summary.time:.9g} cells={summary.cell_count} stop={summary.stop_reason}" ) + return 0 @@ -502,17 +569,33 @@ def main(argv: Sequence[str] | None = None) -> int: """Run the ``microsimulator`` command and return its process status.""" arguments = _parser().parse_args(argv) + try: if arguments.command == "devices": return _devices(cast(bool, arguments.json)) + if arguments.command == "import-legacy-pickle": return _import_legacy_pickle(arguments) + if arguments.command == "view": return _view(arguments) + if arguments.command == "export-analysis": return _export_analysis(arguments) + + if arguments.command == "export-replay": + from .replay import export_replay + + summary = export_replay( + cast(list[Path], arguments.checkpoints), cast(Path, arguments.output) + ) + print(f"wrote {summary.output} frames={summary.frame_count}") + + return 0 + if arguments.command == "run-manifest": return _run_manifest(arguments) + return _run(arguments) except ( BatchError, @@ -523,4 +606,5 @@ def main(argv: Sequence[str] | None = None) -> int: RuntimeError, ) as error: print(f"microsimulator: {error}", file=sys.stderr) + return 2 diff --git a/python/src/microsimulator/compatibility.py b/python/src/microsimulator/compatibility.py index bf891f8..1ae8181 100644 --- a/python/src/microsimulator/compatibility.py +++ b/python/src/microsimulator/compatibility.py @@ -63,66 +63,185 @@ class BackendTarget: def _object(value: object, path: str) -> dict[str, object]: if not isinstance(value, dict): raise LegacyExampleMatrixError(f"{path} must be an object") + mapping = cast(dict[object, object], value) + if not all(isinstance(key, str) for key in mapping): raise LegacyExampleMatrixError(f"{path} must use string keys") + return cast(dict[str, object], mapping) def _string(value: object, path: str) -> str: if not isinstance(value, str) or not value: raise LegacyExampleMatrixError(f"{path} must be a non-empty string") + return value def _relative_python_path(value: object, path: str) -> str: result = _string(value, path) parsed = PurePosixPath(result) + if parsed.is_absolute() or parsed.suffix != ".py" or ".." in parsed.parts: raise LegacyExampleMatrixError(f"{path} must be a relative Python path") + return result def _digest(value: object, path: str) -> str: result = _string(value, path) + if _SHA256.fullmatch(result) is None: raise LegacyExampleMatrixError(f"{path} must be a lowercase SHA-256 digest") + return result def _number(value: object, path: str) -> float: if not isinstance(value, int | float) or isinstance(value, bool): raise LegacyExampleMatrixError(f"{path} must be a number") + result = float(value) + if not math.isfinite(result) or result < 0.0: raise LegacyExampleMatrixError(f"{path} must be finite and non-negative") + return result def _pairs(pairs: list[tuple[str, object]]) -> dict[str, object]: result: dict[str, object] = {} + for key, value in pairs: if key in result: raise LegacyExampleMatrixError(f"duplicate JSON key {key!r}") + result[key] = value + return result +def _parse_legacy_example(value: object, row_path: str) -> LegacyExample: + row = _object(value, row_path) + + if set(row) != { + "path", + "sha256", + "status", + "implementation", + "implementation_sha256", + "steps", + "dt", + "note", + }: + raise LegacyExampleMatrixError(f"{row_path} has unexpected fields") + + legacy_path = _relative_python_path(row["path"], f"{row_path}.path") + status_value = _string(row["status"], f"{row_path}.status") + + if status_value not in _STATUSES: + raise LegacyExampleMatrixError(f"{row_path}.status is unsupported") + + status = cast(LegacyExampleStatus, status_value) + implementation_value = row["implementation"] + implementation = ( + None + if implementation_value is None + else _relative_python_path(implementation_value, f"{row_path}.implementation") + ) + implementation_digest_value = row["implementation_sha256"] + implementation_digest = ( + None + if implementation_digest_value is None + else _digest( + implementation_digest_value, + f"{row_path}.implementation_sha256", + ) + ) + steps_value = row["steps"] + + if not isinstance(steps_value, int) or isinstance(steps_value, bool) or steps_value < 0: + raise LegacyExampleMatrixError(f"{row_path}.steps must be a non-negative integer") + + dt = _number(row["dt"], f"{row_path}.dt") + note = _string(row["note"], f"{row_path}.note") + + _validate_example_execution( + status, implementation, implementation_digest, steps_value, dt, row_path + ) + + return LegacyExample( + path=legacy_path, + sha256=_digest(row["sha256"], f"{row_path}.sha256"), + status=status, + implementation=implementation, + implementation_sha256=implementation_digest, + steps=steps_value, + dt=dt, + note=note, + ) + + +def _validate_example_execution( + status: LegacyExampleStatus, + implementation: str | None, + implementation_digest: str | None, + steps_value: int, + dt: float, + row_path: str, +) -> None: + if status == "migrated": + if implementation is None or implementation_digest is None or steps_value == 0: + raise LegacyExampleMatrixError( + f"{row_path} migrated rows require an implementation and execution" + ) + elif implementation is not None or implementation_digest is not None: + raise LegacyExampleMatrixError(f"{row_path} only migrated rows may name an implementation") + + if status == "runnable" and steps_value == 0: + raise LegacyExampleMatrixError(f"{row_path} runnable rows must execute") + + if status in {"deliberately_retired", "migration_only"} and (steps_value != 0 or dt != 0.0): + raise LegacyExampleMatrixError(f"{row_path} non-runnable rows cannot execute") + + +def _verify_example_sources( + example: LegacyExample, legacy_source: Path, project_directory: Path +) -> None: + actual_digest = _sha256(legacy_source) + + if actual_digest != example.sha256: + raise LegacyExampleMatrixError(f"legacy source digest mismatch for {example.path}") + + if example.implementation is not None: + implementation_source = project_directory / example.implementation + implementation_digest = _sha256(implementation_source) + + if implementation_digest != example.implementation_sha256: + raise LegacyExampleMatrixError(f"implementation digest mismatch for {example.path}") + + def load_legacy_example_matrix(path: str | Path) -> LegacyExampleMatrix: """Load and strictly validate the complete 25-example compatibility matrix.""" source = Path(path) + try: data = source.read_bytes() except OSError as error: raise LegacyExampleMatrixError(f"could not read compatibility matrix {source}") from error + if len(data) > MAX_LEGACY_EXAMPLE_MATRIX_BYTES: raise LegacyExampleMatrixError("compatibility matrix is too large") + try: document = cast(object, json.loads(data, object_pairs_hook=_pairs)) except (json.JSONDecodeError, UnicodeDecodeError) as error: raise LegacyExampleMatrixError("compatibility matrix is not valid JSON") from error + root = _object(document, "matrix") + if set(root) != { "format", "version", @@ -131,92 +250,42 @@ def load_legacy_example_matrix(path: str | Path) -> LegacyExampleMatrix: "examples", }: raise LegacyExampleMatrixError("compatibility matrix has unexpected fields") + if root["format"] not in (LEGACY_EXAMPLE_MATRIX_FORMAT, "cellmodeller2-legacy-example-matrix"): raise LegacyExampleMatrixError("compatibility matrix format is unsupported") + if root["version"] != LEGACY_EXAMPLE_MATRIX_VERSION: raise LegacyExampleMatrixError("compatibility matrix version is unsupported") + repository = _string(root["legacy_repository"], "matrix.legacy_repository") commit = _string(root["legacy_commit"], "matrix.legacy_commit") + if re.fullmatch(r"[0-9a-f]{40}", commit) is None: raise LegacyExampleMatrixError("matrix.legacy_commit must be a full commit ID") + examples_value = root["examples"] + if not isinstance(examples_value, list): raise LegacyExampleMatrixError("matrix.examples must contain exactly 25 rows") + example_values = cast(list[object], examples_value) + if len(example_values) != 25: raise LegacyExampleMatrixError("matrix.examples must contain exactly 25 rows") examples: list[LegacyExample] = [] + for index, value in enumerate(example_values): - row_path = f"matrix.examples[{index}]" - row = _object(value, row_path) - if set(row) != { - "path", - "sha256", - "status", - "implementation", - "implementation_sha256", - "steps", - "dt", - "note", - }: - raise LegacyExampleMatrixError(f"{row_path} has unexpected fields") - legacy_path = _relative_python_path(row["path"], f"{row_path}.path") - status_value = _string(row["status"], f"{row_path}.status") - if status_value not in _STATUSES: - raise LegacyExampleMatrixError(f"{row_path}.status is unsupported") - status = cast(LegacyExampleStatus, status_value) - implementation_value = row["implementation"] - implementation = ( - None - if implementation_value is None - else _relative_python_path(implementation_value, f"{row_path}.implementation") - ) - implementation_digest_value = row["implementation_sha256"] - implementation_digest = ( - None - if implementation_digest_value is None - else _digest( - implementation_digest_value, - f"{row_path}.implementation_sha256", - ) - ) - steps_value = row["steps"] - if not isinstance(steps_value, int) or isinstance(steps_value, bool) or steps_value < 0: - raise LegacyExampleMatrixError(f"{row_path}.steps must be a non-negative integer") - dt = _number(row["dt"], f"{row_path}.dt") - note = _string(row["note"], f"{row_path}.note") - if status == "migrated": - if implementation is None or implementation_digest is None or steps_value == 0: - raise LegacyExampleMatrixError( - f"{row_path} migrated rows require an implementation and execution" - ) - elif implementation is not None or implementation_digest is not None: - raise LegacyExampleMatrixError( - f"{row_path} only migrated rows may name an implementation" - ) - if status == "runnable" and steps_value == 0: - raise LegacyExampleMatrixError(f"{row_path} runnable rows must execute") - if status in {"deliberately_retired", "migration_only"} and (steps_value != 0 or dt != 0.0): - raise LegacyExampleMatrixError(f"{row_path} non-runnable rows cannot execute") - examples.append( - LegacyExample( - path=legacy_path, - sha256=_digest(row["sha256"], f"{row_path}.sha256"), - status=status, - implementation=implementation, - implementation_sha256=implementation_digest, - steps=steps_value, - dt=dt, - note=note, - ) - ) + examples.append(_parse_legacy_example(value, f"matrix.examples[{index}]")) paths = [example.path for example in examples] + if len(set(paths)) != len(paths): raise LegacyExampleMatrixError("matrix.examples contains duplicate paths") + if paths != sorted(paths): raise LegacyExampleMatrixError("matrix.examples must be sorted by path") + return LegacyExampleMatrix(repository, commit, tuple(examples)) @@ -225,16 +294,22 @@ def enumerate_backend_targets(backends: Sequence[BackendKind]) -> tuple[BackendT if not backends: raise LegacyExampleMatrixError("at least one backend must be requested") + if len(set(backends)) != len(backends): raise LegacyExampleMatrixError("requested backends must be unique") + targets: list[BackendTarget] = [] + for backend in backends: count = backend_device_count(backend) + if count == 0: raise LegacyExampleMatrixError( f"requested backend {backend.name.lower()} is unavailable" ) + targets.extend(BackendTarget(backend, index) for index in range(count)) + return tuple(targets) @@ -257,46 +332,46 @@ def run_legacy_example_matrix( if not targets: raise LegacyExampleMatrixError("at least one backend target is required") + legacy_directory = Path(legacy_root).resolve() project_directory = Path(project_root).resolve() rows: list[JSONValue] = [] passed = True + for example in matrix.examples: legacy_source = legacy_directory / "Examples" / example.path source_error: str | None = None + try: - actual_digest = _sha256(legacy_source) - if actual_digest != example.sha256: - raise LegacyExampleMatrixError(f"legacy source digest mismatch for {example.path}") - if example.implementation is not None: - implementation_source = project_directory / example.implementation - implementation_digest = _sha256(implementation_source) - if implementation_digest != example.implementation_sha256: - raise LegacyExampleMatrixError( - f"implementation digest mismatch for {example.path}" - ) + _verify_example_sources(example, legacy_source, project_directory) except LegacyExampleMatrixError as error: source_error = str(error) passed = False runs: list[JSONValue] = [] + if source_error is None and example.status in {"runnable", "migrated"}: for target in targets: backend_name = target.backend.name.lower() + try: context = ModelContext(target.backend, target.device_index, seed=seed) + if example.status == "runnable": model, _ = build_legacy_model(legacy_source, context) else: if example.implementation is None: raise AssertionError("migrated example has no implementation") + model, _ = build_model( project_directory / example.implementation, context, expected_sha256=example.implementation_sha256, ) + for _ in range(example.steps): model.step(example.dt) + simulation = native_simulation(model) simulation.validate() info = simulation.backend_info @@ -321,6 +396,7 @@ def run_legacy_example_matrix( "error": f"{type(error).__name__}: {error}", } ) + row: dict[str, JSONValue] = { "path": example.path, "sha256": example.sha256, @@ -330,8 +406,10 @@ def run_legacy_example_matrix( "source_result": "fail" if source_error is not None else "pass", "runs": runs, } + if source_error is not None: row["source_error"] = source_error + rows.append(row) return { diff --git a/python/src/microsimulator/controller.py b/python/src/microsimulator/controller.py index 2e41d4e..4fc8a34 100644 --- a/python/src/microsimulator/controller.py +++ b/python/src/microsimulator/controller.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy import math import random import re @@ -18,6 +19,7 @@ MechanicsSolveResult, Simulation, ) +from .channels import UNNAMED_CHANNELS, ChannelMetadata from .checkpoint import CheckpointBundle, JSONValue _RANDOM_STATE_KIND = "python-random-mt19937" @@ -127,12 +129,14 @@ def _finite_number(value: object, path: str) -> float: or abs(value) > _FLOAT32_MAX ): raise ControllerStateError(f"{path} must be a finite float32 value") + return float(value) def _integer(value: object, path: str, lower: int, upper: int) -> int: if not isinstance(value, int) or isinstance(value, bool) or value < lower or value > upper: raise ControllerStateError(f"{path} must be an integer in [{lower}, {upper}]") + return value @@ -164,8 +168,10 @@ def _json_object(value: Mapping[str, JSONValue], path: str) -> dict[str, JSONVal decoded = cast(object, json.loads(encoded)) except (TypeError, ValueError, RecursionError) as error: raise ControllerStateError(f"{path} must be finite JSON data") from error + if not isinstance(decoded, dict): raise ControllerStateError(f"{path} must be a JSON object") + return cast(dict[str, JSONValue], decoded) @@ -212,16 +218,22 @@ def __post_init__(self) -> None: self.constraint_degeneracy_epsilon, "mechanics.constraint_degeneracy_epsilon", ) + if mu_a <= 0.0 or gamma <= 0.0: raise ControllerStateError("mechanics mu_a and gamma must be positive") + if tolerance < 0.0 or contact_margin < 0.0 or rotation < 0.0: raise ControllerStateError("mechanics tolerances, margins, and limits are invalid") + if parallel_threshold < 0.0 or parallel_threshold > 1.0: raise ControllerStateError("mechanics contact parallel threshold is invalid") + if contact_epsilon <= 0.0 or constraint_epsilon <= 0.0 or constraint_margin < 0.0: raise ControllerStateError("mechanics constraint/contact parameters are invalid") + if not isinstance(cast(object, self.require_convergence), bool): raise ControllerStateError("mechanics.require_convergence must be Boolean") + if not isinstance(cast(object, self.flow_drift), bool): raise ControllerStateError("mechanics.flow_drift must be Boolean") @@ -248,6 +260,7 @@ def native_parameters( constraints = ConstraintContactParameters() constraints.activation_margin = self.constraint_activation_margin constraints.degeneracy_epsilon = self.constraint_degeneracy_epsilon + return mechanics, contacts, integration, constraints def to_json(self) -> dict[str, JSONValue]: @@ -262,12 +275,17 @@ def from_json(cls, value: JSONValue) -> MechanicsConfig: field.name for field in fields(MechanicsConfig) }: raise ControllerStateError("controller mechanics configuration is invalid") + require_convergence = value["require_convergence"] + if not isinstance(require_convergence, bool): raise ControllerStateError("mechanics.require_convergence must be Boolean") + flow_drift = value["flow_drift"] + if not isinstance(flow_drift, bool): raise ControllerStateError("mechanics.flow_drift must be Boolean") + return cls( passes=_integer(value["passes"], "mechanics.passes", 1, _UINT32_MAX), mu_a=_finite_number(value["mu_a"], "mechanics.mu_a"), @@ -309,9 +327,93 @@ def from_json(cls, value: JSONValue) -> MechanicsConfig: def _model_identity(model_id: object, model_version: object) -> tuple[str, int]: if not isinstance(model_id, str) or _MODEL_ID.fullmatch(model_id) is None: raise ControllerStateError("native controller model ID is invalid") + return model_id, _integer(model_version, "native controller model version", 1, _UINT32_MAX) +def _validate_update_payload(update: CellUpdate, species_count: int) -> None: + if update.growth_rate is not None: + _plan_number(update.growth_rate, "cell update growth rate") + + if update.cell_type is not None: + _plan_integer(update.cell_type, "cell update cell type", _INT32_MIN, _INT32_MAX) + + if update.fixed is not None and not isinstance(cast(object, update.fixed), bool): + raise ControllerPlanError("cell update fixed value must be Boolean") + + if update.species is not None: + species_value = cast(object, update.species) + + if not isinstance(species_value, tuple): + raise ControllerPlanError("cell update species shape is invalid") + + species = cast(tuple[object, ...], species_value) + + if len(species) != species_count: + raise ControllerPlanError("cell update species shape is invalid") + + for value in species: + _plan_number(value, "cell update species value") + + +def _validate_updates( + plan: StepPlan, snapshots: Mapping[int, CellSnapshot], species_count: int +) -> None: + updated: set[int] = set() + updates_value = cast(object, plan.updates) + + if not isinstance(updates_value, tuple): + raise ControllerPlanError("step plan updates must be a tuple") + + updates = cast(tuple[object, ...], updates_value) + + for update_value in updates: + if not isinstance(update_value, CellUpdate): + raise ControllerPlanError("step plan contains an invalid cell update") + + update = update_value + cell_id = _plan_integer(update.cell_id, "cell update ID", 1, _UINT64_MAX) + + if cell_id not in snapshots or cell_id in updated: + raise ControllerPlanError("step plan updates an unknown or duplicate cell") + + updated.add(cell_id) + + _validate_update_payload(update, species_count) + + +def _validate_divisions(plan: StepPlan, snapshots: Mapping[int, CellSnapshot]) -> set[int]: + dividing: set[int] = set() + divisions_value = cast(object, plan.divisions) + + if not isinstance(divisions_value, tuple): + raise ControllerPlanError("step plan divisions must be a tuple") + + divisions = cast(tuple[object, ...], divisions_value) + + for request_value in divisions: + if not isinstance(request_value, DivisionRequest): + raise ControllerPlanError("step plan contains an invalid division request") + + request = request_value + parent_id = _plan_integer(request.parent_id, "division parent ID", 1, _UINT64_MAX) + parent = snapshots.get(parent_id) + + if parent is None or parent_id in dividing: + raise ControllerPlanError("step plan divides an unknown or duplicate parent") + + dividing.add(parent_id) + fraction = _plan_number(request.first_fraction, "division fraction") + + if fraction <= 0.0 or fraction >= 1.0: + raise ControllerPlanError("division fraction must be strictly between zero and one") + + if parent.length < 2.0 * parent.radius: + raise ControllerPlanError("division parent is shorter than its cap diameter") + + return dividing + + class NativeController: """Typed deterministic orchestration over a native simulation.""" @@ -327,13 +429,20 @@ def __init__( mechanics: MechanicsConfig | None = None, state: Mapping[str, JSONValue] | None = None, completed_steps: int = 0, + channel_metadata: ChannelMetadata = UNNAMED_CHANNELS, ) -> None: self._model_id, self._model_version = _model_identity(model_id, model_version) + if not isinstance(cast(object, simulation), Simulation): raise TypeError("native controller simulation must be a Simulation") + if not isinstance(cast(object, rng), random.Random): raise TypeError("native controller requires an explicit random.Random stream") + self.simulation = simulation + self.channel_metadata = channel_metadata.resolved( + simulation.species_count, simulation.signal_count + ) self._rng = rng self._regulate = regulate self._on_division = on_division @@ -369,65 +478,30 @@ def _context(self) -> ControllerStep: def _validate_plan(self, plan: object) -> StepPlan: if not isinstance(plan, StepPlan): raise ControllerPlanError("regulation callback must return a StepPlan") + snapshots = {cell.id: cell for cell in self.simulation.cells()} - updated: set[int] = set() - updates_value = cast(object, plan.updates) - if not isinstance(updates_value, tuple): - raise ControllerPlanError("step plan updates must be a tuple") - updates = cast(tuple[object, ...], updates_value) - for update_value in updates: - if not isinstance(update_value, CellUpdate): - raise ControllerPlanError("step plan contains an invalid cell update") - update = update_value - cell_id = _plan_integer(update.cell_id, "cell update ID", 1, _UINT64_MAX) - if cell_id not in snapshots or cell_id in updated: - raise ControllerPlanError("step plan updates an unknown or duplicate cell") - updated.add(cell_id) - if update.growth_rate is not None: - _plan_number(update.growth_rate, "cell update growth rate") - if update.cell_type is not None: - _plan_integer(update.cell_type, "cell update cell type", _INT32_MIN, _INT32_MAX) - if update.fixed is not None and not isinstance(cast(object, update.fixed), bool): - raise ControllerPlanError("cell update fixed value must be Boolean") - if update.species is not None: - species_value = cast(object, update.species) - if not isinstance(species_value, tuple): - raise ControllerPlanError("cell update species shape is invalid") - species = cast(tuple[object, ...], species_value) - if len(species) != self.simulation.species_count: - raise ControllerPlanError("cell update species shape is invalid") - for value in species: - _plan_number(value, "cell update species value") - dividing: set[int] = set() - divisions_value = cast(object, plan.divisions) - if not isinstance(divisions_value, tuple): - raise ControllerPlanError("step plan divisions must be a tuple") - divisions = cast(tuple[object, ...], divisions_value) - for request_value in divisions: - if not isinstance(request_value, DivisionRequest): - raise ControllerPlanError("step plan contains an invalid division request") - request = request_value - parent_id = _plan_integer(request.parent_id, "division parent ID", 1, _UINT64_MAX) - parent = snapshots.get(parent_id) - if parent is None or parent_id in dividing: - raise ControllerPlanError("step plan divides an unknown or duplicate parent") - dividing.add(parent_id) - fraction = _plan_number(request.first_fraction, "division fraction") - if fraction <= 0.0 or fraction >= 1.0: - raise ControllerPlanError("division fraction must be strictly between zero and one") - if parent.length < 2.0 * parent.radius: - raise ControllerPlanError("division parent is shorter than its cap diameter") + _validate_updates(plan, snapshots, self.simulation.species_count) + + dividing = _validate_divisions(plan, snapshots) + removals_value = cast(object, plan.removals) + if not isinstance(removals_value, tuple): raise ControllerPlanError("step plan removals must be a tuple") + removing: set[int] = set() + for removal_value in cast(tuple[object, ...], removals_value): removal = _plan_integer(removal_value, "removal cell ID", 1, _UINT64_MAX) + if removal not in snapshots or removal in removing: raise ControllerPlanError("step plan removes an unknown or duplicate cell") + if removal in dividing: raise ControllerPlanError("step plan removes a dividing cell") + removing.add(removal) + return plan def _apply_update(self, update: CellUpdate) -> None: @@ -437,25 +511,61 @@ def _apply_update(self, update: CellUpdate) -> None: current.growth_rate if update.growth_rate is None else update.growth_rate, current.cell_type if update.cell_type is None else update.cell_type, ) + if update.fixed is not None: self.simulation.set_cell_fixed(update.cell_id, update.fixed) + if update.species is not None: self.simulation.set_species(update.cell_id, list(update.species)) def step(self, dt: float) -> None: """Apply regulation, division, integration, and configured mechanics.""" + if not self.simulation.has_culture: + self._step_unchecked(dt) + + return + + if self._mechanics is not None: + raise ControllerPlanError( + "culture simulation already includes body motion and contact mechanics" + ) + + checkpoint = self.simulation._checkpoint() # pyright: ignore[reportPrivateUsage] + state = copy.deepcopy(self._state) + random_state = self._rng.getstate() + completed_steps = self._completed_steps + reports = self._last_mechanics_reports + + try: + self._step_unchecked(dt) + except BaseException: + self.simulation._restore_checkpoint(checkpoint) # pyright: ignore[reportPrivateUsage] + self._state.clear() + self._state.update(state) + self._rng.setstate(random_state) + self._completed_steps = completed_steps + self._last_mechanics_reports = reports + raise + + def _step_unchecked(self, dt: float) -> None: + if not math.isfinite(dt) or dt < 0.0: raise ValueError("time step must be finite and non-negative") + if self._completed_steps == _UINT64_MAX: raise ControllerPlanError("native controller completed-step counter is exhausted") + plan = StepPlan() if self._regulate is None else self._regulate(self._context()) plan = self._validate_plan(plan) + for update in plan.updates: self._apply_update(update) + for request in plan.divisions: parent = self.simulation.cell(request.parent_id) first_id, second_id = self.simulation.divide(request.parent_id, request.first_fraction) + if self._on_division is not None: event = DivisionEvent( parent=parent, @@ -463,11 +573,13 @@ def step(self, dt: float) -> None: second=self.simulation.cell(second_id), ) self._on_division(self._context(), event) + for removal in plan.removals: self.simulation.remove_cell(removal) self.simulation.step(dt) reports: list[MechanicsSolveResult] = [] + if ( self._mechanics is not None and self._mechanics.flow_drift @@ -475,10 +587,13 @@ def step(self, dt: float) -> None: ): _, _, integration, _ = self._mechanics.native_parameters() self.simulation.apply_flow_drift(dt, integration) + if self._mechanics is not None and self.simulation.cell_count != 0: parameters = self._mechanics.native_parameters() + for _ in range(self._mechanics.passes): reports.append(self.simulation.relax_cell_mechanics(*parameters)) + self._last_mechanics_reports = tuple(reports) self._completed_steps += 1 @@ -509,6 +624,7 @@ def from_checkpoint( expected_identity = _model_identity(model_id, model_version) value = checkpoint.controller + if not isinstance(value, dict) or set(value) != { "kind", "version", @@ -519,24 +635,34 @@ def from_checkpoint( "mechanics", }: raise ControllerStateError("native controller state is invalid") + if ( value["kind"] not in (_NATIVE_CONTROLLER_KIND, "cellmodeller2-native-controller") or value["version"] != _NATIVE_CONTROLLER_VERSION ): raise ControllerStateError("native controller kind or version is unsupported") + identity = value["model"] + if not isinstance(identity, dict) or set(identity) != {"id", "version"}: raise ControllerStateError("native controller model identity is invalid") + actual_identity = _model_identity(identity["id"], identity["version"]) + if actual_identity != expected_identity: raise ControllerStateError("native controller model identity does not match") + state = value["state"] + if not isinstance(state, dict): raise ControllerStateError("native controller model state must be an object") + mechanics_value = value["mechanics"] mechanics = None if mechanics_value is None else MechanicsConfig.from_json(mechanics_value) + return cls( checkpoint.simulation, + channel_metadata=checkpoint.channel_metadata, model_id=model_id, model_version=model_version, rng=restore_random_state(value["random"]), @@ -557,10 +683,13 @@ def capture_random_state(stream: random.Random) -> dict[str, JSONValue]: """Encode a dedicated Python random stream as closed-schema JSON data.""" state_version, internal_state, gaussian = stream.getstate() + if state_version != 3 or len(internal_state) != _MT_STATE_WORDS + 1: raise ControllerStateError("Python random stream uses an unsupported state format") + if gaussian is not None and not math.isfinite(gaussian): raise ControllerStateError("Python random stream has a non-finite Gaussian cache") + return { "kind": _RANDOM_STATE_KIND, "version": _RANDOM_STATE_VERSION, @@ -570,25 +699,40 @@ def capture_random_state(stream: random.Random) -> dict[str, JSONValue]: } +def _restore_random_words(value: JSONValue) -> list[int]: + words = value + + if not isinstance(words, list) or len(words) != _MT_STATE_WORDS + 1: + raise ControllerStateError("random state vector is invalid") + + for index, word in enumerate(words): + upper = _MT_STATE_WORDS if index == _MT_STATE_WORDS else _UINT32_MAX + + if not isinstance(word, int) or isinstance(word, bool) or word < 0 or word > upper: + raise ControllerStateError("random state vector is invalid") + + return cast(list[int], words) + + def restore_random_state(value: JSONValue) -> random.Random: """Restore a random stream produced by :func:`capture_random_state`.""" if not isinstance(value, dict): raise ControllerStateError("random state must be an object") + if set(value) != {"kind", "version", "state_version", "state", "gauss_next"}: raise ControllerStateError("random state has unexpected fields") + if value["kind"] != _RANDOM_STATE_KIND or value["version"] != _RANDOM_STATE_VERSION: raise ControllerStateError("random state kind or version is unsupported") + if value["state_version"] != 3: raise ControllerStateError("Python random state version is unsupported") - words = value["state"] - if not isinstance(words, list) or len(words) != _MT_STATE_WORDS + 1: - raise ControllerStateError("random state vector is invalid") - for index, word in enumerate(words): - upper = _MT_STATE_WORDS if index == _MT_STATE_WORDS else _UINT32_MAX - if not isinstance(word, int) or isinstance(word, bool) or word < 0 or word > upper: - raise ControllerStateError("random state vector is invalid") + + words = _restore_random_words(value["state"]) + gaussian = value["gauss_next"] + if gaussian is not None and ( not isinstance(gaussian, int | float) or isinstance(gaussian, bool) @@ -597,14 +741,16 @@ def restore_random_state(value: JSONValue) -> random.Random: raise ControllerStateError("random state Gaussian cache is invalid") stream = random.Random() + try: stream.setstate( ( 3, - tuple(cast(list[int], words)), + tuple(words), float(gaussian) if gaussian is not None else None, ) ) except (TypeError, ValueError) as error: raise ControllerStateError("random state is invalid") from error + return stream diff --git a/python/src/microsimulator/culture.py b/python/src/microsimulator/culture.py new file mode 100644 index 0000000..7360a64 --- /dev/null +++ b/python/src/microsimulator/culture.py @@ -0,0 +1,357 @@ +"""Compile fluid, chemical and biological definitions into one native transaction.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType + +from . import _core as core # pyright: ignore[reportPrivateUsage, reportMissingModuleSource] +from .growth import CellGrowth, EssentialNutrientGrowth, MonodGrowth, ReserveBudgetGrowth +from .media import Concentration, Medium +from .schedules import at, times +from .stokes import StokesFlow +from .transport import AdvectiveFeed, ReservoirContact, Solute, SoluteTransport + + +def _concentrations(medium: Medium, solutes: tuple[Solute, ...], length: float) -> list[float]: + unknown = set(medium.concentrations) - {s.id for s in solutes} + + if unknown: + raise ValueError(f"medium {medium.name!r} refers to unknown solutes: {sorted(unknown)}") + + return [ + medium.concentrations[s.id].canonical(s.amount_unit, length, s.molar_mass_g_per_mol) + if s.id in medium.concentrations + else 0.0 + for s in solutes + ] + + +def _chemical_boundaries( + fluid: StokesFlow, + transport: SoluteTransport, + length_unit_m: float, + recipes: dict[str, object], + seconds: float, +) -> list[core.ChemicalBoundary]: + result: list[core.ChemicalBoundary] = [] + + for port in fluid.domain.ports: + authored = transport.boundaries[port] + value = core.ChemicalBoundary() + value.port = port + medium: Medium | None + + if isinstance(authored, (AdvectiveFeed, ReservoirContact)): + value.kind = ( + core.ChemicalBoundaryKind.ADVECTIVE + if isinstance(authored, AdvectiveFeed) + else core.ChemicalBoundaryKind.RESERVOIR_CONTACT + ) + medium = at(authored.medium, seconds) + else: + value.kind = core.ChemicalBoundaryKind.OUTFLOW + value.allow_backflow = authored.backflow != "error" + medium = None if authored.backflow == "error" else at(authored.backflow, seconds) + + value.concentrations = ( + [0.0] * len(transport.solutes) + if medium is None + else _concentrations(medium, transport.solutes, length_unit_m) + ) + + if medium is not None: + definition = { + "name": medium.name, + "provenance": medium.provenance, + "concentrations": { + s: {"value": c.value, "unit": c.unit} for s, c in medium.concentrations.items() + }, + } + + if medium.name in recipes and recipes[medium.name] != definition: + raise ValueError("distinct medium recipes must have distinct names") + + recipes[medium.name] = definition + + result.append(value) + + return result + + +def _configure_growth( + config: core.CultureConfiguration, + simulation: core.Simulation, + transport: SoluteTransport, + cell_growth: Mapping[int, CellGrowth], +) -> None: + solute_index = {s.id: i for i, s in enumerate(transport.solutes)} + existing = {c.id: c for c in simulation.cells()} + + if set(cell_growth) - set(existing): + raise ValueError("growth binding refers to absent cells") + + models: list[core.CellGrowthModel] = [] + reserve_models = [b for b in cell_growth.values() if isinstance(b.model, ReserveBudgetGrowth)] + + if reserve_models and len(reserve_models) != len(cell_growth): + raise ValueError("reserve and kinetic growth cannot share a culture configuration") + + for cell_id, binding in cell_growth.items(): + if isinstance(binding.model, ReserveBudgetGrowth): + if binding != reserve_models[0]: + raise ValueError("the reserve model currently requires shared parameters") + + config.biomass_per_geometric_volume = ( + binding.biomass.biochemical_volume_per_geometric_volume + ) + requirements: list[core.ReserveRequirement] = [] + + for species, amount in binding.model.requirements.items(): + r = core.ReserveRequirement() + r.species, r.amount_per_biomass = species, amount + requirements.append(r) + + config.biomass_requirements = requirements + continue + + if existing[cell_id].growth_rate != 0: + raise ValueError("set prescribed growth_rate to zero when using a kinetic model") + + model = _kinetic_growth_model(cell_id, binding, config, transport, solute_index) + models.append(model) + + config.growth = models + + +def _kinetic_growth_model( + cell_id: int, + binding: CellGrowth, + config: core.CultureConfiguration, + transport: SoluteTransport, + solute_index: dict[str, int], +) -> core.CellGrowthModel: + growth = binding.model + + if isinstance(growth, ReserveBudgetGrowth): + raise TypeError("kinetic growth requires a kinetic model") + + model = core.CellGrowthModel() + model.cell_id = cell_id + model.biomass_density = ( + binding.biomass.density_g_per_um3 * (config.grid.length_unit_m / 1e-6) ** 3 + ) + model.volume_ratio = binding.biomass.biochemical_volume_per_geometric_volume + model.mu_max = growth.mu_max_per_hour / 3600 * config.grid.time_unit_s + + if isinstance(growth, MonodGrowth): + model.kind = core.GrowthKind.MONOD + requirements_data = [ + ( + growth.substrate, + growth.half_saturation, + growth.biomass_yield.grams, + growth.biomass_yield.amount_unit, + ) + ] + elif isinstance(growth, EssentialNutrientGrowth): # pyright: ignore[reportUnnecessaryIsInstance] + model.kind = core.GrowthKind.ESSENTIAL + requirements_data = [ + (s, Concentration.mM(r.half_saturation_mM), r.yield_g_per_mol, "mol") + for s, r in growth.requirements.items() + ] + else: + raise TypeError("unsupported growth model") + + native_requirements: list[core.GrowthRequirement] = [] + + for name, concentration, yield_value, yield_basis in requirements_data: + if name not in solute_index: + raise ValueError(f"growth requires unknown solute {name!r}") + + solute = transport.solutes[solute_index[name]] + requirement = core.GrowthRequirement() + requirement.solute = solute_index[name] + requirement.half_saturation = concentration.canonical( + solute.amount_unit, config.grid.length_unit_m, solute.molar_mass_g_per_mol + ) + + if yield_basis != solute.amount_unit: + if solute.molar_mass_g_per_mol is None: + raise ValueError("yield unit conversion requires molecular weight") + + yield_value *= ( + 1 / solute.molar_mass_g_per_mol + if yield_basis == "mol" + else solute.molar_mass_g_per_mol + ) + + requirement.biomass_yield = yield_value + native_requirements.append(requirement) + + model.requirements = native_requirements + + return model + + +def configure_culture( + simulation: core.Simulation, + fluid: StokesFlow, + transport: SoluteTransport, + cell_growth: Mapping[int, CellGrowth], +) -> None: + if set(transport.boundaries) != set(fluid.domain.ports): + raise ValueError("each fluid port needs exactly one chemical boundary") + + config = core.CultureConfiguration() + config.grid, config.fluid = fluid.domain.native_grid(), fluid.properties.native() + config.solver, config.geometry, config.stepping = fluid.solver, fluid.geometry, fluid.stepping + native_solutes: list[core.Solute] = [] + + for solute in transport.solutes: + value = core.Solute() + value.name, value.amount_unit = solute.id, solute.amount_unit + value.diffusion = ( + solute.diffusion_um2_per_s + * 1e-12 + / config.grid.length_unit_m**2 + * config.grid.time_unit_s + ) + native_solutes.append(value) + + config.solutes = native_solutes + event_times: set[float] = set() + + for drive in fluid.boundaries.values(): + event_times.update(times(drive)) + + for boundary in transport.boundaries.values(): + if isinstance(boundary, (AdvectiveFeed, ReservoirContact)): + event_times.update(times(boundary.medium)) + elif boundary.backflow != "error": + event_times.update(times(boundary.backflow)) + + recipes: dict[str, object] = {} + + config.ports, config.reservoirs = ( + fluid.native_ports(), + _chemical_boundaries(fluid, transport, config.grid.length_unit_m, recipes, 0.0), + ) + events: list[core.CultureEvent] = [] + + for seconds in sorted(event_times): + event = core.CultureEvent() + event.time = seconds / config.grid.time_unit_s + event.ports, event.reservoirs = ( + fluid.native_ports(seconds), + _chemical_boundaries(fluid, transport, config.grid.length_unit_m, recipes, seconds), + ) + events.append(event) + + config.events = events + _configure_growth(config, simulation, transport, cell_growth) + config.authoring_json = json.dumps( + { + "version": 1, + "recipes": recipes, + "initial_medium": { + "name": transport.initial_medium.name, + "provenance": transport.initial_medium.provenance, + "concentrations": { + s: {"value": c.value, "unit": c.unit} + for s, c in transport.initial_medium.concentrations.items() + }, + }, + "solutes": [ + { + "id": s.id, + "label": s.label, + "amount_unit": s.amount_unit, + "molar_mass_g_per_mol": s.molar_mass_g_per_mol, + } + for s in transport.solutes + ], + }, + sort_keys=True, + allow_nan=False, + ) + initial = _concentrations( + transport.initial_medium, transport.solutes, config.grid.length_unit_m + ) + simulation._configure_culture(config, initial) + + +@dataclass(frozen=True, slots=True) +class CultureCell: + dry_biomass_g: float | None + biomass_produced_g: float + realized_specific_growth_rate_per_hour: float + surface_concentrations_mM: Mapping[str, float] # noqa: N815 + uptake: Mapping[str, float] + + +@dataclass(frozen=True, slots=True) +class CultureState: + time_seconds: float + cells: Mapping[int, CultureCell] + extracellular_amounts: Mapping[str, float] + boundary_transfer: Mapping[str, Mapping[str, float]] + budget_residual: Mapping[str, float] + native: core.CultureCheckpoint + + +def capture_culture(simulation: core.Simulation) -> CultureState | None: + state = simulation.culture_checkpoint + + if state is None: + return None + + config = state.configuration + models = {m.cell_id: m for m in config.growth} + cells: dict[int, CultureCell] = {} + + for cell in state.cells: + model = models.get(cell.body.id) + concentrations = simulation.cell_surface_concentrations(cell.body.id) + cells[cell.body.id] = CultureCell( + None if model is None else cell.biochemical_volume * model.biomass_density, + cell.biomass_produced, + cell.realized_specific_rate * 3600 / config.grid.time_unit_s, + MappingProxyType( + { + s.name: concentrations[i] / config.grid.length_unit_m**3 + for i, s in enumerate(config.solutes) + if s.amount_unit == "mol" + } + ), + MappingProxyType({s.name: cell.uptake_totals[i] for i, s in enumerate(config.solutes)}), + ) + + ns = len(config.solutes) + + return CultureState( + state.time * config.grid.time_unit_s, + MappingProxyType(cells), + MappingProxyType( + {s.name: sum(state.extracellular_amounts[i::ns]) for i, s in enumerate(config.solutes)} + ), + MappingProxyType( + { + t.port: MappingProxyType( + {s.name: t.amounts[i] for i, s in enumerate(config.solutes)} + ) + for t in state.reservoir_totals + } + ), + MappingProxyType( + { + s.name: state.last_report.transport.mass_balance_error[i] + if i < len(state.last_report.transport.mass_balance_error) + else 0.0 + for i, s in enumerate(config.solutes) + } + ), + state, + ) diff --git a/python/src/microsimulator/division.py b/python/src/microsimulator/division.py index 0b013d0..947737f 100644 --- a/python/src/microsimulator/division.py +++ b/python/src/microsimulator/division.py @@ -8,7 +8,9 @@ from dataclasses import dataclass from typing import cast -from ._core import Vec3 # pyright: ignore[reportMissingModuleSource] +import numpy as np + +from ._core import CellInit, Simulation, Vec3 # pyright: ignore[reportMissingModuleSource] from .checkpoint import JSONValue from .controller import ControllerStateError, ControllerStep, DivisionEvent, DivisionRequest @@ -19,9 +21,39 @@ def _valid_cell_id(value: object) -> bool: return isinstance(value, int) and not isinstance(value, bool) and value > 0 +def capped_founder_length(requested: float, target: float) -> float: + """Return a native-representable centerline length no greater than target. + + This is an opt-in model initialization policy. It never changes the sampled + target, and must not be applied when restoring existing cells. + """ + + if any(not math.isfinite(value) or value < 0.0 for value in (requested, target)): + raise ValueError("founder length and target must be finite and non-negative") + + bounded = min(requested, target) + + if bounded > float(np.finfo(np.float32).max): + raise ValueError("founder length exceeds native single precision range") + + result = np.float32(bounded) + + if float(result) > bounded: + result = np.nextafter(result, np.float32(0.0)) + + return float(result) + + @dataclass(frozen=True, slots=True) class UniformLengthDivision: - """Divide above per-cell thresholds sampled from one uniform distribution.""" + """Divide above per-cell thresholds sampled from one uniform distribution. + + ``jitter_z=None`` disables orientation jitter; ``False`` adds XY-only + perturbations; ``True`` adds XYZ perturbations. XY-only jitter leaves the + inherited Z component unchanged before native direction normalization, + which can change its normalized value. Neither option constrains daughter + positions, contact mechanics, or flow drift to a plane. + """ minimum: float maximum: float @@ -36,14 +68,47 @@ def __post_init__(self) -> None: or self.maximum < self.minimum ): raise ValueError("division target range must be finite, ordered, and non-negative") + if self.jitter_z is not None and not isinstance(cast(object, self.jitter_z), bool): raise ValueError("division jitter_z must be Boolean or None") + if _STATE_KEY.fullmatch(self.state_key) is None: raise ValueError("division state key is invalid") def _sample(self, rng: random.Random) -> float: return rng.uniform(self.minimum, self.maximum) + def initialize_founders( + self, + simulation: Simulation, + state: dict[str, JSONValue], + rng: random.Random, + founders: tuple[CellInit, ...], + ) -> tuple[int, ...]: + """Sample once per founder, cap its length, then add it to the simulation. + + Mutates only each input's length. Use ``initialize`` with existing IDs + instead for intentionally oversized founders. Neither initializer runs + during checkpoint restoration. + """ + + if self.state_key in state: + raise ControllerStateError(f"controller state already contains {self.state_key!r}") + + targets: dict[str, JSONValue] = {} + ids: list[int] = [] + + for founder in founders: + target = self._sample(rng) + founder.length = capped_founder_length(founder.length, target) + cell_id = simulation.add_cell(founder) + ids.append(cell_id) + targets[str(cell_id)] = target + + state[self.state_key] = {"targets": targets} + + return tuple(ids) + def initialize( self, state: dict[str, JSONValue], @@ -54,21 +119,27 @@ def initialize( if self.state_key in state: raise ControllerStateError(f"controller state already contains {self.state_key!r}") + if len(cell_ids) != len(set(cell_ids)) or any( not _valid_cell_id(cell_id) for cell_id in cell_ids ): raise ControllerStateError("founder cell IDs are invalid") + state[self.state_key] = { "targets": {str(cell_id): self._sample(rng) for cell_id in cell_ids} } def _targets(self, step: ControllerStep) -> dict[str, JSONValue]: policy = step.state.get(self.state_key) + if not isinstance(policy, dict) or set(policy) != {"targets"}: raise ControllerStateError(f"controller state {self.state_key!r} is invalid") + targets = policy["targets"] + if not isinstance(targets, dict): raise ControllerStateError(f"controller state {self.state_key!r} targets are invalid") + for target in targets.values(): if ( not isinstance(target, int | float) @@ -77,14 +148,17 @@ def _targets(self, step: ControllerStep) -> dict[str, JSONValue]: or target < 0.0 ): raise ControllerStateError("division target is invalid") + return targets def requests(self, step: ControllerStep) -> tuple[DivisionRequest, ...]: """Return stable-ID-ordered division requests for cells above target length.""" targets = self._targets(step) + if set(targets) != {str(cell.id) for cell in step.cells}: raise ControllerStateError("division targets do not match active cell identities") + return tuple( DivisionRequest(cell.id) for cell in step.cells @@ -95,6 +169,7 @@ def forget(self, step: ControllerStep, cell_ids: tuple[int, ...]) -> None: """Drop division targets for cells the returned plan removes.""" targets = self._targets(step) + for cell_id in cell_ids: targets.pop(str(cell_id), None) @@ -109,14 +184,19 @@ def on_division(self, step: ControllerStep, event: DivisionEvent) -> None: # this step's removals are still active here; targets may be a subset # of the pre-division identities but never contain anything else. expected = (active - daughter_keys) | {parent_key} + if parent_key not in targets or not set(targets) <= expected: raise ControllerStateError("division targets do not match pre-division identities") + del targets[parent_key] + if self.jitter_z is not None: for daughter in (event.first, event.second): jitter = [step.rng.uniform(-1.0e-3, 1.0e-3) for _ in range(3)] + if not self.jitter_z: jitter[2] = 0.0 + direction = Vec3( daughter.direction.x + jitter[0], daughter.direction.y + jitter[1], @@ -128,5 +208,6 @@ def on_division(self, step: ControllerStep, event: DivisionEvent) -> None: direction, daughter.length, ) + targets[str(event.first.id)] = self._sample(step.rng) targets[str(event.second.id)] = self._sample(step.rng) diff --git a/python/src/microsimulator/flow.py b/python/src/microsimulator/flow.py index c57f543..a197bdc 100644 --- a/python/src/microsimulator/flow.py +++ b/python/src/microsimulator/flow.py @@ -19,11 +19,16 @@ import math from collections.abc import Iterable, Sequence +from collections.abc import Mapping as _Mapping +from dataclasses import dataclass as _dataclass +from types import MappingProxyType as _MappingProxyType +from typing import Literal as _Literal from typing import Protocol import numpy as np from numpy.typing import NDArray +from . import _core as _native # pyright: ignore[reportPrivateUsage, reportMissingModuleSource] from ._core import ( # pyright: ignore[reportMissingModuleSource] BackendKind, DepthAveragedFlowParameters, @@ -65,18 +70,22 @@ def _flow_axis_index(spec: SignalGridSpec, axis: str) -> int: if axis not in _AXES: raise FlowError("flow axis must be one of x, y, z") + flow_axis = _AXES[axis] boundaries = ( (spec.x_lower, spec.x_upper), (spec.y_lower, spec.y_upper), (spec.z_lower, spec.z_upper), ) + for lower, upper in boundaries: if lower.kind == GridBoundaryKind.PERIODIC or upper.kind == GridBoundaryKind.PERIODIC: raise FlowError("the flow solver does not support periodic boundaries") + for boundary in boundaries[flow_axis]: if boundary.kind != GridBoundaryKind.FIXED: raise FlowError("the flow axis boundaries must be FIXED to act as inlet and outlet") + return flow_axis @@ -85,6 +94,7 @@ def _kozeny_carman_drag(fraction: _FloatGrid, drag_coefficient: float) -> _Float if not math.isfinite(drag_coefficient) or drag_coefficient < 0.0: raise FlowError("drag coefficient must be finite and non-negative") + return drag_coefficient * fraction * fraction / (1.0 - fraction) ** 3 @@ -119,6 +129,7 @@ def solve_flow_field( selected = ( simulation if simulation is not None else Simulation(backend, device_index=device_index) ) + try: result = selected.solve_depth_averaged_flow( spec, @@ -127,6 +138,7 @@ def solve_flow_field( ) except (OverflowError, RuntimeError, ValueError) as error: raise FlowError(str(error)) from error + return result.field, result.report @@ -142,20 +154,51 @@ def gap_mobility(spec: SignalGridSpec) -> list[float]: dims = (spec.shape.x, spec.shape.y, spec.shape.z) obstacles = spec.obstacles + if obstacles: if len(obstacles) != dims[0] * dims[1] * dims[2]: raise FlowError("obstacles must hold one flag per grid site") + fluid = (np.asarray(obstacles, dtype=np.uint8).reshape(dims) == 0).astype(np.float64) else: fluid = np.ones(dims, dtype=np.float64) + gaps = fluid.sum(axis=2, keepdims=True) max_gap = float(np.max(gaps)) + if max_gap == 0.0: raise FlowError("the grid contains no fluid sites") + mobility = fluid * (gaps / max_gap) ** 2 + return [float(value) for value in mobility.ravel()] +def _restrict_connected_support(kernel: _FloatGrid) -> None: + # Restrict to one face-connected fluid component of the kernel support. + connected = np.zeros(kernel.shape, dtype=bool) + seed = tuple(int(i) for i in np.unravel_index(int(np.argmax(kernel)), kernel.shape)) + pending = [seed] + + while pending: + index = pending.pop() + + if connected[index] or kernel[index] <= 0: + continue + + connected[index] = True + + for axis in range(3): + for offset in (-1, 1): + adjacent = list(index) + adjacent[axis] += offset + + if 0 <= adjacent[axis] < kernel.shape[axis]: + pending.append(tuple(adjacent)) + + kernel[~connected] = 0 + + def _deposit_amount( spec: SignalGridSpec, position: Vec3, @@ -168,15 +211,19 @@ def _deposit_amount( origin = (spec.origin.x, spec.origin.y, spec.origin.z) spacing = (spec.spacing.x, spec.spacing.y, spec.spacing.z) centers = (position.x, position.y, position.z) + if any(not math.isfinite(v) for v in centers) or not math.isfinite(amount) or amount < 0: raise FlowError("deposited positions and nonnegative amounts must be finite") + if any( p < o - h / 2 or p >= o + (n - 0.5) * h for p, o, h, n in zip(centers, origin, spacing, dims, strict=True) ): return # Outside the modeled volume: removal/washout is the caller's responsibility. + slices: list[slice] = [] weights: list[_FloatGrid] = [] + for p, o, h, n in zip(centers, origin, spacing, dims, strict=True): lo = max(0, math.floor((p - averaging_radius - o) / h + 0.5)) hi = min(n, math.ceil((p + averaging_radius - o) / h + 0.5)) @@ -192,30 +239,20 @@ def _deposit_amount( ) slices.append(slice(lo, hi)) weights.append(np.diff(cdf)) + kernel = weights[0][:, None, None] * weights[1][None, :, None] * weights[2][None, None, :] region = tuple(slices) + if spec.obstacles: solid = np.asarray(spec.obstacles, dtype=np.uint8).reshape(dims)[region] != 0 kernel[solid] = 0 - # Restrict to one face-connected fluid component of the kernel support. - connected = np.zeros(kernel.shape, dtype=bool) - seed = tuple(int(i) for i in np.unravel_index(int(np.argmax(kernel)), kernel.shape)) - pending = [seed] - while pending: - index = pending.pop() - if connected[index] or kernel[index] <= 0: - continue - connected[index] = True - for axis in range(3): - for offset in (-1, 1): - adjacent = list(index) - adjacent[axis] += offset - if 0 <= adjacent[axis] < kernel.shape[axis]: - pending.append(tuple(adjacent)) - kernel[~connected] = 0 + _restrict_connected_support(kernel) + total = float(kernel.sum()) + if total <= 0: raise FlowError("biomass deposition has no connected fluid support") + target[region] += (amount / total) * kernel @@ -232,14 +269,18 @@ def colony_volume_fraction( mesh refinement. Boundary-truncated kernels are renormalized within one connected fluid region. Cells outside the grid's physical extent are omitted. """ + if not math.isfinite(averaging_radius) or averaging_radius <= 0: raise FlowError("averaging radius must be finite and positive") + dims = (spec.shape.x, spec.shape.y, spec.shape.z) volume = np.zeros(dims, dtype=np.float64) + for cell in cells: _deposit_amount( spec, cell.position, biomass_volume(cell.length, cell.radius), averaging_radius, volume ) + return volume / spec.voxel_volume @@ -258,16 +299,22 @@ def colony_species_density( averaging_radius: float = 4.0, ) -> list[float]: """Conservatively deposit intracellular amount concentration * biomass volume.""" + if species < 0: raise FlowError("species index must be non-negative") + if not math.isfinite(averaging_radius) or averaging_radius <= 0: raise FlowError("averaging radius must be finite and positive") + totals = np.zeros((spec.shape.x, spec.shape.y, spec.shape.z), dtype=np.float64) + for cell in cells: if species >= len(cell.species): raise FlowError("species index is outside the cell's species") + amount = cell.species[species] * biomass_volume(cell.length, cell.radius) _deposit_amount(spec, cell.position, amount, averaging_radius, totals) + return [float(value) for value in (totals / spec.voxel_volume).ravel()] @@ -285,19 +332,26 @@ def colony_mobility( The density cap regularizes only the resistance law; deposited biomass is never discarded. Both base mobility and returned mobility are gap means. """ + if not 0 < max_volume_fraction < 1: raise FlowError("maximum volume fraction must lie strictly between zero and one") + dims = (spec.shape.x, spec.shape.y, spec.shape.z) + if isinstance(base, (int, float)): if not math.isfinite(base) or base <= 0.0: raise FlowError("base mobility must be finite and positive") + base_grid = np.full(dims, float(base), dtype=np.float64) else: if len(base) != dims[0] * dims[1] * dims[2]: raise FlowError("base mobility must hold one value per grid site") + base_grid = np.asarray(base, dtype=np.float64).reshape(dims) + if not bool(np.all(np.isfinite(base_grid))) or bool(np.any(base_grid < 0.0)): raise FlowError("base mobility values must be finite and non-negative") + fraction = colony_volume_fraction(spec, cells, averaging_radius=averaging_radius) fluid = ( np.ones(dims, dtype=np.float64) @@ -311,7 +365,118 @@ def colony_mobility( # m = b / (1 + b * drag) is 1 / (1/b + drag) extended continuously to b = 0. mobility = base_grid / (1.0 + base_grid * drag) obstacles = spec.obstacles + if obstacles: solid = np.asarray(obstacles, dtype=np.uint8).reshape(dims) != 0 mobility[solid] = 0.0 + return [float(value) for value in mobility.ravel()] + + +# Physical pressure/rate flow is distinct from the normalized shallow solver. + + +@_dataclass(frozen=True, slots=True) +class FluidProperties: + viscosity_pa_s: float = 1e-3 + density_kg_m3: float = 1000.0 + + def native(self) -> _native.FluidProperties: + value = _native.FluidProperties() + value.viscosity_pa_s, value.density_kg_m3 = self.viscosity_pa_s, self.density_kg_m3 + value.validate() + + return value + + +@_dataclass(frozen=True, slots=True) +class Pressure: + pa: float = 0.0 + + def __post_init__(self) -> None: + if not math.isfinite(self.pa): + raise ValueError("pressure must be finite") + + +@_dataclass(frozen=True, slots=True) +class VolumeFlow: + ul_per_min: float + direction: _Literal["in", "out"] = "in" + + def __post_init__(self) -> None: + if ( + not math.isfinite(self.ul_per_min) + or self.ul_per_min < 0 + or self.direction not in ("in", "out") + ): + raise ValueError("invalid volume flow") + + +@_dataclass(frozen=True, slots=True) +class BoundaryPatch: + axis: _Literal["x", "y", "z"] + upper: bool + sites: tuple[int, ...] = () + + def __post_init__(self) -> None: + if self.axis not in ("x", "y", "z"): + raise ValueError("invalid port axis") + + object.__setattr__(self, "sites", tuple(self.sites)) + + +@_dataclass(frozen=True, slots=True) +class FluidDomain: + size_um: tuple[float, float, float] + spacing_um: float + ports: _Mapping[str, BoundaryPatch] + origin_um: tuple[float, float, float] = (0.0, 0.0, 0.0) + obstacles: tuple[int, ...] = () + + def __post_init__(self) -> None: + object.__setattr__(self, "ports", _MappingProxyType(dict(self.ports))) + object.__setattr__(self, "obstacles", tuple(self.obstacles)) + self.native_grid().validate() + + @classmethod + def rectangular_channel( + cls, + *, + size_um: tuple[float, float, float], + spacing_um: float, + flow_axis: _Literal["x", "y", "z"] = "y", + ) -> FluidDomain: + return cls( + size_um, + spacing_um, + { + "inlet": BoundaryPatch(flow_axis, False), + "outlet": BoundaryPatch(flow_axis, True), + }, + ) + + def native_grid(self) -> _native.FluidGridSpec: + if not math.isfinite(self.spacing_um) or self.spacing_um <= 0: + raise ValueError("grid spacing must be positive") + + dimensions: list[int] = [] + + for size in self.size_um: + if not math.isfinite(size) or size <= 0: + raise ValueError("grid size must be positive") + + n = round(size / self.spacing_um) + + if n < 1 or not math.isclose(n * self.spacing_um, size, rel_tol=1e-6): + raise ValueError("device sizes must be integer multiples of grid spacing") + + dimensions.append(n) + + grid = _native.FluidGridSpec() + grid.shape.x, grid.shape.y, grid.shape.z = dimensions + grid.origin = Vec3(*(x + self.spacing_um / 2 for x in self.origin_um)) + grid.spacing = self.spacing_um + grid.length_unit_m, grid.time_unit_s = 1e-6, 1.0 + grid.obstacles = list(self.obstacles) + + return grid diff --git a/python/src/microsimulator/flow_reference.py b/python/src/microsimulator/flow_reference.py index 293aa2a..fc31504 100644 --- a/python/src/microsimulator/flow_reference.py +++ b/python/src/microsimulator/flow_reference.py @@ -32,9 +32,7 @@ """Peak-to-mean axial velocity of fully developed flow in a square duct.""" -def duct_grid( - nx: int, ny: int, nz: int, spacing: tuple[float, float, float] -) -> SignalGridSpec: +def duct_grid(nx: int, ny: int, nz: int, spacing: tuple[float, float, float]) -> SignalGridSpec: """A duct grid flowing along y between fixed inlet and outlet boundaries.""" shape = GridShape() @@ -45,11 +43,13 @@ def duct_grid( spec.spacing = Vec3(*spacing) spec.diffusion = [1.0] spec.advection = [Vec3()] + for name in ("y_lower", "y_upper"): boundary = getattr(spec, name) boundary.kind = GridBoundaryKind.FIXED boundary.values = [0.0] setattr(spec, name, boundary) + return spec @@ -77,6 +77,7 @@ def two_layer_brinkman(drag: float, positions: _Profile) -> _Profile: if drag <= 0.0: raise ValueError("drag must be positive") + root = math.sqrt(drag) matrix = np.array( [ @@ -92,6 +93,7 @@ def two_layer_brinkman(drag: float, positions: _Profile) -> _Profile: -positions * positions / 2.0 + linear * positions, 1.0 / drag + cosh_c * np.cosh(root * positions) + sinh_c * np.sinh(root * positions), ) + return profile @@ -105,12 +107,16 @@ def centerline_value(profile: _Profile) -> float: """ result = profile + for _ in range(profile.ndim): count = result.shape[0] + if count < 4 or count % 2 != 0: raise ValueError("centerline interpolation needs at least four cells per axis") + middle = count // 2 inner = 0.5 * (result[middle - 1] + result[middle]) outer = 0.5 * (result[middle - 2] + result[middle + 1]) result = (9.0 * inner - outer) / 8.0 + return float(result) diff --git a/python/src/microsimulator/growth.py b/python/src/microsimulator/growth.py new file mode 100644 index 0000000..132e6c9 --- /dev/null +++ b/python/src/microsimulator/growth.py @@ -0,0 +1,139 @@ +"""Cell-specific nutritional kinetics and explicit biomass conversion.""" + +from __future__ import annotations + +# ruff: noqa: N803, N815 -- mM denotes millimolar, not millimeters. +import math +from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType +from typing import Literal + +from .media import Concentration + + +def _positive(value: float, name: str) -> None: + if not math.isfinite(value) or value <= 0: + raise ValueError(f"{name} must be finite and positive") + + +@dataclass(frozen=True, slots=True) +class BiomassYield: + grams: float + amount_unit: Literal["mol", "g"] + + def __post_init__(self) -> None: + _positive(self.grams, "biomass yield") + + if self.amount_unit not in ("mol", "g"): + raise ValueError("yield basis must be mol or g") + + @classmethod + def grams_per_mol(cls, value: float) -> BiomassYield: + return cls(value, "mol") + + +@dataclass(frozen=True, slots=True) +class MonodGrowth: + substrate: str + mu_max_per_hour: float + half_saturation: Concentration + biomass_yield: BiomassYield + + def __post_init__(self) -> None: + if ( + not self.substrate + or not math.isfinite(self.mu_max_per_hour) + or self.mu_max_per_hour < 0 + ): + raise ValueError("invalid substrate or specific growth rate") + + _positive(self.half_saturation.value, "half saturation") + + @classmethod + def molar( + cls, + *, + substrate: str, + mu_max_per_hour: float, + half_saturation_mM: float, + yield_g_per_mol: float, + ) -> MonodGrowth: + return cls( + substrate, + mu_max_per_hour, + Concentration.mM(half_saturation_mM), + BiomassYield.grams_per_mol(yield_g_per_mol), + ) + + +@dataclass(frozen=True, slots=True) +class NutrientRequirement: + half_saturation_mM: float + yield_g_per_mol: float + + def __post_init__(self) -> None: + _positive(self.half_saturation_mM, "half saturation") + _positive(self.yield_g_per_mol, "biomass yield") + + +@dataclass(frozen=True, slots=True) +class EssentialNutrientGrowth: + mu_max_per_hour: float + requirements: Mapping[str, NutrientRequirement] + limitation: Literal["liebig_minimum"] = "liebig_minimum" + + def __post_init__(self) -> None: + if self.limitation != "liebig_minimum" or not self.requirements: + raise ValueError("essential growth requires nutrients and liebig_minimum") + + if not math.isfinite(self.mu_max_per_hour) or self.mu_max_per_hour < 0: + raise ValueError("invalid specific growth rate") + + object.__setattr__(self, "requirements", MappingProxyType(dict(self.requirements))) + + @classmethod + def molar( + cls, + *, + mu_max_per_hour: float, + requirements: Mapping[str, NutrientRequirement], + limitation: Literal["liebig_minimum"] = "liebig_minimum", + ) -> EssentialNutrientGrowth: + return cls(mu_max_per_hour, requirements, limitation) + + +@dataclass(frozen=True, slots=True) +class ReserveBudgetGrowth: + """Existing prescribed elongation, limited by named intracellular pools. + + Keys are intracellular species indices; values are amount per biochemical + volume. Surface exchange remains explicit in the native configuration. + """ + + requirements: Mapping[int, float] + + def __post_init__(self) -> None: + for species, amount in self.requirements.items(): + if species < 0: + raise ValueError("invalid intracellular species index") + + _positive(amount, "reserve requirement") + + object.__setattr__(self, "requirements", MappingProxyType(dict(self.requirements))) + + +@dataclass(frozen=True, slots=True) +class BiomassConversion: + density_g_per_um3: float + biochemical_volume_per_geometric_volume: float = 1.0 + + def __post_init__(self) -> None: + _positive(self.density_g_per_um3, "biomass density") + _positive(self.biochemical_volume_per_geometric_volume, "biochemical volume ratio") + + +@dataclass(frozen=True, slots=True) +class CellGrowth: + model: MonodGrowth | EssentialNutrientGrowth | ReserveBudgetGrowth + biomass: BiomassConversion diff --git a/python/src/microsimulator/legacy.py b/python/src/microsimulator/legacy.py index af721f4..2d6e6e3 100644 --- a/python/src/microsimulator/legacy.py +++ b/python/src/microsimulator/legacy.py @@ -79,6 +79,7 @@ def _ends( position: list[float], direction: list[float], length: float ) -> tuple[tuple[float, float, float], tuple[float, float, float]]: half = length * 0.5 + return ( ( position[0] - direction[0] * half, @@ -98,14 +99,18 @@ def _division_fraction(value: object) -> float: weights = list(cast(Any, value)) except TypeError as error: raise LegacyCompatibilityError("legacy asymm must contain two positive weights") from error + if len(weights) != 2: raise LegacyCompatibilityError("legacy asymm must contain two positive weights") + try: first = float(weights[0]) second = float(weights[1]) except (TypeError, ValueError, OverflowError) as error: raise LegacyCompatibilityError("legacy asymm must contain two positive weights") from error + total = first + second + if ( not math.isfinite(first) or not math.isfinite(second) @@ -114,114 +119,162 @@ def _division_fraction(value: object) -> float: or second <= 0.0 ): raise LegacyCompatibilityError("legacy asymm must contain two finite positive weights") + fraction = first / total + if fraction <= 0.0 or fraction >= 1.0: raise LegacyCompatibilityError("legacy asymm weights cannot produce a valid split") + return fraction +def _encode_numpy_array(value: object, path: str) -> JSONValue: + array = cast(Any, value) + + if array.dtype.kind not in "biuf": + raise LegacyCompatibilityError(f"{path} has unsupported NumPy dtype {array.dtype}") + + if array.dtype.kind == "f" and not bool(np.isfinite(array).all()): + raise LegacyCompatibilityError(f"{path} must contain finite values") + + return { + "$type": "ndarray", + "dtype": cast(str, array.dtype.str), + "shape": cast(list[JSONValue], list(array.shape)), + "items": _encoded(cast(object, array.tolist()), f"{path}.items"), + } + + +def _decode_dict(value: dict[str, JSONValue], path: str) -> Any: + if set(value) != {"$type", "items"} or not isinstance(value["items"], list): + raise LegacyCompatibilityError(f"{path} has an invalid dictionary encoding") + + result: dict[str, Any] = {} + + for index, entry in enumerate(value["items"]): + if ( + not isinstance(entry, list) + or len(entry) != 2 + or not isinstance(entry[0], str) + or entry[0] in result + ): + raise LegacyCompatibilityError(f"{path}.items[{index}] is invalid") + + result[entry[0]] = _decoded(entry[1], f"{path}[{entry[0]!r}]") + + return result + + +def _decode_ndarray(value: dict[str, JSONValue], path: str) -> Any: + if set(value) != {"$type", "dtype", "shape", "items"}: + raise LegacyCompatibilityError(f"{path} has an invalid NumPy encoding") + + dtype_value = value["dtype"] + shape_value = value["shape"] + + if not isinstance(dtype_value, str) or not isinstance(shape_value, list): + raise LegacyCompatibilityError(f"{path} has invalid NumPy metadata") + + if not all( + isinstance(item, int) and not isinstance(item, bool) and item >= 0 for item in shape_value + ): + raise LegacyCompatibilityError(f"{path} has an invalid NumPy shape") + + shape = [cast(int, item) for item in shape_value] + + try: + dtype = np.dtype(dtype_value) + + if dtype.kind not in "biuf": + raise LegacyCompatibilityError(f"{path} has unsupported NumPy dtype {dtype}") + + array = cast(Any, np.asarray(_decoded(value["items"], f"{path}.items"), dtype=dtype)) + + return array.reshape(tuple(shape)) + except (TypeError, ValueError, OverflowError) as error: + raise LegacyCompatibilityError(f"{path} has invalid NumPy data") from error + + def _encoded(value: object, path: str) -> JSONValue: if value is None or isinstance(value, str | bool | int): return value + if isinstance(value, float): if not math.isfinite(value): raise LegacyCompatibilityError(f"{path} must be finite") + return value + if isinstance(value, np.generic): scalar = cast(Any, value) + return _encoded(cast(object, scalar.item()), path) + if isinstance(value, np.ndarray): - array = cast(Any, value) - if array.dtype.kind not in "biuf": - raise LegacyCompatibilityError(f"{path} has unsupported NumPy dtype {array.dtype}") - if array.dtype.kind == "f" and not bool(np.isfinite(array).all()): - raise LegacyCompatibilityError(f"{path} must contain finite values") - return { - "$type": "ndarray", - "dtype": cast(str, array.dtype.str), - "shape": cast(list[JSONValue], list(array.shape)), - "items": _encoded(cast(object, array.tolist()), f"{path}.items"), - } + return _encode_numpy_array(cast(object, value), path) + if isinstance(value, list): sequence = cast(list[object], value) + return { "$type": "list", - "items": [ - _encoded(item, f"{path}[{index}]") for index, item in enumerate(sequence) - ], + "items": [_encoded(item, f"{path}[{index}]") for index, item in enumerate(sequence)], } + if isinstance(value, tuple): sequence = cast(tuple[object, ...], value) + return { "$type": "tuple", - "items": [ - _encoded(item, f"{path}[{index}]") for index, item in enumerate(sequence) - ], + "items": [_encoded(item, f"{path}[{index}]") for index, item in enumerate(sequence)], } + if isinstance(value, dict): mapping = cast(dict[object, object], value) items: list[JSONValue] = [] + for key, item in mapping.items(): if not isinstance(key, str): raise LegacyCompatibilityError(f"{path} dictionary keys must be strings") + items.append([key, _encoded(item, f"{path}[{key!r}]")]) + return {"$type": "dict", "items": items} + raise LegacyCompatibilityError(f"{path} has unsupported value type {type(value).__name__}") def _decoded(value: JSONValue, path: str) -> Any: if value is None or isinstance(value, str | bool | int): return value + if isinstance(value, float): if not math.isfinite(value): raise LegacyCompatibilityError(f"{path} must be finite") + return value + if not isinstance(value, dict): raise LegacyCompatibilityError(f"{path} is not a tagged legacy value") + kind = value.get("$type") + if kind in {"list", "tuple"}: if set(value) != {"$type", "items"} or not isinstance(value["items"], list): raise LegacyCompatibilityError(f"{path} has an invalid {kind} encoding") + items = [ - _decoded(item, f"{path}.items[{index}]") - for index, item in enumerate(value["items"]) + _decoded(item, f"{path}.items[{index}]") for index, item in enumerate(value["items"]) ] + return items if kind == "list" else tuple(items) + if kind == "dict": - if set(value) != {"$type", "items"} or not isinstance(value["items"], list): - raise LegacyCompatibilityError(f"{path} has an invalid dictionary encoding") - result: dict[str, Any] = {} - for index, entry in enumerate(value["items"]): - if ( - not isinstance(entry, list) - or len(entry) != 2 - or not isinstance(entry[0], str) - or entry[0] in result - ): - raise LegacyCompatibilityError(f"{path}.items[{index}] is invalid") - result[entry[0]] = _decoded(entry[1], f"{path}[{entry[0]!r}]") - return result + return _decode_dict(value, path) + if kind == "ndarray": - if set(value) != {"$type", "dtype", "shape", "items"}: - raise LegacyCompatibilityError(f"{path} has an invalid NumPy encoding") - dtype_value = value["dtype"] - shape_value = value["shape"] - if not isinstance(dtype_value, str) or not isinstance(shape_value, list): - raise LegacyCompatibilityError(f"{path} has invalid NumPy metadata") - if not all( - isinstance(item, int) and not isinstance(item, bool) and item >= 0 - for item in shape_value - ): - raise LegacyCompatibilityError(f"{path} has an invalid NumPy shape") - shape = [cast(int, item) for item in shape_value] - try: - dtype = np.dtype(dtype_value) - if dtype.kind not in "biuf": - raise LegacyCompatibilityError(f"{path} has unsupported NumPy dtype {dtype}") - array = cast(Any, np.asarray(_decoded(value["items"], f"{path}.items"), dtype=dtype)) - return array.reshape(tuple(shape)) - except (TypeError, ValueError, OverflowError) as error: - raise LegacyCompatibilityError(f"{path} has invalid NumPy data") from error + return _decode_ndarray(value, path) + raise LegacyCompatibilityError(f"{path} has an unknown tagged value type") @@ -245,6 +298,7 @@ def __init__( ) -> None: if simulation.cell_count != 0: raise LegacyCompatibilityError("legacy adapter requires an empty simulation") + self._configure( simulation, init=init, @@ -278,17 +332,22 @@ def _configure( ) -> None: if mechanics and not simulation.supports(BackendFeature.CELL_MECHANICS): raise LegacyCompatibilityError("backend does not implement cell mechanics") + if compute_neighbors and not simulation.supports(BackendFeature.CELL_CONTACTS): raise LegacyCompatibilityError("backend does not implement cell contacts") + if division_jitter_z is not None and rng is None: raise LegacyCompatibilityError( "legacy division jitter requires an explicit random stream" ) + if division_jitter_z is not None and alternate_divisions: raise LegacyCompatibilityError( "legacy random jitter and alternating division axes are mutually exclusive" ) + max_substeps_value = cast(object, max_substeps) + if ( not isinstance(max_substeps_value, int) or isinstance(max_substeps_value, bool) @@ -296,6 +355,7 @@ def _configure( or max_substeps_value > (1 << 32) - 1 ): raise LegacyCompatibilityError("legacy max_substeps must be an unsigned 32-bit integer") + self.simulation = simulation self._init = init self._update = update @@ -319,9 +379,12 @@ def controller_state(self) -> dict[str, JSONValue]: """Return complete data-only callback and random-stream state.""" snapshots = self.simulation.cells() + if {snapshot.id for snapshot in snapshots} != self._cells.keys(): raise LegacyCompatibilityError("legacy cells and native simulation identities disagree") + cells: list[JSONValue] = [] + for snapshot in snapshots: cell = self._cells[snapshot.id] attributes = { @@ -329,7 +392,9 @@ def controller_state(self) -> dict[str, JSONValue]: for name, value in vars(cell).items() } cells.append({"id": snapshot.id, "attributes": attributes}) + parameters = self._mechanics_parameters + return { "kind": "microsimulator-legacy-python", "version": 4, @@ -354,19 +419,9 @@ def controller_state(self) -> dict[str, JSONValue]: } @classmethod - def from_controller_state( - cls, - simulation: Simulation, - controller: JSONValue, - *, - init: InitCallback, - update: UpdateCallback, - divide: DivideCallback | None = None, - rng: random.Random | None = None, - ) -> LegacyModelAdapter: - """Restore callback state onto an already-restored native simulation.""" - + def _controller_header(cls, controller: JSONValue) -> tuple[dict[str, JSONValue], int]: data = cls._controller_object(controller, "controller") + if set(data) != { "kind", "version", @@ -376,7 +431,9 @@ def from_controller_state( "cells", }: raise LegacyCompatibilityError("legacy controller has unexpected fields") + version = data["version"] + if ( data["kind"] not in ("microsimulator-legacy-python", "cellmodeller2-legacy-python") or not isinstance(version, int) @@ -384,40 +441,15 @@ def from_controller_state( or version not in (2, 3, 4) ): raise LegacyCompatibilityError("legacy controller kind or version is unsupported") - options = cls._controller_object(data["options"], "controller.options") - expected_options = { - "mechanics", - "compute_neighbors", - "division_jitter_z", - "mechanics_parameters", - } - if version >= 3: - expected_options.add("alternate_divisions") - if version >= 4: - expected_options.add("max_substeps") - if set(options) != expected_options: - raise LegacyCompatibilityError("legacy controller options are invalid") - mechanics = options["mechanics"] - compute_neighbors = options["compute_neighbors"] - division_jitter_z = options["division_jitter_z"] - alternate_divisions = options["alternate_divisions"] if version >= 3 else False - max_substeps = options["max_substeps"] if version >= 4 else 2 - if not isinstance(mechanics, bool) or not isinstance(compute_neighbors, bool): - raise LegacyCompatibilityError("legacy controller Boolean options are invalid") - if division_jitter_z is not None and not isinstance(division_jitter_z, bool): - raise LegacyCompatibilityError("legacy division jitter option is invalid") - if not isinstance(alternate_divisions, bool): - raise LegacyCompatibilityError("legacy alternating division option is invalid") - if ( - not isinstance(max_substeps, int) - or isinstance(max_substeps, bool) - or max_substeps < 0 - or max_substeps > (1 << 32) - 1 - ): - raise LegacyCompatibilityError("legacy max_substeps option is invalid") + + return data, version + + @classmethod + def _controller_mechanics(cls, options: dict[str, JSONValue]) -> MechanicsParameters: mechanics_data = cls._controller_object( options["mechanics_parameters"], "controller.options.mechanics_parameters" ) + if set(mechanics_data) != { "mu_a", "gamma", @@ -425,7 +457,9 @@ def from_controller_state( "max_iterations", }: raise LegacyCompatibilityError("legacy mechanics parameters are invalid") + parameters = MechanicsParameters() + try: parameters.mu_a = float(cast(Any, mechanics_data["mu_a"])) parameters.gamma = float(cast(Any, mechanics_data["gamma"])) @@ -436,58 +470,57 @@ def from_controller_state( except (TypeError, ValueError, OverflowError) as error: raise LegacyCompatibilityError("legacy mechanics parameters are invalid") from error + return parameters + + @staticmethod + def _controller_random_state( + data: dict[str, JSONValue], rng: random.Random | None + ) -> random.Random | None: random_state = data["random_state"] restored_rng = rng + if random_state is not None: restored_rng = restored_rng or random.Random() decoded_random_state = _decoded(random_state, "controller.random_state") + if not isinstance(decoded_random_state, tuple): raise LegacyCompatibilityError("legacy random state is invalid") + try: restored_rng.setstate(cast(tuple[Any, ...], decoded_random_state)) except (TypeError, ValueError) as error: raise LegacyCompatibilityError("legacy random state is invalid") from error - instance = cls.__new__(cls) - instance._configure( - simulation, - init=init, - update=update, - divide=divide, - mechanics=mechanics, - compute_neighbors=compute_neighbors, - division_jitter_z=division_jitter_z, - alternate_divisions=alternate_divisions, - max_substeps=max_substeps, - rng=restored_rng, - mechanics_parameters=parameters, - ) + return restored_rng + + @staticmethod + def _controller_setup_ids(data: dict[str, JSONValue]) -> list[int]: setup_cell_ids = data["setup_cell_ids"] + if not isinstance(setup_cell_ids, list) or not all( isinstance(item, int) and not isinstance(item, bool) and item > 0 for item in setup_cell_ids ): raise LegacyCompatibilityError("legacy setup cell identifiers are invalid") - instance._setup_cell_ids = [cast(int, item) for item in setup_cell_ids] - instance._cells = instance._restore_cells(data["cells"]) - return instance - @staticmethod - def _controller_object(value: JSONValue, path: str) -> dict[str, JSONValue]: - if not isinstance(value, dict): - raise LegacyCompatibilityError(f"{path} must be an object") - return value + return [cast(int, item) for item in setup_cell_ids] - def _restore_cells(self, value: JSONValue) -> dict[int, LegacyCell]: + @classmethod + def _controller_cell_records(cls, value: JSONValue) -> dict[int, dict[str, JSONValue]]: if not isinstance(value, list): raise LegacyCompatibilityError("controller.cells must be an array") + records: dict[int, dict[str, JSONValue]] = {} + for index, item in enumerate(value): - record = self._controller_object(item, f"controller.cells[{index}]") + record = cls._controller_object(item, f"controller.cells[{index}]") + if set(record) != {"id", "attributes"}: raise LegacyCompatibilityError(f"controller.cells[{index}] is invalid") + cell_id = record["id"] attributes = record["attributes"] + if ( not isinstance(cell_id, int) or isinstance(cell_id, bool) @@ -496,12 +529,106 @@ def _restore_cells(self, value: JSONValue) -> dict[int, LegacyCell]: or not isinstance(attributes, dict) ): raise LegacyCompatibilityError(f"controller.cells[{index}] is invalid") + records[cell_id] = attributes + + return records + + @classmethod + def from_controller_state( + cls, + simulation: Simulation, + controller: JSONValue, + *, + init: InitCallback, + update: UpdateCallback, + divide: DivideCallback | None = None, + rng: random.Random | None = None, + ) -> LegacyModelAdapter: + """Restore callback state onto an already-restored native simulation.""" + + data, version = cls._controller_header(controller) + + options = cls._controller_object(data["options"], "controller.options") + expected_options = { + "mechanics", + "compute_neighbors", + "division_jitter_z", + "mechanics_parameters", + } + + if version >= 3: + expected_options.add("alternate_divisions") + + if version >= 4: + expected_options.add("max_substeps") + + if set(options) != expected_options: + raise LegacyCompatibilityError("legacy controller options are invalid") + + mechanics = options["mechanics"] + compute_neighbors = options["compute_neighbors"] + division_jitter_z = options["division_jitter_z"] + alternate_divisions = options["alternate_divisions"] if version >= 3 else False + max_substeps = options["max_substeps"] if version >= 4 else 2 + + if not isinstance(mechanics, bool) or not isinstance(compute_neighbors, bool): + raise LegacyCompatibilityError("legacy controller Boolean options are invalid") + + if division_jitter_z is not None and not isinstance(division_jitter_z, bool): + raise LegacyCompatibilityError("legacy division jitter option is invalid") + + if not isinstance(alternate_divisions, bool): + raise LegacyCompatibilityError("legacy alternating division option is invalid") + + if ( + not isinstance(max_substeps, int) + or isinstance(max_substeps, bool) + or max_substeps < 0 + or max_substeps > (1 << 32) - 1 + ): + raise LegacyCompatibilityError("legacy max_substeps option is invalid") + + parameters = cls._controller_mechanics(options) + + restored_rng = cls._controller_random_state(data, rng) + + instance = cls.__new__(cls) + instance._configure( + simulation, + init=init, + update=update, + divide=divide, + mechanics=mechanics, + compute_neighbors=compute_neighbors, + division_jitter_z=division_jitter_z, + alternate_divisions=alternate_divisions, + max_substeps=max_substeps, + rng=restored_rng, + mechanics_parameters=parameters, + ) + instance._setup_cell_ids = cls._controller_setup_ids(data) + instance._cells = instance._restore_cells(data["cells"]) + + return instance + + @staticmethod + def _controller_object(value: JSONValue, path: str) -> dict[str, JSONValue]: + if not isinstance(value, dict): + raise LegacyCompatibilityError(f"{path} must be an object") + + return value + + def _restore_cells(self, value: JSONValue) -> dict[int, LegacyCell]: + records = self._controller_cell_records(value) + snapshots = self.simulation.cells() + if set(records) != {snapshot.id for snapshot in snapshots}: raise LegacyCompatibilityError("legacy controller and native cell identities disagree") cells: dict[int, LegacyCell] = {} + for snapshot in snapshots: decoded_attributes = { name: _decoded(item, f"controller.cells[{snapshot.id}].attributes.{name}") @@ -510,10 +637,13 @@ def _restore_cells(self, value: JSONValue) -> dict[int, LegacyCell]: cell = LegacyCell(snapshot) vars(cell).clear() vars(cell).update(decoded_attributes) + if cell.id != snapshot.id or cell.idx != snapshot.slot: raise LegacyCompatibilityError("legacy controller cell identity is invalid") + self._validate_engine_owned_geometry(cell, snapshot) self._validate_mutable_state(cell) + if ( float(cell.growthRate) != snapshot.growth_rate or int(cell.cellType) != snapshot.cell_type @@ -525,7 +655,9 @@ def _restore_cells(self, value: JSONValue) -> dict[int, LegacyCell]: raise LegacyCompatibilityError( "legacy controller mutable state disagrees with native state" ) + cells[snapshot.id] = cell + return cells @property @@ -541,6 +673,7 @@ def add_cell(self, cell: CellInit) -> int: self._validate_engine_owned_geometry(legacy_cell, self.simulation.cell(cell_id)) self._apply_mutable_state(legacy_cell) self._cells[cell_id] = legacy_cell + return cell_id def step(self, dt: float) -> None: @@ -551,26 +684,32 @@ def step(self, dt: float) -> None: self._update(self._cells) snapshots = {snapshot.id: snapshot for snapshot in self.simulation.cells()} + for cell_id, cell in self._cells.items(): self._validate_engine_owned_geometry(cell, snapshots[cell_id]) self._validate_mutable_state(cell) + for cell in self._cells.values(): self._apply_mutable_state(cell) cell.time = self.simulation.time dividing = [cell_id for cell_id, cell in self._cells.items() if cell.divideFlag] + for parent_id in dividing: self._divide_cell(parent_id) self.simulation.step(dt) self._last_mechanics_reports = () + if self._mechanics and self.simulation.cell_count != 0: self._last_mechanics_reports = self._relax_new_contact_frontier() + self._refresh_cells() def _relax_new_contact_frontier(self) -> tuple[MechanicsSolveResult, ...]: seen: set[tuple[object, ...]] = set() reports: list[MechanicsSolveResult] = [] + for _ in range(max(0, self._max_substeps - 1)): cell_contacts = self.simulation.find_cell_contacts().contacts external_contacts = self.simulation.find_external_contacts().contacts @@ -588,10 +727,13 @@ def _relax_new_contact_frontier(self) -> tuple[MechanicsSolveResult, ...]: ) for contact in external_contacts ) + if not current.difference(seen): break + seen.update(current) reports.append(self.simulation.relax_cell_mechanics(self._mechanics_parameters)) + return tuple(reports) def _divide_cell(self, parent_id: int) -> None: @@ -607,8 +749,10 @@ def _divide_cell(self, parent_id: int) -> None: second.cellAge = 0 self._set_identity(first, self.simulation.cell(first_id)) self._set_identity(second, self.simulation.cell(second_id)) + if self._divide is not None: self._divide(parent, first, second) + self._validate_engine_owned_geometry(first, self.simulation.cell(first_id)) self._validate_engine_owned_geometry(second, self.simulation.cell(second_id)) self._validate_mutable_state(first) @@ -621,6 +765,7 @@ def _divide_cell(self, parent_id: int) -> None: def _apply_division_orientation(self, cell_id: int) -> None: snapshot = self.simulation.cell(cell_id) + if self._alternate_divisions: direction = Vec3( -snapshot.direction.y, @@ -630,14 +775,20 @@ def _apply_division_orientation(self, cell_id: int) -> None: self.simulation.set_cell_geometry( cell_id, snapshot.position, direction, snapshot.length ) + return + if self._division_jitter_z is None: return + if self._rng is None: raise AssertionError("division jitter random stream is missing") + jitter = [self._rng.uniform(-0.001, 0.001) for _ in range(3)] + if not self._division_jitter_z: jitter[2] = 0.0 + direction = Vec3( snapshot.direction.x + jitter[0], snapshot.direction.y + jitter[1], @@ -670,6 +821,7 @@ def _validate_engine_owned_geometry(cell: LegacyCell, snapshot: CellSnapshot) -> snapshot.length, snapshot.radius, ) + if actual != expected: raise LegacyCompatibilityError( "legacy callbacks may not mutate native position, direction, length, or radius" @@ -677,27 +829,34 @@ def _validate_engine_owned_geometry(cell: LegacyCell, snapshot: CellSnapshot) -> def _validate_mutable_state(self, cell: LegacyCell) -> None: growth_rate = float(cell.growthRate) + if not math.isfinite(growth_rate): raise LegacyCompatibilityError("legacy growthRate must be finite") + cell_type = cast(object, cell.cellType) + if not isinstance(cell_type, int): raise LegacyCompatibilityError("legacy cellType must be an integer") + if ( self.simulation.species_count != 0 and len(cell.species) != self.simulation.species_count ): raise LegacyCompatibilityError("legacy species count does not match the simulation") + if not all(math.isfinite(float(value)) for value in cell.species): raise LegacyCompatibilityError("legacy species levels must be finite") def _apply_mutable_state(self, cell: LegacyCell) -> None: self._validate_mutable_state(cell) self.simulation.set_cell_attributes(cell.id, float(cell.growthRate), int(cell.cellType)) + if self.simulation.species_count != 0: self.simulation.set_species(cell.id, [float(value) for value in cell.species]) def _refresh_cells(self) -> None: graph = self.simulation.find_cell_contacts() if self._compute_neighbors else None + for snapshot in self.simulation.cells(): cell = self._cells[snapshot.id] previous_position = cell.pos @@ -709,8 +868,10 @@ def _refresh_cells(self) -> None: cell.radius = snapshot.radius cell.growthRate = snapshot.growth_rate cell.cellType = snapshot.cell_type + if self.simulation.species_count != 0: cell.species = list(snapshot.species) + cell.signals = ( self.simulation.sample_signals(snapshot.position) if self.simulation.has_signal_grid @@ -720,9 +881,9 @@ def _refresh_cells(self) -> None: cell.strainRate = ( (cell.length - previous_length) / previous_length if previous_length != 0.0 else 0.0 ) - cell.effGrowth = ( - cell.effGrowth * cell.cellAge + cell.strainRate * previous_length - ) / (cell.cellAge + 1) + cell.effGrowth = (cell.effGrowth * cell.cellAge + cell.strainRate * previous_length) / ( + cell.cellAge + 1 + ) cell.cellAge += 1 cell.oldLen = cell.length cell.volume = cell.length diff --git a/python/src/microsimulator/legacy_loader.py b/python/src/microsimulator/legacy_loader.py index a6890e5..936c665 100644 --- a/python/src/microsimulator/legacy_loader.py +++ b/python/src/microsimulator/legacy_loader.py @@ -39,8 +39,10 @@ def _vec3(value: object, name: str) -> Vec3: coordinates = list(cast(Any, value)) except TypeError as error: raise LegacyCompatibilityError(f"legacy {name} must contain three coordinates") from error + if len(coordinates) != 3: raise LegacyCompatibilityError(f"legacy {name} must contain three coordinates") + try: return Vec3(float(coordinates[0]), float(coordinates[1]), float(coordinates[2])) except (TypeError, ValueError, OverflowError) as error: @@ -87,12 +89,15 @@ def __init__( ) -> None: del max_cells, max_contacts, max_planes, max_spheres del max_sqs, grid_spacing, printing + if dt is not None: raise LegacyCompatibilityError("a CLBacterium-specific time step is not supported") + self._setup = simulator self.jitter_z = bool(jitter_z) self.alternate_divisions = bool(alternate_divisions) max_substeps_value = cast(object, max_substeps) + if ( not isinstance(max_substeps_value, int) or isinstance(max_substeps_value, bool) @@ -100,6 +105,7 @@ def __init__( or max_substeps_value > (1 << 32) - 1 ): raise LegacyCompatibilityError("legacy max_substeps must be an unsigned 32-bit integer") + self.max_substeps = max_substeps_value self.compute_neighbors = bool(compNeighbours) self.mechanics_parameters = MechanicsParameters() @@ -114,8 +120,10 @@ def addPlane( # noqa: N802 - legacy API plane.point = _vec3(point, "plane point") plane.inward_normal = _vec3(normal, "plane normal") plane.coefficient = float(coefficient) + if self._setup.restoring: return self._setup.record_restored_constraint() + return self._setup.simulation.add_plane_constraint(plane) def addSphere( # noqa: N802 - legacy API @@ -127,15 +135,16 @@ def addSphere( # noqa: N802 - legacy API ) -> int: if normal_sign not in (-1, 1): raise LegacyCompatibilityError("legacy sphere normal sign must be -1 or 1") + sphere = SphereConstraintInit() sphere.center = _vec3(center, "sphere center") sphere.radius = float(radius) sphere.coefficient = float(coefficient) - sphere.allowed_region = ( - SphereRegion.INSIDE if normal_sign == -1 else SphereRegion.OUTSIDE - ) + sphere.allowed_region = SphereRegion.INSIDE if normal_sign == -1 else SphereRegion.OUTSIDE + if self._setup.restoring: return self._setup.record_restored_constraint() + return self._setup.simulation.add_sphere_constraint(sphere) @@ -168,12 +177,14 @@ def restoring(self) -> bool: def record_restored_constraint(self) -> int: self._restored_constraint_cursor += 1 + return self._restored_constraint_cursor @property def adapter(self) -> LegacyModelAdapter: if self._adapter is None: raise LegacyCompatibilityError("legacy setup did not call sim.init") + return self._adapter @property @@ -188,20 +199,26 @@ def init( integrator: object | None, ) -> None: del regulator + if self._adapter is not None: raise LegacyCompatibilityError("legacy setup called sim.init more than once") + if not isinstance(biophysics, _LegacyCLBacterium): raise LegacyCompatibilityError("only legacy CLBacterium physics is supported") + if signaling is not None or integrator is not None: raise LegacyCompatibilityError( "legacy signaling and integration objects must be replaced by typed rate plans" ) + if self.module is None: raise AssertionError("legacy module is not attached") + initialize = cast(InitCallback, _required_callback(self.module, "init")) update = cast(UpdateCallback, _required_callback(self.module, "update")) divide_value = _optional_callback(self.module, "divide") divide = cast(DivideCallback, divide_value) if divide_value is not None else None + if self.restoring: self._adapter = LegacyModelAdapter.from_controller_state( self.simulation, @@ -218,9 +235,7 @@ def init( divide=divide, mechanics=True, compute_neighbors=biophysics.compute_neighbors, - division_jitter_z=( - None if biophysics.alternate_divisions else biophysics.jitter_z - ), + division_jitter_z=(None if biophysics.alternate_divisions else biophysics.jitter_z), alternate_divisions=biophysics.alternate_divisions, max_substeps=biophysics.max_substeps, rng=self._context.rng, @@ -240,21 +255,29 @@ def addCell(self, **values: object) -> int: # noqa: N802 - legacy API "color", } unknown = values.keys() - aliases + if unknown: raise LegacyCompatibilityError(f"unsupported legacy addCell fields: {sorted(unknown)}") + if "len" in values and "length" in values: raise LegacyCompatibilityError("legacy addCell supplied both len and length") + if "rad" in values and "radius" in values: raise LegacyCompatibilityError("legacy addCell supplied both rad and radius") + if self.restoring: setup_ids = self.adapter._setup_cell_ids # pyright: ignore[reportPrivateUsage] + if self._restored_cell_cursor >= len(setup_ids): raise LegacyCompatibilityError( "legacy setup adds more cells than the saved controller" ) + cell_id = setup_ids[self._restored_cell_cursor] self._restored_cell_cursor += 1 + return cell_id + cell = CellInit() cell.position = _vec3(values.get("pos", (0.0, 0.0, 0.0)), "cell position") cell.direction = _vec3(values.get("dir", (1.0, 0.0, 0.0)), "cell direction") @@ -265,8 +288,10 @@ def addCell(self, **values: object) -> int: # noqa: N802 - legacy API self.adapter._setup_cell_ids.append(cell_id) # pyright: ignore[reportPrivateUsage] legacy_cell = self.adapter.cells[cell_id] legacy_cell.cellAdh = int(cast(Any, values.get("cellAdh", 0))) + if "color" in values: legacy_cell.color = values["color"] + return cell_id def addRenderer(self, renderer: object) -> None: # noqa: N802 - legacy API @@ -289,23 +314,29 @@ def validate_setup_complete(self) -> None: def _required_callback(module: ModuleType, name: str) -> Callable[..., Any]: value = module.__dict__.get(name) + if not callable(value): raise LegacyCompatibilityError(f"legacy model must define {name}(...)") + return value def _optional_callback(module: ModuleType, name: str) -> Callable[..., Any] | None: value = module.__dict__.get(name) + if value is None: return None + if not callable(value): raise LegacyCompatibilityError(f"legacy model {name} must be callable") + return value def _module(name: str) -> ModuleType: result = ModuleType(name) result.__package__ = name.rpartition(".")[0] + return result @@ -340,13 +371,16 @@ def _legacy_modules() -> dict[str, ModuleType]: signaling, ) } + for component in ("CLEulerIntegrator", "CLEulerSigIntegrator", "CLCrankNicIntegrator"): module = _module(f"CellModeller.Integration.{component}") setattr(module, component, _UnsupportedLegacyComponent) modules[module.__name__] = module + grid = _module("CellModeller.Signalling.GridDiffusion") grid.GridDiffusion = _UnsupportedLegacyComponent # type: ignore[attr-defined] modules[grid.__name__] = grid + return modules @@ -355,6 +389,7 @@ def _installed_legacy_modules() -> Generator[None]: modules = _legacy_modules() previous = {name: sys.modules.get(name, _MISSING) for name in modules} sys.modules.update(modules) + try: yield finally: @@ -382,14 +417,18 @@ def resume_legacy_model( if bundle.controller is None: raise LegacyCompatibilityError("checkpoint does not contain legacy controller state") + source_path = Path(path).resolve() model_value = bundle.provenance.get("model") + if not isinstance(model_value, dict): raise LegacyCompatibilityError("checkpoint is missing legacy model provenance") + digest_value = model_value.get("sha256") seed_value = model_value.get("seed") parameters_value = model_value.get("parameters") compatibility_value = model_value.get("compatibility") + if ( not isinstance(digest_value, str) or not isinstance(seed_value, int) @@ -398,8 +437,10 @@ def resume_legacy_model( or compatibility_value != "legacy-python-callbacks-v1" ): raise LegacyCompatibilityError("checkpoint legacy model provenance is invalid") + if seed_value != context.seed or parameters_value != dict(context.parameters): raise LegacyCompatibilityError("legacy resume context differs from checkpoint provenance") + return _load_legacy_model( source_path, context, @@ -421,9 +462,12 @@ def _load_legacy_model( source = source_path.read_bytes() except OSError as error: raise BatchError(f"could not read legacy model {source_path}") from error + digest = hashlib.sha256(source).hexdigest() + if expected_digest is not None and digest != expected_digest: raise LegacyCompatibilityError("legacy model source digest does not match checkpoint") + module_name = f"_microsimulator_legacy_{digest[:16]}" module = ModuleType(module_name) module.__file__ = str(source_path) @@ -440,20 +484,27 @@ def _load_legacy_model( previous_module = sys.modules.get(module_name, _MISSING) sys.modules[module_name] = module sys.path.insert(0, str(source_path.parent)) + try: with _installed_legacy_modules(): code = compile(source, str(source_path), "exec") exec(code, module.__dict__) + if module.__dict__.get("random") is stdlib_random: module.__dict__["random"] = context.rng + setup = module.__dict__.get("setup") + if not callable(setup): raise LegacyCompatibilityError("legacy model must define setup(sim)") + cast(Callable[[_LegacySetupFacade], object], setup)(setup_facade) adapter = setup_facade.adapter setup_facade.validate_setup_complete() + if setup_facade.restoring: restored_rng = adapter._rng # pyright: ignore[reportPrivateUsage] + if restored_rng is not None: module.__dict__["random"] = restored_rng context.rng = restored_rng @@ -463,6 +514,7 @@ def _load_legacy_model( raise BatchError(f"legacy model {source_path} failed: {error}") from error finally: sys.path.pop(0) + if previous_module is _MISSING: del sys.modules[module_name] else: @@ -477,4 +529,5 @@ def _load_legacy_model( "compatibility": "legacy-python-callbacks-v1", } } + return adapter, provenance diff --git a/python/src/microsimulator/legacy_pickle.py b/python/src/microsimulator/legacy_pickle.py index 1ec4359..96b22b7 100644 --- a/python/src/microsimulator/legacy_pickle.py +++ b/python/src/microsimulator/legacy_pickle.py @@ -62,22 +62,28 @@ class _LegacyCellRecord(dict[str, object]): def __setstate__(self, state: object) -> None: if not isinstance(state, dict): raise LegacyPickleError("legacy CellState has unsupported instance state") + mapping = cast(dict[object, object], state) + if not all(isinstance(key, str) for key in mapping): raise LegacyPickleError("legacy CellState has unsupported instance state") + vars(self).update(cast(dict[str, object], mapping)) def _legacy_reconstructor(cls: object, base: object, state: object) -> _LegacyCellRecord: if cls is not _LegacyCellRecord or (base is not object and base is not dict): raise LegacyPickleError("legacy pickle requested an unsupported class reconstruction") + del state + return _LegacyCellRecord() def _legacy_bytes(value: object, encoding: object) -> bytes: if not isinstance(value, str) or encoding != "latin1": raise LegacyPickleError("legacy pickle requested an unsupported byte encoding") + return value.encode("latin1") @@ -124,50 +130,64 @@ def _read_pickle(path: Path) -> tuple[object, bytes]: size = path.stat().st_size except OSError as error: raise LegacyPickleError(f"could not inspect legacy pickle {path}") from error + if size > MAX_LEGACY_PICKLE_BYTES: raise LegacyPickleError("legacy pickle exceeds the 1 GiB import limit") + try: encoded = path.read_bytes() except OSError as error: raise LegacyPickleError(f"could not read legacy pickle {path}") from error + if len(encoded) > MAX_LEGACY_PICKLE_BYTES: raise LegacyPickleError("legacy pickle exceeds the 1 GiB import limit") + stream = io.BytesIO(encoded) + try: value = _LegacyUnpickler(stream, fix_imports=True, encoding="latin1").load() except LegacyPickleError: raise except (EOFError, pickle.UnpicklingError, AttributeError, ImportError, IndexError) as error: raise LegacyPickleError("legacy pickle is malformed or unsupported") from error + if stream.read(1): raise LegacyPickleError("legacy pickle contains trailing data") + return value, encoded def _mapping(value: object, path: str) -> Mapping[object, object]: if not isinstance(value, Mapping): raise LegacyPickleError(f"{path} must be a mapping") + return cast(Mapping[object, object], value) def _integer(value: object, path: str, minimum: int, maximum: int) -> int: if isinstance(value, bool) or not isinstance(value, int | np.integer): raise LegacyPickleError(f"{path} must be an integer") + result = int(cast(Any, value)) + if result < minimum or result > maximum: raise LegacyPickleError(f"{path} is outside [{minimum}, {maximum}]") + return result def _number(value: object, path: str) -> float: if isinstance(value, bool) or not isinstance(value, int | float | np.integer | np.floating): raise LegacyPickleError(f"{path} must be a number") + try: result = float(cast(Any, value)) except (TypeError, ValueError, OverflowError) as error: raise LegacyPickleError(f"{path} must be a finite float32 value") from error + if not math.isfinite(result) or abs(result) > _FLOAT32_MAX: raise LegacyPickleError(f"{path} must be a finite float32 value") + return result @@ -175,16 +195,21 @@ def _sequence(value: object, path: str) -> Sequence[object]: if isinstance(value, np.ndarray): if value.ndim != 1: raise LegacyPickleError(f"{path} must be one-dimensional") + return cast(list[object], value.tolist()) + if isinstance(value, list | tuple): return cast(Sequence[object], value) + raise LegacyPickleError(f"{path} must be a sequence") def _vec3(value: object, path: str) -> Vec3: sequence = _sequence(value, path) + if len(sequence) != 3: raise LegacyPickleError(f"{path} must contain three coordinates") + return Vec3( _number(sequence[0], f"{path}[0]"), _number(sequence[1], f"{path}[1]"), @@ -195,8 +220,10 @@ def _vec3(value: object, path: str) -> Vec3: def _attributes(value: object, path: str) -> dict[str, object]: if not isinstance(value, _LegacyCellRecord): raise LegacyPickleError(f"{path} is not a legacy CellState") + attributes: dict[str, object] = dict(value) attributes.update(vars(value)) + return attributes @@ -207,15 +234,21 @@ def _root_parts( root = cast(dict[str, object], value) cells = _mapping(root.get("cellStates"), "legacy.cellStates") lineage = _mapping(root.get("lineage", {}), "legacy.lineage") + return cells, lineage, root, "mapping" + if isinstance(value, tuple): sequence = cast(tuple[object, ...], value) + if len(sequence) not in {2, 3}: raise LegacyPickleError("legacy tuple snapshot must have two or three fields") + cells = _mapping(sequence[0], "legacy[0]") lineage_index = 1 if len(sequence) == 2 else 2 lineage = _mapping(sequence[lineage_index], f"legacy[{lineage_index}]") + return cells, lineage, {}, f"tuple-v{len(sequence)}" + raise LegacyPickleError("legacy pickle root must be a snapshot mapping or tuple") @@ -224,52 +257,80 @@ def _physical_time( ) -> tuple[float, int | None, str]: if time is not None and dt is not None: raise LegacyPickleError("supply either physical time or legacy dt, not both") + step_value = root.get("stepNum") step_number = ( - _integer(step_value, "legacy.stepNum", 0, _UINT64_MAX) - if step_value is not None - else None + _integer(step_value, "legacy.stepNum", 0, _UINT64_MAX) if step_value is not None else None ) + if time is not None: if not math.isfinite(time) or time < 0.0: raise LegacyPickleError("physical time must be finite and non-negative") + return time, step_number, "explicit-time" + if dt is not None: if not math.isfinite(dt) or dt < 0.0: raise LegacyPickleError("legacy dt must be finite and non-negative") + if step_number is None: raise LegacyPickleError("tuple snapshots require explicit physical time") + result = float(step_number) * dt + if not math.isfinite(result): raise LegacyPickleError("derived physical time is not finite") + return result, step_number, "step-number-times-dt" + raise LegacyPickleError("physical time is required; supply time or legacy dt") -def import_legacy_pickle( - path: str | Path, - *, - time: float | None = None, - dt: float | None = None, - trusted: bool = False, - native_state_only: bool = False, -) -> LegacyPickleImport: - """Migrate geometry, species, IDs, and lineage from a trusted legacy snapshot.""" +def _legacy_cell_snapshot( + cell_id: int, slot: int, attributes: dict[str, object], species: list[float] +) -> CellSnapshot: + snapshot = CellSnapshot() + snapshot.id = cell_id + snapshot.slot = slot + snapshot.position = _vec3(attributes.get("pos"), f"legacy.cellStates[{cell_id}].pos") + direction = _vec3(attributes.get("dir"), f"legacy.cellStates[{cell_id}].dir") + magnitude = math.sqrt( + direction.x * direction.x + direction.y * direction.y + direction.z * direction.z + ) - if not trusted: - raise LegacyPickleError("legacy pickle import requires trusted=True") - if not native_state_only: - raise LegacyPickleError("legacy pickle import requires native_state_only=True") + if not math.isfinite(magnitude) or magnitude <= 0.0: + raise LegacyPickleError(f"legacy.cellStates[{cell_id}].dir must be non-zero") - source = Path(path).resolve() - value, encoded = _read_pickle(source) - cell_values, lineage_values, root, legacy_format = _root_parts(value) - physical_time, step_number, time_basis = _physical_time(root, time, dt) + snapshot.direction = Vec3( + direction.x / magnitude, + direction.y / magnitude, + direction.z / magnitude, + ) + snapshot.length = _number(attributes.get("length"), f"legacy.cellStates[{cell_id}].length") + snapshot.radius = _number(attributes.get("radius"), f"legacy.cellStates[{cell_id}].radius") + snapshot.growth_rate = _number( + attributes.get("growthRate", 1.0), + f"legacy.cellStates[{cell_id}].growthRate", + ) + snapshot.cell_type = _integer( + attributes.get("cellType", 0), + f"legacy.cellStates[{cell_id}].cellType", + _INT32_MIN, + _INT32_MAX, + ) + snapshot.species = species + + return snapshot + +def _legacy_world( + cell_values: Mapping[object, object], lineage_values: Mapping[object, object] +) -> tuple[_WorldStateCheckpoint, set[str]]: indexed_cells: list[tuple[int, CellSnapshot]] = [] dropped_fields: set[str] = set() species_count: int | None = None active_ids: set[int] = set() + for raw_id, record in cell_values.items(): cell_id = _integer(raw_id, "legacy.cellStates key", 1, _UINT64_MAX) attributes = _attributes(record, f"legacy.cellStates[{cell_id}]") @@ -279,12 +340,12 @@ def import_legacy_pickle( 1, _UINT64_MAX, ) + if record_id != cell_id or cell_id in active_ids: raise LegacyPickleError("legacy cell identifiers are inconsistent or duplicated") + active_ids.add(cell_id) - slot = _integer( - attributes.get("idx"), f"legacy.cellStates[{cell_id}].idx", 0, _UINT32_MAX - ) + slot = _integer(attributes.get("idx"), f"legacy.cellStates[{cell_id}].idx", 0, _UINT32_MAX) species_value = attributes.get("species", []) species = [ _number(item, f"legacy.cellStates[{cell_id}].species[{index}]") @@ -292,69 +353,42 @@ def import_legacy_pickle( _sequence(species_value, f"legacy.cellStates[{cell_id}].species") ) ] + if species_count is None: species_count = len(species) elif len(species) != species_count: raise LegacyPickleError("legacy cells have inconsistent species counts") - snapshot = CellSnapshot() - snapshot.id = cell_id - snapshot.slot = slot - snapshot.position = _vec3( - attributes.get("pos"), f"legacy.cellStates[{cell_id}].pos" - ) - direction = _vec3( - attributes.get("dir"), f"legacy.cellStates[{cell_id}].dir" - ) - magnitude = math.sqrt( - direction.x * direction.x + direction.y * direction.y + direction.z * direction.z - ) - if not math.isfinite(magnitude) or magnitude <= 0.0: - raise LegacyPickleError(f"legacy.cellStates[{cell_id}].dir must be non-zero") - snapshot.direction = Vec3( - direction.x / magnitude, - direction.y / magnitude, - direction.z / magnitude, - ) - snapshot.length = _number( - attributes.get("length"), f"legacy.cellStates[{cell_id}].length" - ) - snapshot.radius = _number( - attributes.get("radius"), f"legacy.cellStates[{cell_id}].radius" - ) - snapshot.growth_rate = _number( - attributes.get("growthRate", 1.0), - f"legacy.cellStates[{cell_id}].growthRate", - ) - snapshot.cell_type = _integer( - attributes.get("cellType", 0), - f"legacy.cellStates[{cell_id}].cellType", - _INT32_MIN, - _INT32_MAX, - ) - snapshot.species = species + snapshot = _legacy_cell_snapshot(cell_id, slot, attributes, species) dropped_fields.update(attributes.keys() - _MIGRATED_CELL_FIELDS) indexed_cells.append((slot, snapshot)) indexed_cells.sort(key=lambda item: item[0]) + if [slot for slot, _ in indexed_cells] != list(range(len(indexed_cells))): raise LegacyPickleError("legacy active cell slots must be compact and unique") + cells = [cell for _, cell in indexed_cells] lineage: list[_LineageEntry] = [] all_ids = set(active_ids) + for raw_child, raw_parent in lineage_values.items(): child = _integer(raw_child, "legacy.lineage child", 1, _UINT64_MAX) parent = _integer(raw_parent, f"legacy.lineage[{child}]", 1, _UINT64_MAX) + if parent >= child: raise LegacyPickleError("legacy lineage must have monotonic parent-child IDs") + entry = _LineageEntry() entry.child = child entry.parent = parent lineage.append(entry) all_ids.update((child, parent)) + lineage.sort(key=lambda entry: entry.child) maximum_id = max(all_ids, default=0) + if maximum_id == _UINT64_MAX: raise LegacyPickleError("legacy cell identifier space is exhausted") @@ -363,6 +397,33 @@ def import_legacy_pickle( world.next_id = maximum_id + 1 world.cells = cells world.lineage = lineage + + return world, dropped_fields + + +def import_legacy_pickle( + path: str | Path, + *, + time: float | None = None, + dt: float | None = None, + trusted: bool = False, + native_state_only: bool = False, +) -> LegacyPickleImport: + """Migrate geometry, species, IDs, and lineage from a trusted legacy snapshot.""" + + if not trusted: + raise LegacyPickleError("legacy pickle import requires trusted=True") + + if not native_state_only: + raise LegacyPickleError("legacy pickle import requires native_state_only=True") + + source = Path(path).resolve() + value, encoded = _read_pickle(source) + cell_values, lineage_values, root, legacy_format = _root_parts(value) + physical_time, step_number, time_basis = _physical_time(root, time, dt) + + world, dropped_fields = _legacy_world(cell_values, lineage_values) + try: world.validate() native = _SimulationCheckpoint() @@ -380,8 +441,10 @@ def import_legacy_pickle( module_name = root.get("moduleName") module_source = root.get("moduleStr") source_digest: JSONValue = None + if isinstance(module_source, str): source_digest = hashlib.sha256(module_source.encode("utf-8")).hexdigest() + dropped_values: list[JSONValue] = [field for field in sorted(dropped_fields)] limitations: list[JSONValue] = [ "legacy constraints were not stored in pickle output", @@ -401,6 +464,7 @@ def import_legacy_pickle( "limitations": limitations, } provenance: dict[str, JSONValue] = {"legacy_pickle": legacy_provenance} + return LegacyPickleImport( simulation=simulation, provenance=provenance, diff --git a/python/src/microsimulator/masks.py b/python/src/microsimulator/masks.py index b6c6d0a..7fb8466 100644 --- a/python/src/microsimulator/masks.py +++ b/python/src/microsimulator/masks.py @@ -40,6 +40,79 @@ class MaskRectangle: height: float +class _PolylineReader: + def __init__(self) -> None: + self.polylines: list[MaskPolyline] = [] + self.in_entities = False + self.in_blocks = False + self.block_name: str | None = None + self.pending_block_name = False + self.layer = "" + self.closed = False + self.xs: list[float] = [] + self.ys: list[float] = [] + self.collecting = False + + def finish(self) -> None: + + if self.collecting and len(self.xs) == len(self.ys) and len(self.xs) >= 2: + self.polylines.append( + MaskPolyline( + layer=self.layer, + closed=self.closed, + vertices=tuple(zip(self.xs, self.ys, strict=True)), + block=self.block_name, + ) + ) + + self.collecting = False + + def group(self, code: str, value: str) -> None: + if self.pending_block_name and code == "2": + self.block_name = value + self.pending_block_name = False + elif self.collecting: + try: + if code == "8": + self.layer = value + elif code == "70": + self.closed = bool(int(value) & 1) + elif code == "10": + self.xs.append(float(value)) + elif code == "20": + self.ys.append(float(value)) + except ValueError as error: + raise MaskError(f"mask contains a malformed {code} group") from error + + def entity(self, value: str, lines: list[str], index: int, include_blocks: bool) -> None: + self.finish() + self.pending_block_name = False + + if value == "SECTION": + # A section names itself in the group pair that follows: code 2, + # then the name. Anything else leaves the section unnamed rather + # than silently reading the next value as its name. + named = index + 1 < len(lines) and lines[index].strip() == "2" + section = lines[index + 1].strip() if named else "" + self.in_entities = section == "ENTITIES" + self.in_blocks = section == "BLOCKS" + elif value == "ENDSEC": + self.in_entities = False + self.in_blocks = False + elif value == "BLOCK": + self.pending_block_name = True + elif value == "ENDBLK": + self.block_name = None + elif value == "LWPOLYLINE" and ( + self.in_entities or (include_blocks and self.in_blocks and self.block_name is not None) + ): + self.collecting = True + self.layer = "" + self.closed = False + self.xs = [] + self.ys = [] + + def load_mask_polylines( path: str | os.PathLike[str], *, @@ -56,101 +129,77 @@ def load_mask_polylines( """ source = Path(path) + try: with source.open("rb") as stream: encoded = stream.read(max_bytes + 1) except OSError as error: raise MaskError(f"could not read mask {source}") from error + if not encoded: raise MaskError("mask file is empty") + if len(encoded) > max_bytes: raise MaskError(f"mask exceeds the {max_bytes}-byte limit") + try: text = encoded.decode("ascii", errors="replace") except UnicodeDecodeError as error: # pragma: no cover - replace never raises raise MaskError("mask is not ASCII DXF") from error lines = text.splitlines() + if len(lines) < 2: raise MaskError("mask is not a group-coded DXF document") - polylines: list[MaskPolyline] = [] - in_entities = False - in_blocks = False - block_name: str | None = None - pending_block_name = False - layer = "" - closed = False - xs: list[float] = [] - ys: list[float] = [] - collecting = False - - def finish() -> None: - nonlocal collecting - if collecting and len(xs) == len(ys) and len(xs) >= 2: - polylines.append( - MaskPolyline( - layer=layer, - closed=closed, - vertices=tuple(zip(xs, ys, strict=True)), - block=block_name, - ) - ) - collecting = False - + reader = _PolylineReader() index = 0 + while index + 1 < len(lines): code = lines[index].strip() value = lines[index + 1].strip() index += 2 + if code != "0": - if pending_block_name and code == "2": - block_name = value - pending_block_name = False - elif collecting: - try: - if code == "8": - layer = value - elif code == "70": - closed = bool(int(value) & 1) - elif code == "10": - xs.append(float(value)) - elif code == "20": - ys.append(float(value)) - except ValueError as error: - raise MaskError(f"mask contains a malformed {code} group") from error - continue - finish() - pending_block_name = False - if value == "SECTION": - # A section names itself in the group pair that follows: code 2, - # then the name. Anything else leaves the section unnamed rather - # than silently reading the next value as its name. - named = index + 1 < len(lines) and lines[index].strip() == "2" - section = lines[index + 1].strip() if named else "" - in_entities = section == "ENTITIES" - in_blocks = section == "BLOCKS" - elif value == "ENDSEC": - in_entities = False - in_blocks = False - elif value == "BLOCK": - pending_block_name = True - elif value == "ENDBLK": - block_name = None - elif value == "LWPOLYLINE" and ( - in_entities or (include_blocks and in_blocks and block_name is not None) - ): - collecting = True - layer = "" - closed = False - xs = [] - ys = [] - finish() + reader.group(code, value) + else: + reader.entity(value, lines, index, include_blocks) + + reader.finish() + polylines = reader.polylines + if not polylines: raise MaskError("mask contains no model-space polylines") + return tuple(polylines) +def _matches_corners( + polyline: MaskPolyline, corners: set[tuple[float, float]], alignment_tolerance: float +) -> bool: + matched: set[tuple[float, float]] = set() + aligned = True + + for x, y in polyline.vertices: + corner = next( + ( + candidate + for candidate in corners + if abs(x - candidate[0]) <= alignment_tolerance + and abs(y - candidate[1]) <= alignment_tolerance + ), + None, + ) + + if corner is None: + aligned = False + break + + matched.add(corner) + + return aligned and matched == corners + + def extract_rectangles( polylines: tuple[MaskPolyline, ...], *, @@ -167,42 +216,34 @@ def extract_rectangles( if unit_scale <= 0.0: raise MaskError("unit scale must be positive") + rectangles: list[MaskRectangle] = [] + for polyline in polylines: if layer is not None and polyline.layer != layer: continue + if not polyline.closed or not (4 <= len(polyline.vertices) <= 5): continue + xs = [vertex[0] for vertex in polyline.vertices] ys = [vertex[1] for vertex in polyline.vertices] low_x, high_x = min(xs), max(xs) low_y, high_y = min(ys), max(ys) + if high_x - low_x <= 0.0 or high_y - low_y <= 0.0: continue + corners = { (low_x, low_y), (low_x, high_y), (high_x, low_y), (high_x, high_y), } - matched: set[tuple[float, float]] = set() - aligned = True - for x, y in polyline.vertices: - corner = next( - ( - candidate - for candidate in corners - if abs(x - candidate[0]) <= alignment_tolerance - and abs(y - candidate[1]) <= alignment_tolerance - ), - None, - ) - if corner is None: - aligned = False - break - matched.add(corner) - if not aligned or matched != corners: + + if not _matches_corners(polyline, corners, alignment_tolerance): continue + rectangles.append( MaskRectangle( layer=polyline.layer, @@ -214,6 +255,7 @@ def extract_rectangles( height=(high_y - low_y) * unit_scale, ) ) + return tuple(rectangles) @@ -243,6 +285,7 @@ def matches(rectangle: MaskRectangle) -> bool: abs(rectangle.width - height) <= tolerance and abs(rectangle.height - width) <= tolerance ) + return direct or (allow_rotated and rotated) return tuple(rectangle for rectangle in rectangles if matches(rectangle)) diff --git a/python/src/microsimulator/media.py b/python/src/microsimulator/media.py new file mode 100644 index 0000000..22b92d3 --- /dev/null +++ b/python/src/microsimulator/media.py @@ -0,0 +1,85 @@ +"""Medium compositions and explicit concentration units; no organism kinetics.""" + +from __future__ import annotations + +import math +from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType +from typing import Literal + +from .schedules import PiecewiseConstant as PiecewiseConstant + + +@dataclass(frozen=True, slots=True) +class Concentration: + value: float + unit: Literal["mM", "mol/m3", "mol/L", "g/L"] + + def __post_init__(self) -> None: + if not math.isfinite(self.value) or self.value < 0: + raise ValueError("concentration must be finite and nonnegative") + + if self.unit not in ("mM", "mol/m3", "mol/L", "g/L"): + raise ValueError("unsupported concentration unit") + + @classmethod + def mM(cls, value: float) -> Concentration: # noqa: N802 + return cls(value, "mM") + + def canonical( + self, amount_unit: str, length_unit_m: float, molar_mass_g_per_mol: float | None = None + ) -> float: + basis = "g" if self.unit == "g/L" else "mol" + per_m3 = self.value * (1000 if self.unit in ("g/L", "mol/L") else 1) + + if basis != amount_unit: + if molar_mass_g_per_mol is None: + raise ValueError("mass/molar conversion requires molecular weight") + + per_m3 *= molar_mass_g_per_mol if basis == "mol" else 1 / molar_mass_g_per_mol + + return per_m3 * length_unit_m**3 + + +@dataclass(frozen=True, slots=True) +class Medium: + name: str + concentrations: Mapping[str, Concentration] + provenance: str | None = None + + def __post_init__(self) -> None: + if not self.name: + raise ValueError("medium needs a name") + + if any( + not key or not isinstance(c, Concentration) # pyright: ignore[reportUnnecessaryIsInstance] + for key, c in self.concentrations.items() + ): + raise ValueError("medium entries require solute identifiers and Concentration values") + + object.__setattr__(self, "concentrations", MappingProxyType(dict(self.concentrations))) + + @classmethod + def millimolar( + cls, name: str, concentrations: Mapping[str, float], *, provenance: str | None = None + ) -> Medium: + return cls( + name, + {key: Concentration.mM(value) for key, value in concentrations.items()}, + provenance, + ) + + +@dataclass(frozen=True, slots=True) +class Perfusion: + """Convenience pairing; imports keep physics independent of recipes.""" + + port: str + drive: object + medium: Medium | PiecewiseConstant[Medium] + + def boundaries(self) -> tuple[object, object]: + from .transport import AdvectiveFeed + + return self.drive, AdvectiveFeed(self.medium) diff --git a/python/src/microsimulator/microfluidics.py b/python/src/microsimulator/microfluidics.py index 535a871..6262d10 100644 --- a/python/src/microsimulator/microfluidics.py +++ b/python/src/microsimulator/microfluidics.py @@ -15,7 +15,8 @@ from __future__ import annotations -from dataclasses import dataclass +import math +from dataclasses import dataclass, replace from ._core import ( # pyright: ignore[reportMissingModuleSource] BackendKind, @@ -27,7 +28,7 @@ Simulation, Vec3, ) -from .flow import gap_mobility, solve_flow_field +from .flow import BoundaryPatch, FluidDomain, gap_mobility, solve_flow_field # A voxel edge that lands on a wall plane belongs to the wall, so the voxel # tests admit a rounding margin: without it a wall drawn exactly on a lattice @@ -83,6 +84,40 @@ def _wall_boxes( box.allowed_region = region simulation.add_box_constraint(box) + def fluid_domain( + self, + *, + size_um: tuple[float, float, float], + spacing_um: float, + origin_um: tuple[float, float, float] = (0.0, 0.0, 0.0), + ) -> FluidDomain: + """Materialize walls/ports only; pressure and flow are authored separately.""" + domain = FluidDomain( + size_um, + spacing_um, + { + "inlet": BoundaryPatch("y", False), + "outlet": BoundaryPatch("y", True), + }, + origin_um, + ) + grid = domain.native_grid() + mask = tuple( + int( + self._solid( + grid.origin.x + x * spacing_um, + grid.origin.y + y * spacing_um, + grid.origin.z + z * spacing_um, + (0.0, 0.0, 0.0), + ) + ) + for x in range(grid.shape.x) + for y in range(grid.shape.y) + for z in range(grid.shape.z) + ) + + return replace(domain, obstacles=mask) + def apply_to_grid( self, spec: SignalGridSpec, @@ -106,14 +141,19 @@ def apply_to_grid( # to make interpolation easier: sampling handles fluid support separately. half = (0.0, 0.0, 0.0) obstacles = [0] * (shape.x * shape.y * shape.z) + for x in range(shape.x): px = origin.x + spacing.x * x + for y in range(shape.y): py = origin.y + spacing.y * y + for z in range(shape.z): pz = origin.z + spacing.z * z + if self._solid(px, py, pz, half): obstacles[x * shape.y * shape.z + y * shape.z + z] = 1 + spec.obstacles = obstacles spec.advection = [Vec3() for _ in range(spec.signal_count)] @@ -216,17 +256,135 @@ def add_constraints(self, simulation: Simulation) -> None: def _solid(self, px: float, py: float, pz: float, half: tuple[float, float, float]) -> bool: hx, hy, hz = half + if _recedes(px + hx, self.channel_far_x, hx): return True + if _reaches(px - hx, self.trap_back_x + self.wall_thickness, hx): return True + if _reaches(abs(pz) - hz, self.trap_half_z, hz): return True + if _reaches(px - hx, self.trap_open_x, hx) and _reaches(abs(py) - hy, self.trap_half_y, hy): return True + return _reaches(px - hx, self.trap_back_x, hx) +@dataclass(frozen=True, slots=True) +class MotherMachineDevice(_ChannelDevice): + """Single-file dead-end growth channels beside a perfusion channel along +y. + + Growth channels open at x=0 and end at ``growth_length``. All passages + share the floor z=0. The default 25 x 1.5 x 1.4 micrometer growth channels + follow Wang et al. (2010), doi:10.1016/j.cub.2010.04.045, supplement. + Array size, pitch, perfusion dimensions, and speed are modeling choices. + Geometry supplies confinement; this device never fixes a cell in place. + """ + + growth_length: float = 25.0 + growth_width: float = 1.5 + growth_height: float = 1.4 + channel_count: int = 6 + channel_pitch: float = 7.5 + channel_width: float = 12.0 + channel_height: float = 7.0 + channel_half_length: float = 22.5 + wall_thickness: float = 1.0 + mean_flow_speed: float = 40.0 + + def __post_init__(self) -> None: + dimensions = ( + self.growth_length, + self.growth_width, + self.growth_height, + self.channel_pitch, + self.channel_width, + self.channel_height, + self.channel_half_length, + self.wall_thickness, + ) + + if any(not math.isfinite(value) or value <= 0 for value in dimensions): + raise ValueError("mother-machine dimensions must be finite and positive") + + if type(self.channel_count) is not int or self.channel_count < 1: + raise ValueError("channel_count must be a positive integer") + + if self.growth_width >= self.channel_pitch or self.growth_height > self.channel_height: + raise ValueError("growth channels must be separated and fit below the channel ceiling") + + extent = (self.channel_count - 1) * self.channel_pitch + self.growth_width + + if extent >= 2 * self.channel_half_length: + raise ValueError("growth channels must fit between the inlet and outlet") + + if not math.isfinite(self.mean_flow_speed) or self.mean_flow_speed < 0: + raise ValueError("mean_flow_speed must be finite and nonnegative") + + @property + def growth_centers(self) -> tuple[float, ...]: + return tuple( + (i - (self.channel_count - 1) / 2) * self.channel_pitch + for i in range(self.channel_count) + ) + + def add_constraints(self, simulation: Simulation) -> None: + """Use the same walls for mechanical confinement and grid projection.""" + back = self.growth_length + self.wall_thickness + top = self.channel_height + self.wall_thickness + blocks = [ + ( + (self.growth_length, -self.channel_half_length, -self.wall_thickness), + (back, self.channel_half_length, top), + ), + ( + (0.0, -self.channel_half_length, self.growth_height), + (back, self.channel_half_length, top), + ), + ] + lower = -self.channel_half_length + + for center in self.growth_centers: + blocks.append( + ((0.0, lower, -self.wall_thickness), (back, center - self.growth_width / 2, top)) + ) + lower = center + self.growth_width / 2 + + blocks.append(((0.0, lower, -self.wall_thickness), (back, self.channel_half_length, top))) + self._wall_boxes(simulation, tuple(blocks), ConstraintRegion.OUTSIDE) + self._wall_boxes( + simulation, + ( + ( + (-self.channel_width, -self.channel_half_length, 0.0), + (back, self.channel_half_length, self.channel_height), + ), + ), + ConstraintRegion.INSIDE, + ) + + def _solid(self, px: float, py: float, pz: float, half: tuple[float, float, float]) -> bool: + hx, hy, hz = half + + if _recedes(px + hx, -self.channel_width, hx) or _reaches(px - hx, self.growth_length, hx): + return True + + if _recedes(pz + hz, 0.0, hz) or _reaches(pz - hz, self.channel_height, hz): + return True + + if not _reaches(px - hx, 0.0, hx): + return False + + in_channel = any( + not _reaches(abs(py - center) - hy, self.growth_width / 2, hy) + for center in self.growth_centers + ) + + return not in_channel or _reaches(pz - hz, self.growth_height, hz) + + @dataclass(frozen=True, slots=True) class BiopixelTrapDevice(_ChannelDevice): """One trap of a biopixel array: a shallow monolayer cavity beside a tall channel. @@ -323,14 +481,20 @@ def add_constraints(self, simulation: Simulation) -> None: def _solid(self, px: float, py: float, pz: float, half: tuple[float, float, float]) -> bool: hx, hy, hz = half + if _recedes(px + hx, -self.channel_width, hx): return True + if _reaches(px - hx, self.trap_depth + self.wall_thickness, hx): return True + if _recedes(pz + hz, 0.0, hz) or _reaches(pz - hz, self.channel_height, hz): return True + if _reaches(px - hx, 0.0, hx) and _reaches(abs(py) - hy, self.trap_width * 0.5, hy): return True + if _reaches(px - hx, self.trap_depth, hx): return True + return _reaches(px - hx, 0.0, hx) and _reaches(pz - hz, self.trap_height, hz) diff --git a/python/src/microsimulator/occupancy.py b/python/src/microsimulator/occupancy.py new file mode 100644 index 0000000..548d252 --- /dev/null +++ b/python/src/microsimulator/occupancy.py @@ -0,0 +1,265 @@ +"""Native Metal/CUDA primitives for ADR 0025's coarse geometric porosity model. + +The method signatures and value types match :mod:`occupancy_reference`. Arrays +and device arithmetic are float32; ledgers are accumulated in host float64. +This standalone API does not enable occupancy in Simulation or checkpoints. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import cast + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ._core import BackendKind, OccupancyCapsule, OccupancyFace, OccupancyReservoir +from ._core import OccupancySolver as NativeOccupancySolver +from .occupancy_reference import Balance, Capsule, Face, ReservoirFace + +__all__ = ["Balance", "Capsule", "Face", "OccupancySolver", "ReservoirFace", "SolverReport"] + +Array = NDArray[np.float32] + + +def _array(values: ArrayLike, name: str) -> Array: + original = np.asarray(values, dtype=np.float64) + + with np.errstate(over="ignore", under="ignore", invalid="ignore"): + result = original.astype(np.float32) + + if not np.isfinite(result).all() or np.any((original != 0) & (result == 0)): + raise ValueError(f"{name} must be finite and representable in float32") + + return result + + +def _vector(values: ArrayLike, name: str, count: int | None = None) -> list[float]: + result = _array(values, name) + + if result.ndim != 1 or (count is not None and len(result) != count): + raise ValueError(f"{name} must be a vector with matching size") + + return cast(list[float], result.tolist()) + + +def _index(value: int, name: str) -> int: + if ( + isinstance(value, bool) + or not isinstance(cast(object, value), int) + or not 0 <= value < 2**32 - 1 + ): + raise ValueError(f"{name} must be a nonnegative integer below 2**32 - 1") + + return value + + +@dataclass(frozen=True) +class SolverReport: + iterations: int + relative_residual: float + + +class OccupancySolver: + """Explicit GPU device and reproducible cutoff; no automatic CPU fallback. + + Pass ``"metal"`` or ``"cuda"`` (or the corresponding BackendKind). Use + ``occupancy_reference`` for the independent float64 CPU implementation. + Each method returns a candidate without mutating input arrays. The caller + owns geometry barriers, support connectivity, intrinsic face velocities, + and the commit/rollback of a complete simulation transaction. + """ + + def __init__( + self, + backend: str | BackendKind, + *, + device_index: int = 0, + epsilon_cutoff: float = 1e-8, + ) -> None: + if isinstance(backend, str): + if backend not in ("metal", "cuda"): + raise ValueError("native occupancy requires 'metal' or 'cuda'") + + backend = BackendKind.METAL if backend == "metal" else BackendKind.CUDA + + self._native = NativeOccupancySolver( + backend, _index(device_index, "device index"), epsilon_cutoff + ) + self._backend = backend + self._device_index = device_index + self._epsilon_cutoff = float(np.float32(epsilon_cutoff)) + self._last_report: SolverReport | None = None + + @property + def backend(self) -> BackendKind: + return self._backend + + @property + def device_index(self) -> int: + return self._device_index + + @property + def epsilon_cutoff(self) -> float: + return self._epsilon_cutoff + + @property + def last_report(self) -> SolverReport | None: + """Report from the last successfully returned backward-Euler candidate.""" + + return self._last_report + + def geometric_porosity( + self, + centers: ArrayLike, + spacing: tuple[float, float, float], + cells: Sequence[Capsule], + *, + subdivisions: int = 8, + walls: Sequence[bool] | None = None, + ) -> Array: + """Midpoint capsule-union quadrature; m is restricted to 1..256.""" + points = _array(centers, "centers") + + if points.ndim != 2 or points.shape[1] != 3: + raise ValueError("centers must be finite N by 3 coordinates") + + mask: list[int] = [] + + if walls is not None: + solid = np.asarray(walls) + + if solid.shape != (len(points),) or solid.dtype != np.bool_: + raise ValueError("walls must contain one Boolean per voxel") + + mask = solid.astype(np.uint32).tolist() + + return np.asarray( + self._native.geometric_porosity( + cast(list[tuple[float, float, float]], points.tolist()), + spacing, + [ + OccupancyCapsule(cell.center, cell.direction, cell.length, cell.radius) + for cell in cells + ], + _index(subdivisions, "subdivisions"), + mask, + ), + dtype=np.float32, + ) + + def accessible_volumes(self, porosity: ArrayLike, voxel_volume: float) -> Array: + return np.asarray( + self._native.accessible_volumes(_vector(porosity, "porosity"), voxel_volume), + dtype=np.float32, + ) + + def concentration(self, amount: ArrayLike, volume: ArrayLike) -> Array: + return np.asarray( + self._native.concentration(_vector(amount, "amount"), _vector(volume, "volume")), + dtype=np.float32, + ) + + def porosity_face( + self, + first: int, + second: int, + epsilon_first: float, + epsilon_second: float, + *, + diffusion: float, + area: float, + distance: float, + intrinsic_velocity: float = 0.0, + ) -> Face: + face = self._native.porosity_face( + _index(first, "first site"), + _index(second, "second site"), + epsilon_first, + epsilon_second, + diffusion, + area, + distance, + intrinsic_velocity, + ) + + return Face(face.first, face.second, face.conductance, face.volume_flux) + + def remap_amounts( + self, + amount: ArrayLike, + old_volume: ArrayLike, + new_volume: ArrayLike, + neighbors: Sequence[tuple[int, int]], + ) -> Array: + return np.asarray( + self._native.remap_amounts( + _vector(amount, "amount"), + _vector(old_volume, "old volume"), + _vector(new_volume, "new volume"), + [(_index(i, "neighbor"), _index(j, "neighbor")) for i, j in neighbors], + ), + dtype=np.float32, + ) + + def exchange_weights(self, kernel: ArrayLike, volume: ArrayLike) -> Array: + """Normalize phi*W on caller-supplied, already connected physical support.""" + + return np.asarray( + self._native.exchange_weights(_vector(kernel, "kernel"), _vector(volume, "volume")), + dtype=np.float32, + ) + + def backward_euler( + self, + amount: ArrayLike, + volume: ArrayLike, + faces: Sequence[Face], + dt: float, + *, + source: ArrayLike | None = None, + loss: ArrayLike | None = None, + reservoirs: Sequence[ReservoirFace] = (), + max_iterations: int = 20000, + relative_tolerance: float = 1e-7, + ) -> tuple[Array, Balance]: + """Sparse float32 Jacobi solve with explicit amounts and a signed ledger. + + Source is amount/time and loss is 1/time. Supply accessible-fluid affine + production as W*b in source. Face coefficients already include aperture. + Periodicity is an explicit shared internal face; no-flux faces are absent. + Reject negative candidates, nonconvergence, or a ledger residual above + 5e-6 times the normalized amount scale. No amount renormalization occurs. + """ + amounts = _vector(amount, "amount") + result = self._native.backward_euler( + amounts, + _vector(volume, "volume", len(amounts)), + [ + OccupancyFace( + _index(f.first, "first site"), + _index(f.second, "second site"), + f.conductance, + f.volume_flux, + ) + for f in faces + ], + dt, + [] if source is None else _vector(source, "source", len(amounts)), + [] if loss is None else _vector(loss, "loss", len(amounts)), + [ + OccupancyReservoir( + _index(f.site, "reservoir site"), f.concentration, f.conductance, f.volume_flux + ) + for f in reservoirs + ], + _index(max_iterations, "max iterations"), + relative_tolerance, + ) + balance = result.balance + self._last_report = SolverReport(result.iterations, result.relative_residual) + + return np.asarray(result.amount, dtype=np.float32), Balance( + balance.before, balance.after, balance.source, balance.reaction, balance.boundary + ) diff --git a/python/src/microsimulator/occupancy_reference.py b/python/src/microsimulator/occupancy_reference.py new file mode 100644 index 0000000..37201e1 --- /dev/null +++ b/python/src/microsimulator/occupancy_reference.py @@ -0,0 +1,412 @@ +"""Float64 design reference for ADR 0025; not a native transport implementation. + +Amounts are authoritative. Coarse geometric porosity is distinct from the +biochemical biomass density used by the flow-resistance closure. +""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from dataclasses import dataclass +from typing import cast + +import numpy as np +from numpy.typing import NDArray + +Array = NDArray[np.float64] +EPSILON_CUTOFF = 1.0e-8 + + +@dataclass(frozen=True) +class Capsule: + center: tuple[float, float, float] + direction: tuple[float, float, float] + length: float + radius: float + + def __post_init__(self) -> None: + values = (*self.center, *self.direction, self.length, self.radius) + + if not all(math.isfinite(value) for value in values): + raise ValueError("capsule geometry must be finite") + + if len(self.center) != 3 or len(self.direction) != 3: + raise ValueError("capsule vectors must have three coordinates") + + if self.length < 0 or self.radius <= 0 or math.hypot(*self.direction) == 0: + raise ValueError("capsule requires nonnegative length, positive radius and direction") + + +def _validate_quadrature( + points: Array, spacing: tuple[float, float, float], subdivisions: int +) -> None: + if points.ndim != 2 or points.shape[1] != 3 or not np.isfinite(points).all(): + raise ValueError("centers must be finite N by 3 coordinates") + + if len(spacing) != 3 or any(not math.isfinite(h) or h <= 0 for h in spacing): + raise ValueError("spacing must contain three positive finite lengths") + + if ( + isinstance(subdivisions, bool) + or not isinstance(cast(object, subdivisions), int) + or subdivisions < 1 + ): + raise ValueError("subdivisions must be a positive integer") + + +def _neighbor_adjacency(count: int, neighbors: Sequence[tuple[int, int]]) -> list[list[int]]: + adjacency: list[list[int]] = [[] for _ in range(count)] + + for first, second in neighbors: + if first == second or not 0 <= first < count or not 0 <= second < count: + raise ValueError("invalid neighbor edge") + + adjacency[first].append(second) + adjacency[second].append(first) + + return adjacency + + +def _accessible_component( + start: int, active: NDArray[np.bool_], adjacency: list[list[int]], visited: set[int] +) -> list[int]: + pending = [start] + component: list[int] = [] + + while pending: + index = pending.pop() + + if index in visited or not active[index]: + continue + + visited.add(index) + component.append(index) + pending.extend(adjacency[index]) + + component.sort() + + return component + + +def _add_transport_face(operator: Array, w: Array, face: Face) -> None: + i, j, g, q = face.first, face.second, face.conductance, face.volume_flux + + if i == j or not 0 <= i < len(w) or not 0 <= j < len(w): + raise ValueError("invalid transport face indices") + + if not math.isfinite(g) or g < 0 or not math.isfinite(q): + raise ValueError("invalid transport coefficients") + + if (w[i] == 0 or w[j] == 0) and (g != 0 or q != 0): + raise ValueError("closed storage cannot have an open face") + + operator[i, i] += g + max(q, 0) + operator[j, j] += g + max(-q, 0) + operator[i, j] -= g + max(-q, 0) + operator[j, i] -= g + max(q, 0) + + +def _add_reservoir_face( + operator: Array, rhs: Array, w: Array, face: ReservoirFace, dt: float +) -> None: + i, c, g, q = face.site, face.concentration, face.conductance, face.volume_flux + + if not 0 <= i < len(w) or w[i] == 0: + raise ValueError("reservoir must connect accessible storage") + + if not all(math.isfinite(value) for value in (c, g, q)) or c < 0 or g < 0: + raise ValueError("invalid reservoir coefficients") + + operator[i, i] += g + max(q, 0) + rhs[i] += dt * (g + max(-q, 0)) * c + + +def geometric_porosity( + centers: Array, + spacing: tuple[float, float, float], + cells: Sequence[Capsule], + *, + subdivisions: int = 8, + walls: Sequence[bool] | None = None, +) -> Array: + """Midpoint quadrature of the union of capsules, clipped to fluid voxels. + + centers use native lattice-center convention. Walls use the existing binary + voxel mask, not a second interpretation of mechanical constraint surfaces. + Overlaps count once. The result is a coarse storage fraction, not a resolved + aperture or a sub-voxel connectivity claim. + """ + points = np.asarray(centers, dtype=np.float64) + + _validate_quadrature(points, spacing, subdivisions) + + solid = np.zeros(len(points), dtype=np.bool_) if walls is None else np.asarray(walls) + + if solid.shape != (len(points),) or solid.dtype != np.bool_: + raise ValueError("walls must contain one Boolean per voxel") + + samples = (np.arange(subdivisions, dtype=np.float64) + 0.5) / subdivisions - 0.5 + offsets = np.stack(np.meshgrid(samples, samples, samples, indexing="ij"), axis=-1) + offsets = offsets.reshape(-1, 3) * np.asarray(spacing) + result = np.zeros(len(points)) + + for index, center in enumerate(points): + if solid[index]: + continue + + coordinates = center + offsets + occupied = np.zeros(len(coordinates), dtype=np.bool_) + + for cell in cells: + direction = np.asarray(cell.direction, dtype=np.float64) + direction = direction / np.linalg.norm(direction) + relative = coordinates - np.asarray(cell.center) + axial = np.clip(relative @ direction, -cell.length / 2, cell.length / 2) + distance = relative - axial[:, None] * direction + squared = cast(Array, np.sum(distance * distance, axis=1)) + within = squared <= cell.radius * cell.radius + occupied = np.logical_or(occupied, within) + + result[index] = 1.0 - np.mean(occupied) + + result[result < EPSILON_CUTOFF] = 0 + + return result + + +def _vector(values: Sequence[float] | Array, name: str, count: int | None = None) -> Array: + result = np.asarray(values, dtype=np.float64) + + if result.ndim != 1 or not np.isfinite(result).all(): + raise ValueError(f"{name} must be a finite vector") + + if count is not None and result.shape != (count,): + raise ValueError(f"{name} size mismatch") + + return result + + +def accessible_volumes(porosity: Sequence[float] | Array, voxel_volume: float) -> Array: + epsilon = _vector(porosity, "porosity") + + if np.any(epsilon < 0) or np.any(epsilon > 1): + raise ValueError("porosity must lie in [0, 1]") + + if not math.isfinite(voxel_volume) or voxel_volume <= 0: + raise ValueError("voxel volume must be finite and positive") + + return np.where(epsilon < EPSILON_CUTOFF, 0.0, epsilon) * voxel_volume + + +def concentration(amount: Sequence[float] | Array, volume: Sequence[float] | Array) -> Array: + n = _vector(amount, "amount") + w = _vector(volume, "accessible volume", len(n)) + + if np.any(n < 0) or np.any(w < 0) or np.any((w == 0) & (n != 0)): + raise ValueError("nonnegative amounts require accessible storage") + + return np.divide(n, w, out=np.zeros_like(n), where=w > 0) + + +def remap_amounts( + amount: Sequence[float] | Array, + old_volume: Sequence[float] | Array, + new_volume: Sequence[float] | Array, + neighbors: Sequence[tuple[int, int]], +) -> Array: + """Keep surviving voxel amounts; expel closing storage conservatively. + + Recipients are all newly accessible voxels in the old-or-new accessible + face-connected component, weighted by new accessible volume. A closing + component with nonzero amount fails atomically. Inputs are never mutated. + """ + n = _vector(amount, "amount") + old = _vector(old_volume, "old volume", len(n)) + new = _vector(new_volume, "new volume", len(n)) + concentration(n, old) + + if np.any(new < 0): + raise ValueError("new volume must be nonnegative") + + result = n.copy() + adjacency = _neighbor_adjacency(len(n), neighbors) + + active = (old > 0) | (new > 0) + visited: set[int] = set() + + for start in range(len(n)): + if not active[start] or start in visited: + continue + + component = _accessible_component(start, active, adjacency, visited) + donors = [index for index in component if new[index] == 0] + recipients = [index for index in component if new[index] > 0] + expelled = math.fsum(float(n[index]) for index in donors) + + if expelled > 0 and not recipients: + raise ValueError( + f"closing component at voxel {start} has solute but no accessible recipient" + ) + + result[donors] = 0 + + if expelled: + capacity = math.fsum(float(new[index]) for index in recipients) + + for index in recipients: + result[index] += expelled * new[index] / capacity + + concentration(result, new) + + return result + + +@dataclass(frozen=True) +class Face: + """Internal oriented face: diffusive conductance L^3/T, fluid flux L^3/T.""" + + first: int + second: int + conductance: float + volume_flux: float = 0.0 + + +def porosity_face( + first: int, + second: int, + epsilon_first: float, + epsilon_second: float, + *, + diffusion: float, + area: float, + distance: float, + intrinsic_velocity: float = 0.0, +) -> Face: + """Harmonic porosity closure; aperture is applied exactly once to flux.""" + values = (epsilon_first, epsilon_second, diffusion, area, distance, intrinsic_velocity) + + if not all(math.isfinite(value) for value in values): + raise ValueError("face data must be finite") + + if not 0 <= epsilon_first <= 1 or not 0 <= epsilon_second <= 1: + raise ValueError("face porosities must lie in [0, 1]") + + if diffusion < 0 or area <= 0 or distance <= 0: + raise ValueError("invalid face geometry or diffusion") + + aperture = ( + 0.0 + if min(epsilon_first, epsilon_second) < EPSILON_CUTOFF + else 2 * epsilon_first * epsilon_second / (epsilon_first + epsilon_second) + ) + + return Face( + first, second, diffusion * aperture * area / distance, aperture * area * intrinsic_velocity + ) + + +@dataclass(frozen=True) +class ReservoirFace: + """Exterior reservoir: positive flux leaves domain; concentration is amount/L^3.""" + + site: int + concentration: float + conductance: float = 0.0 + volume_flux: float = 0.0 + + +@dataclass(frozen=True) +class Balance: + before: float + after: float + source: float + reaction: float + boundary: float + + @property + def residual(self) -> float: + return self.after - self.before - self.source - self.reaction - self.boundary + + +def backward_euler( + amount: Sequence[float] | Array, + volume: Sequence[float] | Array, + faces: Sequence[Face], + dt: float, + *, + source: Sequence[float] | Array | None = None, + loss: Sequence[float] | Array | None = None, + reservoirs: Sequence[ReservoirFace] = (), +) -> tuple[Array, Balance]: + """Dense float64 finite-volume reference with an explicit amount ledger. + + source is amount/time, loss is 1/time. Interior faces are equal/opposite; + first-order advection and reservoir/loss terms are implicit. Not scalable. + """ + n = _vector(amount, "amount") + w = _vector(volume, "volume", len(n)) + concentration(n, w) + + if not math.isfinite(dt) or dt < 0: + raise ValueError("dt must be finite and nonnegative") + + s = np.zeros_like(n) if source is None else _vector(source, "source", len(n)) + k = np.zeros_like(n) if loss is None else _vector(loss, "loss", len(n)) + + if np.any(k < 0) or np.any((w == 0) & (s != 0)): + raise ValueError("loss must be nonnegative; sources require accessible storage") + + operator = np.diag(k * w) + rhs = n + dt * s + + for face in faces: + _add_transport_face(operator, w, face) + + for face in reservoirs: + _add_reservoir_face(operator, rhs, w, face, dt) + + matrix = np.diag(w) + dt * operator + + for i in range(len(n)): + if w[i] == 0: + matrix[i, i] = 1.0 + + c = np.linalg.solve(matrix, rhs) + updated = c * w + + if not np.isfinite(updated).all() or np.any(updated < 0): + raise ValueError("step produced invalid amount; no clipping is permitted") + + boundary = dt * math.fsum( + f.conductance * (f.concentration - c[f.site]) + - f.volume_flux * (c[f.site] if f.volume_flux >= 0 else f.concentration) + for f in reservoirs + ) + + return updated, Balance( + float(n.sum()), + float(updated.sum()), + float(dt * s.sum()), + float(-dt * np.dot(k, updated)), + float(boundary), + ) + + +def exchange_weights( + kernel: Sequence[float] | Array, + volume: Sequence[float] | Array, +) -> Array: + """Accessible-volume weighted partition of unity in a declared connected support.""" + base = _vector(kernel, "kernel") + accessible = _vector(volume, "volume", len(base)) + + if np.any(base < 0) or np.any(accessible < 0): + raise ValueError("exchange kernel and volume must be nonnegative") + + weights = base * accessible + + if np.any(weights < 0) or not np.isfinite(weights).all() or weights.sum() <= 0: + raise ValueError("cell has no valid accessible exchange support") + + return weights / weights.sum() diff --git a/python/src/microsimulator/rates.py b/python/src/microsimulator/rates.py index 90eed4a..3a2f641 100644 --- a/python/src/microsimulator/rates.py +++ b/python/src/microsimulator/rates.py @@ -85,6 +85,7 @@ def _emit( ) -> RateExpression: if len(self._instructions) >= _UINT32_MAX: raise RatePlanError("rate plan exceeds the uint32 instruction space") + instruction = RateInstruction() instruction.operation = operation instruction.first = first @@ -92,17 +93,21 @@ def _emit( instruction.third = third instruction.value = value self._instructions.append(instruction) + return RateExpression(self, len(self._instructions) - 1) def _coerce(self, value: float | int | RateExpression) -> RateExpression: if isinstance(value, RateExpression): if value._builder is not self: raise RatePlanError("rate expressions from different builders cannot be mixed") + return value + return self.constant(value) def _unary(self, operation: RateOp, value: RateExpression) -> RateExpression: operand = self._coerce(value) + return self._emit(operation, first=operand.index) def _binary( @@ -113,10 +118,12 @@ def _binary( ) -> RateExpression: left = self._coerce(first) right = self._coerce(second) + return self._emit(operation, first=left.index, second=right.index) def constant(self, value: float | int) -> RateExpression: candidate = cast(object, value) + if ( not isinstance(candidate, int | float) or isinstance(candidate, bool) @@ -124,10 +131,12 @@ def constant(self, value: float | int) -> RateExpression: or abs(candidate) > _FLOAT32_MAX ): raise RatePlanError("rate constant must be a finite float32 value") + return self._emit(RateOp.CONSTANT, value=float(candidate)) def _source(self, operation: RateOp, index: int = 0) -> RateExpression: candidate = cast(object, index) + if ( not isinstance(candidate, int) or isinstance(candidate, bool) @@ -135,6 +144,7 @@ def _source(self, operation: RateOp, index: int = 0) -> RateExpression: or candidate > _UINT32_MAX ): raise RatePlanError("rate source index must be an unsigned 32-bit integer") + return self._emit(operation, first=candidate) def species(self, index: int) -> RateExpression: @@ -166,6 +176,7 @@ def cell_type(self) -> RateExpression: def cell_volume(self) -> RateExpression: """Effective biomass volume pi*r**2*(length + 2*r).""" + return self._source(RateOp.CELL_VOLUME) def cell_volume_change_rate(self) -> RateExpression: @@ -175,6 +186,7 @@ def cell_volume_change_rate(self) -> RateExpression: This includes the discrete growth increment instead of approximating it with the elongation rate times the post-growth cell volume. """ + return self._source(RateOp.CELL_VOLUME_CHANGE_RATE) def cell_surface_area(self) -> RateExpression: @@ -244,6 +256,7 @@ def select( predicate = self._coerce(condition) selected = self._coerce(when_true) fallback = self._coerce(when_false) + return self._emit( RateOp.SELECT, first=predicate.index, diff --git a/python/src/microsimulator/replay.py b/python/src/microsimulator/replay.py new file mode 100644 index 0000000..c99340e --- /dev/null +++ b/python/src/microsimulator/replay.py @@ -0,0 +1,174 @@ +"""Data-only replay export from an explicitly ordered checkpoint sequence.""" + +from __future__ import annotations + +import hashlib +import json +import os +import tempfile +from collections.abc import Sequence +from dataclasses import asdict, dataclass, replace +from pathlib import Path +from typing import cast + +import rfc8785 + +from ._core import BackendKind # pyright: ignore[reportMissingModuleSource] +from .checkpoint import MAX_CHECKPOINT_BYTES, CheckpointError, JSONValue, load_checkpoint_bundle +from .scene import MAX_SCENE_BYTES, SceneBackend, SceneBackendKind, capture_scene, dumps_scene + +REPLAY_FORMAT = "microsimulator-replay" +REPLAY_VERSION = 1 +MAX_REPLAY_FRAMES = 100_000 +MAX_REPLAY_MANIFEST_BYTES = 16 * 1024 * 1024 + + +class ReplayExportError(ValueError): + """Raised when an ordered recording cannot be exported without loss.""" + + +@dataclass(frozen=True, slots=True) +class ReplayExportSummary: + output: Path + frame_count: int + + +def _copy_checkpoint(checkpoint_path: Path, snapshot: Path) -> str: + digest = hashlib.sha256() + size = 0 + + with checkpoint_path.open("rb") as source, snapshot.open("wb") as target: + while chunk := source.read(1024 * 1024): + size += len(chunk) + + if size > MAX_CHECKPOINT_BYTES: + raise ReplayExportError("checkpoint exceeds its byte limit") + + target.write(chunk) + digest.update(chunk) + + return digest.hexdigest() + + +def export_replay( + checkpoints: Sequence[str | os.PathLike[str]], output: str | os.PathLike[str] +) -> ReplayExportSummary: + """Export exact snapshots on CPU, never loading model source or stepping biology. + + Input order is authoritative. Equal times remain distinct frames; decreasing + time is rejected. The destination must not exist, including an empty folder. + """ + + if not 1 <= len(checkpoints) <= MAX_REPLAY_FRAMES: + raise ReplayExportError(f"expected 1 to {MAX_REPLAY_FRAMES} ordered checkpoints") + + destination = Path(output).absolute() + + if destination.exists() or destination.is_symlink(): + raise ReplayExportError(f"output already exists: {destination}") + + try: + destination.parent.mkdir(parents=True, exist_ok=True) + except OSError as error: + raise ReplayExportError( + f"could not prepare replay destination {destination}: {error}" + ) from error + + entries: list[JSONValue] = [] + previous_time = -1.0 + export_backend: JSONValue = None + + try: + with tempfile.TemporaryDirectory( + prefix=f".{destination.name}.", dir=destination.parent + ) as temporary: + stage = Path(temporary) + frames_dir = stage / "frames" + frames_dir.mkdir() + snapshot = stage / ".checkpoint.json" + + for ordinal, checkpoint_path in enumerate(checkpoints): + try: + # Parse exactly the bytes whose digest is recorded, even if a + # running producer replaces the original checkpoint concurrently. + checkpoint_digest = _copy_checkpoint(Path(checkpoint_path), snapshot) + + bundle = load_checkpoint_bundle(snapshot, backend=BackendKind.CPU) + captured = capture_scene( + bundle.simulation, channel_metadata=bundle.channel_metadata + ) + + if captured.time < previous_time: + raise ReplayExportError( + f"time {captured.time} precedes previous frame time {previous_time}" + ) + + previous_time = captured.time + + if export_backend is None: + export_backend = cast(JSONValue, asdict(captured.backend)) + + source_backend = bundle.source_backend + # The displayed scene describes its source run, not the CPU + # used solely to deserialize portable state during export. + frame = replace( + captured, + backend=SceneBackend( + kind=cast(SceneBackendKind, source_backend.kind), + name=source_backend.name, + device=source_backend.device, + device_index=source_backend.device_index, + native=source_backend.native, + ), + ) + encoded = dumps_scene(frame).encode("utf-8") + + if len(encoded) > MAX_SCENE_BYTES: + raise ReplayExportError("scene exceeds its byte limit") + + relative = f"frames/{ordinal:08d}.scene.json" + (stage / relative).write_bytes(encoded) + entries.append( + { + "ordinal": ordinal, + "time": frame.time, + "file": relative, + "bytes": len(encoded), + "sha256": hashlib.sha256(encoded).hexdigest(), + "checkpoint_sha256": checkpoint_digest, + "source_backend": cast(JSONValue, asdict(source_backend)), + } + ) + except (OSError, ValueError, RuntimeError) as error: + raise ReplayExportError( + f"frame {ordinal} ({checkpoint_path}): {error}" + ) from error + + snapshot.unlink() + recording: dict[str, JSONValue] = {"export_backend": export_backend, "frames": entries} + document: dict[str, JSONValue] = { + "format": REPLAY_FORMAT, + "version": REPLAY_VERSION, + "integrity": { + "algorithm": "sha256", + "recording": hashlib.sha256(rfc8785.dumps(recording)).hexdigest(), + }, + "recording": recording, + } + encoded_manifest = ( + json.dumps(document, allow_nan=False, ensure_ascii=False, indent=2) + "\n" + ).encode("utf-8") + + if len(encoded_manifest) > MAX_REPLAY_MANIFEST_BYTES: + raise ReplayExportError("replay manifest exceeds the 16 MiB limit") + + (stage / "manifest.json").write_bytes(encoded_manifest) + + if destination.exists() or destination.is_symlink(): + raise ReplayExportError(f"output already exists: {destination}") + + stage.rename(destination) + except (OSError, CheckpointError) as error: + raise ReplayExportError(f"could not export replay to {destination}: {error}") from error + + return ReplayExportSummary(destination, len(entries)) diff --git a/python/src/microsimulator/run_manifest.py b/python/src/microsimulator/run_manifest.py index 7a57927..4b3c1c0 100644 --- a/python/src/microsimulator/run_manifest.py +++ b/python/src/microsimulator/run_manifest.py @@ -76,6 +76,7 @@ def job(self, job_id: str) -> RunJob: for job in self.jobs: if job.id == job_id: return job + raise RunManifestError(f"run manifest has no job {job_id!r}") @@ -89,33 +90,42 @@ def _reject_constant(value: str) -> NoReturn: def _reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]: result: dict[str, object] = {} + for key, value in pairs: if key in result: raise RunManifestError(f"run manifest contains duplicate key {key!r}") + result[key] = value + return result def _object(value: object, path: str) -> dict[str, object]: if not isinstance(value, dict): _fail(path, "expected an object") + mapping = cast(dict[object, object], value) + if not all(isinstance(key, str) for key in mapping): _fail(path, "expected string object keys") + return cast(dict[str, object], mapping) def _array(value: object, path: str) -> list[object]: if not isinstance(value, list): _fail(path, "expected an array") + return cast(list[object], value) def _keys(value: dict[str, object], path: str, required: set[str]) -> None: missing = required - value.keys() unknown = value.keys() - required + if missing: _fail(path, f"missing keys {sorted(missing)}") + if unknown: _fail(path, f"unknown keys {sorted(unknown)}") @@ -123,62 +133,79 @@ def _keys(value: dict[str, object], path: str, required: set[str]) -> None: def _string(value: object, path: str) -> str: if not isinstance(value, str): _fail(path, "expected a string") + return value def _integer(value: object, path: str, minimum: int, maximum: int) -> int: if isinstance(value, bool) or not isinstance(value, int): _fail(path, "expected an integer") + if value < minimum or value > maximum: _fail(path, f"integer is outside [{minimum}, {maximum}]") + return value def _number(value: object, path: str) -> float: if isinstance(value, bool) or not isinstance(value, int | float): _fail(path, "expected a number") + result = float(value) + if not math.isfinite(result): _fail(path, "number must be finite") + return result def _digest(value: object, path: str) -> str: result = _string(value, path) + if len(result) != 64 or result != result.lower(): _fail(path, "expected a lowercase SHA-256 digest") + try: bytes.fromhex(result) except ValueError: _fail(path, "expected a lowercase SHA-256 digest") + return result def _path(value: object, path: str, directory: Path) -> Path: encoded = _string(value, path) + if not encoded or "\0" in encoded: _fail(path, "expected a nonempty filesystem path") + result = Path(encoded) + return (result if result.is_absolute() else directory / result).resolve() def _json_value(value: object, path: str) -> JSONValue: if value is None or isinstance(value, str | bool | int): return value + if isinstance(value, float): if not math.isfinite(value): _fail(path, "number must be finite") + return value + if isinstance(value, list): return [ _json_value(item, f"{path}[{index}]") for index, item in enumerate(cast(list[object], value)) ] + if isinstance(value, dict): return { key: _json_value(item, f"{path}.{key}") for key, item in _object(cast(object, value), path).items() } + _fail(path, "expected JSON data") @@ -206,6 +233,7 @@ def _job(value: object, path: str, directory: Path) -> RunJob: }, ) job_id = _string(data["id"], f"{path}.id") + if _RUN_ID_PATTERN.fullmatch(job_id) is None: _fail(f"{path}.id", "expected 1-128 ASCII letters, digits, '.', '_', or '-'") @@ -213,8 +241,10 @@ def _job(value: object, path: str, directory: Path) -> RunJob: _keys(model, f"{path}.model", {"path", "sha256"}) backend_name = _string(data["backend"], f"{path}.backend") backend = _BACKENDS.get(backend_name) + if backend is None: _fail(f"{path}.backend", f"unknown backend {backend_name!r}") + stopping = _object(data["stopping"], f"{path}.stopping") _keys(stopping, f"{path}.stopping", {"maximum_steps", "dt", "cell_count"}) stop_value = stopping["cell_count"] @@ -224,8 +254,10 @@ def _job(value: object, path: str, directory: Path) -> RunJob: else _integer(stop_value, f"{path}.stopping.cell_count", 1, _UINT64_MAX) ) dt = _number(stopping["dt"], f"{path}.stopping.dt") + if dt < 0.0: _fail(f"{path}.stopping.dt", "number must be non-negative") + return RunJob( id=job_id, model=_path(model["path"], f"{path}.model.path", directory), @@ -249,17 +281,25 @@ def _job(value: object, path: str, directory: Path) -> RunJob: def _periodic_contains(job: RunJob, candidate: Path) -> bool: if job.checkpoint_every == 0 or job.checkpoint_every > job.maximum_steps: return False + parent, stem, suffix = periodic_checkpoint_parts(job.output) + if candidate.parent != parent: return False + prefix = f"{stem}.step-" name = candidate.name + if not name.startswith(prefix) or not name.endswith(suffix): return False + encoded_step = name[len(prefix) : -len(suffix)] + if len(encoded_step) < 8 or not encoded_step.isascii() or not encoded_step.isdigit(): return False + step = int(encoded_step) + return 0 < step <= job.maximum_steps and step % job.checkpoint_every == 0 @@ -269,12 +309,15 @@ def _validate_output_disjointness(jobs: tuple[RunJob, ...]) -> None: raise RunManifestError( f"jobs {first.id!r} and {second.id!r} use the same output {first.output}" ) + if _periodic_contains(first, second.output) or _periodic_contains(second, first.output): raise RunManifestError( f"jobs {first.id!r} and {second.id!r} have colliding final/periodic outputs" ) + first_parent, first_stem, first_suffix = periodic_checkpoint_parts(first.output) second_parent, second_stem, second_suffix = periodic_checkpoint_parts(second.output) + if ( first.checkpoint_every > 0 and second.checkpoint_every > 0 @@ -295,16 +338,21 @@ def load_run_manifest(path: str | os.PathLike[str]) -> RunManifest: """Parse a strict manifest without importing or executing any model.""" source = Path(path).resolve() + try: with source.open("rb") as stream: encoded = stream.read(MAX_RUN_MANIFEST_BYTES + 1) except OSError as error: raise RunManifestError(f"could not read run manifest {source}") from error + if not encoded: raise RunManifestError("run manifest is empty") + if len(encoded) > MAX_RUN_MANIFEST_BYTES: raise RunManifestError(f"run manifest exceeds the {MAX_RUN_MANIFEST_BYTES}-byte limit") + digest = hashlib.sha256(encoded).hexdigest() + try: decoded = json.loads( encoded, @@ -318,23 +366,33 @@ def load_run_manifest(path: str | os.PathLike[str]) -> RunManifest: root = _object(cast(object, decoded), "$") _keys(root, "$", {"format", "version", "jobs"}) + if _string(root["format"], "$.format") not in ( - RUN_MANIFEST_FORMAT, "cellmodeller2-run-manifest" + RUN_MANIFEST_FORMAT, + "cellmodeller2-run-manifest", ): _fail("$.format", "not a MicroSimulator run manifest") + version = _integer(root["version"], "$.version", 0, _UINT32_MAX) + if version != RUN_MANIFEST_VERSION: _fail("$.version", f"unsupported run manifest version {version}") + values = _array(root["jobs"], "$.jobs") + if not values: _fail("$.jobs", "at least one job is required") + jobs = tuple( _job(value, f"$.jobs[{index}]", source.parent) for index, value in enumerate(values) ) ids = [job.id for job in jobs] + if len(ids) != len(set(ids)): raise RunManifestError("run manifest job IDs must be unique") + _validate_output_disjointness(jobs) + return RunManifest(source=source, sha256=digest, jobs=jobs) @@ -367,6 +425,7 @@ def execute_run_job( }, "job_id": job.id, } + return run_simulation( simulation, steps=job.maximum_steps, diff --git a/python/src/microsimulator/runner.py b/python/src/microsimulator/runner.py index 7aecc0b..5b46ebb 100644 --- a/python/src/microsimulator/runner.py +++ b/python/src/microsimulator/runner.py @@ -18,6 +18,7 @@ Simulation, backend_available, ) +from .channels import ChannelMetadata, ChannelMetadataError from .checkpoint import CheckpointBundle, JSONValue, save_checkpoint from .controller import SimulationController @@ -45,12 +46,15 @@ class ModelContext: def __post_init__(self) -> None: if self.device_index < 0: raise BatchError("device index must be non-negative") + if self.seed < 0 or self.seed > _UINT64_MAX: raise BatchError("seed must be an unsigned 64-bit integer") + if not backend_available(self.backend, self.device_index): raise BatchError( f"backend {self.backend.name.lower()} device {self.device_index} is unavailable" ) + self.parameters = MappingProxyType(dict(self.parameters)) self.rng = random.Random(self.seed) @@ -94,22 +98,45 @@ def native_simulation(model: object) -> Simulation: if isinstance(model, Simulation): return model + if not isinstance(model, SimulationController): raise BatchError("runnable model does not implement the controller protocol") + simulation = cast(object, model.simulation) + if not isinstance(simulation, Simulation): raise BatchError("controller simulation is not a native Simulation") + return simulation +def model_channel_metadata(model: RunnableModel) -> ChannelMetadata: + """Read optional model labels without extending the required controller protocol.""" + + value = cast(object, getattr(model, "channel_metadata", ChannelMetadata())) + + if not isinstance(value, ChannelMetadata): + raise BatchError("model channel_metadata must be ChannelMetadata") + + native = native_simulation(model) + + try: + return value.resolved(native.species_count, native.signal_count) + except ChannelMetadataError as error: + raise BatchError(str(error)) from error + + def controller_state(model: RunnableModel) -> JSONValue: """Capture optional data-only controller state for a runnable model.""" if isinstance(model, Simulation): return None + state = model.controller_state() + if state is None: raise BatchError("controller_state() must return non-null JSON data") + return state @@ -120,11 +147,14 @@ def _checkpoint_model_context( checkpoint: CheckpointBundle, ) -> None: model = checkpoint.provenance.get("model") + if not isinstance(model, dict): raise BatchError("checkpoint is missing model provenance") + saved_digest = model.get("sha256") saved_seed = model.get("seed") saved_parameters = model.get("parameters") + if ( not isinstance(saved_digest, str) or not isinstance(saved_seed, int) @@ -132,8 +162,10 @@ def _checkpoint_model_context( or not isinstance(saved_parameters, dict) ): raise BatchError("checkpoint model provenance is invalid") + if digest != saved_digest: raise BatchError(f"model digest does not match checkpoint: {source_path}") + if saved_seed != context.seed or saved_parameters != dict(context.parameters): raise BatchError("resume context differs from checkpoint model provenance") @@ -164,60 +196,145 @@ def _run_provenance( "cell_count": stop_cell_count, }, } + return result -def run_simulation( - simulation: RunnableModel, - *, - steps: int, - dt: float, - output: str | Path, - checkpoint_every: int = 0, - stop_cell_count: int | None = None, - overwrite: bool = False, - provenance: Mapping[str, JSONValue] | None = None, - progress: ProgressCallback | None = None, -) -> RunSummary: - """Advance a simulation and write periodic and final atomic checkpoints.""" - +def _validate_run_limits( + steps: int, dt: float, checkpoint_every: int, stop_cell_count: int | None +) -> None: if steps < 0: raise BatchError("steps must be non-negative") + if steps > _UINT64_MAX: raise BatchError("steps must be an unsigned 64-bit integer") + if not math.isfinite(dt) or dt < 0.0: raise BatchError("time step must be finite and non-negative") + if checkpoint_every < 0: raise BatchError("checkpoint interval must be non-negative") + if checkpoint_every > _UINT64_MAX: raise BatchError("checkpoint interval must be an unsigned 64-bit integer") + if stop_cell_count is not None and ( isinstance(stop_cell_count, bool) or stop_cell_count <= 0 or stop_cell_count > _UINT64_MAX ): raise BatchError("cell-count threshold must be a positive uint64 value") + + +def _save_periodic_checkpoint( + simulation: RunnableModel, + native: Simulation, + periodic: Path, + base_provenance: Mapping[str, JSONValue], + completed_steps: int, + steps: int, + dt: float, + stop_cell_count: int | None, + reached_cell_count: bool, +) -> None: + finished = reached_cell_count or completed_steps == steps + save_checkpoint( + native, + periodic, + provenance=_run_provenance( + base_provenance, + status="complete" if finished else "running", + completed_steps=completed_steps, + steps=steps, + dt=dt, + stop_reason=( + "cell_count" + if reached_cell_count + else "step_limit" + if completed_steps == steps + else None + ), + stop_cell_count=stop_cell_count, + ), + controller=controller_state(simulation), + channel_metadata=model_channel_metadata(simulation), + ) + + +def _invoke_model( + module: ModuleType, + source_path: Path, + context: ModelContext, + checkpoint: CheckpointBundle | None, +) -> tuple[object, str]: + if checkpoint is None: + build_value = module.__dict__.get("build") + + if not callable(build_value): + raise BatchError(f"model {source_path} must define build(context)") + + build = cast(Callable[[ModelContext], object], build_value) + model_value = build(context) + entrypoint = "build(context)" + else: + resume_value = module.__dict__.get("resume") + + if not callable(resume_value): + raise BatchError(f"model {source_path} must define resume(context, checkpoint)") + + resume = cast(Callable[[ModelContext, CheckpointBundle], object], resume_value) + model_value = resume(context, checkpoint) + entrypoint = "resume(context, checkpoint)" + + return model_value, entrypoint + + +def _check_output_collisions( + destination: Path, periodic_paths: tuple[Path, ...], overwrite: bool +) -> None: + if not overwrite: + collisions = [path for path in (*periodic_paths, destination) if path.exists()] + + if collisions: + raise BatchError(f"output already exists: {collisions[0]}") + + +def run_simulation( + simulation: RunnableModel, + *, + steps: int, + dt: float, + output: str | Path, + checkpoint_every: int = 0, + stop_cell_count: int | None = None, + overwrite: bool = False, + provenance: Mapping[str, JSONValue] | None = None, + progress: ProgressCallback | None = None, +) -> RunSummary: + """Advance a simulation and write periodic and final atomic checkpoints.""" + + _validate_run_limits(steps, dt, checkpoint_every, stop_cell_count) + native = native_simulation(simulation) native.validate() + model_channel_metadata(simulation) destination = Path(output) periodic_steps = ( - tuple(range(checkpoint_every, steps + 1, checkpoint_every)) - if checkpoint_every > 0 - else () + tuple(range(checkpoint_every, steps + 1, checkpoint_every)) if checkpoint_every > 0 else () ) periodic_paths = tuple(_periodic_path(destination, step) for step in periodic_steps) destination.parent.mkdir(parents=True, exist_ok=True) - if not overwrite: - collisions = [path for path in (*periodic_paths, destination) if path.exists()] - if collisions: - raise BatchError(f"output already exists: {collisions[0]}") + + _check_output_collisions(destination, periodic_paths, overwrite) base_provenance = dict(provenance) if provenance is not None else {} periodic_by_step = dict(zip(periodic_steps, periodic_paths, strict=True)) written_periodic: list[Path] = [] completed_steps = 0 stop_reason: RunStopReason = "step_limit" + if stop_cell_count is not None and native.cell_count >= stop_cell_count: stop_reason = "cell_count" + if stop_reason != "cell_count": for step_number in range(1, steps + 1): simulation.step(dt) @@ -225,6 +342,7 @@ def run_simulation( reached_cell_count = ( stop_cell_count is not None and native.cell_count >= stop_cell_count ) + if progress is not None: progress( RunProgress( @@ -234,30 +352,23 @@ def run_simulation( cell_count=native.cell_count, ) ) + periodic = periodic_by_step.get(completed_steps) + if periodic is not None: - finished = reached_cell_count or completed_steps == steps - save_checkpoint( + _save_periodic_checkpoint( + simulation, native, periodic, - provenance=_run_provenance( - base_provenance, - status="complete" if finished else "running", - completed_steps=completed_steps, - steps=steps, - dt=dt, - stop_reason=( - "cell_count" - if reached_cell_count - else "step_limit" - if completed_steps == steps - else None - ), - stop_cell_count=stop_cell_count, - ), - controller=controller_state(simulation), + base_provenance, + completed_steps, + steps, + dt, + stop_cell_count, + reached_cell_count, ) written_periodic.append(periodic) + if reached_cell_count: stop_reason = "cell_count" break @@ -275,7 +386,9 @@ def run_simulation( stop_cell_count=stop_cell_count, ), controller=controller_state(simulation), + channel_metadata=model_channel_metadata(simulation), ) + return RunSummary( completed_steps=completed_steps, stop_reason=stop_reason, @@ -287,6 +400,33 @@ def run_simulation( ) +def _validate_built_model( + model_value: RunnableModel, + source_path: Path, + context: ModelContext, + checkpoint: CheckpointBundle | None, +) -> None: + simulation = native_simulation(model_value) + + if checkpoint is not None and simulation is not checkpoint.simulation: + raise BatchError( + f"model {source_path} resume(context, checkpoint) did not use checkpoint.simulation" + ) + + info = simulation.backend_info + + if info.kind != context.backend or info.device_index != context.device_index: + raise BatchError("model returned a simulation on a different backend or device") + + simulation.validate() + labels = model_channel_metadata(model_value) + + if checkpoint is not None and labels != checkpoint.channel_metadata.resolved( + simulation.species_count, simulation.signal_count + ): + raise BatchError("resumed model channel metadata differs from checkpoint") + + def build_model( path: str | Path, context: ModelContext, @@ -297,15 +437,20 @@ def build_model( """Build or resume a runnable model from explicitly selected Python source.""" source_path = Path(path).resolve() + try: source = source_path.read_bytes() except OSError as error: raise BatchError(f"could not read model {source_path}") from error + digest = hashlib.sha256(source).hexdigest() + if expected_sha256 is not None and digest != expected_sha256: raise BatchError(f"model digest does not match manifest: {source_path}") + if checkpoint is not None: _checkpoint_model_context(source_path, digest, context, checkpoint) + module_name = f"_microsimulator_model_{digest[:16]}" module = ModuleType(module_name) module.__file__ = str(source_path) @@ -314,31 +459,19 @@ def build_model( previous_module = sys.modules.get(module_name) sys.modules[module_name] = module sys.path.insert(0, str(source_path.parent)) + try: code = compile(source, str(source_path), "exec") exec(code, module.__dict__) - if checkpoint is None: - build_value = module.__dict__.get("build") - if not callable(build_value): - raise BatchError(f"model {source_path} must define build(context)") - build = cast(Callable[[ModelContext], object], build_value) - model_value = build(context) - entrypoint = "build(context)" - else: - resume_value = module.__dict__.get("resume") - if not callable(resume_value): - raise BatchError( - f"model {source_path} must define resume(context, checkpoint)" - ) - resume = cast(Callable[[ModelContext, CheckpointBundle], object], resume_value) - model_value = resume(context, checkpoint) - entrypoint = "resume(context, checkpoint)" + + model_value, entrypoint = _invoke_model(module, source_path, context, checkpoint) except BatchError: raise except Exception as error: raise BatchError(f"model {source_path} failed: {error}") from error finally: sys.path.pop(0) + if previous_module is None: del sys.modules[module_name] else: @@ -346,19 +479,11 @@ def build_model( if not isinstance(model_value, Simulation | SimulationController): raise BatchError( - f"model {source_path} {entrypoint} did not return a Simulation or " - "SimulationController" - ) - simulation = native_simulation(model_value) - if checkpoint is not None and simulation is not checkpoint.simulation: - raise BatchError( - f"model {source_path} resume(context, checkpoint) did not use " - "checkpoint.simulation" + f"model {source_path} {entrypoint} did not return a Simulation or SimulationController" ) - info = simulation.backend_info - if info.kind != context.backend or info.device_index != context.device_index: - raise BatchError("model returned a simulation on a different backend or device") - simulation.validate() + + _validate_built_model(model_value, source_path, context, checkpoint) + provenance: dict[str, JSONValue] = { "model": { "path": str(source_path), @@ -367,4 +492,5 @@ def build_model( "parameters": dict(context.parameters), } } + return model_value, provenance diff --git a/python/src/microsimulator/sbml.py b/python/src/microsimulator/sbml.py index f17bb5f..d92bb03 100644 --- a/python/src/microsimulator/sbml.py +++ b/python/src/microsimulator/sbml.py @@ -15,6 +15,7 @@ RateOp, SpeciesRatePlan, ) +from .channels import ChannelMetadata _FLOAT32_MAX = 3.4028234663852886e38 _UINT32_MAX = (1 << 32) - 1 @@ -36,6 +37,17 @@ class SBMLRateModel: rate_plan: SpeciesRatePlan warnings: tuple[str, ...] + @property + def channel_metadata(self) -> ChannelMetadata: + """Use nonempty SBML names, falling back to stable SBML identifiers.""" + + return ChannelMetadata( + species=tuple( + name if name.strip() else identifier + for name, identifier in zip(self.species_names, self.species_ids, strict=True) + ) + ) + @property def species_count(self) -> int: return len(self.species_ids) @@ -173,18 +185,24 @@ def _libsbml() -> _LibSBML: def _finite_float32(value: float, path: str) -> float: result = float(value) + if not math.isfinite(result) or abs(result) > _FLOAT32_MAX: raise SBMLImportError(f"{path}: expected a finite float32 value") + return result def _diagnostic(error: _SBMLError) -> str: location = "" + if error.getLine() > 0: location = f"line {error.getLine()}" + if error.getColumn() > 0: location += f", column {error.getColumn()}" + location += ": " + return f"{location}{error.getMessage().strip()}" @@ -192,18 +210,24 @@ def _validate_document(document: _Document, libsbml: _LibSBML) -> tuple[str, ... document.checkConsistency() failures: list[str] = [] warnings: list[str] = [] + for index in range(document.getNumErrors()): error = document.getError(index) message = _diagnostic(error) + if error.getSeverity() >= libsbml.LIBSBML_SEV_ERROR: failures.append(message) else: warnings.append(message) + if failures: detail = "; ".join(failures[:8]) + if len(failures) > 8: detail += f"; and {len(failures) - 8} more errors" + raise SBMLImportError(f"SBML document is invalid: {detail}") + return tuple(warnings) @@ -216,8 +240,10 @@ def _reject_unsupported_model_features(model: _Model) -> None: "function definitions": model.getNumFunctionDefinitions(), } present = [name for name, count in unsupported.items() if count != 0] + if present: raise SBMLImportError(f"unsupported SBML constructs: {', '.join(present)}") + if model.isSetConversionFactor(): raise SBMLImportError("model conversion factors are not supported") @@ -225,34 +251,47 @@ def _reject_unsupported_model_features(model: _Model) -> None: def _unit_compartment(model: _Model) -> tuple[str, float]: if model.getNumCompartments() != 1: raise SBMLImportError("SBML import requires exactly one compartment") + compartment = model.getCompartment(0) identifier = compartment.getId() + if not identifier: raise SBMLImportError("compartment must have an identifier") + if not compartment.getConstant(): raise SBMLImportError(f"compartment {identifier!r} must be constant") + if not compartment.isSetSize(): raise SBMLImportError(f"compartment {identifier!r} must declare size 1") + size = _finite_float32(compartment.getSize(), f"compartment {identifier!r} size") + if size != 1.0: raise SBMLImportError(f"compartment {identifier!r} must have size 1") + return identifier, size def _parameters(model: _Model) -> dict[str, float]: values: dict[str, float] = {} + for index in range(model.getNumParameters()): parameter = model.getParameter(index) identifier = parameter.getId() + if not identifier: raise SBMLImportError(f"global parameter {index} must have an identifier") + if not parameter.getConstant(): raise SBMLImportError(f"global parameter {identifier!r} must be constant") + if not parameter.isSetValue(): raise SBMLImportError(f"global parameter {identifier!r} must declare a value") + values[identifier] = _finite_float32( parameter.getValue(), f"global parameter {identifier!r}" ) + return values @@ -263,26 +302,32 @@ def _local_parameters(kinetic_law: _KineticLaw, reaction_id: str) -> dict[str, f for index in range(kinetic_law.getNumLocalParameters()) ] parameters.extend( - (kinetic_law.getParameter(index), True) - for index in range(kinetic_law.getNumParameters()) + (kinetic_law.getParameter(index), True) for index in range(kinetic_law.getNumParameters()) ) + for parameter, check_constant in parameters: identifier = parameter.getId() + if not identifier or identifier in values: if identifier in values: continue + raise SBMLImportError(f"reaction {reaction_id!r} has an unnamed local parameter") + if check_constant and not parameter.getConstant(): raise SBMLImportError( f"reaction {reaction_id!r} parameter {identifier!r} must be constant" ) + if not parameter.isSetValue(): raise SBMLImportError( f"reaction {reaction_id!r} parameter {identifier!r} must declare a value" ) + values[identifier] = _finite_float32( parameter.getValue(), f"reaction {reaction_id!r} parameter {identifier!r}" ) + return values @@ -310,12 +355,14 @@ def emit( ) -> int: if len(self.instructions) >= _UINT32_MAX: raise SBMLImportError("SBML rate plan exceeds the uint32 instruction space") + instruction = RateInstruction() instruction.operation = operation instruction.first = first instruction.second = second instruction.value = value self.instructions.append(instruction) + return len(self.instructions) - 1 def constant(self, value: float, path: str) -> int: @@ -324,11 +371,30 @@ def constant(self, value: float, path: str) -> int: def fold(self, operation: RateOp, operands: list[int], path: str) -> int: if not operands: raise SBMLImportError(f"{path}: expression has no operands") + result = operands[0] + for operand in operands[1:]: result = self.emit(operation, first=result, second=operand) + return result + def identifier(self, name: str, local_parameters: dict[str, float], path: str) -> int: + + if name in local_parameters: + return self.constant(local_parameters[name], path) + + if name in self.global_parameters: + return self.constant(self.global_parameters[name], path) + + if name in self.species: + return self.emit(RateOp.SPECIES, first=self.species[name]) + + if name == self.compartment[0]: + return self.constant(self.compartment[1], path) + + raise SBMLImportError(f"{path}: unresolved identifier {name!r}") + def expression( self, node: _ASTNode, @@ -338,63 +404,80 @@ def expression( node_type = node.getType() child_count = node.getNumChildren() children = [node.getChild(index) for index in range(child_count)] + if node.isRational(): denominator = node.getDenominator() + if denominator == 0: raise SBMLImportError(f"{path}: rational literal has zero denominator") + return self.constant(node.getNumerator() / denominator, path) + if node.isInteger(): return self.constant(node.getInteger(), path) + if node.isReal(): return self.constant(node.getReal(), path) + if node_type == self.libsbml.AST_CONSTANT_E: return self.constant(math.e, path) + if node_type == self.libsbml.AST_CONSTANT_PI: return self.constant(math.pi, path) + if node_type == self.libsbml.AST_NAME_TIME: raise SBMLImportError(f"{path}: time-dependent kinetic laws are not supported") + if node.isName(): - name = node.getName() - if name in local_parameters: - return self.constant(local_parameters[name], path) - if name in self.global_parameters: - return self.constant(self.global_parameters[name], path) - if name in self.species: - return self.emit(RateOp.SPECIES, first=self.species[name]) - if name == self.compartment[0]: - return self.constant(self.compartment[1], path) - raise SBMLImportError(f"{path}: unresolved identifier {name!r}") + return self.identifier(node.getName(), local_parameters, path) compiled = [ self.expression(child, local_parameters, f"{path}.child[{index}]") for index, child in enumerate(children) ] + + return self.operator(node_type, child_count, compiled, path) + + def operator(self, node_type: int, child_count: int, compiled: list[int], path: str) -> int: if node_type == self.libsbml.AST_PLUS: return self.fold(RateOp.ADD, compiled, path) + if node_type == self.libsbml.AST_TIMES: return self.fold(RateOp.MULTIPLY, compiled, path) + if node_type == self.libsbml.AST_MINUS: if child_count == 1: return self.emit(RateOp.NEGATE, first=compiled[0]) + if child_count == 2: return self.emit(RateOp.SUBTRACT, first=compiled[0], second=compiled[1]) + raise SBMLImportError(f"{path}: subtraction requires one or two operands") + if node_type == self.libsbml.AST_DIVIDE: if child_count != 2: raise SBMLImportError(f"{path}: division requires two operands") + return self.emit(RateOp.DIVIDE, first=compiled[0], second=compiled[1]) + if node_type in {self.libsbml.AST_POWER, self.libsbml.AST_FUNCTION_POWER}: if child_count != 2: raise SBMLImportError(f"{path}: power requires two operands") + return self.emit(RateOp.POWER, first=compiled[0], second=compiled[1]) + if node_type == self.libsbml.AST_FUNCTION_EXP: if child_count != 1: raise SBMLImportError(f"{path}: exponential requires one operand") + return self.emit(RateOp.EXPONENTIAL, first=compiled[0]) + if node_type == self.libsbml.AST_FUNCTION_LN: if child_count != 1: raise SBMLImportError(f"{path}: natural logarithm requires one operand") + return self.emit(RateOp.LOGARITHM, first=compiled[0]) + raise SBMLImportError(f"{path}: unsupported MathML node type {node_type}") @@ -405,53 +488,97 @@ def _species_metadata( identifiers: list[str] = [] names: list[str] = [] levels: list[float] = [] + for index in range(model.getNumSpecies()): species = model.getSpecies(index) identifier = species.getId() + if not identifier: raise SBMLImportError(f"species {index} must have an identifier") + if identifier in identifiers: raise SBMLImportError(f"duplicate species identifier {identifier!r}") + if species.getCompartment() != compartment_id: raise SBMLImportError( f"species {identifier!r} is not in compartment {compartment_id!r}" ) + if species.getHasOnlySubstanceUnits(): raise SBMLImportError(f"species {identifier!r} must be concentration-valued") + if species.isSetConversionFactor(): raise SBMLImportError(f"species {identifier!r} conversion factors are not supported") + has_concentration = species.isSetInitialConcentration() has_amount = species.isSetInitialAmount() + if has_concentration == has_amount: raise SBMLImportError( f"species {identifier!r} must declare exactly one initial concentration or amount" ) + initial = ( - species.getInitialConcentration() - if has_concentration - else species.getInitialAmount() + species.getInitialConcentration() if has_concentration else species.getInitialAmount() ) initial = _finite_float32(initial, f"species {identifier!r} initial level") + if initial < 0.0: raise SBMLImportError(f"species {identifier!r} initial level must be non-negative") + species_values.append(species) identifiers.append(identifier) names.append(species.getName() or identifier) levels.append(initial) + return species_values, tuple(identifiers), tuple(names), tuple(levels) def _stoichiometry(reference: _SpeciesReference, reaction_id: str) -> float: if reference.isSetStoichiometryMath() or not reference.getConstant(): raise SBMLImportError(f"reaction {reaction_id!r} uses dynamic stoichiometry") - value = _finite_float32( - reference.getStoichiometry(), f"reaction {reaction_id!r} stoichiometry" - ) + + value = _finite_float32(reference.getStoichiometry(), f"reaction {reaction_id!r} stoichiometry") + if value < 0.0: raise SBMLImportError(f"reaction {reaction_id!r} stoichiometry must be non-negative") + return value +def _compile_species_outputs( + builder: _PlanBuilder, + species_values: list[_Species], + species_ids: tuple[str, ...], + contributions: list[list[tuple[float, int]]], + zero: int, +) -> list[int]: + outputs: list[int] = [] + + for index, species in enumerate(species_values): + if species.getBoundaryCondition() or species.getConstant(): + outputs.append(zero) + continue + + output = zero + + for coefficient, rate in contributions[index]: + if coefficient == 1.0: + output = builder.emit(RateOp.ADD, first=output, second=rate) + elif coefficient == -1.0: + output = builder.emit(RateOp.SUBTRACT, first=output, second=rate) + else: + factor = builder.constant( + coefficient, f"species {species_ids[index]!r} coefficient" + ) + term = builder.emit(RateOp.MULTIPLY, first=factor, second=rate) + output = builder.emit(RateOp.ADD, first=output, second=term) + + outputs.append(output) + + return outputs + + def _compile_model(model: _Model, libsbml: _LibSBML, warnings: tuple[str, ...]) -> SBMLRateModel: _reject_unsupported_model_features(model) compartment = _unit_compartment(model) @@ -466,16 +593,21 @@ def _compile_model(model: _Model, libsbml: _LibSBML, warnings: tuple[str, ...]) for reaction_index in range(model.getNumReactions()): reaction = model.getReaction(reaction_index) reaction_id = reaction.getId() or f"reaction[{reaction_index}]" + if not reaction.isSetKineticLaw(): raise SBMLImportError(f"reaction {reaction_id!r} must declare a kinetic law") + kinetic_law = reaction.getKineticLaw() + if not kinetic_law.isSetMath(): raise SBMLImportError(f"reaction {reaction_id!r} kinetic law must contain MathML") + rate = builder.expression( kinetic_law.getMath(), _local_parameters(kinetic_law, reaction_id), f"reaction {reaction_id!r} kinetic law", ) + for sign, count, getter in ( (-1.0, reaction.getNumReactants(), reaction.getReactant), (1.0, reaction.getNumProducts(), reaction.getProduct), @@ -483,36 +615,22 @@ def _compile_model(model: _Model, libsbml: _LibSBML, warnings: tuple[str, ...]) for reference_index in range(count): reference = getter(reference_index) identifier = reference.getSpecies() + if identifier not in species_indices: raise SBMLImportError( f"reaction {reaction_id!r} references unknown species {identifier!r}" ) + coefficient = sign * _stoichiometry(reference, reaction_id) contributions[species_indices[identifier]].append((coefficient, rate)) - outputs: list[int] = [] - for index, species in enumerate(species_values): - if species.getBoundaryCondition() or species.getConstant(): - outputs.append(zero) - continue - output = zero - for coefficient, rate in contributions[index]: - if coefficient == 1.0: - output = builder.emit(RateOp.ADD, first=output, second=rate) - elif coefficient == -1.0: - output = builder.emit(RateOp.SUBTRACT, first=output, second=rate) - else: - factor = builder.constant( - coefficient, f"species {species_ids[index]!r} coefficient" - ) - term = builder.emit(RateOp.MULTIPLY, first=factor, second=rate) - output = builder.emit(RateOp.ADD, first=output, second=term) - outputs.append(output) + outputs = _compile_species_outputs(builder, species_values, species_ids, contributions, zero) try: rate_plan = SpeciesRatePlan(len(species_values), builder.instructions, outputs) except (ValueError, OverflowError) as error: raise SBMLImportError(f"compiled SBML rate plan is invalid: {error}") from error + return SBMLRateModel( model_id=model.getId(), model_name=model.getName(), @@ -529,16 +647,19 @@ def parse_sbml(source: str) -> SBMLRateModel: if not source.strip(): raise SBMLImportError("SBML source must be a nonempty string") + libsbml = _libsbml() document = libsbml.readSBMLFromString(source) - if document.getLevel() > 0 and ( - document.getLevel() != 3 or document.getVersion() != 2 - ): + + if document.getLevel() > 0 and (document.getLevel() != 3 or document.getVersion() != 2): raise SBMLImportError("SBML import currently requires Level 3 Version 2 Core") + warnings = _validate_document(document, libsbml) model = document.getModel() + if model is None: raise SBMLImportError("SBML document does not contain a model") + return _compile_model(model, libsbml, warnings) @@ -546,8 +667,10 @@ def load_sbml(path: str | Path) -> SBMLRateModel: """Read an SBML file as UTF-8 and compile the supported subset.""" source_path = Path(path) + try: source = source_path.read_text(encoding="utf-8") except (OSError, UnicodeError) as error: raise SBMLImportError(f"could not read SBML file {source_path}") from error + return parse_sbml(source) diff --git a/python/src/microsimulator/scene.py b/python/src/microsimulator/scene.py index c3a4668..761a451 100644 --- a/python/src/microsimulator/scene.py +++ b/python/src/microsimulator/scene.py @@ -20,16 +20,22 @@ from ._core import ( # pyright: ignore[reportMissingModuleSource] BackendKind, ConstraintRegion, + CultureCheckpoint, + FluidFragment, GridBoundary, GridBoundaryKind, Simulation, Vec3, + _SimulationCheckpoint, ) +from .channels import UNNAMED_CHANNELS, ChannelMetadata, ChannelMetadataError from .checkpoint import JSONValue SCENE_FORMAT = "microsimulator-scene" -SCENE_VERSION = 2 +SCENE_VERSION = 5 MAX_SCENE_BYTES = 1 << 30 +# Presentation resource budget, independent of native simulation channel counts. +MAX_SCENE_CHANNELS = 4096 _UINT32_MAX = (1 << 32) - 1 _UINT64_MAX = (1 << 64) - 1 @@ -153,6 +159,53 @@ class SceneConstraints: cylinders: tuple[SceneCylinderConstraint, ...] +@dataclass(frozen=True, slots=True) +class SceneCultureCell: + id: int + orientation: tuple[float, float, float, float] + biochemical_volume: float + species_amounts: tuple[float, ...] + dry_biomass_g: float | None = None + realized_specific_rate_per_hour: float = 0.0 + biomass_produced_g: float = 0.0 + uptake_totals: tuple[float, ...] = () + + +@dataclass(frozen=True, slots=True) +class SceneFluidFragment: + site: int + component: int + volume: float + centroid: tuple[float, float, float] + amounts: tuple[float, ...] + + +@dataclass(frozen=True, slots=True) +class SceneChemicalTransfer: + name: str + amounts: tuple[float, ...] + + +@dataclass(frozen=True, slots=True) +class SceneCulture: + length_unit_m: float + time_unit_s: float + viscosity_pa_s: float + density_kg_m3: float + shape: tuple[int, int, int] + origin: tuple[float, float, float] + spacing: float + obstacles: tuple[int, ...] + solutes: tuple[str, ...] + cells: tuple[SceneCultureCell, ...] + fragments: tuple[SceneFluidFragment, ...] + reservoirs: tuple[SceneChemicalTransfer, ...] + max_speed_m_s: float + flow_relative_residual: float + maximum_volume_residual: float + solute_amount_units: tuple[str, ...] = () + + @dataclass(frozen=True, slots=True) class SceneFrame: time: float @@ -161,6 +214,24 @@ class SceneFrame: cells: tuple[SceneCell, ...] constraints: SceneConstraints signal_grid: SceneSignalGrid | None + channel_metadata: ChannelMetadata = UNNAMED_CHANNELS + culture: SceneCulture | None = None + + def __post_init__(self) -> None: + _scene_channel_count(self.species_count, "$.frame.species_count") + + if self.signal_grid is not None: + _scene_channel_count( + self.signal_grid.signal_count, "$.frame.signal_grid.signal_count", 1 + ) + + object.__setattr__( + self, + "channel_metadata", + self.channel_metadata.resolved( + self.species_count, self.signal_grid.signal_count if self.signal_grid else 0 + ), + ) def _installed_version() -> str: @@ -181,29 +252,7 @@ def _capture_boundary(boundary: GridBoundary) -> SceneGridBoundary: ) -def capture_scene(simulation: Simulation) -> SceneFrame: - """Capture a complete immutable presentation frame after a simulation step.""" - - checkpoint = simulation._checkpoint() - checkpoint.validate() - backend = simulation.backend_info - lineage = {entry.child: entry.parent for entry in checkpoint.world.lineage} - cells = tuple( - SceneCell( - id=cell.id, - parent_id=lineage.get(cell.id), - slot=cell.slot, - position=_tuple3(cell.position), - direction=_tuple3(cell.direction), - length=cell.length, - radius=cell.radius, - growth_rate=cell.growth_rate, - cell_type=cell.cell_type, - fixed=cell.fixed, - species=tuple(cell.species), - ) - for cell in checkpoint.world.cells - ) +def _capture_constraints(checkpoint: _SimulationCheckpoint) -> SceneConstraints: constraints = SceneConstraints( planes=tuple( ScenePlaneConstraint( @@ -246,7 +295,139 @@ def capture_scene(simulation: Simulation) -> SceneFrame: for cylinder in checkpoint.constraints.cylinders ), ) + + return constraints + + +def _culture_display_grid( + state: CultureCheckpoint, fragments: list[FluidFragment], culture: SceneCulture +) -> SceneSignalGrid: + spec = state.configuration.grid + ns = len(state.configuration.solutes) + # Presentation averages only. The fragment amounts/volumes above + # remain the authoritative values for analysis and conservation. + volumes = [0.0] * spec.site_count + display = [0.0] * (spec.site_count * ns) + + for i, fragment in enumerate(fragments): + volumes[fragment.site] += fragment.volume + + for s in range(ns): + display[s * spec.site_count + fragment.site] += state.extracellular_amounts[i * ns + s] + + for s in range(ns): + for site, volume in enumerate(volumes): + if volume: + display[s * spec.site_count + site] /= volume + + boundary = SceneGridBoundary("no_flux", ()) + signal_grid = SceneSignalGrid( + ns, + culture.shape, + culture.origin, + (spec.spacing, spec.spacing, spec.spacing), + boundary, + boundary, + boundary, + boundary, + boundary, + boundary, + tuple(display), + ) + + return signal_grid + + +def _capture_culture( + simulation: Simulation, state: CultureCheckpoint, signal_grid: SceneSignalGrid | None +) -> tuple[SceneCulture, SceneSignalGrid | None]: + config = state.configuration + spec = config.grid + ns = len(config.solutes) + fragments = simulation.fluid_fragments + growth_models = {m.cell_id: m for m in config.growth} + culture = SceneCulture( + length_unit_m=spec.length_unit_m, + time_unit_s=spec.time_unit_s, + viscosity_pa_s=config.fluid.viscosity_pa_s, + density_kg_m3=config.fluid.density_kg_m3, + shape=(spec.shape.x, spec.shape.y, spec.shape.z), + origin=_tuple3(spec.origin), + spacing=spec.spacing, + obstacles=tuple(spec.obstacles), + solutes=tuple(s.name for s in config.solutes), + solute_amount_units=tuple(s.amount_unit for s in config.solutes), + cells=tuple( + SceneCultureCell( + c.body.id, + c.body.orientation, + c.biochemical_volume, + tuple(c.species_amounts), + c.biochemical_volume * growth_models[c.body.id].biomass_density + if c.body.id in growth_models + else None, + c.realized_specific_rate * 3600 / spec.time_unit_s, + c.biomass_produced, + tuple(c.uptake_totals), + ) + for c in state.cells + ), + fragments=tuple( + SceneFluidFragment( + f.site, + f.component, + f.volume, + f.centroid, + tuple(state.extracellular_amounts[i * ns : (i + 1) * ns]), + ) + for i, f in enumerate(fragments) + ), + reservoirs=tuple( + SceneChemicalTransfer(t.port, tuple(t.amounts)) for t in state.reservoir_totals + ), + max_speed_m_s=state.last_report.flow.max_speed_m_s, + flow_relative_residual=state.last_report.flow.relative_residual, + maximum_volume_residual=state.last_report.transport.maximum_volume_residual, + ) + + if ns: + signal_grid = _culture_display_grid(state, fragments, culture) + + return culture, signal_grid + + +def capture_scene( + simulation: Simulation, *, channel_metadata: ChannelMetadata = UNNAMED_CHANNELS +) -> SceneFrame: + """Capture a complete immutable presentation frame after a simulation step.""" + + # Reject before copying native state or expanding omitted channel labels. + _scene_channel_count(simulation.species_count, "$.frame.species_count") + _scene_channel_count(simulation.signal_count, "$.frame.signal_grid.signal_count") + checkpoint = simulation._checkpoint() + checkpoint.validate() + backend = simulation.backend_info + lineage = {entry.child: entry.parent for entry in checkpoint.world.lineage} + cells = tuple( + SceneCell( + id=cell.id, + parent_id=lineage.get(cell.id), + slot=cell.slot, + position=_tuple3(cell.position), + direction=_tuple3(cell.direction), + length=cell.length, + radius=cell.radius, + growth_rate=cell.growth_rate, + cell_type=cell.cell_type, + fixed=cell.fixed, + species=tuple(cell.species), + ) + for cell in checkpoint.world.cells + ) + constraints = _capture_constraints(checkpoint) signal_grid = None + culture = None + if checkpoint.signal_grid is not None: grid = checkpoint.signal_grid spec = grid.spec @@ -263,6 +444,10 @@ def capture_scene(simulation: Simulation) -> SceneFrame: z_upper=_capture_boundary(spec.z_upper), levels=tuple(grid.levels), ) + + if checkpoint.culture is not None: + culture, signal_grid = _capture_culture(simulation, checkpoint.culture, signal_grid) + frame = SceneFrame( time=checkpoint.time, backend=SceneBackend( @@ -276,8 +461,13 @@ def capture_scene(simulation: Simulation) -> SceneFrame: cells=cells, constraints=constraints, signal_grid=signal_grid, + culture=culture, + channel_metadata=channel_metadata.resolved( + simulation.species_count, simulation.signal_count + ), ) _validate_frame(frame) + return frame @@ -303,6 +493,7 @@ def _frame_to_json(frame: SceneFrame) -> dict[str, JSONValue]: for cell in frame.cells ] grid: JSONValue = None + if frame.signal_grid is not None: value = frame.signal_grid grid = { @@ -320,6 +511,7 @@ def _frame_to_json(frame: SceneFrame) -> dict[str, JSONValue]: }, "levels": list(value.levels), } + constraints: JSONValue = { "planes": [ { @@ -362,6 +554,7 @@ def _frame_to_json(frame: SceneFrame) -> dict[str, JSONValue]: for cylinder in frame.constraints.cylinders ], } + return { "time": frame.time, "backend": { @@ -375,6 +568,10 @@ def _frame_to_json(frame: SceneFrame) -> dict[str, JSONValue]: "cells": cells, "constraints": constraints, "signal_grid": grid, + "culture": _culture_to_json(frame.culture), + "channel_metadata": frame.channel_metadata.to_json( + frame.species_count, frame.signal_grid.signal_count if frame.signal_grid else 0 + ), } @@ -400,23 +597,30 @@ def dumps_scene(frame: SceneFrame) -> str: }, "frame": payload, } - return json.dumps( - document, - allow_nan=False, - ensure_ascii=False, - indent=2, - sort_keys=True, - ) + "\n" + + return ( + json.dumps( + document, + allow_nan=False, + ensure_ascii=False, + indent=2, + sort_keys=True, + ) + + "\n" + ) def save_scene(frame: SceneFrame, path: str | os.PathLike[str]) -> None: """Atomically save an immutable scene frame.""" encoded = dumps_scene(frame).encode("utf-8") + if len(encoded) > MAX_SCENE_BYTES: raise SceneError(f"scene exceeds the {MAX_SCENE_BYTES}-byte limit") + destination = Path(path) temporary: Path | None = None + try: with tempfile.NamedTemporaryFile( mode="wb", @@ -429,6 +633,7 @@ def save_scene(frame: SceneFrame, path: str | os.PathLike[str]) -> None: stream.write(encoded) stream.flush() os.fsync(stream.fileno()) + os.replace(temporary, destination) temporary = None except OSError as error: @@ -442,39 +647,57 @@ def _fail(path: str, message: str) -> NoReturn: raise SceneError(f"{path}: {message}") +def _scene_channel_count(value: object, path: str, minimum: int = 0) -> int: + count = _integer(value, path, minimum, _UINT32_MAX) + + if count > MAX_SCENE_CHANNELS: + _fail(path, f"exceeds scene presentation channel budget of {MAX_SCENE_CHANNELS} per group") + + return count + + def _reject_constant(value: str) -> NoReturn: raise SceneError(f"scene contains non-finite JSON number {value}") def _reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]: result: dict[str, object] = {} + for key, value in pairs: if key in result: raise SceneError(f"scene contains duplicate key {key!r}") + result[key] = value + return result def _object(value: object, path: str) -> dict[str, object]: if not isinstance(value, dict): _fail(path, "expected an object") + mapping = cast(dict[object, object], value) + if not all(isinstance(key, str) for key in mapping): _fail(path, "expected string object keys") + return cast(dict[str, object], mapping) def _array(value: object, path: str) -> list[object]: if not isinstance(value, list): _fail(path, "expected an array") + return cast(list[object], value) def _keys(value: dict[str, object], path: str, required: set[str]) -> None: missing = required - value.keys() unknown = value.keys() - required + if missing: _fail(path, f"missing keys {sorted(missing)}") + if unknown: _fail(path, f"unknown keys {sorted(unknown)}") @@ -482,51 +705,65 @@ def _keys(value: dict[str, object], path: str, required: set[str]) -> None: def _string(value: object, path: str) -> str: if not isinstance(value, str): _fail(path, "expected a string") + return value def _boolean(value: object, path: str) -> bool: if not isinstance(value, bool): _fail(path, "expected a boolean") + return value def _integer(value: object, path: str, minimum: int, maximum: int) -> int: if isinstance(value, bool) or not isinstance(value, int): _fail(path, "expected an integer") + if value < minimum or value > maximum: _fail(path, f"integer is outside [{minimum}, {maximum}]") + return value def _identifier(value: object, path: str) -> int: encoded = _string(value, path) + if not encoded.isascii() or not encoded.isdecimal() or encoded.startswith("0"): _fail(path, "expected a canonical positive decimal uint64 string") + result = int(encoded) + if result <= 0 or result > _UINT64_MAX: _fail(path, "identifier is outside the positive uint64 range") + return result def _number(value: object, path: str, *, float32: bool = False) -> float: if isinstance(value, bool) or not isinstance(value, int | float): _fail(path, "expected a number") + try: result = float(value) except (OverflowError, ValueError): _fail(path, "number is outside the finite float64 range") + if not math.isfinite(result): _fail(path, "number must be finite") + if float32 and abs(result) > _FLOAT32_MAX: _fail(path, "number is outside the finite float32 range") + return result def _tuple3_from_json(value: object, path: str) -> tuple[float, float, float]: items = _array(value, path) + if len(items) != 3: _fail(path, "expected exactly three values") + return ( _number(items[0], f"{path}[0]", float32=True), _number(items[1], f"{path}[1]", float32=True), @@ -545,13 +782,17 @@ def _boundary(value: object, path: str, signal_count: int) -> SceneGridBoundary: data = _object(value, path) _keys(data, path, {"kind", "values"}) kind_value = _string(data["kind"], f"{path}.kind") + if kind_value not in _BOUNDARY_KINDS: _fail(f"{path}.kind", f"unknown boundary kind {kind_value!r}") + kind = kind_value values = _float_tuple(data["values"], f"{path}.values") expected = signal_count if kind == "fixed" else 0 + if len(values) != expected: _fail(f"{path}.values", f"expected {expected} values for {kind} boundary") + return SceneGridBoundary(kind=kind, values=values) @@ -559,14 +800,19 @@ def _backend(value: object, path: str) -> SceneBackend: data = _object(value, path) _keys(data, path, {"kind", "name", "device", "device_index", "native"}) kind_value = _string(data["kind"], f"{path}.kind") + if kind_value not in _BACKEND_KINDS: _fail(f"{path}.kind", f"unknown backend kind {kind_value!r}") + name = _string(data["name"], f"{path}.name") device = _string(data["device"], f"{path}.device") + if not name: _fail(f"{path}.name", "must not be empty") + if not device: _fail(f"{path}.device", "must not be empty") + return SceneBackend( kind=kind_value, name=name, @@ -599,8 +845,10 @@ def _cell(value: object, path: str, species_count: int) -> SceneCell: parent_value = data["parent_id"] parent_id = None if parent_value is None else _identifier(parent_value, f"{path}.parent_id") species = _float_tuple(data["species"], f"{path}.species") + if len(species) != species_count: _fail(f"{path}.species", f"expected {species_count} values") + return SceneCell( id=identifier, parent_id=parent_id, @@ -618,21 +866,26 @@ def _cell(value: object, path: str, species_count: int) -> SceneCell: def _region(value: object, path: str) -> SceneRegionKind: name = _string(value, path) + if name not in _REGION_KINDS: _fail(path, f"unknown region kind {name!r}") + return name def _positive_number(value: object, path: str) -> float: result = _number(value, path, float32=True) + if result <= 0.0: _fail(path, "must be positive") + return result def _plane_constraint(value: object, path: str) -> ScenePlaneConstraint: data = _object(value, path) _keys(data, path, {"id", "point", "inward_normal", "coefficient"}) + return ScenePlaneConstraint( id=_identifier(data["id"], f"{path}.id"), point=_tuple3_from_json(data["point"], f"{path}.point"), @@ -644,6 +897,7 @@ def _plane_constraint(value: object, path: str) -> ScenePlaneConstraint: def _sphere_constraint(value: object, path: str) -> SceneSphereConstraint: data = _object(value, path) _keys(data, path, {"id", "center", "radius", "coefficient", "allowed_region"}) + return SceneSphereConstraint( id=_identifier(data["id"], f"{path}.id"), center=_tuple3_from_json(data["center"], f"{path}.center"), @@ -657,8 +911,10 @@ def _box_constraint(value: object, path: str) -> SceneBoxConstraint: data = _object(value, path) _keys(data, path, {"id", "center", "half_extents", "coefficient", "allowed_region"}) half_extents = _tuple3_from_json(data["half_extents"], f"{path}.half_extents") + if any(extent <= 0.0 for extent in half_extents): _fail(f"{path}.half_extents", "values must be positive") + return SceneBoxConstraint( id=_identifier(data["id"], f"{path}.id"), center=_tuple3_from_json(data["center"], f"{path}.center"), @@ -671,6 +927,7 @@ def _box_constraint(value: object, path: str) -> SceneBoxConstraint: def _cylinder_constraint(value: object, path: str) -> SceneCylinderConstraint: data = _object(value, path) _keys(data, path, {"id", "center", "radius", "half_height", "coefficient", "allowed_region"}) + return SceneCylinderConstraint( id=_identifier(data["id"], f"{path}.id"), center=_tuple3_from_json(data["center"], f"{path}.center"), @@ -684,6 +941,7 @@ def _cylinder_constraint(value: object, path: str) -> SceneCylinderConstraint: def _constraints(value: object, path: str) -> SceneConstraints: data = _object(value, path) _keys(data, path, {"planes", "spheres", "boxes", "cylinders"}) + return SceneConstraints( planes=tuple( _plane_constraint(item, f"{path}.planes[{index}]") @@ -707,16 +965,19 @@ def _constraints(value: object, path: str) -> SceneConstraints: def _signal_grid(value: object, path: str) -> SceneSignalGrid | None: if value is None: return None + data = _object(value, path) _keys( data, path, {"signal_count", "shape", "origin", "spacing", "boundaries", "levels"}, ) - signal_count = _integer(data["signal_count"], f"{path}.signal_count", 1, _UINT32_MAX) + signal_count = _scene_channel_count(data["signal_count"], f"{path}.signal_count", 1) shape_values = _array(data["shape"], f"{path}.shape") + if len(shape_values) != 3: _fail(f"{path}.shape", "expected exactly three dimensions") + shape = cast( tuple[int, int, int], tuple( @@ -729,8 +990,10 @@ def _signal_grid(value: object, path: str) -> SceneSignalGrid | None: _keys(boundaries, f"{path}.boundaries", boundary_names) levels = _float_tuple(data["levels"], f"{path}.levels") expected_levels = signal_count * shape[0] * shape[1] * shape[2] + if len(levels) != expected_levels: _fail(f"{path}.levels", f"expected {expected_levels} values") + return SceneSignalGrid( signal_count=signal_count, shape=shape, @@ -746,11 +1009,321 @@ def _signal_grid(value: object, path: str) -> SceneSignalGrid | None: ) -def _frame(value: object, path: str) -> SceneFrame: +def _culture_to_json(culture: SceneCulture | None) -> JSONValue: + if culture is None: + return None + + return cast( + JSONValue, + { + "length_unit_m": culture.length_unit_m, + "time_unit_s": culture.time_unit_s, + "viscosity_pa_s": culture.viscosity_pa_s, + "density_kg_m3": culture.density_kg_m3, + "shape": list(culture.shape), + "origin": list(culture.origin), + "spacing": culture.spacing, + "obstacles": list(culture.obstacles), + "solutes": list(culture.solutes), + "solute_amount_units": list(culture.solute_amount_units) + if culture.solute_amount_units + else ["model"] * len(culture.solutes), + "cells": [ + { + "id": str(c.id), + "orientation": list(c.orientation), + "biochemical_volume": c.biochemical_volume, + "species_amounts": list(c.species_amounts), + "dry_biomass_g": c.dry_biomass_g, + "realized_specific_rate_per_hour": c.realized_specific_rate_per_hour, + "biomass_produced_g": c.biomass_produced_g, + "uptake_totals": list(c.uptake_totals) + if c.uptake_totals + else [0.0] * len(culture.solutes), + } + for c in culture.cells + ], + "fragments": [ + { + "site": f.site, + "component": f.component, + "volume": f.volume, + "centroid": list(f.centroid), + "amounts": list(f.amounts), + } + for f in culture.fragments + ], + "reservoirs": [ + {"name": r.name, "amounts": list(r.amounts)} for r in culture.reservoirs + ], + "max_speed_m_s": culture.max_speed_m_s, + "flow_relative_residual": culture.flow_relative_residual, + "maximum_volume_residual": culture.maximum_volume_residual, + }, + ) + + +def _culture_positive(data: dict[str, object], path: str, key: str) -> float: + number = _number(data[key], f"{path}.{key}") + + if number <= 0: + _fail(f"{path}.{key}", "must be positive") + + return number + + +def _culture_amounts( + value: object, field: str, count: int, signed: bool = False +) -> tuple[float, ...]: + result = tuple(_number(x, f"{field}[{i}]") for i, x in enumerate(_array(value, field))) + + if len(result) != count or (not signed and any(x < 0 for x in result)): + _fail(field, "invalid culture amount array") + + return result + + +def _culture_lattice(data: dict[str, object], path: str) -> tuple[tuple[int, ...], tuple[int, ...]]: + shape_items = _array(data["shape"], f"{path}.shape") + + if len(shape_items) != 3: + _fail(f"{path}.shape", "expected three dimensions") + + shape = tuple( + _integer(x, f"{path}.shape[{i}]", 2, _UINT32_MAX) for i, x in enumerate(shape_items) + ) + site_count = math.prod(shape) + obstacles = tuple( + _integer(x, f"{path}.obstacles[{i}]", 0, 1) + for i, x in enumerate(_array(data["obstacles"], f"{path}.obstacles")) + ) + + if obstacles and len(obstacles) != site_count: + _fail(f"{path}.obstacles", "obstacle count differs from grid") + + return shape, obstacles + + +def _culture_cell( + item: object, field: str, species_count: int, signal_count: int, version: int +) -> SceneCultureCell: + c = _object(item, field) + _keys( + c, + field, + {"id", "orientation", "biochemical_volume", "species_amounts"} + | ( + { + "dry_biomass_g", + "realized_specific_rate_per_hour", + "biomass_produced_g", + "uptake_totals", + } + if version >= 5 + else set() + ), + ) + mass = ( + None + if version < 5 or c["dry_biomass_g"] is None + else _number(c["dry_biomass_g"], f"{field}.dry_biomass_g") + ) + rate = ( + _number(c["realized_specific_rate_per_hour"], f"{field}.realized_specific_rate_per_hour") + if version >= 5 + else 0.0 + ) + produced = ( + _number(c["biomass_produced_g"], f"{field}.biomass_produced_g") if version >= 5 else 0.0 + ) + + if (mass is not None and mass <= 0) or rate < 0 or produced < 0: + _fail(field, "invalid biomass or realized growth") + + q = _culture_amounts(c["orientation"], f"{field}.orientation", 4, True) + + if abs(sum(x * x for x in q) - 1) > 1e-10: + _fail(f"{field}.orientation", "quaternion must have unit norm") + + biomass = _number(c["biochemical_volume"], f"{field}.biochemical_volume") + + if biomass <= 0: + _fail(f"{field}.biochemical_volume", "must be positive") + + return SceneCultureCell( + _identifier(c["id"], f"{field}.id"), + cast(tuple[float, float, float, float], q), + biomass, + _culture_amounts(c["species_amounts"], f"{field}.species_amounts", species_count), + mass, + rate, + produced, + _culture_amounts(c["uptake_totals"], f"{field}.uptake_totals", signal_count) + if version >= 5 + else (0.0,) * signal_count, + ) + + +def _culture_fragments( + data: dict[str, object], path: str, site_count: int, signal_count: int +) -> list[SceneFluidFragment]: + fragments: list[SceneFluidFragment] = [] + + for i, item in enumerate(_array(data["fragments"], f"{path}.fragments")): + field = f"{path}.fragments[{i}]" + f = _object(item, field) + _keys(f, field, {"site", "component", "volume", "centroid", "amounts"}) + volume = _number(f["volume"], f"{field}.volume") + + if volume <= 0: + _fail(f"{field}.volume", "must be positive") + + fragments.append( + SceneFluidFragment( + _integer(f["site"], f"{field}.site", 0, site_count - 1), + _integer(f["component"], f"{field}.component", 0, _UINT32_MAX), + volume, + cast( + tuple[float, float, float], + _culture_amounts(f["centroid"], f"{field}.centroid", 3, True), + ), + _culture_amounts(f["amounts"], f"{field}.amounts", signal_count), + ) + ) + + return fragments + + +def _culture_solutes(data: dict[str, object], path: str, signal_count: int) -> tuple[str, ...]: + solutes = tuple( + _string(x, f"{path}.solutes[{i}]") + for i, x in enumerate(_array(data["solutes"], f"{path}.solutes")) + ) + + if ( + len(solutes) != signal_count + or len(set(solutes)) != len(solutes) + or any(not x for x in solutes) + ): + _fail(f"{path}.solutes", "invalid culture solute names") + + return solutes + + +def _culture( + value: object, path: str, species_count: int, signal_count: int, version: int = 5 +) -> SceneCulture | None: + if value is None: + return None + + data = _object(value, path) + _keys( + data, + path, + { + "length_unit_m", + "time_unit_s", + "viscosity_pa_s", + "density_kg_m3", + "shape", + "origin", + "spacing", + "obstacles", + "solutes", + "cells", + "fragments", + "reservoirs", + "max_speed_m_s", + "flow_relative_residual", + "maximum_volume_residual", + } + | ({"solute_amount_units"} if version >= 5 else set()), + ) + + shape, obstacles = _culture_lattice(data, path) + site_count = math.prod(shape) + + solutes = _culture_solutes(data, path, signal_count) + + if version >= 5: + units = _array(data["solute_amount_units"], f"{path}.solute_amount_units") + + if len(units) != signal_count or any(u not in ("mol", "g", "model") for u in units): + _fail(f"{path}.solute_amount_units", "invalid solute amount units") + + cells: list[SceneCultureCell] = [] + + for i, item in enumerate(_array(data["cells"], f"{path}.cells")): + cells.append( + _culture_cell(item, f"{path}.cells[{i}]", species_count, signal_count, version) + ) + + fragments = _culture_fragments(data, path, site_count, signal_count) + + reservoirs: list[SceneChemicalTransfer] = [] + + for i, item in enumerate(_array(data["reservoirs"], f"{path}.reservoirs")): + field = f"{path}.reservoirs[{i}]" + r = _object(item, field) + _keys(r, field, {"name", "amounts"}) + reservoirs.append( + SceneChemicalTransfer( + _string(r["name"], f"{field}.name"), + _culture_amounts(r["amounts"], f"{field}.amounts", signal_count, True), + ) + ) + + return SceneCulture( + _culture_positive(data, path, "length_unit_m"), + _culture_positive(data, path, "time_unit_s"), + _culture_positive(data, path, "viscosity_pa_s"), + _culture_positive(data, path, "density_kg_m3"), + cast(tuple[int, int, int], shape), + _tuple3_from_json(data["origin"], f"{path}.origin"), + _culture_positive(data, path, "spacing"), + obstacles, + solutes, + tuple(cells), + tuple(fragments), + tuple(reservoirs), + _number(data["max_speed_m_s"], f"{path}.max_speed_m_s"), + _number(data["flow_relative_residual"], f"{path}.flow_relative_residual"), + _number(data["maximum_volume_residual"], f"{path}.maximum_volume_residual"), + tuple( + _string(x, f"{path}.solute_amount_units") + for x in _array(data["solute_amount_units"], f"{path}.solute_amount_units") + ) + if version >= 5 + else ("model",) * signal_count, + ) + + +def _frame(value: object, path: str, schema_version: int) -> SceneFrame: data = _object(value, path) - _keys(data, path, {"time", "backend", "species_count", "cells", "constraints", "signal_grid"}) - species_count = _integer(data["species_count"], f"{path}.species_count", 0, _UINT32_MAX) + keys = {"time", "backend", "species_count", "cells", "constraints", "signal_grid"} + + if schema_version >= 3: + keys.add("channel_metadata") + + if schema_version >= 4: + keys.add("media" if schema_version == 4 else "culture") + + _keys(data, path, keys) + species_count = _scene_channel_count(data["species_count"], f"{path}.species_count") + signal_grid = _signal_grid(data["signal_grid"], f"{path}.signal_grid") + signal_count = signal_grid.signal_count if signal_grid else 0 + + try: + labels = ( + ChannelMetadata.from_json(data["channel_metadata"], species_count, signal_count) + if schema_version >= 3 + else ChannelMetadata().resolved(species_count, signal_count) + ) + except ChannelMetadataError as error: + raise SceneError(str(error)) from error + frame = SceneFrame( + channel_metadata=labels, time=_number(data["time"], f"{path}.time"), backend=_backend(data["backend"], f"{path}.backend"), species_count=species_count, @@ -759,167 +1332,283 @@ def _frame(value: object, path: str) -> SceneFrame: for index, item in enumerate(_array(data["cells"], f"{path}.cells")) ), constraints=_constraints(data["constraints"], f"{path}.constraints"), - signal_grid=_signal_grid(data["signal_grid"], f"{path}.signal_grid"), + signal_grid=signal_grid, + culture=_culture( + data["media" if schema_version == 4 else "culture"], + f"{path}.culture", + species_count, + signal_count, + schema_version, + ) + if schema_version >= 4 + else None, ) _validate_frame(frame) + return frame def _validate_boundary(boundary: SceneGridBoundary, signal_count: int, path: str) -> None: if boundary.kind not in _BOUNDARY_KINDS: _fail(f"{path}.kind", f"unknown boundary kind {boundary.kind!r}") + expected = signal_count if boundary.kind == "fixed" else 0 + if len(boundary.values) != expected: _fail(f"{path}.values", f"expected {expected} values for {boundary.kind} boundary") + for index, value in enumerate(boundary.values): _number(value, f"{path}.values[{index}]", float32=True) -def _validate_frame(frame: SceneFrame) -> None: - _number(frame.time, "$.frame.time") - if frame.time < 0.0: - _fail("$.frame.time", "must be non-negative") - if frame.backend.kind not in _BACKEND_KINDS: - _fail("$.frame.backend.kind", f"unknown backend kind {frame.backend.kind!r}") - if not frame.backend.name or not frame.backend.device: - _fail("$.frame.backend", "name and device must not be empty") - _integer(frame.backend.device_index, "$.frame.backend.device_index", 0, _UINT32_MAX) - _boolean(frame.backend.native, "$.frame.backend.native") - _integer(frame.species_count, "$.frame.species_count", 0, _UINT32_MAX) +def _validate_scene_cell( + cell: SceneCell, index: int, identifiers: set[int], species_count: int +) -> None: + path = f"$.frame.cells[{index}]" - identifiers: set[int] = set() - for index, cell in enumerate(frame.cells): - path = f"$.frame.cells[{index}]" - if cell.slot != index: - _fail(f"{path}.slot", "cells must be compact and ordered by slot") - _integer(cell.id, f"{path}.id", 1, _UINT64_MAX) - if cell.id in identifiers: - _fail(f"{path}.id", "duplicate cell identifier") - identifiers.add(cell.id) - if cell.parent_id is not None: - _integer(cell.parent_id, f"{path}.parent_id", 1, _UINT64_MAX) - if cell.parent_id >= cell.id: - _fail(f"{path}.parent_id", "must precede the child identifier") - for vector_name, vector in (("position", cell.position), ("direction", cell.direction)): - if len(vector) != 3: - _fail(f"{path}.{vector_name}", "expected exactly three values") - for component, value in enumerate(vector): - _number(value, f"{path}.{vector_name}[{component}]", float32=True) - direction_norm = math.sqrt(sum(value * value for value in cell.direction)) - if abs(direction_norm - 1.0) > 1.0e-5: - _fail(f"{path}.direction", "must be normalized") - length = _number(cell.length, f"{path}.length", float32=True) - radius = _number(cell.radius, f"{path}.radius", float32=True) - if length < 0.0: - _fail(f"{path}.length", "must be non-negative") - if radius <= 0.0: - _fail(f"{path}.radius", "must be positive") - _number(cell.growth_rate, f"{path}.growth_rate", float32=True) - _integer(cell.cell_type, f"{path}.cell_type", _INT32_MIN, _INT32_MAX) - _boolean(cell.fixed, f"{path}.fixed") - if len(cell.species) != frame.species_count: - _fail(f"{path}.species", f"expected {frame.species_count} values") - for species_index, level in enumerate(cell.species): - _number(level, f"{path}.species[{species_index}]", float32=True) + if cell.slot != index: + _fail(f"{path}.slot", "cells must be compact and ordered by slot") - constraint_ids: set[int] = set() + _integer(cell.id, f"{path}.id", 1, _UINT64_MAX) + + if cell.id in identifiers: + _fail(f"{path}.id", "duplicate cell identifier") + + identifiers.add(cell.id) - def _check_constraint_id(identifier: int, path: str) -> None: - _integer(identifier, path, 1, _UINT64_MAX) - if identifier in constraint_ids: - _fail(path, "duplicate constraint identifier") - constraint_ids.add(identifier) + if cell.parent_id is not None: + _integer(cell.parent_id, f"{path}.parent_id", 1, _UINT64_MAX) - def _check_tuple3(vector: tuple[float, float, float], path: str) -> None: + if cell.parent_id >= cell.id: + _fail(f"{path}.parent_id", "must precede the child identifier") + + for vector_name, vector in (("position", cell.position), ("direction", cell.direction)): if len(vector) != 3: - _fail(path, "expected exactly three values") + _fail(f"{path}.{vector_name}", "expected exactly three values") + for component, value in enumerate(vector): - _number(value, f"{path}[{component}]", float32=True) + _number(value, f"{path}.{vector_name}[{component}]", float32=True) + + direction_norm = math.sqrt(sum(value * value for value in cell.direction)) + + if abs(direction_norm - 1.0) > 1.0e-5: + _fail(f"{path}.direction", "must be normalized") + + length = _number(cell.length, f"{path}.length", float32=True) + radius = _number(cell.radius, f"{path}.radius", float32=True) + + if length < 0.0: + _fail(f"{path}.length", "must be non-negative") + + if radius <= 0.0: + _fail(f"{path}.radius", "must be positive") + + _number(cell.growth_rate, f"{path}.growth_rate", float32=True) + _integer(cell.cell_type, f"{path}.cell_type", _INT32_MIN, _INT32_MAX) + _boolean(cell.fixed, f"{path}.fixed") + + if len(cell.species) != species_count: + _fail(f"{path}.species", f"expected {species_count} values") + + for species_index, level in enumerate(cell.species): + _number(level, f"{path}.species[{species_index}]", float32=True) - for index, plane in enumerate(frame.constraints.planes): + +def _check_constraint_id(identifier: int, path: str, constraint_ids: set[int]) -> None: + _integer(identifier, path, 1, _UINT64_MAX) + + if identifier in constraint_ids: + _fail(path, "duplicate constraint identifier") + + constraint_ids.add(identifier) + + +def _check_tuple3(vector: tuple[float, float, float], path: str) -> None: + if len(vector) != 3: + _fail(path, "expected exactly three values") + + for component, value in enumerate(vector): + _number(value, f"{path}[{component}]", float32=True) + + +def _validate_scene_planes(constraints: SceneConstraints, constraint_ids: set[int]) -> None: + for index, plane in enumerate(constraints.planes): path = f"$.frame.constraints.planes[{index}]" - _check_constraint_id(plane.id, f"{path}.id") + _check_constraint_id(plane.id, f"{path}.id", constraint_ids) _check_tuple3(plane.point, f"{path}.point") _check_tuple3(plane.inward_normal, f"{path}.inward_normal") normal_norm = math.sqrt(sum(value * value for value in plane.inward_normal)) + if abs(normal_norm - 1.0) > 1.0e-5: _fail(f"{path}.inward_normal", "must be normalized") + if _number(plane.coefficient, f"{path}.coefficient", float32=True) <= 0.0: _fail(f"{path}.coefficient", "must be positive") - for index, sphere in enumerate(frame.constraints.spheres): + + +def _validate_scene_spheres(constraints: SceneConstraints, constraint_ids: set[int]) -> None: + for index, sphere in enumerate(constraints.spheres): path = f"$.frame.constraints.spheres[{index}]" - _check_constraint_id(sphere.id, f"{path}.id") + _check_constraint_id(sphere.id, f"{path}.id", constraint_ids) _check_tuple3(sphere.center, f"{path}.center") + if _number(sphere.radius, f"{path}.radius", float32=True) <= 0.0: _fail(f"{path}.radius", "must be positive") + if _number(sphere.coefficient, f"{path}.coefficient", float32=True) <= 0.0: _fail(f"{path}.coefficient", "must be positive") + if sphere.allowed_region not in _REGION_KINDS: _fail(f"{path}.allowed_region", f"unknown region kind {sphere.allowed_region!r}") - for index, box in enumerate(frame.constraints.boxes): + + +def _validate_scene_boxes(constraints: SceneConstraints, constraint_ids: set[int]) -> None: + for index, box in enumerate(constraints.boxes): path = f"$.frame.constraints.boxes[{index}]" - _check_constraint_id(box.id, f"{path}.id") + _check_constraint_id(box.id, f"{path}.id", constraint_ids) _check_tuple3(box.center, f"{path}.center") _check_tuple3(box.half_extents, f"{path}.half_extents") + if any(extent <= 0.0 for extent in box.half_extents): _fail(f"{path}.half_extents", "values must be positive") + if _number(box.coefficient, f"{path}.coefficient", float32=True) <= 0.0: _fail(f"{path}.coefficient", "must be positive") + if box.allowed_region not in _REGION_KINDS: _fail(f"{path}.allowed_region", f"unknown region kind {box.allowed_region!r}") - for index, cylinder in enumerate(frame.constraints.cylinders): + +def _validate_scene_cylinders(constraints: SceneConstraints, constraint_ids: set[int]) -> None: + for index, cylinder in enumerate(constraints.cylinders): path = f"$.frame.constraints.cylinders[{index}]" - _check_constraint_id(cylinder.id, f"{path}.id") + _check_constraint_id(cylinder.id, f"{path}.id", constraint_ids) _check_tuple3(cylinder.center, f"{path}.center") + if _number(cylinder.radius, f"{path}.radius", float32=True) <= 0.0: _fail(f"{path}.radius", "must be positive") + if _number(cylinder.half_height, f"{path}.half_height", float32=True) <= 0.0: _fail(f"{path}.half_height", "must be positive") + if _number(cylinder.coefficient, f"{path}.coefficient", float32=True) <= 0.0: _fail(f"{path}.coefficient", "must be positive") + if cylinder.allowed_region not in _REGION_KINDS: _fail(f"{path}.allowed_region", f"unknown region kind {cylinder.allowed_region!r}") - grid = frame.signal_grid + +def _validate_scene_grid(grid: SceneSignalGrid | None) -> None: + if grid is None: return + _integer(grid.signal_count, "$.frame.signal_grid.signal_count", 1, _UINT32_MAX) + if len(grid.shape) != 3: _fail("$.frame.signal_grid.shape", "expected exactly three dimensions") + for index, dimension in enumerate(grid.shape): _integer(dimension, f"$.frame.signal_grid.shape[{index}]", 1, _UINT32_MAX) + for vector_name, vector in (("origin", grid.origin), ("spacing", grid.spacing)): if len(vector) != 3: _fail(f"$.frame.signal_grid.{vector_name}", "expected exactly three values") + for component, value in enumerate(vector): number = _number( value, f"$.frame.signal_grid.{vector_name}[{component}]", float32=True, ) + if vector_name == "spacing" and number <= 0.0: _fail(f"$.frame.signal_grid.{vector_name}[{component}]", "must be positive") + for name in ("x_lower", "x_upper", "y_lower", "y_upper", "z_lower", "z_upper"): _validate_boundary( cast(SceneGridBoundary, getattr(grid, name)), grid.signal_count, f"$.frame.signal_grid.boundaries.{name}", ) + expected_levels = grid.signal_count * grid.shape[0] * grid.shape[1] * grid.shape[2] + if len(grid.levels) != expected_levels: _fail("$.frame.signal_grid.levels", f"expected {expected_levels} values") + for index, level in enumerate(grid.levels): _number(level, f"$.frame.signal_grid.levels[{index}]", float32=True) +def _validate_frame(frame: SceneFrame) -> None: + if frame.culture is not None: + checked = _culture( + _culture_to_json(frame.culture), + "$.frame.culture", + frame.species_count, + frame.signal_grid.signal_count if frame.signal_grid else 0, + ) + + if checked is None or tuple(c.id for c in checked.cells) != tuple( + c.id for c in frame.cells + ): + _fail("$.frame.culture.cells", "culture cells must match frame cells") + + _scene_channel_count(frame.species_count, "$.frame.species_count") + + if frame.signal_grid is not None: + _scene_channel_count(frame.signal_grid.signal_count, "$.frame.signal_grid.signal_count", 1) + + try: + frame.channel_metadata.resolved( + frame.species_count, frame.signal_grid.signal_count if frame.signal_grid else 0 + ) + except ChannelMetadataError as error: + raise SceneError(str(error)) from error + + _number(frame.time, "$.frame.time") + + if frame.time < 0.0: + _fail("$.frame.time", "must be non-negative") + + if frame.backend.kind not in _BACKEND_KINDS: + _fail("$.frame.backend.kind", f"unknown backend kind {frame.backend.kind!r}") + + if not frame.backend.name or not frame.backend.device: + _fail("$.frame.backend", "name and device must not be empty") + + _integer(frame.backend.device_index, "$.frame.backend.device_index", 0, _UINT32_MAX) + _boolean(frame.backend.native, "$.frame.backend.native") + _integer(frame.species_count, "$.frame.species_count", 0, _UINT32_MAX) + + identifiers: set[int] = set() + + for index, cell in enumerate(frame.cells): + _validate_scene_cell(cell, index, identifiers, frame.species_count) + + constraint_ids: set[int] = set() + + _validate_scene_planes(frame.constraints, constraint_ids) + + _validate_scene_spheres(frame.constraints, constraint_ids) + + _validate_scene_boxes(frame.constraints, constraint_ids) + + _validate_scene_cylinders(frame.constraints, constraint_ids) + + _validate_scene_grid(frame.signal_grid) + + def parse_scene(source: str | bytes) -> SceneFrame: """Decode a scene document without importing models or executable state.""" if not source: raise SceneError("scene is empty") + if len(source) > MAX_SCENE_BYTES: raise SceneError(f"scene exceeds the {MAX_SCENE_BYTES}-byte limit") + try: decoded = json.loads( source, @@ -933,39 +1622,49 @@ def parse_scene(source: str | bytes) -> SceneFrame: root = _object(cast(object, decoded), "$") _keys(root, "$", {"format", "version", "producer", "integrity", "frame"}) + if _string(root["format"], "$.format") not in (SCENE_FORMAT, "cellmodeller2-scene"): _fail("$.format", "not a MicroSimulator scene") + schema_version = _integer(root["version"], "$.version", 0, _UINT32_MAX) - if schema_version != SCENE_VERSION: + + if schema_version not in {2, 3, 4, SCENE_VERSION}: _fail("$.version", f"unsupported scene version {schema_version}") + producer = _object(root["producer"], "$.producer") _keys(producer, "$.producer", {"name", "version"}) _string(producer["name"], "$.producer.name") _string(producer["version"], "$.producer.version") integrity = _object(root["integrity"], "$.integrity") _keys(integrity, "$.integrity", {"algorithm", "frame"}) + if _string(integrity["algorithm"], "$.integrity.algorithm") != "sha256": _fail("$.integrity.algorithm", "unsupported integrity algorithm") + expected_digest = _string(integrity["frame"], "$.integrity.frame") - actual_digest = hashlib.sha256( - _canonical_json(cast(JSONValue, root["frame"])) - ).hexdigest() + actual_digest = hashlib.sha256(_canonical_json(cast(JSONValue, root["frame"]))).hexdigest() + if not hmac.compare_digest(actual_digest, expected_digest): _fail("$.integrity.frame", "frame digest does not match") - return _frame(root["frame"], "$.frame") + + return _frame(root["frame"], "$.frame", schema_version) def load_scene(path: str | os.PathLike[str]) -> SceneFrame: """Load and validate a bounded scene document.""" source = Path(path) + try: with source.open("rb") as stream: encoded = stream.read(MAX_SCENE_BYTES + 1) + if not encoded: raise SceneError("scene is empty") + if len(encoded) > MAX_SCENE_BYTES: raise SceneError(f"scene exceeds the {MAX_SCENE_BYTES}-byte limit") except OSError as error: raise SceneError(f"could not read scene {source}") from error + return parse_scene(encoded) diff --git a/python/src/microsimulator/schedules.py b/python/src/microsimulator/schedules.py new file mode 100644 index 0000000..bdc52f8 --- /dev/null +++ b/python/src/microsimulator/schedules.py @@ -0,0 +1,50 @@ +"""Immutable, right-continuous simulation schedules, authored in seconds.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import cast + + +@dataclass(frozen=True, slots=True) +class PiecewiseConstant[T]: + initial: T + changes: tuple[tuple[float, T], ...] = () + + def __post_init__(self) -> None: + object.__setattr__(self, "changes", tuple((float(t), v) for t, v in self.changes)) + previous = 0.0 + + for time, _ in self.changes: + if not math.isfinite(time) or time <= previous: + raise ValueError("schedule times must be finite, positive and strictly increasing") + + previous = time + + def at(self, seconds: float) -> T: + result = self.initial + + for time, value in self.changes: + if time > seconds: + break + + result = value + + return result + + +def at[T](value: T | PiecewiseConstant[T], seconds: float) -> T: + return ( + cast(PiecewiseConstant[T], value).at(seconds) + if isinstance(value, PiecewiseConstant) + else cast(T, value) + ) + + +def times[T](value: T | PiecewiseConstant[T]) -> tuple[float, ...]: + return ( + tuple(t for t, _ in cast(PiecewiseConstant[T], value).changes) + if isinstance(value, PiecewiseConstant) + else () + ) diff --git a/python/src/microsimulator/stokes.py b/python/src/microsimulator/stokes.py index 34aa814..dcad5b2 100644 --- a/python/src/microsimulator/stokes.py +++ b/python/src/microsimulator/stokes.py @@ -38,9 +38,14 @@ from __future__ import annotations from collections.abc import Iterable, Sequence +from collections.abc import Mapping as _Mapping +from dataclasses import dataclass as _dataclass +from dataclasses import field as _field +from types import MappingProxyType as _MappingProxyType import numpy as np +from . import _core as _native from ._core import ( # pyright: ignore[reportMissingModuleSource] BackendKind, FlowAxis, @@ -52,11 +57,16 @@ ) from .flow import ( FlowError, + FluidDomain, + FluidProperties, + Pressure, + VolumeFlow, _flow_axis_index, _kozeny_carman_drag, _RodLike, colony_volume_fraction, ) +from .schedules import PiecewiseConstant, at _NATIVE_AXES = {"x": FlowAxis.X, "y": FlowAxis.Y, "z": FlowAxis.Z} @@ -81,15 +91,18 @@ def colony_drag( if not 0 < max_volume_fraction < 1: raise FlowError("maximum volume fraction must lie strictly between zero and one") + fraction = np.minimum( colony_volume_fraction(spec, cells, averaging_radius=averaging_radius), max_volume_fraction ) drag = _kozeny_carman_drag(fraction, drag_coefficient) obstacles = spec.obstacles + if obstacles: dims = (spec.shape.x, spec.shape.y, spec.shape.z) solid = np.asarray(obstacles, dtype=np.uint8).reshape(dims) != 0 drag[solid] = 0.0 + return [float(value) for value in drag.ravel()] @@ -128,6 +141,7 @@ def solve_stokes_field( selected = ( simulation if simulation is not None else Simulation(backend, device_index=device_index) ) + try: result = selected.solve_resolved_flow( spec, @@ -136,4 +150,58 @@ def solve_stokes_field( ) except (OverflowError, RuntimeError, ValueError) as error: raise FlowError(str(error)) from error + return result.field, result.report + + +@_dataclass(frozen=True, slots=True) +class StokesFlow: + """Constant-property incompressible Stokes flow with physical port values.""" + + domain: FluidDomain + properties: FluidProperties + boundaries: _Mapping[str, Pressure | VolumeFlow | PiecewiseConstant[Pressure | VolumeFlow]] + solver: _native.LinearSolveParameters = _field(default_factory=_native.LinearSolveParameters) + geometry: _native.FluidGeometryParameters = _field( + default_factory=_native.FluidGeometryParameters + ) + stepping: _native.FluidBodyStepParameters = _field( + default_factory=_native.FluidBodyStepParameters + ) + + def __post_init__(self) -> None: + object.__setattr__(self, "boundaries", _MappingProxyType(dict(self.boundaries))) + + if set(self.boundaries) != set(self.domain.ports): + raise ValueError("each hydraulic port needs exactly one boundary condition") + + def native_ports(self, seconds: float = 0.0) -> list[_native.FlowPort]: + result: list[_native.FlowPort] = [] + + for name, patch in self.domain.ports.items(): + port = _native.FlowPort() + port.name, port.axis, port.upper = name, _NATIVE_AXES[patch.axis], patch.upper + port.sites = list(patch.sites) + drive = at(self.boundaries[name], seconds) + + if isinstance(drive, Pressure): + port.kind, port.value = _native.FlowPortKind.PRESSURE, drive.pa + elif isinstance(drive, VolumeFlow): # pyright: ignore[reportUnnecessaryIsInstance] + port.kind = _native.FlowPortKind.FLOW_RATE + port.value = drive.ul_per_min * 1e-9 / 60 * (-1 if drive.direction == "in" else 1) + else: + raise TypeError("hydraulic boundary must specify pressure or volume flow") + + result.append(port) + + return result + + def solve( + self, *, backend: BackendKind = BackendKind.CPU, device_index: int = 0 + ) -> _native.FluidFlowResult: + return _native.StokesFlowSolver(backend, device_index).solve( + self.domain.native_grid(), + self.properties.native(), + self.native_ports(), + self.solver, + ) diff --git a/python/src/microsimulator/transport.py b/python/src/microsimulator/transport.py new file mode 100644 index 0000000..259033b --- /dev/null +++ b/python/src/microsimulator/transport.py @@ -0,0 +1,67 @@ +"""Solute identities, diffusion and chemical boundary authoring.""" + +from __future__ import annotations + +import math +from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType +from typing import Literal + +from .media import Medium +from .schedules import PiecewiseConstant + + +@dataclass(frozen=True, slots=True) +class Solute: + id: str + diffusion_um2_per_s: float + amount_unit: Literal["mol", "g"] = "mol" + label: str | None = None + molar_mass_g_per_mol: float | None = None + + def __post_init__(self) -> None: + if ( + not self.id + or not math.isfinite(self.diffusion_um2_per_s) + or self.diffusion_um2_per_s < 0 + ): + raise ValueError("invalid solute identifier or diffusion coefficient") + + if self.amount_unit not in ("mol", "g"): + raise ValueError("solute amount_unit must be mol or g") + + if self.molar_mass_g_per_mol is not None and ( + not math.isfinite(self.molar_mass_g_per_mol) or self.molar_mass_g_per_mol <= 0 + ): + raise ValueError("molecular weight must be finite and positive") + + +@dataclass(frozen=True, slots=True) +class AdvectiveFeed: + medium: Medium | PiecewiseConstant[Medium] + + +@dataclass(frozen=True, slots=True) +class ReservoirContact: + medium: Medium | PiecewiseConstant[Medium] + + +@dataclass(frozen=True, slots=True) +class ConvectiveOutflow: + backflow: Literal["error"] | Medium | PiecewiseConstant[Medium] = "error" + + +@dataclass(frozen=True, slots=True) +class SoluteTransport: + solutes: tuple[Solute, ...] + boundaries: Mapping[str, AdvectiveFeed | ReservoirContact | ConvectiveOutflow] + initial_medium: Medium + + def __post_init__(self) -> None: + object.__setattr__(self, "solutes", tuple(self.solutes)) + object.__setattr__(self, "boundaries", MappingProxyType(dict(self.boundaries))) + ids = [s.id for s in self.solutes] + + if len(set(ids)) != len(ids): + raise ValueError("duplicate solute identifier") diff --git a/python/src/microsimulator/viewer_server.py b/python/src/microsimulator/viewer_server.py index b0d6bf2..9019edd 100644 --- a/python/src/microsimulator/viewer_server.py +++ b/python/src/microsimulator/viewer_server.py @@ -13,20 +13,24 @@ from dataclasses import dataclass from functools import partial from pathlib import Path +from threading import Event from typing import Any, Literal, cast from urllib.parse import urlencode from aiohttp import WSMsgType, web from .checkpoint import JSONValue, save_checkpoint -from .runner import RunnableModel, controller_state, native_simulation +from .runner import RunnableModel, controller_state, model_channel_metadata, native_simulation from .scene import capture_scene, dumps_scene MAX_COMMAND_BYTES = 4096 MAX_STEP_BATCH = 10_000 +MAX_QUEUED_COMMANDS = 32 +SOCKET_SEND_TIMEOUT = 1.0 +SOCKET_CLOSE_TIMEOUT = 1.0 type ModelFactory = Callable[[], tuple[RunnableModel, Mapping[str, JSONValue]]] -type CommandName = Literal["frame", "step", "play", "pause", "reset", "checkpoint"] +type CommandName = Literal["frame", "step", "play", "pause", "reset", "checkpoint", "stop"] class LiveViewerError(RuntimeError): @@ -44,18 +48,24 @@ def parse_command(encoded: str) -> LiveCommand: if len(encoded.encode("utf-8")) > MAX_COMMAND_BYTES: raise LiveViewerError(f"command exceeds the {MAX_COMMAND_BYTES}-byte limit") + try: value = cast(object, json.loads(encoded)) except (json.JSONDecodeError, RecursionError) as error: raise LiveViewerError("command is not valid JSON") from error + if not isinstance(value, dict): raise LiveViewerError("command must be a JSON object") + command = cast(dict[object, object], value) name = command.get("type") + if name == "step": if set(command) - {"type", "steps"}: raise LiveViewerError("step command has unknown fields") + steps = command.get("steps", 1) + if ( not isinstance(steps, int) or isinstance(steps, bool) @@ -63,12 +73,17 @@ def parse_command(encoded: str) -> LiveCommand: or steps > MAX_STEP_BATCH ): raise LiveViewerError(f"step count must be an integer in [1, {MAX_STEP_BATCH}]") + return LiveCommand("step", steps) - names: set[str] = {"frame", "play", "pause", "reset", "checkpoint"} + + names: set[str] = {"frame", "play", "pause", "reset", "checkpoint", "stop"} + if not isinstance(name, str) or name not in names: raise LiveViewerError("unknown command type") + if set(command) != {"type"}: raise LiveViewerError(f"{name} command has unknown fields") + return LiveCommand(cast(CommandName, name)) @@ -84,6 +99,7 @@ def __init__( ) -> None: if not math.isfinite(dt) or dt <= 0.0: raise LiveViewerError("time step must be finite and positive") + self._factory = factory self._dt = dt self._checkpoint_output = ( @@ -92,6 +108,11 @@ def __init__( self._model, self._provenance = self._build() self._completed_steps = 0 self._revision = 0 + self._stop_requested = Event() + + def request_stop(self) -> None: + """Signal the worker without waiting for its current operation.""" + self._stop_requested.set() @property def completed_steps(self) -> int: @@ -104,14 +125,21 @@ def checkpoint_enabled(self) -> bool: def _build(self) -> tuple[RunnableModel, dict[str, JSONValue]]: model, provenance = self._factory() native_simulation(model).validate() + model_channel_metadata(model) + return model, dict(provenance) def step(self, steps: int = 1) -> None: if steps < 1 or steps > MAX_STEP_BATCH: raise LiveViewerError(f"step count must be in [1, {MAX_STEP_BATCH}]") + completed = 0 + try: for _ in range(steps): + if self._stop_requested.is_set(): + break + self._model.step(self._dt) completed += 1 self._completed_steps += 1 @@ -128,8 +156,10 @@ def reset(self) -> None: def checkpoint(self) -> Path: destination = self._checkpoint_output + if destination is None: raise LiveViewerError("checkpoint output is not configured") + provenance = dict(self._provenance) provenance["live_session"] = { "completed_steps": self._completed_steps, @@ -141,12 +171,22 @@ def checkpoint(self) -> Path: destination, provenance=provenance, controller=controller_state(self._model), + channel_metadata=model_channel_metadata(self._model), ) + return destination def frame_message(self, *, playing: bool) -> dict[str, JSONValue]: native = native_simulation(self._model) - scene = cast(dict[str, JSONValue], json.loads(dumps_scene(capture_scene(native)))) + scene = cast( + dict[str, JSONValue], + json.loads( + dumps_scene( + capture_scene(native, channel_metadata=model_channel_metadata(self._model)) + ) + ), + ) + return { "type": "frame", "revision": self._revision, @@ -163,22 +203,46 @@ class LiveController: def __init__(self, session: LiveSession, *, frame_steps: int = 1, fps: float = 30.0) -> None: if frame_steps < 1 or frame_steps > MAX_STEP_BATCH: raise LiveViewerError(f"frame step count must be in [1, {MAX_STEP_BATCH}]") + if not math.isfinite(fps) or fps <= 0.0 or fps > 240.0: raise LiveViewerError("frame rate must be finite and in (0, 240]") + self.session = session self.frame_steps = frame_steps self.frame_interval = 1.0 / fps self.playing = False self._sockets: set[web.WebSocketResponse] = set() + self._transports: dict[web.WebSocketResponse, asyncio.Transport | None] = {} self._play_task: asyncio.Task[None] | None = None self._play_wakeup = asyncio.Event() self._worker = ThreadPoolExecutor(max_workers=1, thread_name_prefix="microsimulator-live") self._operation_lock = asyncio.Lock() + self._close_task: asyncio.Task[None] | None = None + self.stopped = asyncio.Event() + + @property + def stopping(self) -> bool: + return self._close_task is not None + + def _require_active(self) -> None: + if self.stopping: + raise LiveViewerError("live session is stopping or stopped") async def _run(self, operation: Callable[..., Any], *arguments: object) -> Any: async with self._operation_lock: + self._require_active() loop = asyncio.get_running_loop() - return await loop.run_in_executor(self._worker, operation, *arguments) + work = loop.run_in_executor(self._worker, operation, *arguments) + + try: + return await asyncio.shield(work) + except asyncio.CancelledError: + # Canceling an asyncio waiter does not cancel native/thread work. + # Keep the operation lock until the worker has really finished. + with suppress(Exception): + await asyncio.shield(work) + + raise async def _message(self) -> dict[str, JSONValue]: return cast( @@ -187,42 +251,85 @@ async def _message(self) -> dict[str, JSONValue]: ) async def _send_frame(self, socket: web.WebSocketResponse) -> None: - await socket.send_str(json.dumps(await self._message(), separators=(",", ":"))) + encoded = json.dumps(await self._message(), separators=(",", ":")) + await asyncio.wait_for(socket.send_str(encoded), timeout=SOCKET_SEND_TIMEOUT) async def broadcast_frame(self) -> None: - if not self._sockets: + sockets = tuple(self._sockets) + + if not sockets: return + encoded = json.dumps(await self._message(), separators=(",", ":")) stale: list[web.WebSocketResponse] = [] - for socket in tuple(self._sockets): + + # A frame captured for earlier clients must not arrive ahead of a newly + # connected client's initial frame (or carry its stale playing flag). + for socket in sockets: + if self.stopping: + break + if socket.closed: stale.append(socket) continue + try: - await socket.send_str(encoded) + await asyncio.wait_for(socket.send_str(encoded), timeout=SOCKET_SEND_TIMEOUT) + except TimeoutError: + # Retain this socket for shutdown even if it cannot drain a + # frame; Stop must not wait indefinitely on frame delivery. + continue except (ConnectionError, RuntimeError): stale.append(socket) + except asyncio.CancelledError: + # Another writer can cancel aiohttp's shared drain waiter. + # An externally canceled task must still propagate cancellation. + task = asyncio.current_task() + + if task is not None and task.cancelling(): + raise + + transport = self._transports.get(socket) + + if transport is not None: + transport.abort() + + stale.append(socket) + self._sockets.difference_update(stale) async def _play(self) -> None: loop = asyncio.get_running_loop() + try: - while self.playing and self._sockets: + while self.playing and self._sockets and not self.stopping: started = loop.time() await self._run(self.session.step, self.frame_steps) + + if self.stopping: + break + await self.broadcast_frame() delay = self.frame_interval - (loop.time() - started) + if delay > 0.0: with suppress(TimeoutError): await asyncio.wait_for(self._play_wakeup.wait(), timeout=delay) + self._play_wakeup.clear() + except Exception as error: + if not self.stopping: + await self._broadcast({"type": "error", "message": str(error)}) finally: self.playing = False self._play_task = None async def play(self) -> None: + self._require_active() + if self.playing: return + self.playing = True self._play_wakeup.clear() self._play_task = asyncio.create_task(self._play(), name="microsimulator-live-play") @@ -232,12 +339,21 @@ async def pause(self, *, broadcast: bool = True) -> None: self.playing = False self._play_wakeup.set() task = self._play_task + if task is not None and task is not asyncio.current_task(): - await task - if broadcast: + await asyncio.shield(task) + + if broadcast and not self.stopping: await self.broadcast_frame() async def command(self, command: LiveCommand) -> str | None: + if command.name == "stop": + self.request_stop() + + return None + + self._require_active() + if command.name == "frame": await self.broadcast_frame() elif command.name == "step": @@ -254,25 +370,118 @@ async def command(self, command: LiveCommand) -> str | None: await self.broadcast_frame() else: destination = cast(Path, await self._run(self.session.checkpoint)) + return str(destination) + return None - async def connect(self, socket: web.WebSocketResponse) -> None: + async def connect( + self, socket: web.WebSocketResponse, transport: asyncio.Transport | None = None + ) -> None: + self._require_active() + stale = {client for client in self._sockets if client.closed} + + if stale: + self._sockets.difference_update(stale) + + if not self._sockets: + # The peer may receive its close handshake before the old + # request handler reaches finally. Honor last-client pause + # before admitting a replacement connection in that window. + await self.pause(broadcast=False) + self._require_active() + self._sockets.add(socket) + self._transports[socket] = transport await self._send_frame(socket) async def disconnect(self, socket: web.WebSocketResponse) -> None: self._sockets.discard(socket) - if not self._sockets: + self._transports.pop(socket, None) + + if not self._sockets and not self.stopping: await self.pause(broadcast=False) - async def close(self) -> None: + async def _broadcast(self, message: dict[str, JSONValue]) -> None: + async def send(socket: web.WebSocketResponse) -> None: + if not socket.closed: + try: + await asyncio.wait_for(socket.send_json(message), timeout=SOCKET_SEND_TIMEOUT) + except (ConnectionError, RuntimeError, TimeoutError): + pass + except asyncio.CancelledError: + task = asyncio.current_task() + + if task is not None and task.cancelling(): + raise + + transport = self._transports.get(socket) + + if transport is not None: + transport.abort() + + # Each receiver gets the same bounded opportunity to consume a message; + # unresponsive clients do not add serial delays to session shutdown. + await asyncio.gather(*(send(socket) for socket in tuple(self._sockets))) + + def request_stop(self) -> None: + """Begin idempotent shutdown outside any socket/command task.""" + + if self.stopping: + return + + self.session.request_stop() + self.playing = False + self._play_wakeup.set() + self._close_task = asyncio.create_task(self._close(), name="microsimulator-live-stop") + + async def _close(self) -> None: + await self._broadcast({"type": "session", "state": "stopping"}) await self.pause(broadcast=False) - sockets = tuple(self._sockets) + + # Wait for a manual batch, reset, frame capture, or atomic checkpoint. + # New/queued operations fail the admission check inside this lock. + async with self._operation_lock: + await asyncio.to_thread(self._worker.shutdown, wait=True, cancel_futures=True) + + await self._broadcast({"type": "session", "state": "stopped"}) + sockets = tuple((socket, self._transports.get(socket)) for socket in self._sockets) self._sockets.clear() - for socket in sockets: - await socket.close(code=1001, message=b"server shutdown") - self._worker.shutdown(wait=True, cancel_futures=True) + self._transports.clear() + await asyncio.gather(*(_close_socket(socket, transport) for socket, transport in sockets)) + self.stopped.set() + + async def close(self) -> None: + self.request_stop() + assert self._close_task is not None + await asyncio.shield(self._close_task) + + +async def _close_socket(socket: web.WebSocketResponse, transport: asyncio.Transport | None) -> None: + closed = False + + try: + # aiohttp applies its own timeout only AFTER writing/draining the close + # frame. Bound the whole operation, including that preceding drain. + await asyncio.wait_for( + socket.close(code=1000, message=b"session stopped"), timeout=SOCKET_CLOSE_TIMEOUT + ) + closed = True + except (ConnectionError, RuntimeError, TimeoutError): + pass + except asyncio.CancelledError: + # aiohttp shares one drain waiter between writes. A timed-out initial + # send may cancel that waiter, independently of this cleanup task. + # Treat that as a failed close, but preserve real task cancellation. + task = asyncio.current_task() + + if task is not None and task.cancelling(): + raise + finally: + if not closed and transport is not None: + # Transport.close() still tries to flush queued bytes; a receiver + # that never reads requires abort() to release the drain waiters. + transport.abort() _CONTROLLER_KEY = web.AppKey("microsimulator.controller", LiveController) @@ -281,40 +490,95 @@ async def close(self) -> None: def _authorized(request: web.Request) -> bool: token = request.query.get("token", "") + if not secrets.compare_digest(token, request.app[_TOKEN_KEY]): return False + origin = request.headers.get("Origin") + return origin == f"{request.scheme}://{request.host}" async def _websocket(request: web.Request) -> web.StreamResponse: if not _authorized(request): raise web.HTTPForbidden(text="invalid live-viewer authority") + + controller = request.app[_CONTROLLER_KEY] + + if controller.stopping: + raise web.HTTPServiceUnavailable(text="live session is stopping or stopped") + socket = web.WebSocketResponse(max_msg_size=MAX_COMMAND_BYTES, heartbeat=20.0) await socket.prepare(request) - controller = request.app[_CONTROLLER_KEY] - await controller.connect(socket) + commands: asyncio.Queue[LiveCommand] = asyncio.Queue(maxsize=MAX_QUEUED_COMMANDS) + + async def send_error(error: Exception) -> None: + if not socket.closed: + with suppress(ConnectionError, RuntimeError): + await socket.send_json({"type": "error", "message": str(error)}) + + async def execute_commands() -> None: + while True: + command = await commands.get() + + try: + result = await controller.command(command) + + if result is not None and not socket.closed: + await socket.send_json({"type": "checkpoint", "path": result}) + except Exception as error: + await send_error(error) + + # Read continuously so Stop on this socket can interrupt its own long batch. + # A bounded per-client queue retains ordinary command order without creating + # an unbounded number of tasks or blocking Stop behind queue backpressure. + consumer = asyncio.create_task(execute_commands(), name="microsimulator-live-commands") + try: + await controller.connect(socket, request.transport) + async for message in socket: if message.type is not WSMsgType.TEXT: if message.type is WSMsgType.ERROR: break + await socket.send_json({"type": "error", "message": "text commands required"}) continue + try: - result = await controller.command(parse_command(cast(str, message.data))) - if result is not None: - await socket.send_json( - {"type": "checkpoint", "path": result}, - dumps=lambda value: json.dumps(value, separators=(",", ":")), - ) + command = parse_command(cast(str, message.data)) + + if command.name == "stop": + controller.request_stop() + else: + if controller.stopping: + raise LiveViewerError("live session is stopping or stopped") + + if commands.full(): + raise LiveViewerError("too many queued commands") + + commands.put_nowait(command) except Exception as error: - await socket.send_json( - {"type": "error", "message": str(error)}, - dumps=lambda value: json.dumps(value, separators=(",", ":")), - ) + await send_error(error) + except (ConnectionError, TimeoutError): + # Initial-frame delivery is also bounded; a stalled receiver has no + # authority to keep an upgraded request alive during runner cleanup. + pass finally: - await controller.disconnect(socket) + consumer.cancel() + + try: + # Drop client authority and pause before waiting for canceled work + # to drain. Otherwise a reconnect during that wait keeps playback + # alive because the old socket still appears to be connected. + await controller.disconnect(socket) + finally: + try: + with suppress(asyncio.CancelledError): + await consumer + finally: + await _close_socket(socket, request.transport) + return socket @@ -331,9 +595,12 @@ def create_live_app( dist = Path(viewer_dist).resolve() index = dist / "index.html" assets = dist / "assets" + if not index.is_file() or not assets.is_dir(): raise LiveViewerError(f"viewer distribution is incomplete: {dist}") + authority = token or secrets.token_urlsafe(32) + if len(authority) < 32: raise LiveViewerError("live-viewer token must contain at least 32 characters") @@ -347,6 +614,7 @@ async def index_response(_: web.Request) -> web.FileResponse: response.headers["Cache-Control"] = "no-store" response.headers["Referrer-Policy"] = "no-referrer" response.headers["X-Content-Type-Options"] = "nosniff" + return response async def cleanup(_: web.Application) -> None: @@ -355,7 +623,9 @@ async def cleanup(_: web.Application) -> None: application.router.add_get("/", index_response) application.router.add_get("/api/v1/session", _websocket) application.router.add_static("/assets", assets, show_index=False) - application.on_cleanup.append(cleanup) + # Stop before aiohttp waits for active WebSocket request handlers. + application.on_shutdown.append(cleanup) + return application, authority @@ -369,12 +639,14 @@ def serve_live( fps: float = 30.0, open_browser: bool = False, ) -> None: - """Serve one live session on loopback until interrupted.""" + """Serve one live session on loopback until Stop or terminal interruption.""" if host not in {"127.0.0.1", "::1", "localhost"}: raise LiveViewerError("live viewer host must be a loopback address") + if port < 1 or port > 65_535: raise LiveViewerError("live viewer port must be in [1, 65535]") + application, token = create_live_app( session, viewer_dist, @@ -387,18 +659,23 @@ def serve_live( async def run() -> None: runner = web.AppRunner(application) await runner.setup() + try: site = web.TCPSite(runner, host=host, port=port) + try: await site.start() except OSError as error: raise LiveViewerError( f"could not bind live viewer to {host}:{port}: {error}" ) from error + print(f"MicroSimulator live viewer: {url}", flush=True) + if open_browser: webbrowser.open(url) - await asyncio.Event().wait() + + await application[_CONTROLLER_KEY].stopped.wait() finally: await runner.cleanup() diff --git a/python/tests/test_analysis.py b/python/tests/test_analysis.py index cb13f11..8350c0b 100644 --- a/python/tests/test_analysis.py +++ b/python/tests/test_analysis.py @@ -92,6 +92,7 @@ def _manifest(path: Path) -> dict[str, Any]: def _recipe_simulation() -> Simulation: simulation = Simulation(BackendKind.CPU, species_count=1) + for x, length, level in ((0.0, 1.0, 2.0), (1.0, 2.0, 4.0), (2.0, 3.0, 6.0)): cell = CellInit() cell.position = Vec3(x, 0.0, 0.0) @@ -99,6 +100,7 @@ def _recipe_simulation() -> Simulation: cell.radius = 0.5 cell.species = [level] simulation.add_cell(cell) + return simulation @@ -115,8 +117,10 @@ def test_previous_dataset_format_preserves_identity_and_verification( manifest["format"] = "cellmodeller2-analysis" manifest["version"] = schema_version identity_keys = ["format", "version", "sources", "options"] + if schema_version >= 2: identity_keys.extend(["tables", "signals"]) + identity = {key: manifest[key] for key in identity_keys} manifest["dataset_id"] = hashlib.sha256( json.dumps( @@ -131,6 +135,7 @@ def test_previous_dataset_format_preserves_identity_and_verification( # Changing the envelope must not silently replace the authenticated identity. manifest["format"] = ANALYSIS_FORMAT manifest_path.write_text(json.dumps(manifest)) + with pytest.raises(AnalysisError, match="dataset identity digest"): open_dataset(output) @@ -284,8 +289,10 @@ def test_export_dataset_rejects_existing_output_and_reverse_time(tmp_path: Path) output.mkdir() sentinel = output / "keep.txt" sentinel.write_text("mine") + with pytest.raises(AnalysisError, match="output already exists"): export_dataset([earlier], output) + assert sentinel.read_text() == "mine" replaced = export_dataset([earlier], output, replace=True) @@ -433,6 +440,7 @@ def test_dataset_reader_detects_manifest_and_table_tampering(tmp_path: Path) -> export_dataset([checkpoint], table_output) table = table_output / "cells.parquet" table.write_bytes(table.read_bytes() + b"tampered") + with pytest.raises(AnalysisError, match="table digest"): open_dataset(table_output) @@ -441,6 +449,7 @@ def test_dataset_reader_detects_manifest_and_table_tampering(tmp_path: Path) -> manifest = _manifest(manifest_output) manifest["options"]["path_provenance"] = True (manifest_output / "manifest.json").write_text(json.dumps(manifest)) + with pytest.raises(AnalysisError, match="dataset_id"): open_dataset(manifest_output) diff --git a/python/tests/test_application_workflow.py b/python/tests/test_application_workflow.py index 6f86086..2572508 100644 --- a/python/tests/test_application_workflow.py +++ b/python/tests/test_application_workflow.py @@ -54,16 +54,19 @@ def _build( checkpoint=checkpoint, ) assert isinstance(model, NativeController) + if checkpoint is None: plane = PlaneConstraintInit() plane.point = Vec3(0.0, -0.25, 0.0) plane.inward_normal = Vec3(0.0, 1.0, 0.0) model.simulation.add_plane_constraint(plane) + return model, provenance def _simulation_state(path: Path) -> object: document = cast(dict[str, object], json.loads(path.read_text(encoding="utf-8"))) + return document["simulation"] diff --git a/python/tests/test_biomass.py b/python/tests/test_biomass.py index ac8ae48..d50ade3 100644 --- a/python/tests/test_biomass.py +++ b/python/tests/test_biomass.py @@ -48,6 +48,7 @@ def test_realized_growth_consumes_exactly_its_yield_on_every_backend( ) -> None: if not backend_available(backend): pytest.skip("backend unavailable") + simulation = Simulation(backend, species_count=1) spec = SignalGridSpec() spec.integration = integration @@ -65,6 +66,7 @@ def test_realized_growth_consumes_exactly_its_yield_on_every_backend( cell.length, cell.radius, cell.growth_rate, cell.species = 2, 0.5, 0.7, [3] cid = simulation.add_cell(cell) initial = biomass_volume(cell.length, cell.radius) + for dt in [0, 0.03, 0.1, 0.2]: simulation.step(dt) current = simulation.cell(cid) @@ -72,6 +74,7 @@ def test_realized_growth_consumes_exactly_its_yield_on_every_backend( consumed = (10 - simulation.signal_levels[0]) * spec.voxel_volume assert math.isclose(0.4 * consumed, volume - initial, abs_tol=3e-5) assert math.isclose(current.species[0] * volume, 3 * initial, rel_tol=2e-6) + save_checkpoint(simulation, tmp_path / "biomass.json") restored = load_checkpoint(tmp_path / "biomass.json", backend=backend) simulation.step(0.02) diff --git a/python/tests/test_channels.py b/python/tests/test_channels.py new file mode 100644 index 0000000..640499c --- /dev/null +++ b/python/tests/test_channels.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +# ruff: noqa: RUF001 -- explicit Unicode-label coverage. +import hashlib +import json +from dataclasses import replace +from pathlib import Path +from typing import Any + +import pytest +import rfc8785 +from microsimulator import ( + MAX_SCENE_CHANNELS, + BackendKind, + ChannelMetadata, + ChannelMetadataError, + CheckpointError, + ModelContext, + NativeController, + SceneError, + Simulation, + build_model, + capture_scene, + dumps_scene, + load_checkpoint, + load_checkpoint_bundle, + load_scene, + parse_scene, + run_simulation, + save_checkpoint, + save_scene, +) +from microsimulator.runner import BatchError, model_channel_metadata +from microsimulator.viewer_server import LiveSession + +ROOT = Path(__file__).resolve().parents[2] +EXAMPLE = ROOT / "examples/named_channels.py" +LABELS = ChannelMetadata( + species=("Green reporter", "Red reporter"), signals=("Nutrient", "Extracellular cue") +) + + +def _build() -> tuple[NativeController, dict[str, Any]]: + model, provenance = build_model(EXAMPLE, ModelContext(BackendKind.CPU, 0, 17)) + assert isinstance(model, NativeController) + + return model, provenance + + +def test_named_model_periodic_checkpoint_resume_and_standalone_export(tmp_path: Path) -> None: + model, provenance = _build() + output = tmp_path / "named.json" + summary = run_simulation( + model, steps=2, dt=0.01, output=output, checkpoint_every=1, provenance=provenance + ) + + for path in (*summary.periodic_checkpoints, output): + assert load_checkpoint_bundle(path).channel_metadata == LABELS + + bundle = load_checkpoint_bundle(output) + resumed, provenance = build_model( + EXAMPLE, ModelContext(BackendKind.CPU, 0, 17), checkpoint=bundle + ) + assert model_channel_metadata(resumed) == LABELS + resumed_output = tmp_path / "continued.json" + run_simulation(resumed, steps=1, dt=0.01, output=resumed_output, provenance=provenance) + continued = load_checkpoint_bundle(resumed_output) + assert continued.channel_metadata == LABELS + model.step(0.01) + assert continued.simulation.signal_levels == model.simulation.signal_levels + assert continued.simulation.cell(1).species == model.simulation.cell(1).species + # Only data and the bundle API are needed to export; no model import/execute. + destination = tmp_path / "named.scene.json" + save_scene( + capture_scene(bundle.simulation, channel_metadata=bundle.channel_metadata), destination + ) + assert load_scene(destination).channel_metadata == LABELS + + +def test_live_labels_survive_step_reset_and_checkpoint(tmp_path: Path) -> None: + session = LiveSession(_build, dt=0.01, checkpoint_output=tmp_path / "live.json") + + for operation in (lambda: None, session.step, session.reset): + operation() + message = session.frame_message(playing=False) + frame = parse_scene(json.dumps(message["scene"])) + assert frame.channel_metadata == LABELS + + assert load_checkpoint_bundle(session.checkpoint()).channel_metadata == LABELS + + +def test_metadata_counts_fail_before_stepping_or_writing(tmp_path: Path) -> None: + model, _ = _build() + model.channel_metadata = ChannelMetadata(species=("only one",)) + + with pytest.raises(BatchError, match=r"species: expected 2 labels, got 1"): + run_simulation(model, steps=1, dt=0.1, output=tmp_path / "bad.json") + + assert model.simulation.time == 0 + assert not (tmp_path / "bad.json").exists() + + with pytest.raises(ChannelMetadataError, match=r"species: expected 2 labels"): + capture_scene(model.simulation, channel_metadata=model.channel_metadata) + + with pytest.raises(CheckpointError, match=r"species: expected 2 labels"): + save_checkpoint( + model.simulation, tmp_path / "bad.json", channel_metadata=model.channel_metadata + ) + + with pytest.raises(ChannelMetadataError, match=r"signals: expected 2 labels"): + ChannelMetadata(signals=()).resolved(2, 2) + + +def test_missing_duplicate_unicode_empty_and_markup_labels_roundtrip(tmp_path: Path) -> None: + model, _ = _build() + labels = ChannelMetadata(species=("α 🧪", "α 🧪"), signals=(None, " ")) + save_checkpoint(model.simulation, tmp_path / "labels.json", channel_metadata=labels) + bundle = load_checkpoint_bundle(tmp_path / "labels.json") + assert bundle.channel_metadata == labels + frame = capture_scene(bundle.simulation, channel_metadata=bundle.channel_metadata) + assert parse_scene(dumps_scene(frame)).channel_metadata == labels + + with pytest.raises(CheckpointError, match="contains channel metadata"): + load_checkpoint(tmp_path / "labels.json") + + with pytest.raises(ChannelMetadataError, match="invalid Unicode"): + ChannelMetadata(species=("\ud800",)) + + with pytest.raises(ChannelMetadataError, match="expected a string"): + ChannelMetadata.from_json({"species": [7], "signals": []}, 1, 0) + + +def test_metadata_tampering_rejected_and_v8_migrates_only_after_verification( + tmp_path: Path, +) -> None: + model, _ = _build() + path = tmp_path / "labels.json" + save_checkpoint(model.simulation, path, channel_metadata=LABELS) + document = json.loads(path.read_text()) + document["channel_metadata"]["species"][0] = "tampered" + path.write_text(json.dumps(document)) + + with pytest.raises(CheckpointError, match="channel metadata digest does not match"): + load_checkpoint_bundle(path) + + document["version"] = 8 + document["simulation"].pop("culture", None) + document["integrity"]["simulation"] = hashlib.sha256( + json.dumps( + document["simulation"], + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + ).hexdigest() + del document["channel_metadata"] + del document["integrity"]["channel_metadata"] + path.write_text(json.dumps(document)) + bundle = load_checkpoint_bundle(path) + assert bundle.channel_metadata == ChannelMetadata() + assert capture_scene( + bundle.simulation, channel_metadata=bundle.channel_metadata + ).channel_metadata == ChannelMetadata().resolved(2, 2) + document["simulation"]["time"] = 999 + path.write_text(json.dumps(document)) + + with pytest.raises(CheckpointError, match="state digest does not match"): + load_checkpoint_bundle(path) + + +def test_legacy_checkpoint_keeps_unspecified_labels_compact_before_native_restore( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # Native state is allowed more channels than a presentation scene. No cells + # or label arrays are necessary to represent the legacy checkpoint. + simulation = Simulation(BackendKind.CPU, species_count=MAX_SCENE_CHANNELS + 1) + path = tmp_path / "legacy.json" + save_checkpoint(simulation, path) + document = json.loads(path.read_text()) + document["version"] = 8 + document["simulation"].pop("culture", None) + document["integrity"]["simulation"] = hashlib.sha256( + json.dumps( + document["simulation"], + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + ).hexdigest() + del document["channel_metadata"] + del document["integrity"]["channel_metadata"] + path.write_text(json.dumps(document)) + + def forbid_expansion( + self: ChannelMetadata, species_count: int, signal_count: int + ) -> ChannelMetadata: + raise AssertionError("legacy restore must not expand absent presentation labels") + + monkeypatch.setattr(ChannelMetadata, "resolved", forbid_expansion) + bundle = load_checkpoint_bundle(path) + assert bundle.simulation.species_count == MAX_SCENE_CHANNELS + 1 + assert bundle.channel_metadata.species is None + assert bundle.channel_metadata.signals is None + assert load_checkpoint(path).species_count == MAX_SCENE_CHANNELS + 1 + + with pytest.raises(SceneError, match="scene presentation channel budget of 4096"): + capture_scene(bundle.simulation, channel_metadata=bundle.channel_metadata) + + # Untrusted input must still pass its original integrity validation first. + document["simulation"]["world"]["species_count"] = (1 << 32) - 1 + path.write_text(json.dumps(document)) + + with pytest.raises(CheckpointError, match="state digest does not match"): + load_checkpoint_bundle(path) + + +def test_scene_v2_verifies_original_payload_and_v3_rejects_invalid_labels() -> None: + model, _ = _build() + document = json.loads(dumps_scene(capture_scene(model.simulation, channel_metadata=LABELS))) + document["version"] = 2 + document["frame"].pop("culture", None) + del document["frame"]["channel_metadata"] + document["integrity"]["frame"] = hashlib.sha256(rfc8785.dumps(document["frame"])).hexdigest() + assert parse_scene(json.dumps(document)).channel_metadata == ChannelMetadata().resolved(2, 2) + document["frame"]["time"] = 999 + + with pytest.raises(SceneError, match="frame digest does not match"): + parse_scene(json.dumps(document)) + + document["version"] = 3 + document["frame"].pop("culture", None) + document["frame"]["channel_metadata"] = {"species": [], "signals": [None, None]} + document["integrity"]["frame"] = hashlib.sha256(rfc8785.dumps(document["frame"])).hexdigest() + + with pytest.raises(SceneError, match="species: expected 2 labels"): + parse_scene(json.dumps(document)) + + +def test_unnamed_native_model_and_closed_channel_schema() -> None: + simulation = Simulation(species_count=2) + assert model_channel_metadata(simulation) == ChannelMetadata(species=(None, None), signals=()) + + with pytest.raises(ChannelMetadataError, match="exactly species and signals"): + ChannelMetadata.from_json({"species": [], "signals": [], "extra": []}, 0, 0) + + +def test_resumed_model_cannot_silently_replace_persisted_labels(tmp_path: Path) -> None: + model, provenance = _build() + path = tmp_path / "labels.json" + run_simulation(model, steps=0, dt=0.1, output=path, provenance=provenance) + bundle = load_checkpoint_bundle(path) + changed = replace( + bundle, + channel_metadata=ChannelMetadata( + species=("Changed", "Red reporter"), signals=LABELS.signals + ), + ) + # Standard native restore treats the checkpoint's labels as authoritative. + resumed, _ = build_model(EXAMPLE, ModelContext(BackendKind.CPU, 0, 17), checkpoint=changed) + assert model_channel_metadata(resumed) == changed.channel_metadata + + +def test_shared_python_typescript_v3_fixture() -> None: + frame = load_scene(ROOT / "viewer/tests/fixtures/channels-v3.scene.json") + assert frame.channel_metadata.species == ("α 🧪", "α 🧪") + assert frame.channel_metadata.signals == (None, " ") diff --git a/python/tests/test_checkpoint.py b/python/tests/test_checkpoint.py index 3ce896d..ae6611e 100644 --- a/python/tests/test_checkpoint.py +++ b/python/tests/test_checkpoint.py @@ -50,6 +50,7 @@ def _instruction( instruction.second = second instruction.third = third instruction.value = value + return instruction @@ -121,6 +122,7 @@ def _make_simulation() -> tuple[Simulation, int, int]: simulation.step(0.125) daughter_a, daughter_b = simulation.divide_equal(first_id) simulation.step(0.03125) + return simulation, daughter_a, daughter_b @@ -132,11 +134,14 @@ def _assert_cells_exact( assert actual.signal_count == expected.signal_count assert actual.has_signal_grid == expected.has_signal_grid assert actual.has_coupled_rate_plan == expected.has_coupled_rate_plan + if actual.has_signal_grid: assert actual.signal_levels == expected.signal_levels + actual_cells = actual.cells() expected_cells = expected.cells() assert len(actual_cells) == len(expected_cells) + for left, right in zip(actual_cells, expected_cells, strict=True): assert left.id == right.id assert left.slot == right.slot @@ -154,8 +159,10 @@ def _assert_cells_exact( assert left.radius == right.radius assert left.growth_rate == right.growth_rate assert left.cell_type == right.cell_type + if compare_fixed: assert left.fixed == right.fixed + assert left.species == right.species @@ -183,6 +190,7 @@ def _remove_fixed_fields(document: dict[str, Any]) -> None: def _remove_affine_reaction(document: dict[str, Any]) -> None: grid = document["simulation"]["signal_grid"] + if grid is not None: del grid["spec"]["reaction"] @@ -194,6 +202,7 @@ def _remove_constraint_boxes(document: dict[str, Any]) -> None: def _remove_grid_obstacles(document: dict[str, Any]) -> None: grid = document["simulation"].get("signal_grid") + if grid is not None: del grid["spec"]["obstacles"] del grid["spec"]["velocity_field"] @@ -253,6 +262,7 @@ def test_checkpoint_bundle_reports_validated_source_metadata(tmp_path: Path) -> document = _document(path) document["source_backend"]["kind"] = "opencl" path.write_text(json.dumps(document), encoding="utf-8") + with pytest.raises(CheckpointError, match="unknown backend kind"): load_checkpoint_bundle(path) @@ -263,6 +273,9 @@ def test_version_one_checkpoint_migrates_to_an_empty_signal_state(tmp_path: Path save_checkpoint(simulation, path) document = _document(path) document["version"] = 1 + document["simulation"].pop("culture", None) + del document["channel_metadata"] + del document["integrity"]["channel_metadata"] del document["controller"] del document["integrity"]["controller"] del document["simulation"]["signal_grid"] @@ -284,6 +297,9 @@ def test_version_two_checkpoint_migrates_without_a_coupled_plan(tmp_path: Path) save_checkpoint(simulation, path) document = _document(path) document["version"] = 2 + document["simulation"].pop("culture", None) + del document["channel_metadata"] + del document["integrity"]["channel_metadata"] del document["controller"] del document["integrity"]["controller"] del document["simulation"]["coupled_rate_plan"] @@ -307,6 +323,9 @@ def test_version_three_checkpoint_migrates_without_controller_state(tmp_path: Pa save_checkpoint(simulation, path) document = _document(path) document["version"] = 3 + document["simulation"].pop("culture", None) + del document["channel_metadata"] + del document["integrity"]["channel_metadata"] del document["controller"] del document["integrity"]["controller"] del document["simulation"]["signal_grid"]["spec"]["integration"] @@ -330,6 +349,9 @@ def test_version_four_signal_grid_migrates_to_forward_euler(tmp_path: Path) -> N save_checkpoint(simulation, path) document = _document(path) document["version"] = 4 + document["simulation"].pop("culture", None) + del document["channel_metadata"] + del document["integrity"]["channel_metadata"] del document["simulation"]["signal_grid"]["spec"]["integration"] del document["simulation"]["signal_grid"]["spec"]["solver"] _remove_affine_reaction(document) @@ -350,6 +372,9 @@ def test_version_five_cells_migrate_to_movable(tmp_path: Path) -> None: save_checkpoint(simulation, path) document = _document(path) document["version"] = 5 + document["simulation"].pop("culture", None) + del document["channel_metadata"] + del document["integrity"]["channel_metadata"] _remove_affine_reaction(document) _remove_fixed_fields(document) _remove_constraint_boxes(document) @@ -366,6 +391,9 @@ def test_version_six_signal_grid_migrates_without_affine_reactions(tmp_path: Pat save_checkpoint(simulation, path) document = _document(path) document["version"] = 6 + document["simulation"].pop("culture", None) + del document["channel_metadata"] + del document["integrity"]["channel_metadata"] _remove_affine_reaction(document) _remove_constraint_boxes(document) _remove_grid_obstacles(document) @@ -383,6 +411,9 @@ def test_version_seven_checkpoint_migrates_without_boxes(tmp_path: Path) -> None save_checkpoint(simulation, path) document = _document(path) document["version"] = 7 + document["simulation"].pop("culture", None) + del document["channel_metadata"] + del document["integrity"]["channel_metadata"] _remove_constraint_boxes(document) _remove_grid_obstacles(document) _rewrite_with_state_digest(path, document) @@ -460,6 +491,7 @@ def test_affine_grid_reaction_round_trips_exactly(tmp_path: Path) -> None: invalid = _document(path) invalid["simulation"]["signal_grid"]["spec"]["reaction"]["loss_rates"][0] = -0.5 _rewrite_with_state_digest(path, invalid) + with pytest.raises(CheckpointError, match="affine loss rates"): load_checkpoint(path) @@ -508,12 +540,14 @@ def test_controller_state_is_authenticated_and_cannot_be_silently_discarded( assert bundle.controller == controller assert bundle.provenance == {"model": "controlled-test"} _assert_cells_exact(bundle.simulation, simulation) + with pytest.raises(CheckpointError, match="controller state"): load_checkpoint(path) document = _document(path) document["controller"]["cells"]["2"]["threshold"] = 4.0 path.write_text(json.dumps(document), encoding="utf-8") + with pytest.raises(CheckpointError, match="controller digest"): load_checkpoint_bundle(path) @@ -561,6 +595,7 @@ def test_coupled_plan_round_trip_is_exact(tmp_path: Path) -> None: invalid = _document(path) invalid["simulation"]["coupled_rate_plan"]["signal_outputs"] = [] _rewrite_with_state_digest(path, invalid) + with pytest.raises(CheckpointError, match="coupled signal output count"): load_checkpoint(path) @@ -573,6 +608,7 @@ def test_checkpoint_rejects_corruption_and_invalid_state(tmp_path: Path) -> None corrupted = _document(path) corrupted["simulation"]["world"]["cells"][0]["length"] = 99.0 path.write_text(json.dumps(corrupted), encoding="utf-8") + with pytest.raises(CheckpointError, match="digest"): load_checkpoint(path) @@ -580,6 +616,7 @@ def test_checkpoint_rejects_corruption_and_invalid_state(tmp_path: Path) -> None invalid = _document(path) invalid["simulation"]["world"]["cells"][0]["slot"] = 7 _rewrite_with_state_digest(path, invalid) + with pytest.raises(CheckpointError, match="slots"): load_checkpoint(path) @@ -587,6 +624,7 @@ def test_checkpoint_rejects_corruption_and_invalid_state(tmp_path: Path) -> None unsupported = _document(path) unsupported["version"] = CHECKPOINT_VERSION + 1 path.write_text(json.dumps(unsupported), encoding="utf-8") + with pytest.raises(CheckpointError, match="unsupported checkpoint version"): load_checkpoint(path) @@ -594,6 +632,7 @@ def test_checkpoint_rejects_corruption_and_invalid_state(tmp_path: Path) -> None unknown = _document(path) unknown["simulation"]["world"]["mystery"] = 1 _rewrite_with_state_digest(path, unknown) + with pytest.raises(CheckpointError, match="unknown keys"): load_checkpoint(path) @@ -601,14 +640,18 @@ def test_checkpoint_rejects_corruption_and_invalid_state(tmp_path: Path) -> None def test_checkpoint_rejects_executable_or_non_json_values(tmp_path: Path) -> None: simulation, _, _ = _make_simulation() path = tmp_path / "colony.cm2.json" + with pytest.raises(CheckpointError, match="provenance"): save_checkpoint(simulation, path, provenance={"callback": object()}) # type: ignore[dict-item] + assert not path.exists() path.write_text('{"format":"microsimulator-checkpoint","value":NaN}', encoding="utf-8") + with pytest.raises(CheckpointError, match="non-finite"): load_checkpoint(path) path.write_text('{"format":"first","format":"second"}', encoding="utf-8") + with pytest.raises(CheckpointError, match="duplicate key"): load_checkpoint(path) diff --git a/python/tests/test_controller.py b/python/tests/test_controller.py index 899d4f6..0b4817e 100644 --- a/python/tests/test_controller.py +++ b/python/tests/test_controller.py @@ -47,11 +47,13 @@ def _one_cell(backend: BackendKind = BackendKind.CPU) -> Simulation: cell.radius = 0.5 cell.species = [2.0] simulation.add_cell(cell) + return simulation def _simulation_payload(path: Path) -> object: document = cast(dict[str, object], json.loads(path.read_text(encoding="utf-8"))) + return document["simulation"] @@ -83,6 +85,7 @@ def _one_cell_in_uniform_flow() -> Simulation: cell.length = 1.0 cell.radius = 0.25 simulation.add_cell(cell) + return simulation @@ -122,9 +125,11 @@ def invalidate_gaussian(value: dict[str, Any]) -> None: invalidate_word, invalidate_gaussian, ] + for mutation in mutations: value = cast(dict[str, Any], copy.deepcopy(capture_random_state(random.Random(1)))) mutation(value) + with pytest.raises(ControllerStateError, match="random state"): restore_random_state(value) @@ -136,6 +141,7 @@ def test_mechanics_config_round_trip_preserves_flow_drift() -> None: invalid = configuration.to_json() invalid["flow_drift"] = 1 + with pytest.raises( ControllerStateError, match=r"mechanics\.flow_drift must be Boolean", @@ -154,12 +160,15 @@ def build() -> NativeController: ) uninterrupted = build() + for _ in range(4): uninterrupted.step(0.25) split = build() + for _ in range(2): split.step(0.25) + midpoint = tmp_path / "flow-midpoint.cm2.json" save_checkpoint(split.simulation, midpoint, controller=split.controller_state()) resumed = NativeController.from_checkpoint( @@ -167,6 +176,7 @@ def build() -> NativeController: model_id="flow-drift-resume-test", model_version=1, ) + for _ in range(2): resumed.step(0.25) @@ -190,11 +200,13 @@ def test_native_controller_composes_regulation_division_and_mechanics( ) -> None: if not backend_available(backend): pytest.skip("native backend is not built") + simulation = _one_cell(backend) def regulate(step: ControllerStep) -> StepPlan: parent = step.cells[0] step.state["regulated"] = True + return StepPlan( updates=( CellUpdate( @@ -230,6 +242,7 @@ def divided(step: ControllerStep, event: DivisionEvent) -> None: daughter_ids = cast(list[JSONValue], controller.state["daughter_ids"]) assert len(daughter_ids) == 2 assert len(controller.last_mechanics_reports) == 2 + for daughter_id in daughter_ids: assert isinstance(daughter_id, int) daughter = simulation.cell(daughter_id) @@ -246,8 +259,10 @@ def test_native_controller_resumes_model_state_rng_and_mechanics( ) -> None: def regulate(step: ControllerStep) -> StepPlan: draws = step.state.get("draws", 0) + if not isinstance(draws, int): raise AssertionError("invalid test state") + step.state["draws"] = draws + 1 updates = tuple( CellUpdate(cell.id, growth_rate=0.05 + 0.1 * step.rng.random()) for cell in step.cells @@ -257,6 +272,7 @@ def regulate(step: ControllerStep) -> StepPlan: if step.completed_steps == 1 and len(step.cells) == 1 else () ) + return StepPlan(updates=updates, divisions=divisions) def build() -> NativeController: @@ -271,12 +287,15 @@ def build() -> NativeController: ) uninterrupted = build() + for _ in range(5): uninterrupted.step(0.125) split = build() + for _ in range(2): split.step(0.125) + midpoint = tmp_path / "midpoint.cm2.json" controller_state = split.controller_state() controller_state["kind"] = controller_kind @@ -291,6 +310,7 @@ def build() -> NativeController: model_version=3, regulate=regulate, ) + for _ in range(3): resumed.step(0.125) @@ -317,6 +337,7 @@ def test_native_controller_validates_complete_plan_before_mutation() -> None: def invalid(step: ControllerStep) -> StepPlan: cell_id = step.cells[0].id + return StepPlan( updates=( CellUpdate(cell_id, growth_rate=0.1), @@ -332,8 +353,10 @@ def invalid(step: ControllerStep) -> StepPlan: regulate=invalid, ) before = simulation.cell(1) + with pytest.raises(ControllerPlanError, match="duplicate"): controller.step(0.1) + after = simulation.cell(1) assert after.growth_rate == before.growth_rate assert simulation.time == 0.0 @@ -354,6 +377,7 @@ def test_native_controller_rejects_wrong_model_identity(tmp_path: Path) -> None: controller=controller.controller_state(), ) bundle = load_checkpoint_bundle(path) + with pytest.raises(ControllerStateError, match="identity does not match"): NativeController.from_checkpoint( bundle, @@ -399,6 +423,7 @@ def test_native_controller_example_builds_and_resumes(tmp_path: Path) -> None: assert load_checkpoint_bundle(output).controller is not None assert resumed.completed_steps == 5 + def test_step_plan_removals_delete_cells_after_divisions() -> None: simulation = Simulation(BackendKind.CPU, species_count=0) first = CellInit() @@ -409,6 +434,7 @@ def test_step_plan_removals_delete_cells_after_divisions() -> None: def regulate(step: ControllerStep) -> StepPlan: del step + return StepPlan(removals=(second_id,)) controller = NativeController( @@ -425,6 +451,7 @@ def regulate(step: ControllerStep) -> StepPlan: def bad_regulate(step: ControllerStep) -> StepPlan: del step + return StepPlan(removals=(second_id,)) bad_controller = NativeController( @@ -434,11 +461,13 @@ def bad_regulate(step: ControllerStep) -> StepPlan: rng=random.Random(3), regulate=bad_regulate, ) + with pytest.raises(ControllerPlanError, match="unknown or duplicate"): bad_controller.step(0.05) def conflicted(step: ControllerStep) -> StepPlan: del step + return StepPlan( divisions=(DivisionRequest(first_id),), removals=(first_id,), @@ -451,5 +480,6 @@ def conflicted(step: ControllerStep) -> StepPlan: rng=random.Random(3), regulate=conflicted, ) + with pytest.raises(ControllerPlanError, match="removes a dividing cell"): conflict_controller.step(0.05) diff --git a/python/tests/test_coupling_foundations.py b/python/tests/test_coupling_foundations.py index 42857b1..0d7ff24 100644 --- a/python/tests/test_coupling_foundations.py +++ b/python/tests/test_coupling_foundations.py @@ -30,13 +30,16 @@ def linear_flow(*, rotation: bool = False) -> SignalGridSpec: spec.shape, spec.origin = shape, Vec3(-4, -4, 0) spec.spacing, spec.signal_count = Vec3(1, 1, 1), 1 spec.diffusion, spec.advection = [0], [Vec3()] + for boundary in (spec.x_lower, spec.x_upper, spec.y_lower, spec.y_upper): boundary.kind, boundary.values = GridBoundaryKind.FIXED, [0] + field = SignalGridVelocityField() field.x_faces = [float((4 - y) if rotation else (y - 4)) for _ in range(10) for y in range(9)] field.y_faces = [float(x - 4) if rotation else 0 for x in range(9) for _ in range(10)] field.z_faces = [0] * 162 spec.velocity_field = field + return spec @@ -65,6 +68,7 @@ def test_finite_aspect_jeffery_shear(length: float) -> None: def test_rigid_rotation_has_second_order_drift_convergence() -> None: errors: list[float] = [] + for n in (4, 8, 16): sim = Simulation() sim.configure_signal_grid(linear_flow(rotation=True)) @@ -73,8 +77,10 @@ def test_rigid_rotation_has_second_order_drift_convergence() -> None: cid = sim.add_cell(cell) parameters = MechanicsIntegrationParameters() parameters.max_rotation_radians = 0.5 + for _ in range(n): sim.apply_flow_drift(0.8 / n, parameters) + final = sim.cell(cid) errors.append( math.hypot(final.position.x - math.cos(0.8), final.position.y - math.sin(0.8)) @@ -82,6 +88,7 @@ def test_rigid_rotation_has_second_order_drift_convergence() -> None: assert ( math.hypot(final.direction.x - math.cos(0.8), final.direction.y - math.sin(0.8)) < 0.002 ) + assert errors[0] / errors[1] > 3.5 assert errors[1] / errors[2] > 3.5 @@ -93,6 +100,7 @@ def reaction_grid(integration: SignalIntegrationKind) -> SignalGridSpec: spec.shape, spec.signal_count = shape, 1 spec.diffusion, spec.advection = [0], [Vec3()] spec.integration = integration + return spec @@ -100,6 +108,7 @@ def reaction_grid(integration: SignalIntegrationKind) -> SignalGridSpec: def test_backward_euler_stiff_reaction_and_restart(backend: BackendKind, tmp_path: Path) -> None: if not backend_available(backend): pytest.skip("backend unavailable") + spec = reaction_grid(SignalIntegrationKind.BACKWARD_EULER) reaction = SignalGridAffineReaction() reaction.loss_rates, reaction.source_rates = [100], [3] @@ -122,6 +131,7 @@ def test_rejected_uptake_rolls_back_growth_species_and_time( ) -> None: if not backend_available(backend): pytest.skip("backend unavailable") + sim = Simulation(backend, species_count=1) sim.configure_signal_grid(reaction_grid(integration), [0.001]) cell = CellInit() @@ -131,8 +141,10 @@ def test_rejected_uptake_rolls_back_growth_species_and_time( sim.set_coupled_rate_plan( rates.coupled_plan(1, 1, (rates.constant(1),), (-rates.cell_volume_change_rate(),)) ) + with pytest.raises((ValueError, RuntimeError)): sim.step(1) + assert sim.time == 0 assert sim.cell(cid).length == cell.length assert sim.cell(cid).species == [2] @@ -147,6 +159,7 @@ def test_rejected_uptake_rolls_back_growth_species_and_time( def test_backward_euler_diffusion_is_positive_and_conservative(backend: BackendKind) -> None: if not backend_available(backend): pytest.skip("backend unavailable") + spec = reaction_grid(SignalIntegrationKind.BACKWARD_EULER) shape = GridShape() shape.x, shape.y, shape.z = 2, 1, 1 diff --git a/python/tests/test_culture.py b/python/tests/test_culture.py new file mode 100644 index 0000000..38201f7 --- /dev/null +++ b/python/tests/test_culture.py @@ -0,0 +1,188 @@ +"""Independent contracts for authored perfusion, nutrient growth, and restart.""" + +from __future__ import annotations + +import json +import math +from pathlib import Path + +import microsimulator as cm +import pytest +from microsimulator.flow import FluidDomain, FluidProperties, Pressure, VolumeFlow +from microsimulator.growth import ( + BiomassConversion, + CellGrowth, + EssentialNutrientGrowth, + MonodGrowth, + NutrientRequirement, +) +from microsimulator.media import Concentration, Medium, PiecewiseConstant +from microsimulator.stokes import StokesFlow +from microsimulator.transport import AdvectiveFeed, ConvectiveOutflow, Solute, SoluteTransport + + +def fluid(flow: float = 1e-6, size: int = 4) -> StokesFlow: + result = StokesFlow( + FluidDomain.rectangular_channel(size_um=(size, size, size), spacing_um=1), + FluidProperties(), + {"inlet": VolumeFlow(flow), "outlet": Pressure(0)}, + ) + result.geometry.surface_resolution = 32 + + return result + + +def test_recipe_units_immutable_and_unknown_solute() -> None: + source = {"glucose": Concentration.mM(1)} + recipe = Medium("feed", source) + source["glucose"] = Concentration.mM(2) + assert math.isclose(recipe.concentrations["glucose"].canonical("mol", 1e-6), 1e-18) + assert math.isclose(Concentration(1, "g/L").canonical("mol", 1e-6, 180), 1000 / 180 * 1e-18) + + with pytest.raises(ValueError, match="molecular weight"): + Concentration(1, "g/L").canonical("mol", 1e-6) + + sim = cm.Simulation() + + with pytest.raises(ValueError, match="unknown solutes"): + sim.configure_culture( + fluid=fluid(), + transport=SoluteTransport( + (Solute("other", 1),), + {"inlet": AdvectiveFeed(recipe), "outlet": ConvectiveOutflow()}, + recipe, + ), + ) + + assert not sim.has_culture + + +def test_schedule_split_restart_and_zero_flow(tmp_path: Path) -> None: + feed = Medium.millimolar("feed", {"tracer": 1}) + empty = Medium.millimolar("empty", {}) + sim = cm.Simulation() + sim.configure_culture( + fluid=fluid(), + transport=SoluteTransport( + (Solute("tracer", 1),), + { + "inlet": AdvectiveFeed(PiecewiseConstant(feed, ((0.5, empty),))), + "outlet": ConvectiveOutflow(), + }, + empty, + ), + ) + sim.step(0.5) + assert sim.culture_checkpoint is not None + assert sim.culture_checkpoint.event_index == 1 + assert sim.culture_checkpoint.configuration.reservoirs[0].concentrations == [0] + path = tmp_path / "culture.json" + cm.save_checkpoint(sim, path) + restarted = cm.load_checkpoint(path) + sim.step(0.5) + restarted.step(0.5) + assert sim.culture_checkpoint is not None and restarted.culture_checkpoint is not None + assert ( + sim.culture_checkpoint.extracellular_amounts + == restarted.culture_checkpoint.extracellular_amounts + ) + no_flow = cm.Simulation() + no_flow.configure_culture( + fluid=fluid(0), + transport=SoluteTransport( + (Solute("tracer", 100),), + {"inlet": AdvectiveFeed(feed), "outlet": ConvectiveOutflow()}, + empty, + ), + ) + no_flow.step(1) + assert no_flow.culture_state is not None + assert no_flow.culture_state.extracellular_amounts["tracer"] == 0 + document = json.loads(path.read_text()) + assert document["version"] == 11 + + +def seeded( + backend: cm.BackendKind, *, essential: bool = False, empty: bool = False +) -> tuple[cm.Simulation, int]: + sim = cm.Simulation(backend) + cell = cm.CellInit() + cell.position = cm.Vec3(5.5, 5.5, 5.5) + cell.radius, cell.length, cell.growth_rate, cell.fixed = 2, 1, 0, True + cid = sim.add_cell(cell) + medium = Medium.millimolar("initial", {} if empty else {"glucose": 1, "nitrogen": 1}) + model = ( + EssentialNutrientGrowth.molar( + mu_max_per_hour=0.8, + requirements={ + "glucose": NutrientRequirement(0.1, 90), + "nitrogen": NutrientRequirement(0.1, 100), + }, + ) + if essential + else MonodGrowth.molar( + substrate="glucose", mu_max_per_hour=0.8, half_saturation_mM=0.1, yield_g_per_mol=90 + ) + ) + sim.configure_culture( + fluid=fluid(0, 12), + transport=SoluteTransport( + (Solute("glucose", 600), Solute("nitrogen", 1500)), + { + "inlet": AdvectiveFeed(medium), + "outlet": ConvectiveOutflow(backflow=Medium.millimolar("external", {})), + }, + medium, + ), + cell_growth={cid: CellGrowth(model, BiomassConversion(3e-13))}, + ) + + return sim, cid + + +@pytest.mark.parametrize("backend", [cm.BackendKind.CPU, cm.BackendKind.METAL]) +@pytest.mark.parametrize("essential", [False, True]) +def test_uptake_produces_biomass(backend: cm.BackendKind, essential: bool, tmp_path: Path) -> None: + if cm.backend_device_count(backend) == 0: + pytest.skip("native device unavailable") + + sim, cid = seeded(backend, essential=essential) + before = sim.culture_state + assert before is not None + sim.step(0.1) + after = sim.culture_state + assert after is not None + a, b = before.cells[cid], after.cells[cid] + assert a.dry_biomass_g is not None and b.dry_biomass_g is not None + gain = b.dry_biomass_g - a.dry_biomass_g + assert gain > 0 + assert math.isclose(gain, 90 * b.uptake["glucose"], rel_tol=1e-8) + + if essential: + assert math.isclose(gain, 100 * b.uptake["nitrogen"], rel_tol=1e-8) + + for name in ("glucose", "nitrogen"): + supplied = sum(t[name] for t in after.boundary_transfer.values()) + error = ( + after.extracellular_amounts[name] + - before.extracellular_amounts[name] + - supplied + + b.uptake[name] + ) + assert abs(error) < 1e-5 * before.extracellular_amounts[name] + + path = tmp_path / "growth.json" + cm.save_checkpoint(sim, path) + restored = cm.load_checkpoint(path).culture_state + assert restored is not None + assert restored.cells[cid] == b + + +def test_no_substrate_produces_no_biomass() -> None: + sim, cid = seeded(cm.BackendKind.CPU, empty=True) + before = sim.culture_state + sim.step(0.1) + after = sim.culture_state + assert before is not None and after is not None + assert after.cells[cid].dry_biomass_g == before.cells[cid].dry_biomass_g + assert after.cells[cid].uptake["glucose"] == 0 diff --git a/python/tests/test_culture_simulation.py b/python/tests/test_culture_simulation.py new file mode 100644 index 0000000..6645264 --- /dev/null +++ b/python/tests/test_culture_simulation.py @@ -0,0 +1,294 @@ +from __future__ import annotations + +import hashlib +import json +import math +import random +from pathlib import Path +from typing import Any, cast + +import microsimulator as cm +import pytest +from microsimulator.analysis import export_dataset, open_dataset +from microsimulator.controller import ControllerStep, NativeController, StepPlan +from microsimulator.scene import capture_scene, dumps_scene, parse_scene + + +def configured(*, division: bool = False) -> cm.Simulation: + sim = cm.Simulation(species_count=1) + cell = cm.CellInit() + cell.position = cm.Vec3(8.5 if division else 5.5, 5.5, 5.5) + cell.radius, cell.length = 2, 8 if division else 1 + cell.growth_rate = 0 + cell.fixed = True + cell.species = [3] + sim.add_cell(cell) + config = cm.CultureConfiguration() + config.grid.shape.x = 18 if division else 12 + config.grid.shape.y = config.grid.shape.z = 12 + config.maximum_retries = 1 + config.stepping.minimum_gap_m = 0.1e-6 + solute = cm.Solute() + solute.name, solute.diffusion = "tracer", 0.1 + config.solutes = [solute] + sim._configure_culture(config, [2], [80]) + + return sim + + +def test_explicit_biochemical_volume_and_checkpoint(tmp_path: Path) -> None: + sim = configured() + instruction = cm.RateInstruction() + instruction.operation = cm.RateOp.CELL_VOLUME + sim.set_species_rate_plan(cm.SpeciesRatePlan(1, [instruction], [0])) + sim.step(0.125) + state = sim.culture_checkpoint + assert state is not None + assert state.cells[0].biochemical_volume == 80 + assert state.cells[0].species_amounts == [1040] + assert sim.cell(1).species == [13] + path = tmp_path / "media.json" + cm.save_checkpoint(sim, path) + restored = cm.load_checkpoint(path) + assert restored.has_culture + saved = restored.culture_checkpoint + assert saved is not None + assert saved.cells[0].species_amounts == state.cells[0].species_amounts + assert saved.extracellular_amounts == state.extracellular_amounts + assert ( + saved.last_report.transport.mass_balance_error + == state.last_report.transport.mass_balance_error + ) + sim.step(0.125) + restored.step(0.125) + assert restored.cell(1).species == sim.cell(1).species + + +def test_volume_and_amount_conserving_division() -> None: + sim = configured(division=True) + before = sim.culture_checkpoint + assert before is not None + first, second = sim.divide(1, 0.4) + after = sim.culture_checkpoint + assert after is not None + assert {c.body.id for c in after.cells} == {first, second} + assert math.isclose( + sum(c.body.geometric_volume for c in after.cells), + before.cells[0].body.geometric_volume, + rel_tol=1e-13, + ) + assert sum(c.biochemical_volume for c in after.cells) == 80 + assert sum(c.species_amounts[0] for c in after.cells) == 240 + assert math.isclose( + math.fsum(after.extracellular_amounts), + math.fsum(before.extracellular_amounts), + rel_tol=2e-5, + ) + assert sim.time == 0 + assert sim.lineage_parent(first) == sim.lineage_parent(second) == 1 + + for daughter in after.cells: + assert sim.cell(daughter.body.id).species == [3] + + +def test_controller_restores_regulation_rng_and_native_state() -> None: + sim = configured() + rng = random.Random(42) + random_state = rng.getstate() + + def regulate(context: ControllerStep) -> StepPlan: + context.rng.random() + context.state["attempt"] = 99 + context.simulation.set_species(1, [10]) + raise RuntimeError("regulation failed") + + controller = NativeController( + sim, + model_id="media-test", + model_version=1, + rng=rng, + regulate=regulate, + state={"attempt": 0}, + ) + + with pytest.raises(RuntimeError, match="regulation failed"): + controller.step(0.1) + + assert controller.state == {"attempt": 0} + assert rng.getstate() == random_state + assert controller.completed_steps == 0 + assert sim.time == 0 + assert sim.cell(1).species == [3] + assert sim.culture_checkpoint is not None + assert sim.culture_checkpoint.cells[0].species_amounts == [240] + + +def test_culture_mode_rejects_unmodelled_geometry_mutations() -> None: + sim = configured() + + with pytest.raises(RuntimeError, match="poses change"): + sim.set_cell_geometry(1, cm.Vec3(5, 5, 5), cm.Vec3(1, 0, 0), 2) + + with pytest.raises(RuntimeError, match="removal"): + sim.remove_cell(1) + + with pytest.raises(RuntimeError, match="seed cells"): + sim.add_cell(cm.CellInit()) + + assert sim.cell_count == 1 + + +def test_culture_scene_and_analysis_preserve_fragment_amounts(tmp_path: Path) -> None: + sim = configured() + frame = capture_scene(sim) + assert frame.culture is not None + assert frame.culture.length_unit_m == 1e-6 + assert frame.culture.cells[0].biochemical_volume == 80 + assert frame.culture.cells[0].species_amounts == (240,) + assert parse_scene(dumps_scene(frame)) == frame + assert frame.signal_grid is not None + + for fragment in frame.culture.fragments: + assert math.isclose(fragment.amounts[0] / fragment.volume, 2) + + source = tmp_path / "source.json" + cm.save_checkpoint(sim, source) + output = tmp_path / "analysis" + export_dataset([source], output) + dataset = open_dataset(output) + assert dataset.has_table("fluid_fragments.parquet") + assert dataset.has_table("culture_cells.parquet") + cells = dataset.scan_table("culture_cells.parquet").collect() + assert cells["biochemical_volume"].to_list() == [80] + + +def _digest(value: object) -> str: + return hashlib.sha256( + json.dumps( + value, allow_nan=False, ensure_ascii=False, separators=(",", ":"), sort_keys=True + ).encode() + ).hexdigest() + + +def _save_version_ten(sim: cm.Simulation, path: Path) -> None: + cm.save_checkpoint(sim, path) + document = cast(dict[str, Any], json.loads(path.read_text())) + document["version"] = 10 + state = document["simulation"].pop("culture") + document["simulation"]["media_flow"] = state + config = state["configuration"] + config["medium"] = config.pop("fluid") + + for key in ( + "growth", + "events", + "authoring_json", + "coupling_tolerance", + "maximum_coupling_iterations", + ): + config.pop(key) + + for solute in config["solutes"]: + solute.pop("amount_unit") + + for boundary in config["reservoirs"]: + boundary.pop("kind") + boundary.pop("allow_backflow") + + for cell in state["cells"]: + for key in ("uptake_totals", "realized_specific_rate", "biomass_produced"): + cell.pop(key) + + state.pop("time") + state.pop("event_index") + document["integrity"]["simulation"] = _digest(document["simulation"]) + path.write_text(json.dumps(document)) + + +def test_version_ten_checkpoint_preserves_reserve_semantics(tmp_path: Path) -> None: + sim = configured() + path = tmp_path / "legacy.json" + _save_version_ten(sim, path) + restored = cm.load_checkpoint(path) + current = restored.culture_checkpoint + assert current is not None + assert current.configuration.growth == [] + assert current.configuration.solutes[0].amount_unit == "model" + assert current.cells[0].species_amounts == [240] + assert current.cells[0].biochemical_volume == 80 + sim.step(0.125) + restored.step(0.125) + assert restored.culture_checkpoint is not None + assert sim.culture_checkpoint is not None + assert ( + restored.culture_checkpoint.extracellular_amounts + == sim.culture_checkpoint.extracellular_amounts + ) + + +def test_version_four_scene_reads_into_culture() -> None: + fixture = Path(__file__).parents[2] / "viewer/tests/fixtures/media-v4.scene.json" + frame = parse_scene(fixture.read_text()) + assert frame.culture is not None + assert frame.culture.cells[0].species_amounts == (240,) + assert frame.culture.solute_amount_units == ("model",) + assert parse_scene(dumps_scene(frame)) == frame + + +def test_version_four_analysis_preserves_identity_and_uses_canonical_names(tmp_path: Path) -> None: + source = tmp_path / "source.json" + cm.save_checkpoint(configured(), source) + output = tmp_path / "dataset" + export_dataset([source], output) + path = output / "manifest.json" + manifest = cast(dict[str, Any], json.loads(path.read_text())) + manifest["version"] = 4 + + for current, legacy in { + "culture_frames.parquet": "media_frames.parquet", + "culture_cells.parquet": "media_cells.parquet", + "fluid_fragments.parquet": "media_fragments.parquet", + "chemical_transfers.parquet": "media_reservoirs.parquet", + }.items(): + (output / current).rename(output / legacy) + manifest["tables"][legacy] = manifest["tables"].pop(current) + + manifest["dataset_id"] = _digest( + { + key: manifest[key] + for key in ("format", "version", "sources", "options", "tables", "signals") + } + ) + path.write_text(json.dumps(manifest)) + dataset = open_dataset(output) + assert dataset.verified + assert dataset.manifest["dataset_id"] == manifest["dataset_id"] + assert dataset.has_table("culture_cells.parquet") + assert dataset.scan_table("culture_cells.parquet").collect()[ + "biochemical_volume" + ].to_list() == [80] + + +@pytest.mark.parametrize( + "malformation", ["missing_fluid", "new_growth", "new_unit", "invalid_array"] +) +def test_version_ten_rejects_malformed_original_schema(tmp_path: Path, malformation: str) -> None: + path = tmp_path / "legacy.json" + _save_version_ten(configured(), path) + document = cast(dict[str, Any], json.loads(path.read_text())) + config = document["simulation"]["media_flow"]["configuration"] + + if malformation == "missing_fluid": + config.pop("medium") + elif malformation == "new_growth": + config["growth"] = [] + elif malformation == "new_unit": + config["solutes"][0]["amount_unit"] = "mol" + else: + config["solutes"] = {} + + document["integrity"]["simulation"] = _digest(document["simulation"]) + path.write_text(json.dumps(document)) + + with pytest.raises(cm.CheckpointError, match=r"schema|array"): + cm.load_checkpoint(path) diff --git a/python/tests/test_division.py b/python/tests/test_division.py index 3bda60b..b9ec8b2 100644 --- a/python/tests/test_division.py +++ b/python/tests/test_division.py @@ -69,6 +69,7 @@ def test_division_and_removal_in_the_same_plan_coexist() -> None: def regulate(step: ControllerStep) -> StepPlan: divisions = policy.requests(step) policy.forget(step, (leaver_id,)) + return StepPlan(divisions=divisions, removals=(leaver_id,)) controller = NativeController( @@ -101,5 +102,68 @@ def test_uniform_length_division_rejects_missing_target_state() -> None: rng=random.Random(0), regulate=lambda step: StepPlan(divisions=policy.requests(step)), ) + with pytest.raises(ControllerStateError, match="length_division"): controller.step(0.1) + + +def test_founder_initialization_caps_native_precision_without_resampling() -> None: + from microsimulator import capped_founder_length + + stream = random.Random(71) + expected = random.Random(71) + policy = UniformLengthDivision(2.5, 3.0) + simulation = Simulation(BackendKind.CPU, species_count=2) + founders: list[CellInit] = [] + + for index, length in enumerate((3.5, 1.0, 3.5, 2.75)): + founder = CellInit() + founder.position = Vec3(index * 10.0, 2.0, 3.0) + founder.direction = Vec3(0.0, 1.0, 0.0) + founder.length = length + founder.radius = 0.4 + founder.cell_type = index + founder.species = [2.0, 3.0] + founders.append(founder) + + state: dict[str, JSONValue] = {} + ids = policy.initialize_founders(simulation, state, stream, tuple(founders)) + target_state = cast(dict[str, JSONValue], state[policy.state_key]) + targets = cast(dict[str, float], target_state["targets"]) + + for index, (cell_id, requested) in enumerate(zip(ids, (3.5, 1.0, 3.5, 2.75), strict=True)): + target = expected.uniform(2.5, 3.0) + cell = simulation.cell(cell_id) + assert targets[str(cell_id)] == target + assert cell.length <= target + assert cell.length == capped_founder_length(requested, target) + assert (cell.position.x, cell.position.y, cell.position.z) == (index * 10.0, 2.0, 3.0) + assert (cell.direction.x, cell.direction.y, cell.direction.z) == (0.0, 1.0, 0.0) + assert abs(cell.radius - 0.4) < 1.0e-7 + assert cell.cell_type == index + assert cell.species == [2.0, 3.0] + + assert stream.getstate() == expected.getstate() + + with pytest.raises(ControllerStateError, match="already contains"): + policy.initialize_founders(simulation, state, stream, ()) + + +def test_capped_founder_length_rounds_down_when_nearest_float_exceeds_target() -> None: + from microsimulator import capped_founder_length + + target = 2.99999999 + founder = CellInit() + founder.length = target + assert founder.length > target # nearest native float rounds up + founder.length = capped_founder_length(3.5, target) + assert founder.length <= target + assert capped_founder_length(1.5, target) == 1.5 + assert capped_founder_length(0.0, 0.0) == 0.0 + + for invalid in (-1.0, float("inf"), float("nan")): + with pytest.raises(ValueError): + capped_founder_length(invalid, 3.0) + + with pytest.raises(ValueError): + capped_founder_length(3.0, invalid) diff --git a/python/tests/test_flow.py b/python/tests/test_flow.py index 5bf4dd7..fc1b796 100644 --- a/python/tests/test_flow.py +++ b/python/tests/test_flow.py @@ -36,11 +36,13 @@ def _duct(nx: int = 4, ny: int = 8, nz: int = 3) -> SignalGridSpec: spec.spacing = Vec3(1.0, 1.0, 1.0) spec.diffusion = [1.0] spec.advection = [Vec3()] + for name in ("y_lower", "y_upper"): boundary = getattr(spec, name) boundary.kind = GridBoundaryKind.FIXED boundary.values = [0.0] setattr(spec, name, boundary) + return spec @@ -78,6 +80,7 @@ def test_uniform_duct_is_exact_plug_flow() -> None: def test_depth_averaged_flow_uses_the_selected_native_backend(backend: BackendKind) -> None: if not backend_available(backend): pytest.skip(f"{backend.name} backend is unavailable") + spec = _duct(nx=3, ny=5, nz=1) expected, _ = solve_flow_field(spec, mean_inlet_speed=2.0) simulation = Simulation(backend) @@ -104,14 +107,18 @@ def test_parallel_channels_split_flux_in_the_mobility_ratio() -> None: def test_a_pillar_routes_flow_around_itself_conservatively() -> None: spec = _duct(nx=5, ny=7, nz=1) obstacles = [0] * (5 * 7) + for y in (2, 3, 4): obstacles[_site(spec, 2, y, 0)] = 1 + spec.obstacles = obstacles field, _ = solve_flow_field(spec, mean_inlet_speed=6.0) fluxes = _cross_section_fluxes(spec, field) + for flux in fluxes[1:]: assert math.isclose(flux, fluxes[0], rel_tol=1.0e-6) + # Faces of the pillar carry no flow; its flanks carry more than the inlet mean. assert field.y_faces[_y_face(spec, 2, 3, 0)] == 0.0 assert field.y_faces[_y_face(spec, 1, 3, 0)] > 6.0 @@ -123,26 +130,33 @@ def test_a_pillar_routes_flow_around_itself_conservatively() -> None: def test_brinkman_drag_diverts_flux_from_a_porous_region() -> None: spec = _duct(nx=2, ny=6, nz=1) mobility = [1.0] * (2 * 6) + for y in (2, 3): mobility[_site(spec, 1, y, 0)] = 0.05 + field, _ = solve_flow_field(spec, mean_inlet_speed=4.0, mobility=mobility) open_flux = field.y_faces[_y_face(spec, 0, 3, 0)] porous_flux = field.y_faces[_y_face(spec, 1, 3, 0)] assert porous_flux > 0.0 assert open_flux > 4.0 > porous_flux fluxes = _cross_section_fluxes(spec, field) + for flux in fluxes[1:]: assert math.isclose(flux, fluxes[0], rel_tol=1.0e-6) def test_ill_posed_problems_are_rejected() -> None: spec = _duct() + with pytest.raises(FlowError, match="one of x, y, z"): solve_flow_field(spec, mean_inlet_speed=1.0, axis="w") + with pytest.raises(FlowError, match="finite and nonzero"): solve_flow_field(spec, mean_inlet_speed=0.0) + with pytest.raises(FlowError, match="must be FIXED"): solve_flow_field(spec, mean_inlet_speed=1.0, axis="x") + with pytest.raises(FlowError, match="one value per grid site"): solve_flow_field(spec, mean_inlet_speed=1.0, mobility=[1.0]) @@ -153,22 +167,29 @@ def test_ill_posed_problems_are_rejected() -> None: boundary = periodic.x_upper boundary.kind = GridBoundaryKind.PERIODIC periodic.x_upper = boundary + with pytest.raises(FlowError, match="periodic"): solve_flow_field(periodic, mean_inlet_speed=1.0) blocked_inlet = _duct(nx=3, ny=4, nz=1) obstacles = [0] * (3 * 4) + for x in range(3): obstacles[_site(blocked_inlet, x, 0, 0)] = 1 + blocked_inlet.obstacles = obstacles + with pytest.raises(FlowError, match="entirely blocked"): solve_flow_field(blocked_inlet, mean_inlet_speed=1.0) dead_end = _duct(nx=3, ny=4, nz=1) obstacles = [0] * (3 * 4) + for x in range(3): obstacles[_site(dead_end, x, 2, 0)] = 1 + dead_end.obstacles = obstacles + with pytest.raises(FlowError, match="no through-flow"): solve_flow_field(dead_end, mean_inlet_speed=1.0) @@ -208,8 +229,10 @@ def test_colony_mobility_adds_drag_where_cells_pack() -> None: with pytest.raises(FlowError, match="finite and positive"): colony_mobility(spec, [], base=0.0) + with pytest.raises(FlowError, match="one value per grid site"): colony_mobility(spec, [], base=[1.0]) + with pytest.raises(FlowError, match="strictly between"): colony_mobility(spec, [], max_volume_fraction=1.0) @@ -217,9 +240,11 @@ def test_colony_mobility_adds_drag_where_cells_pack() -> None: def test_gap_mobility_scales_with_the_squared_gap_height() -> None: spec = _duct(nx=2, ny=4, nz=4) obstacles = [0] * (2 * 4 * 4) + for y in range(4): for z in range(1, 4): obstacles[_site(spec, 1, y, z)] = 1 + spec.obstacles = obstacles mobility = gap_mobility(spec) assert mobility[_site(spec, 0, 0, 0)] == 1.0 @@ -228,6 +253,7 @@ def test_gap_mobility_scales_with_the_squared_gap_height() -> None: blocked = _duct(nx=1, ny=1, nz=1) blocked.obstacles = [1] + with pytest.raises(FlowError, match="no fluid sites"): gap_mobility(blocked) @@ -243,8 +269,10 @@ def test_simulation_swaps_the_solved_field_at_runtime() -> None: invalid.x_faces = [0.0] invalid.y_faces = [0.0] invalid.z_faces = [0.0] + with pytest.raises(ValueError, match="every lattice face"): simulation.set_velocity_field(invalid) + simulation.set_velocity_field(None) @@ -256,13 +284,17 @@ def _mid_level(with_flow: bool) -> float: boundary = spec.y_lower boundary.values = [10.0] spec.y_lower = boundary + if with_flow: field, _ = solve_flow_field(spec, mean_inlet_speed=2.0) spec.velocity_field = field + simulation = Simulation() simulation.configure_signal_grid(spec, [0.0] * spec.site_count) + for _ in range(10): simulation.step(0.5) + return simulation.sample_signals(Vec3(0.5, 6.0, 0.5))[0] advected = _mid_level(with_flow=True) @@ -292,9 +324,7 @@ def test_trap_channel_device_supports_a_numerical_field() -> None: # dead-end trap sees only the weak recirculation at its mouth. mid_face = shape.y // 2 channel_speed = max( - field.y_faces[_y_face(spec, x, mid_face, z)] - for x in range(shape.x) - for z in range(shape.z) + field.y_faces[_y_face(spec, x, mid_face, z)] for x in range(shape.x) for z in range(shape.z) ) trap_column = int((0.0 - spec.origin.x) / spec.spacing.x) trap_speed = abs(field.y_faces[_y_face(spec, trap_column, mid_face, 1)]) @@ -320,11 +350,13 @@ def test_anisotropic_spacing_scales_the_solved_speeds() -> None: spec.spacing = Vec3(5.0, 0.4, 1.65) spec.diffusion = [1.0] spec.advection = [Vec3()] + for name in ("y_lower", "y_upper"): boundary = getattr(spec, name) boundary.kind = GridBoundaryKind.FIXED boundary.values = [0.0] setattr(spec, name, boundary) + field, _ = solve_flow_field(spec, mean_inlet_speed=7.0) spec.velocity_field = field spec.validate() @@ -339,16 +371,19 @@ def test_reversed_and_transverse_flow_axes_solve() -> None: assert all(math.isclose(value, -3.0, abs_tol=2.0e-5) for value in field.y_faces) across = _duct() + for name in ("y_lower", "y_upper"): boundary = getattr(across, name) boundary.kind = GridBoundaryKind.NO_FLUX boundary.values = [] setattr(across, name, boundary) + for name in ("x_lower", "x_upper"): boundary = getattr(across, name) boundary.kind = GridBoundaryKind.FIXED boundary.values = [0.0] setattr(across, name, boundary) + sideways, _ = solve_flow_field(across, mean_inlet_speed=2.0, axis="x") across.velocity_field = sideways across.validate() @@ -358,8 +393,10 @@ def test_reversed_and_transverse_flow_axes_solve() -> None: def test_partly_blocked_inlets_and_walled_off_pockets_solve() -> None: spec = _duct(nx=4, ny=6, nz=1) obstacles = [0] * 24 + for y in range(6): obstacles[_site(spec, 0, y, 0)] = 1 + spec.obstacles = obstacles field, _ = solve_flow_field(spec, mean_inlet_speed=2.0) spec.velocity_field = field @@ -372,9 +409,11 @@ def test_partly_blocked_inlets_and_walled_off_pockets_solve() -> None: pocket = _duct(nx=5, ny=6, nz=1) sealed = [0] * 30 + for y in (2, 4): for x in (3, 4): sealed[_site(pocket, x, y, 0)] = 1 + sealed[_site(pocket, 2, 3, 0)] = 1 pocket.obstacles = sealed sealed_field, _ = solve_flow_field(pocket, mean_inlet_speed=1.0) @@ -410,6 +449,7 @@ class _Cell: assert math.isclose(sum(density) * voxel, amount, rel_tol=1e-7) assert density[_site(spec, 0, 0, 0)] > 0 assert density[_site(spec, 1, 1, 0)] > 0 + with pytest.raises(FlowError, match="outside the cell"): colony_species_density(spec, cells, species=5) @@ -422,11 +462,13 @@ def test_a_running_simulation_swaps_its_signal_reaction() -> None: spec.x_lower.values = [] spec.x_upper.kind = GridBoundaryKind.NO_FLUX spec.x_upper.values = [] + for name in ("y_lower", "y_upper"): boundary = getattr(spec, name) boundary.kind = GridBoundaryKind.NO_FLUX boundary.values = [] setattr(spec, name, boundary) + spec.diffusion = [0.0] spec.integration = SignalIntegrationKind.CRANK_NICOLSON diff --git a/python/tests/test_flow_foundations.py b/python/tests/test_flow_foundations.py index fd34c25..acab383 100644 --- a/python/tests/test_flow_foundations.py +++ b/python/tests/test_flow_foundations.py @@ -24,6 +24,7 @@ class Rod: def test_parallel_depths_have_cubic_conductance(backend: BackendKind) -> None: if not backend_available(backend): pytest.skip("backend unavailable") + spec = duct_grid(2, 6, 4, (1, 1, 1)) solid = np.zeros((2, 6, 4), dtype=np.uint8) solid[1, :, 1:] = 1 @@ -38,6 +39,7 @@ def test_parallel_depths_have_cubic_conductance(backend: BackendKind) -> None: def test_variable_depth_lift_conserves_uniform_tracer(backend: BackendKind) -> None: if not backend_available(backend): pytest.skip("backend unavailable") + spec = duct_grid(2, 8, 4, (1, 1, 1)) solid = np.zeros((2, 8, 4), dtype=np.uint8) solid[:, 4:, 2:] = 1 @@ -57,17 +59,21 @@ def test_variable_depth_lift_conserves_uniform_tracer(backend: BackendKind) -> N def test_shallow_model_rejects_vertical_variation_and_overhangs() -> None: spec = duct_grid(2, 6, 3, (1, 1, 1)) + with pytest.raises(FlowError, match="constant through"): solve_flow_field(spec, mean_inlet_speed=1, mobility=[1, 2, 3] * 12) + solid = [0] * 36 solid[1] = 1 spec.obstacles = solid + with pytest.raises(FlowError, match="contiguous"): solve_flow_field(spec, mean_inlet_speed=1) def test_zero_mobility_barrier_is_rejected() -> None: spec = duct_grid(2, 6, 1, (1, 1, 1)) + with pytest.raises(FlowError, match="unreachable"): solve_flow_field(spec, mean_inlet_speed=1, mobility=[1, 1, 0, 1, 1, 1] * 2) @@ -76,6 +82,7 @@ def test_biomass_deposition_conserves_amount_across_refinement() -> None: densities: list[NDArray[np.float64]] = [] rod = Rod(Vec3(0.3, 0.1, -0.2)) expected = 100 * biomass_volume(rod.length, rod.radius) + for n in (4, 8, 16): h = 16 / n spec = duct_grid(n, n, n, (h, h, h)) @@ -83,6 +90,7 @@ def test_biomass_deposition_conserves_amount_across_refinement() -> None: density = colony_volume_fraction(spec, [rod] * 100, averaging_radius=4) assert math.isclose(float(density.sum()) * spec.voxel_volume, expected, rel_tol=2e-7) densities.append(density) + # Exact cell integrals agree after aggregating fine voxels, even above density 0.9. fine_to_coarse = densities[2].reshape(4, 4, 4, 4, 4, 4).mean(axis=(1, 3, 5)) assert float(np.max(np.abs(fine_to_coarse - densities[0]))) < 1e-10 @@ -93,6 +101,7 @@ def test_biomass_deposition_conserves_amount_across_refinement() -> None: def test_inexact_preconditioner_preserves_true_stokes_solution(backend: BackendKind) -> None: if not backend_available(backend): pytest.skip("backend unavailable") + spec = duct_grid(5, 8, 4, (0.7, 1.1, 0.6)) drag = [0.2 + 0.01 * i for i in range(spec.site_count)] expected, _ = solve_stokes_field(spec, mean_inlet_speed=1, drag=drag) diff --git a/python/tests/test_legacy.py b/python/tests/test_legacy.py index 256c2d5..3a77352 100644 --- a/python/tests/test_legacy.py +++ b/python/tests/test_legacy.py @@ -32,6 +32,7 @@ def tolist(self) -> list[float]: ... def test_legacy_callbacks_drive_native_growth_and_division(backend: BackendKind) -> None: if not backend_available(backend): pytest.skip("native backend is not built") + divided: list[tuple[int, int, int]] = [] def initialize(cell: LegacyCell) -> None: @@ -42,6 +43,7 @@ def initialize(cell: LegacyCell) -> None: def update(cells: dict[int, LegacyCell]) -> None: for cell in cells.values(): cell.growthRate = 0.5 + if cell.volume >= cell.targetVol: cell.divideFlag = True @@ -99,6 +101,7 @@ def update(cells: dict[int, LegacyCell]) -> None: with pytest.raises(LegacyCompatibilityError, match="growthRate"): adapter.step(0.1) + assert simulation.cell(cell_id).growth_rate == 1.0 @@ -142,6 +145,7 @@ def mutate_geometry(cells: dict[int, LegacyCell]) -> None: simulation, init=initialize, update=mutate_geometry, mechanics=False ) adapter.add_cell(CellInit()) + with pytest.raises(LegacyCompatibilityError, match="may not mutate"): adapter.step(0.1) @@ -197,10 +201,12 @@ def divide_immediately(cells: dict[int, LegacyCell]) -> None: adapter.step(0.0) daughters = simulation.cells() + for daughter in daughters: assert math.isclose(daughter.direction.x, -0.8, abs_tol=1.0e-7) assert math.isclose(daughter.direction.y, 0.6, abs_tol=1.0e-7) assert daughter.direction.z == 0.0 + assert daughters[0].position.x < 0.0 assert daughters[0].position.y < 0.0 assert daughters[1].position.x > 0.0 @@ -294,6 +300,7 @@ def make_adapter(max_substeps: int) -> LegacyModelAdapter: second.position = Vec3(0.25, 0.0, 0.0) second.length = 2.0 adapter.add_cell(second) + return adapter disabled = make_adapter(1) @@ -366,6 +373,7 @@ def divide_every_step(cells: dict[int, LegacyCell]) -> None: adapter.step(0.0) restored.step(0.0) + for original_cell, restored_cell in zip( adapter.simulation.cells(), restored.simulation.cells(), strict=True ): @@ -424,4 +432,5 @@ def divide_immediately(cells: dict[int, LegacyCell]) -> None: with pytest.raises(LegacyCompatibilityError, match="positive weights"): adapter.step(0.0) + assert simulation.cell_count == 1 diff --git a/python/tests/test_legacy_loader.py b/python/tests/test_legacy_loader.py index 8becff7..aeda5c5 100644 --- a/python/tests/test_legacy_loader.py +++ b/python/tests/test_legacy_loader.py @@ -25,6 +25,7 @@ def test_unchanged_growth_model_loads_through_setup_facade(backend: BackendKind) -> None: if not backend_available(backend): pytest.skip("native backend is not built") + context = ModelContext(backend, 0, seed=42) model, provenance = build_legacy_model(_FIXTURES / "legacy_growth.py", context) @@ -46,6 +47,7 @@ def test_unchanged_growth_model_loads_through_setup_facade(backend: BackendKind) def test_legacy_loader_rejects_opencl_integrators_explicitly() -> None: context = ModelContext(BackendKind.CPU, 0, seed=0) + with pytest.raises(LegacyCompatibilityError, match="OpenCL integrators"): build_legacy_model(_FIXTURES / "legacy_opencl_integrator.py", context) @@ -108,9 +110,8 @@ def test_legacy_batch_checkpoint_resumes_exactly_and_checks_source(tmp_path: Pat actual = load_checkpoint_bundle(resumed_path) assert actual.controller == expected.controller assert cast(dict[str, JSONValue], actual.controller)["version"] == 4 - for left, right in zip( - actual.simulation.cells(), expected.simulation.cells(), strict=True - ): + + for left, right in zip(actual.simulation.cells(), expected.simulation.cells(), strict=True): assert left.id == right.id assert left.slot == right.slot assert left.position.x == right.position.x @@ -128,6 +129,7 @@ def test_legacy_batch_checkpoint_resumes_exactly_and_checks_source(tmp_path: Pat encoding="utf-8", ) first_bundle = load_checkpoint_bundle(first_path) + with pytest.raises(LegacyCompatibilityError, match="digest"): resume_legacy_model(changed_model, context, first_bundle) diff --git a/python/tests/test_legacy_migrations.py b/python/tests/test_legacy_migrations.py index 4d4e637..93ff633 100644 --- a/python/tests/test_legacy_migrations.py +++ b/python/tests/test_legacy_migrations.py @@ -38,6 +38,7 @@ def _path(relative: str) -> Path: def _simulation_payload(path: Path) -> object: document = cast(dict[str, object], json.loads(path.read_text(encoding="utf-8"))) + return document["simulation"] @@ -50,6 +51,7 @@ def test_species_migrations_run_typed_equations_and_division( ) -> None: if not backend_available(backend): pytest.skip("native backend is not built") + model, _ = build_model(_path(relative), ModelContext(backend, 0, seed=11)) assert isinstance(model, NativeController) @@ -59,14 +61,17 @@ def test_species_migrations_run_typed_equations_and_division( assert len(cells) == 2 assert len(model.last_mechanics_reports) == 1 first = cells[0] + if expected_species is not None: assert len(first.species) == len(expected_species) + for actual, expected in zip(first.species, expected_species, strict=True): assert math.isclose(actual, expected, rel_tol=2.0e-5, abs_tol=1.0e-7) else: dilution = 2.25 / (first.length + 1.0) expected_x0 = 10.0 * dilution assert math.isclose(first.species[0], expected_x0, rel_tol=2.0e-5) + if relative == "ex2b_diluteRepression.py": expected_x1 = 0.01 * 4.0 / (4.0 + expected_x0 * expected_x0) assert math.isclose(first.species[1], expected_x1, rel_tol=2.0e-5) @@ -129,6 +134,7 @@ def test_signal_migrations_run_typed_coupled_equations( ) -> None: if not backend_available(backend): pytest.skip("native backend is not built") + model, _ = build_model(_path(relative), ModelContext(backend, 0, seed=11)) assert isinstance(model, NativeController) @@ -142,6 +148,7 @@ def test_signal_migrations_run_typed_coupled_equations( assert len(signals) == expected_levels assert all(math.isfinite(value) and value >= 0.0 for value in signals) assert sum(signals) > 0.0 + for cell in cells: assert len(cell.species) == species_count assert all(math.isfinite(value) for value in cell.species) diff --git a/python/tests/test_legacy_pickle.py b/python/tests/test_legacy_pickle.py index c7234c6..e7bd3ea 100644 --- a/python/tests/test_legacy_pickle.py +++ b/python/tests/test_legacy_pickle.py @@ -28,6 +28,7 @@ def _snapshot_bytes( module = ModuleType("CellModeller.CellState") bases = (dict,) if dict_style else () namespace: dict[str, object] = {"__module__": "CellModeller.CellState"} + if dict_style: dict_type = cast(Any, dict) namespace.update( @@ -36,6 +37,7 @@ def _snapshot_bytes( "__setattr__": dict_type.__setitem__, } ) + cell_class = cast(type[Any], type("CellState", bases, namespace)) module.CellState = cell_class # type: ignore[attr-defined] package.CellState = module # type: ignore[attr-defined] @@ -69,8 +71,10 @@ def _snapshot_bytes( cells = {7: second, 3: first} lineage = {3: 1, 7: 1} + if tuple_format: return pickle.dumps((cells, lineage), protocol=2) + return pickle.dumps( { "cellStates": cells, @@ -130,6 +134,7 @@ def test_tuple_snapshot_requires_explicit_time( trusted=True, native_state_only=True, ) + imported = import_legacy_pickle( source, time=12.5, @@ -148,6 +153,7 @@ def __reduce__(self) -> tuple[object, tuple[str]]: source = tmp_path / "malicious.pickle" source.write_bytes(pickle.dumps(Malicious(), protocol=4)) + with pytest.raises(LegacyPickleError, match="forbidden global"): import_legacy_pickle( source, @@ -155,6 +161,7 @@ def __reduce__(self) -> tuple[object, tuple[str]]: trusted=True, native_state_only=True, ) + assert not sentinel.exists() diff --git a/python/tests/test_legacy_trajectories.py b/python/tests/test_legacy_trajectories.py index 0002195..76593da 100644 --- a/python/tests/test_legacy_trajectories.py +++ b/python/tests/test_legacy_trajectories.py @@ -91,6 +91,7 @@ def _scenarios() -> list[dict[str, Any]]: assert document["legacy_commit"] == "4896f543c6250f053eea2312e628cc3a96bf7408" scenarios = cast(list[dict[str, Any]], document["scenarios"]) assert {scenario["id"] for scenario in scenarios} == set(_TOLERANCES) + return scenarios @@ -108,11 +109,14 @@ def _capture(model: RunnableModel, step: int) -> dict[str, Any]: ) neighbor_pairs: set[tuple[int, int]] = set() legacy_cells = getattr(model, "cells", None) + if legacy_cells is not None: for cell in legacy_cells.values(): for neighbor in cell.neighbours: neighbor_pairs.add(tuple(sorted((cell.id, neighbor)))) + signals: list[dict[str, float]] = [] + if simulation.has_signal_grid: levels = np.asarray(simulation.signal_levels, dtype=np.float64).reshape( (simulation.signal_count, -1) @@ -125,6 +129,7 @@ def _capture(model: RunnableModel, step: int) -> dict[str, Any]: } for channel in levels ] + return { "step": step, "cell_count": len(cells), @@ -158,8 +163,10 @@ def _compare( relative=tolerance.total_length_relative, absolute=1.0e-6, ) + for value, reference in zip(actual["centroid"], expected["centroid"], strict=True): _close(value, reference, relative=0.0, absolute=tolerance.centroid_absolute) + _close( actual["maximum_centroid_distance"], expected["maximum_centroid_distance"], @@ -171,9 +178,12 @@ def _compare( + math.ceil(tolerance.neighbor_relative * expected["neighbor_pair_count"]) ) assert len(actual["species_sum"]) == len(expected["species_sum"]) + for value, reference in zip(actual["species_sum"], expected["species_sum"], strict=True): _close(value, reference, relative=tolerance.species_relative, absolute=1.0e-8) + assert len(actual["signals"]) == len(expected["signals"]) + for values, references in zip(actual["signals"], expected["signals"], strict=True): _close( values["sum"], @@ -187,8 +197,10 @@ def _compare( relative=tolerance.signal_maximum_relative, absolute=1.0e-8, ) + if identifier != "neighbor_dependent_conjugation": assert set(actual["cell_type_counts"]) == set(expected["cell_type_counts"]) + for cell_type, count in actual["cell_type_counts"].items(): assert ( abs(count - expected["cell_type_counts"][cell_type]) @@ -204,8 +216,10 @@ def test_recorded_legacy_trajectory_contract( ) -> None: if _LEGACY_ROOT_VALUE is None: pytest.skip("CM_LEGACY_ROOT is required for recorded legacy trajectory tests") + if not backend_available(backend): pytest.skip("native backend is not built") + legacy_root = Path(_LEGACY_ROOT_VALUE) identifier = cast(str, scenario["id"]) kind, relative_path = _MODELS[identifier] @@ -222,11 +236,14 @@ def test_recorded_legacy_trajectory_contract( } final_step = max(frames) dt = cast(float, scenario["dt"]) + for step in range(final_step + 1): if step in frames: _compare(identifier, _capture(model, step), frames[step]) + if step != final_step: model.step(dt) + if identifier == "neighbor_dependent_conjugation": final_types = _capture(model, final_step)["cell_type_counts"] assert {"0", "1", "2"}.issubset(final_types) diff --git a/python/tests/test_masks.py b/python/tests/test_masks.py index 5abe653..b8ea996 100644 --- a/python/tests/test_masks.py +++ b/python/tests/test_masks.py @@ -41,13 +41,16 @@ def test_rectangle_extraction_is_selective_and_explicitly_scaled() -> None: def test_mask_reader_rejects_unusable_input(tmp_path: Path) -> None: empty = tmp_path / "empty.dxf" empty.write_text("") + with pytest.raises(MaskError, match="empty"): load_mask_polylines(empty) no_entities = tmp_path / "no-entities.dxf" no_entities.write_text(" 0\nSECTION\n 2\nHEADER\n 0\nENDSEC\n 0\nEOF\n") + with pytest.raises(MaskError, match="no model-space polylines"): load_mask_polylines(no_entities) + with pytest.raises(MaskError, match="byte limit"): load_mask_polylines(no_entities, max_bytes=8) diff --git a/python/tests/test_microfluidic_flow_demo.py b/python/tests/test_microfluidic_flow_demo.py new file mode 100644 index 0000000..0e393d7 --- /dev/null +++ b/python/tests/test_microfluidic_flow_demo.py @@ -0,0 +1,124 @@ +from pathlib import Path + +from microsimulator import ( + BackendKind, + ModelContext, + NativeController, + build_model, + capture_scene, + load_checkpoint_bundle, + run_simulation, +) + +MODEL = Path(__file__).resolve().parents[2] / "examples/microfluidic_flow.py" + + +def test_dye_advects_around_solid_pillars_and_restarts(tmp_path: Path) -> None: + context = ModelContext(BackendKind.CPU, 0, seed=42, parameters={"cells": False}) + model, provenance = build_model(MODEL, context) + assert isinstance(model, NativeController) + grid = model.simulation._checkpoint().signal_grid + assert grid is not None + field = grid.spec.velocity_field + assert field is not None + assert max(field.x_faces) > 1 + assert min(field.x_faces) < -1 + assert max(field.y_faces) > 40 + assert model.simulation.cell_count == 0 + + checkpoint = tmp_path / "flow.json" + run_simulation(model, steps=80, dt=0.025, output=checkpoint, provenance=provenance) + scene = capture_scene(model.simulation, channel_metadata=model.channel_metadata) + assert len(scene.constraints.cylinders) == 5 + assert scene.signal_grid is not None + assert scene.channel_metadata.signals == ( + "Flow tracer (relative concentration)", + "Nutrient (relative concentration)", + ) + levels = scene.signal_grid.levels[: grid.spec.site_count] + ny, nz = grid.spec.shape.y, grid.spec.shape.z + total = sum(levels) + dye_center_y = ( + sum( + value * (grid.spec.origin.y + ((i // nz) % ny) * grid.spec.spacing.y) + for i, value in enumerate(levels) + ) + / total + ) + assert dye_center_y > -80 # The pulse has moved beyond the inlet strip. + assert all( + value == 0 for value, solid in zip(levels, grid.spec.obstacles, strict=True) if solid + ) + + resumed, _ = build_model(MODEL, context, checkpoint=load_checkpoint_bundle(checkpoint)) + assert isinstance(resumed, NativeController) + + for _ in range(50): + model.step(0.025) + resumed.step(0.025) + + assert resumed.simulation.time == model.simulation.time + assert resumed.simulation.signal_levels == model.simulation.signal_levels + assert model.controller_state() == resumed.controller_state() + + +def test_cells_grow_divide_drift_consume_nutrients_and_restart(tmp_path: Path) -> None: + context = ModelContext(BackendKind.CPU, 0, seed=42) + model, provenance = build_model(MODEL, context) + assert isinstance(model, NativeController) + initial = {cell.id: cell for cell in model.simulation.cells()} + assert len(initial) == 32 + assert sum(cell.fixed for cell in initial.values()) == 20 + + model.step(0.025) + current = {cell.id: cell for cell in model.simulation.cells()} + assert any(cell.length > initial[cell.id].length for cell in current.values()) + assert any( + cell.position.y > initial[cell.id].position.y + 0.1 + for cell in current.values() + if not cell.fixed + ) + assert all( + cell.position.y == initial[cell.id].position.y for cell in current.values() if cell.fixed + ) + + # An otherwise identical cell-free run separates uptake from outlet losses. + control, _ = build_model( + MODEL, ModelContext(BackendKind.CPU, 0, seed=42, parameters={"cells": False}) + ) + assert isinstance(control, NativeController) + control.step(0.025) + site_count = len(model.simulation.signal_levels) // 2 + assert sum(model.simulation.signal_levels[site_count:]) < sum( + control.simulation.signal_levels[site_count:] + ) + + checkpoint = tmp_path / "cells.json" + run_simulation(model, steps=40, dt=0.025, output=checkpoint, provenance=provenance) + assert model.simulation.cell_count > len(initial) + assert sum(cell.fixed for cell in model.simulation.cells()) == 20 + assert any(cell.parent_id is not None for cell in capture_scene(model.simulation).cells) + + resumed, _ = build_model(MODEL, context, checkpoint=load_checkpoint_bundle(checkpoint)) + assert isinstance(resumed, NativeController) + + for _ in range(20): + model.step(0.025) + resumed.step(0.025) + + assert capture_scene(model.simulation) == capture_scene(resumed.simulation) + assert model.controller_state() == resumed.controller_state() + + +def test_starved_cells_do_not_grow() -> None: + model, _ = build_model(MODEL, ModelContext(BackendKind.CPU, 0, seed=42)) + assert isinstance(model, NativeController) + levels = model.simulation.signal_levels + site_count = len(levels) // 2 + levels[site_count:] = [0.0] * site_count + model.simulation.set_signal_levels(levels) + initial_lengths = {cell.id: cell.length for cell in model.simulation.cells()} + model.step(0.025) + + assert {cell.id: cell.length for cell in model.simulation.cells()} == initial_lengths + assert all(cell.growth_rate == 0 for cell in model.simulation.cells()) diff --git a/python/tests/test_microfluidics.py b/python/tests/test_microfluidics.py index ede1437..d01443e 100644 --- a/python/tests/test_microfluidics.py +++ b/python/tests/test_microfluidics.py @@ -31,6 +31,7 @@ def _grid() -> SignalGridSpec: grid.spacing = Vec3(4.0, 4.0, 4.0) grid.diffusion = [40.0] grid.advection = [Vec3()] + return grid @@ -56,6 +57,7 @@ def test_device_flow_runs_through_the_channel_and_rests_in_the_trap() -> None: def y_face(x: int, fy: int, z: int) -> float: assert grid.velocity_field is not None + return grid.velocity_field.y_faces[ x * (grid.shape.y + 1) * grid.shape.z + fy * grid.shape.z + z ] @@ -117,6 +119,7 @@ def test_wall_surfaces_stay_inside_the_fluid_mask() -> None: for device, spacing, surfaces in cases: half = (spacing[0] * 0.5, spacing[1] * 0.5, spacing[2] * 0.5) + for surface in surfaces: assert not device._solid(surface[0], surface[1], surface[2], half) @@ -133,6 +136,7 @@ def test_biopixel_cavity_is_shallow_beside_the_model_channel() -> None: def test_planar_mask_wall_error_is_bounded_by_half_spacing() -> None: device = TrapChannelDevice() + for h in (1.0, 2.0, 4.0): for shift in (0.1, 0.4, 0.9): spec = SignalGridSpec() @@ -155,6 +159,7 @@ def test_trap_example_builds_steps_and_transports_nutrient() -> None: ModelContext(BackendKind.CPU, 0, seed=11), ) assert isinstance(model, SimulationController) + for _ in range(20): model.step(0.02) @@ -166,8 +171,10 @@ def test_trap_example_builds_steps_and_transports_nutrient() -> None: assert upstream > 5.0 assert trap_interior > 5.0 assert upstream >= trap_interior - 1.0e-3 + with pytest.raises(ValueError, match="inside a grid obstacle"): simulation.sample_signals(Vec3(0.0, 100.0, 0.0)) + assert len(simulation.cells()) >= 1 @@ -177,6 +184,7 @@ def test_trap_example_builds_its_initial_flow_on_the_selected_backend( ) -> None: if not backend_available(backend): pytest.skip(f"{backend.name} backend is unavailable") + model, _ = build_model( _EXAMPLES / "microfluidic_trap.py", ModelContext(backend, 0, seed=13), @@ -210,6 +218,7 @@ def test_biopixel_example_confines_a_monolayer_under_flow() -> None: ModelContext(BackendKind.CPU, 0, seed=5), ) assert isinstance(model, SimulationController) + # 110 steps crosses the model's Brinkman re-solve cadence at step 100, so # the run exercises the colony-drag solve and the runtime field swap. for _ in range(110): @@ -217,10 +226,12 @@ def test_biopixel_example_confines_a_monolayer_under_flow() -> None: cells = model.simulation.cells() assert len(cells) >= 2 + for cell in cells: assert 0.0 < cell.position.z < 1.65 assert -50.0 < cell.position.y < 50.0 assert cell.position.x < 95.0 + checkpoint = model.simulation._checkpoint() assert checkpoint.signal_grid is not None assert checkpoint.signal_grid.spec.velocity_field is not None diff --git a/python/tests/test_mother_machine.py b/python/tests/test_mother_machine.py new file mode 100644 index 0000000..173a775 --- /dev/null +++ b/python/tests/test_mother_machine.py @@ -0,0 +1,194 @@ +# pyright: reportPrivateUsage=false + +import math +from pathlib import Path + +import pytest +from microsimulator import ( + BackendKind, + ModelContext, + NativeController, + Vec3, + build_model, + capture_scene, + load_checkpoint_bundle, + run_simulation, +) +from microsimulator.checkpoint import JSONValue +from microsimulator.microfluidics import MotherMachineDevice +from microsimulator.runner import BatchError + +MODEL = Path(__file__).resolve().parents[2] / "examples/mother_machine.py" +DEVICE = MotherMachineDevice() + + +def _assert_confined(model: NativeController) -> None: + state = model.controller_state()["state"] + assert isinstance(state, dict) + mothers = state["mother_ids"] + assert isinstance(mothers, list) + assert len(mothers) == DEVICE.channel_count + + for mother_id, center in zip(mothers, DEVICE.growth_centers, strict=True): + assert isinstance(mother_id, int) + mother = model.simulation.cell(mother_id) + assert mother.position.x > 21 + assert abs(mother.position.y - center) < 0.26 + assert mother.direction.x < 0 + assert mother.cell_type == 0 + + for cell in model.simulation.cells(): + assert not cell.fixed + half_x = abs(cell.direction.x) * cell.length / 2 + cell.radius + assert cell.position.x + half_x <= DEVICE.growth_length + 0.03 + + if cell.position.x - half_x > 0: + center = min(DEVICE.growth_centers, key=lambda y: abs(y - cell.position.y)) + half_y = abs(cell.direction.y) * cell.length / 2 + cell.radius + half_z = abs(cell.direction.z) * cell.length / 2 + cell.radius + assert abs(cell.position.y - center) + half_y <= DEVICE.growth_width / 2 + 0.03 + assert cell.position.z - half_z >= -0.03 + assert cell.position.z + half_z <= DEVICE.growth_height + 0.03 + + +@pytest.mark.parametrize("dt", [0.025, 0.0125]) +def test_growth_retains_old_pole_lineages_and_expels_descendants(dt: float) -> None: + model, _ = build_model( + MODEL, ModelContext(BackendKind.CPU, 0, seed=42, parameters={"growth_rate": 0.3}) + ) + assert isinstance(model, NativeController) + assert model.simulation.cell_count == 42 + initial_mothers = {cell.id for cell in model.simulation.cells() if cell.cell_type == 0} + passed_into_flow = False + + for index in range(round(10 / dt)): + model.step(dt) + passed_into_flow |= any(cell.position.x < 0 for cell in model.simulation.cells()) + + if index % 20 == 0: + _assert_confined(model) + + _assert_confined(model) + state = model.controller_state()["state"] + assert isinstance(state, dict) + generations = state["mother_generations"] + assert isinstance(generations, list) + assert all(isinstance(value, int) and value >= 2 for value in generations) + assert passed_into_flow + washed, divisions = state["washed_out"], state["division_count"] + assert isinstance(washed, int) and isinstance(divisions, int) + assert washed > 20 + assert model.simulation.cell_count == 42 + divisions - washed + assert not initial_mothers.intersection(cell.id for cell in model.simulation.cells()) + + +def test_nutrient_controls_growth_and_is_consumed() -> None: + context = ModelContext(BackendKind.CPU, 0, seed=42, parameters={"growth_rate": 0.3}) + model, _ = build_model(MODEL, context) + control, _ = build_model( + MODEL, ModelContext(BackendKind.CPU, 0, seed=42, parameters={"cells": False}) + ) + assert isinstance(model, NativeController) and isinstance(control, NativeController) + before = {cell.id: cell.length for cell in model.simulation.cells()} + model.step(0.025) + control.step(0.025) + sites = len(model.simulation.signal_levels) // 2 + assert all(cell.length > before[cell.id] for cell in model.simulation.cells()) + assert sum(model.simulation.signal_levels[sites:]) < sum( + control.simulation.signal_levels[sites:] + ) + + levels = model.simulation.signal_levels + levels[sites:] = [0.0] * sites + model.simulation.set_signal_levels(levels) + before = {cell.id: cell.length for cell in model.simulation.cells()} + model.step(0.025) + assert all(cell.growth_rate == 0 for cell in model.simulation.cells()) + assert {cell.id: cell.length for cell in model.simulation.cells()} == before + + +def test_transport_supplies_closed_channels_through_their_mouths() -> None: + model, _ = build_model( + MODEL, ModelContext(BackendKind.CPU, 0, seed=42, parameters={"cells": False}) + ) + assert isinstance(model, NativeController) + grid = model.simulation._checkpoint().signal_grid + assert grid is not None + field = grid.spec.velocity_field + assert field is not None + spec = grid.spec + nz, ny = spec.shape.z, spec.shape.y + main_flow = field.y_faces[6 * (ny + 1) * nz + 60 * nz + 1] + deep_flow = field.y_faces[32 * (ny + 1) * nz + 50 * nz + 1] + assert main_flow > 20 + assert abs(deep_flow) < 0.01 + + for _ in range(40): + model.step(0.025) + + mouth = model.simulation.sample_signals(Vec3(1, -11.25, 0.7))[0] + end = model.simulation.sample_signals(Vec3(24, -11.25, 0.7))[0] + assert mouth > 0.01 + assert 0 <= end < mouth * 0.01 + assert all( + value == 0 + for value, solid in zip( + model.simulation.signal_levels[: spec.site_count], spec.obstacles, strict=True + ) + if solid + ) + scene = capture_scene(model.simulation) + assert len(scene.constraints.boxes) == 10 + assert not scene.constraints.cylinders + + +def test_checkpoint_restores_mothers_division_and_washout(tmp_path: Path) -> None: + context = ModelContext(BackendKind.CPU, 0, seed=42, parameters={"growth_rate": 0.3}) + model, provenance = build_model(MODEL, context) + assert isinstance(model, NativeController) + checkpoint = tmp_path / "mother-machine.json" + run_simulation(model, steps=100, dt=0.025, output=checkpoint, provenance=provenance) + resumed, _ = build_model(MODEL, context, checkpoint=load_checkpoint_bundle(checkpoint)) + assert isinstance(resumed, NativeController) + + for _ in range(40): + model.step(0.025) + resumed.step(0.025) + + assert capture_scene(model.simulation) == capture_scene(resumed.simulation) + assert model.controller_state() == resumed.controller_state() + + +def test_default_growth_is_unaccelerated_and_cells_remain_mobile() -> None: + model, _ = build_model(MODEL, ModelContext(BackendKind.CPU, 0, seed=42)) + assert isinstance(model, NativeController) + initial = {cell.id: cell.position.x for cell in model.simulation.cells()} + + for _ in range(10): + model.step(0.025) + + assert all(0 < cell.growth_rate <= math.log(2) / 1800 for cell in model.simulation.cells()) + assert all(not cell.fixed for cell in model.simulation.cells()) + assert model.simulation.cell_count == len(initial) + state = model.controller_state()["state"] + assert isinstance(state, dict) + assert state["washed_out"] == 0 + + +@pytest.mark.parametrize( + "parameters", [{"growth_rate": -1}, {"growth_rate": True}, {"cells": "yes"}] +) +def test_invalid_model_parameters_are_rejected(parameters: dict[str, JSONValue]) -> None: + with pytest.raises(BatchError): + build_model(MODEL, ModelContext(BackendKind.CPU, 0, seed=42, parameters=parameters)) + + +def test_device_rejects_overlapping_or_out_of_bounds_growth_channels() -> None: + with pytest.raises(ValueError): + MotherMachineDevice(channel_pitch=1) + + with pytest.raises(ValueError): + MotherMachineDevice(channel_count=20) + + with pytest.raises(ValueError): + MotherMachineDevice(growth_height=8) diff --git a/python/tests/test_occupancy_native.py b/python/tests/test_occupancy_native.py new file mode 100644 index 0000000..5ecdf58 --- /dev/null +++ b/python/tests/test_occupancy_native.py @@ -0,0 +1,345 @@ +"""The same independent float64 oracle is used for every native GPU device.""" + +from __future__ import annotations + +import math +from collections.abc import Callable + +import numpy as np +import pytest +from microsimulator import BackendKind, backend_device_count +from microsimulator import occupancy_reference as reference +from microsimulator.occupancy import Capsule, Face, OccupancySolver, ReservoirFace +from numpy.typing import NDArray + +DEVICES = [ + (backend, index) + for backend in (BackendKind.METAL, BackendKind.CUDA) + for index in range(backend_device_count(backend)) +] + + +@pytest.fixture(scope="module", params=DEVICES or [None]) +def solver(request: pytest.FixtureRequest) -> OccupancySolver: + device: tuple[BackendKind, int] | None = request.param + + if device is None: + pytest.skip("no native Metal or CUDA device") + + return OccupancySolver(device[0], device_index=device[1]) + + +def assert_parity(actual: NDArray[np.float32], expected: NDArray[np.float64]) -> None: + np.testing.assert_allclose(actual, expected, rtol=2e-4, atol=2e-6) + assert actual.dtype == np.float32 + + +def test_geometry_empty_overlap_walls_rotation_translation(solver: OccupancySolver) -> None: + centers = np.array([(x, y, z) for x in range(-2, 3) for y in range(-1, 2) for z in (-1, 0)]) + cells = [Capsule((0.13, -0.17, 0.09), (3, 1, -2), 1.6, 0.43)] + walls = [i in (4, 12) for i in range(len(centers))] + + for m in (4, 8, 16): + expected = reference.geometric_porosity( + centers, (1, 0.8, 0.7), cells, subdivisions=m, walls=walls + ) + actual = solver.geometric_porosity( + centers, (1, 0.8, 0.7), cells, subdivisions=m, walls=walls + ) + np.testing.assert_array_equal(actual, expected) + np.testing.assert_array_equal( + actual, + solver.geometric_porosity( + centers, (1, 0.8, 0.7), cells * 2, subdivisions=m, walls=walls + ), + ) + + np.testing.assert_array_equal(solver.geometric_porosity(centers, (1, 1, 1), []), 1) + np.testing.assert_array_equal( + solver.geometric_porosity([[0, 0, 0]], (1, 1, 1), [Capsule((0, 0, 0), (1, 0, 0), 0, 2)]), + [0], + ) + assert solver.geometric_porosity(np.empty((0, 3)), (1, 1, 1), []).size == 0 + + +def test_geometry_refinement_and_division(solver: OccupancySolver) -> None: + centers = np.array([[0, 0, 0]], dtype=np.float64) + sphere = Capsule((0, 0, 0), (1, 0, 0), 0, 0.4) + exact = 4 / 3 * math.pi * 0.4**3 + errors: list[float] = [] + + for m in (8, 16, 32): + epsilon = solver.geometric_porosity(centers, (1, 1, 1), [sphere], subdivisions=m) + errors.append(abs(1 - float(epsilon[0]) - exact)) + + assert errors[-1] < errors[0] and errors[-1] / exact < 0.02 + centers = np.array([[x, 0, 0] for x in range(-3, 4)], dtype=np.float64) + parent = Capsule((0, 0, 0), (1, 0, 0), 4, 0.4) + daughters = [Capsule((x, 0, 0), (1, 0, 0), 1.6, 0.4) for x in (-1.2, 1.2)] + old = solver.geometric_porosity(centers, (1, 1, 1), [parent], subdivisions=16) + divided = solver.geometric_porosity(centers, (1, 1, 1), daughters, subdivisions=16) + assert divided.sum() > old.sum() + remapped = solver.remap_amounts(old * 2, old, divided, []) + removed = solver.remap_amounts(remapped, divided, np.ones_like(divided), []) + np.testing.assert_array_equal(removed, old * 2) + + +def test_cutoff_and_face_aperture_once(solver: OccupancySolver) -> None: + epsilon = np.array([0, 1e-12, 1e-8, 1e-7, 0.25, 0.75, 1], dtype=np.float32) + expected = np.where(epsilon < np.float32(1e-8), 0, epsilon).astype(np.float64) * 2 + assert_parity(solver.accessible_volumes(epsilon, 2), expected) + + for velocity in (-5.0, 0.0, 5.0): + expected_face = reference.porosity_face( + 0, 1, 0.25, 0.75, diffusion=2, area=3, distance=4, intrinsic_velocity=velocity + ) + actual = solver.porosity_face( + 0, 1, 0.25, 0.75, diffusion=2, area=3, distance=4, intrinsic_velocity=velocity + ) + assert actual == expected_face + + for cutoff in (1e-9, 1e-8, 1e-7): + configured = OccupancySolver( + solver.backend, device_index=solver.device_index, epsilon_cutoff=cutoff + ) + np.testing.assert_array_equal( + configured.accessible_volumes([cutoff / 2, cutoff * 2], 1), [0, np.float32(cutoff * 2)] + ) + face = configured.porosity_face(0, 1, cutoff / 2, 1, diffusion=1, area=1, distance=1) + assert face.conductance == face.volume_flux == 0 + + +def test_remap_components_periodic_edges_reopening_and_rejection(solver: OccupancySolver) -> None: + amount = np.array([2.0, 3.0, 1.0]) + old = np.ones(3) + new = np.array([0, 0.25, 0.75]) + neighbors = [(0, 1), (1, 2)] + result = solver.remap_amounts(amount, old, new, neighbors) + np.testing.assert_array_equal(result, [0, 3.5, 2.5]) + np.testing.assert_array_equal(solver.remap_amounts(result, new, old, neighbors), result) + + # A persistent wall separates components; an explicit periodic edge joins ends. + with pytest.raises(ValueError, match="no accessible recipient"): + solver.remap_amounts([2, 0, 1], [1, 0, 1], [0, 0, 1], neighbors) + + np.testing.assert_array_equal( + solver.remap_amounts([2, 0, 1], [1, 0, 1], [0, 0, 1], [*neighbors, (2, 0)]), [0, 0, 3] + ) + + with pytest.raises(ValueError, match="no accessible recipient"): + solver.remap_amounts(amount, old, [0, 0, 0], neighbors) + + np.testing.assert_array_equal(amount, [2, 3, 1]) + np.testing.assert_array_equal(old, 1) + np.testing.assert_array_equal(solver.remap_amounts([0], [1], [0], []), [0]) + assert solver.remap_amounts([], [], [], []).size == 0 + + +def test_exchange_sampling_scatter_adjoint(solver: OccupancySolver) -> None: + volume = np.array([0, 0.25, 0.75, 1.5]) + kernel = np.array([1, 0.5, 0.5, 0.2]) + weights = solver.exchange_weights(kernel, volume) + assert_parity(weights, reference.exchange_weights(kernel, volume.astype(np.float64))) + concentration = np.array([0, 2, 4, 3]) + rate = 1.25 + assert math.isclose(float(weights.sum()), 1, abs_tol=1e-7) + assert math.isclose( + float((weights * rate) @ concentration), float(weights @ concentration) * rate, rel_tol=1e-6 + ) + assert weights[0] == 0 + + with pytest.raises(ValueError, match="accessible exchange"): + solver.exchange_weights([1], [0]) + + +def test_transport_empty_limit_periodic_and_unequal_storage(solver: OccupancySolver) -> None: + for volume, faces in [ + ([1, 1, 1], [Face(0, 1, 1), Face(1, 2, 1)]), + ([0.25, 0.75, 1.0], [Face(0, 1, 0.2, 0.1), Face(1, 2, 0.3, 0.1), Face(2, 0, 0.4, 0.1)]), + ([1, 0, 0.5], [Face(0, 1, 0), Face(1, 2, 0)]), + ]: + amount = np.array([1, 0, 0.5]) + result, ledger = solver.backward_euler(amount, volume, faces, 0.1) + expected, _ = reference.backward_euler(amount, volume, faces, 0.1) + assert_parity(result, expected) + assert abs(ledger.residual) <= 5e-6 + np.testing.assert_array_equal(amount, [1, 0, 0.5]) + + empty, ledger = solver.backward_euler([], [], [], 1) + assert empty.size == 0 and ledger.residual == 0 + np.testing.assert_array_equal(solver.backward_euler([0], [0], [], 1)[0], [0]) + + +def test_closed_1000_step_drift(solver: OccupancySolver) -> None: + volume = np.array([0.5, 1.5]) + face = solver.porosity_face(0, 1, 0.25, 0.75, diffusion=1, area=1, distance=1) + amount = np.array([4, 0], dtype=np.float32) + + for _ in range(1000): + amount, ledger = solver.backward_euler(amount, volume, [face], 1) + assert abs(ledger.residual) / 4 <= 5e-6 + + assert abs(float(amount.sum(dtype=np.float64)) - 4) / 4 <= 5e-5 + assert_parity(solver.concentration(amount, volume), np.array([2, 2])) + + +def test_boundary_reaction_exchange_and_signed_flux(solver: OccupancySolver) -> None: + for flux in (-0.05, 0.05): + volume = [0.25, 0.75] + faces = [Face(0, 1, 0.1, flux)] + reservoirs = [ReservoirFace(0, 5, 0.2, -0.1), ReservoirFace(1, 0, 0, 0.1)] + source = [0.75, 2.25] + loss = [0.3, 0.7] + actual, ledger = solver.backward_euler( + [0.5, 3], volume, faces, 0.2, source=source, loss=loss, reservoirs=reservoirs + ) + expected, balance = reference.backward_euler( + [0.5, 3], volume, faces, 0.2, source=source, loss=loss, reservoirs=reservoirs + ) + assert_parity(actual, expected) + assert abs(ledger.residual) < 5e-6 + + for name in ("before", "after", "source", "reaction", "boundary"): + assert math.isclose( + getattr(ledger, name), getattr(balance, name), rel_tol=2e-4, abs_tol=2e-6 + ) + + # Prescribed divergent intrinsic flow conserves amount but need not preserve c. + actual, ledger = solver.backward_euler([1, 1], [1, 1], [Face(0, 1, 0, 0.5)], 0.2) + assert actual[0] != actual[1] and abs(ledger.residual) < 5e-6 + + +def test_timestep_and_spatial_refinement(solver: OccupancySolver) -> None: + errors: list[float] = [] + + for steps in (10, 20, 40): + amount = np.array([1, 0], dtype=np.float32) + + for _ in range(steps): + amount, _ = solver.backward_euler(amount, [1, 1], [Face(0, 1, 1)], 1 / steps) + + exact = np.array([0.5 * (1 + math.exp(-2)), 0.5 * (1 - math.exp(-2))]) + errors.append(float(np.max(np.abs(amount - exact)))) + + assert errors[2] < 0.55 * errors[1] < 0.31 * errors[0] + # Diffusion eigenmode on a fixed physical no-flux interval, fixed dt. + # At h=1/40, even the float64 solution rounded to float32 has an amount + # equation residual near 1.1e-6. Use an explicit 2e-6 solve tolerance while + # keeping the independent 5e-6 ledger and spatial convergence gates. + errors = [] + + for count in (10, 20, 40): + h = 1 / count + x = (np.arange(count) + 0.5) * h + initial = (1 + 0.5 * np.cos(math.pi * x)) * h + faces = [Face(i, i + 1, 1 / h) for i in range(count - 1)] + amount, _ = solver.backward_euler( + initial, np.full(count, h), faces, 0.01, relative_tolerance=2e-6 + ) + exact = 1 + 0.5 * np.cos(math.pi * x) / (1 + 0.01 * math.pi**2) + errors.append(float(np.max(np.abs(amount / h - exact)))) + + assert errors[2] < 0.32 * errors[1] < 0.1 * errors[0] + + +def test_invalid_inputs_and_failed_candidates_are_atomic(solver: OccupancySolver) -> None: + invalid: list[Callable[[], object]] = [ + lambda: solver.concentration([1], [0]), + lambda: solver.concentration([-1], [1]), + lambda: solver.accessible_volumes([1.1], 1), + lambda: solver.accessible_volumes([1], -1), + lambda: solver.remap_amounts([1], [1], [1], [(0, 1)]), + lambda: solver.geometric_porosity([[0, 0, 0]], (1, 1, 1), [], subdivisions=0), + lambda: solver.geometric_porosity([[0, 0, 0]], (1, 1, 1), [], subdivisions=True), + lambda: solver.backward_euler([1, 0], [1, 0], [Face(0, 1, 1)], 1), + lambda: solver.backward_euler([1], [1], [], 1, source=[]), + lambda: solver.backward_euler([0], [0], [], 1, source=[1]), + lambda: solver.backward_euler([1], [1], [], 1, loss=[-1]), + lambda: solver.backward_euler([1], [1], [], -1), + lambda: solver.backward_euler([1], [1], [], 1, source=[-2]), + lambda: solver.backward_euler([float("nan")], [1], [], 1), + lambda: solver.backward_euler([1e-60], [1], [], 1), + ] + + for operation in invalid: + with pytest.raises((ValueError, TypeError)): + operation() + + amount = np.array([1.0, 0.0]) + previous_report = solver.last_report + + with pytest.raises(RuntimeError, match="did not converge"): + solver.backward_euler(amount, [1, 1], [Face(0, 1, 1)], 100, max_iterations=1) + + np.testing.assert_array_equal(amount, [1, 0]) + assert solver.last_report is previous_report + np.testing.assert_array_equal(solver.backward_euler(amount, [1, 1], [], 0)[0], amount) + + +def test_no_implicit_cpu_fallback() -> None: + with pytest.raises(ValueError, match="Metal or CUDA"): + OccupancySolver(BackendKind.CPU) + + with pytest.raises(ValueError): + OccupancySolver("auto") + + for backend in (BackendKind.METAL, BackendKind.CUDA): + with pytest.raises((RuntimeError, IndexError)): + OccupancySolver(backend, device_index=backend_device_count(backend)) + + +def test_multiple_components_and_cross_block_transport(solver: OccupancySolver) -> None: + # Cross both CUDA's 128-thread block and Metal's 64-thread dispatch width. + count = 257 + rng = np.random.default_rng(31) + old = rng.uniform(0.2, 1, count) + new = rng.uniform(0.2, 1, count) + amount = rng.uniform(0, 2, count) + old[128] = new[128] = amount[128] = 0 + new[::7] = 0 + neighbors = [(i, i + 1) for i in range(count - 1)] + expected = reference.remap_amounts(amount, old, new, neighbors) + actual = solver.remap_amounts(amount, old, new, neighbors) + assert_parity(actual, expected) + np.testing.assert_array_equal(actual, solver.remap_amounts(amount, old, new, neighbors)) + faces = [ + Face(i, j, 0.1, float(rng.uniform(-0.03, 0.03))) + for i, j in neighbors + if new[i] > 0 and new[j] > 0 + ] + sources = np.where(new > 0, 0.02, 0) + losses = rng.uniform(0, 0.1, count) + updated, balance = solver.backward_euler(actual, new, faces, 0.03, source=sources, loss=losses) + oracle, _ = reference.backward_euler( + actual.astype(np.float64), new, faces, 0.03, source=sources, loss=losses + ) + assert_parity(updated, oracle) + assert abs(balance.residual) / balance.before < 5e-6 + + +def test_fixed_physical_geometry_and_exchange_support_refinement(solver: OccupancySolver) -> None: + sphere = Capsule((0.03, -0.05, 0.04), (1, 0, 0), 0, 0.37) + exact = 4 / 3 * math.pi * sphere.radius**3 + errors: list[float] = [] + + for count in (4, 8, 16): + h = 2 / count + axis = -1 + (np.arange(count) + 0.5) * h + centers = ( + np.stack(np.meshgrid(axis, axis, axis, indexing="ij"), axis=-1) + .reshape(-1, 3) + .astype(np.float64) + ) + epsilon = solver.geometric_porosity(centers, (h, h, h), [sphere], subdivisions=8) + oracle = reference.geometric_porosity(centers, (h, h, h), [sphere], subdivisions=8) + assert_parity(epsilon, oracle) + errors.append(abs(float((1 - epsilon).sum(dtype=np.float64)) * h**3 - exact)) + volume = solver.accessible_volumes(epsilon, h**3) + # Same physical Gaussian support at every grid resolution. + distance_squared = np.sum((centers - np.array(sphere.center)) ** 2, axis=1) + kernel = np.exp(-distance_squared / (2 * 0.5**2)) + weights = solver.exchange_weights(kernel, volume) + assert_parity(weights, reference.exchange_weights(kernel, volume.astype(np.float64))) + assert abs(float(weights.sum(dtype=np.float64)) - 1) < 1e-6 + + assert errors[-1] < errors[0] and errors[-1] / exact < 0.01 diff --git a/python/tests/test_occupancy_reference.py b/python/tests/test_occupancy_reference.py new file mode 100644 index 0000000..e0d3c49 --- /dev/null +++ b/python/tests/test_occupancy_reference.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import math + +import numpy as np +import pytest +from microsimulator.occupancy_reference import ( + Capsule, + Face, + ReservoirFace, + accessible_volumes, + backward_euler, + concentration, + exchange_weights, + geometric_porosity, + porosity_face, + remap_amounts, +) + + +def test_empty_grid_matches_existing_native_backward_euler() -> None: + from microsimulator import GridShape, SignalGridSpec, SignalIntegrationKind, Simulation, Vec3 + + shape = GridShape() + shape.x, shape.y, shape.z = 3, 1, 1 + spec = SignalGridSpec() + spec.shape, spec.signal_count = shape, 1 + spec.diffusion, spec.advection = [1.0], [Vec3()] + spec.integration = SignalIntegrationKind.BACKWARD_EULER + simulation = Simulation() + simulation.configure_signal_grid(spec, [0.0, 1.0, 0.0]) + simulation.step(0.1) + result, balance = backward_euler([0, 1, 0], [1, 1, 1], [Face(0, 1, 1), Face(1, 2, 1)], 0.1) + np.testing.assert_allclose(result, simulation.signal_levels, rtol=2e-6, atol=2e-7) + assert abs(balance.residual) < 1e-12 + centers = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]) + np.testing.assert_array_equal(geometric_porosity(centers, (1, 1, 1), []), [1, 1]) + + +def test_partial_closed_grid_uses_amount_and_unequal_storage() -> None: + volume = accessible_volumes([0.25, 0.75], 2.0) + amount = volume * np.array([8.0, 0.0]) + face = porosity_face(0, 1, 0.25, 0.75, diffusion=1, area=1, distance=1) + initial = amount.sum() + + for _ in range(100): + amount, ledger = backward_euler(amount, volume, [face], 1.0) + assert abs(ledger.residual) < 1e-12 + + np.testing.assert_allclose(concentration(amount, volume), [2, 2], atol=1e-12) + assert abs(amount.sum() - initial) < 1e-12 + assert not math.isclose(float(amount[0]), float(amount[1])) + + +def test_changing_occupancy_and_full_closure_conserve_without_division_by_zero() -> None: + amount = np.array([2.0, 3.0, 1.0]) + old = np.ones(3) + new = accessible_volumes([0, 0.25, 0.75], 1) + result = remap_amounts(amount, old, new, [(0, 1), (1, 2)]) + np.testing.assert_allclose(result, [0, 3.5, 2.5], atol=1e-15) + assert result.sum() == amount.sum() + assert np.isfinite(concentration(result, new)).all() + reopened = remap_amounts(result, new, [1, 1, 1], [(0, 1), (1, 2)]) + assert reopened[0] == 0 # no invented solute in newly exposed storage + assert reopened.sum() == amount.sum() + + with pytest.raises(ValueError, match="no accessible recipient"): + remap_amounts(amount, old, [0, 0, 0], [(0, 1), (1, 2)]) + + np.testing.assert_array_equal(amount, [2, 3, 1]) # rejection is atomic + + # A persistent wall separates the only potential recipient. + with pytest.raises(ValueError, match="no accessible recipient"): + remap_amounts([1, 0, 0], [1, 0, 1], [0, 0, 1], [(0, 1), (1, 2)]) + + np.testing.assert_array_equal(accessible_volumes([1e-12, 0], 1), [0, 0]) + np.testing.assert_array_equal(remap_amounts([0], [1], [0], []), [0]) + + +def test_geometry_union_wall_clipping_division_and_removal() -> None: + centers = np.array( + [(x, y, z) for x in range(-3, 4) for y in range(-1, 2) for z in range(-1, 2)], + dtype=np.float64, + ) + parent = Capsule((0, 0, 0), (1, 0, 0), 4, 0.4) + daughters = [ + Capsule((-1.2, 0, 0), (1, 0, 0), 1.6, 0.4), + Capsule((1.2, 0, 0), (1, 0, 0), 1.6, 0.4), + ] + old = geometric_porosity(centers, (1, 1, 1), [parent], subdivisions=16) + np.testing.assert_array_equal( + old, geometric_porosity(centers, (1, 1, 1), [parent, parent], subdivisions=16) + ) + divided = geometric_porosity(centers, (1, 1, 1), daughters, subdivisions=16) + assert divided.sum() > old.sum() # native division reduces geometric solid volume + b_parent = math.pi * 0.4**2 * (4 + 0.8) + b_daughters = 2 * math.pi * 0.4**2 * (1.6 + 0.8) + assert math.isclose(b_parent, b_daughters) + assert math.isclose( + (math.pi * 0.4**2 * 4 + 4 / 3 * math.pi * 0.4**3) + - 2 * (math.pi * 0.4**2 * 1.6 + 4 / 3 * math.pi * 0.4**3), + 2 / 3 * math.pi * 0.4**3, + ) + # No voxel closes in this geometry transition; amount remains voxel-local. + amount = old * 2 + updated = remap_amounts(amount, old, divided, []) + removed = remap_amounts(updated, divided, np.ones_like(old), []) + assert abs(removed.sum() - amount.sum()) < 1e-12 + wall_mask = [bool(index == 31) for index in range(len(centers))] + with_wall = geometric_porosity(centers, (1, 1, 1), [parent], walls=wall_mask) + assert with_wall[31] == 0 + assert np.all((with_wall >= 0) & (with_wall <= 1)) + + +def test_boundary_reaction_and_cell_exchange_have_explicit_amount_ledgers() -> None: + volume = np.array([0.25, 0.75]) + weights = exchange_weights([0.5, 0.5], volume) + c = np.array([2.0, 4.0]) + source = weights * 3.0 + assert float(weights @ c) == 3.5 + assert float(source.sum()) == 3.0 + updated, ledger = backward_euler( + c * volume, + volume, + [Face(0, 1, 0.1, 0.05)], + 0.2, + source=source, + loss=[0.3, 0.7], + reservoirs=[ReservoirFace(0, 5, 0.2, -0.1), ReservoirFace(1, 0, 0, 0.1)], + ) + assert np.all(updated >= 0) + assert ledger.boundary > 0 and ledger.reaction < 0 and ledger.source > 0 + assert abs(ledger.residual) < 1e-12 + face = porosity_face(0, 1, 0.25, 0.75, diffusion=2, area=3, distance=4, intrinsic_velocity=5) + assert face.volume_flux == 0.375 * 3 * 5 # porosity appears exactly once + + with pytest.raises(ValueError, match="accessible exchange"): + exchange_weights([1, 0], [0, 1]) + + +def test_diffusion_timestep_refinement_and_geometric_quadrature_refinement() -> None: + # Two-cell antisymmetric diffusion mode has eigenvalue -2 for epsilon=1. + errors: list[float] = [] + + for steps in (10, 20, 40): + amount = np.array([1.5, 0.5]) + + for _ in range(steps): + amount, _ = backward_euler(amount, [1, 1], [Face(0, 1, 1)], 1 / steps) + + errors.append(abs(float(amount[0]) - (1 + 0.5 * math.exp(-2)))) + + assert errors[2] < 0.55 * errors[1] < 0.31 * errors[0] + sphere = Capsule((0, 0, 0), (1, 0, 0), 0, 0.5) + exact = 4 / 3 * math.pi * 0.5**3 + geometric_errors: list[float] = [] + + for resolution in (8, 16, 32): + epsilon = geometric_porosity(np.zeros((1, 3)), (2, 2, 2), [sphere], subdivisions=resolution) + geometric_errors.append(abs(float((1 - epsilon[0]) * 8) - exact)) + + assert geometric_errors[-1] < geometric_errors[0] + assert geometric_errors[-1] < 0.02 * exact + + +def test_invalid_reference_inputs_fail_without_silent_clipping() -> None: + with pytest.raises(ValueError): + accessible_volumes([1.1], 1) + + with pytest.raises(ValueError): + concentration([1], [0]) + + with pytest.raises(ValueError): + backward_euler([0], [1], [], 1, source=[-1]) + + with pytest.raises(ValueError): + backward_euler([0, 1], [0, 1], [Face(0, 1, 1)], 0.1) + + +def test_empty_limit_spatial_operator_is_second_order() -> None: + errors: list[float] = [] + + for count in (10, 20, 40): + h = 1 / count + centers = (np.arange(count, dtype=np.float64) + 0.5) * h + values = 2 + np.cos(math.pi * centers) + rate = np.zeros(count) + + for i in range(count - 1): + face = porosity_face(i, i + 1, 1, 1, diffusion=1, area=1, distance=h) + amount_flux = face.conductance * (values[i + 1] - values[i]) + rate[i] += amount_flux / h + rate[i + 1] -= amount_flux / h + + exact = -(math.pi**2) * np.cos(math.pi * centers) + errors.append(float(np.max(np.abs(rate - exact)))) + + assert errors[2] < 0.26 * errors[1] < 0.07 * errors[0] diff --git a/python/tests/test_planarity.py b/python/tests/test_planarity.py new file mode 100644 index 0000000..243723e --- /dev/null +++ b/python/tests/test_planarity.py @@ -0,0 +1,118 @@ +"""Shared native-backend regressions for the documented three-dimensional contract.""" + +from __future__ import annotations + +import math +import runpy +from itertools import pairwise +from pathlib import Path + +import pytest +from microsimulator import ( + BackendKind, + ModelContext, + NativeController, + Simulation, + backend_available, + build_model, +) +from microsimulator.scene import capture_scene, dumps_scene + +ROOT = Path(__file__).resolve().parents[2] +DIAGNOSTIC = runpy.run_path(str(ROOT / "scripts" / "diagnose_planarity.py")) + + +@pytest.mark.parametrize("backend", list(BackendKind)) +def test_three_dimensional_diagnostic_fixtures(backend: BackendKind) -> None: + if not backend_available(backend): + pytest.skip(f"{backend} runtime unavailable; no fallback") + + results = DIAGNOSTIC["fixtures"](backend, 17, 0.02) + + for name in ("separated_planar", "planar_division"): + assert results[name]["first_out_of_plane"] is None + assert all( + stage["max_center_displacement_from_plane"] < 1e-6 for stage in results[name]["stages"] + ) + assert all(stage["max_direction_z"] < 1e-6 for stage in results[name]["stages"]) + + for name in ("crossing", "coincident_parallel"): + result = results[name] + assert any(abs(normal[2]) > 0.99 for normal in result["contact_normals"]) + assert result["first_out_of_plane"]["stage"] == "contact_and_constraint_relaxation" + negative, positive = sorted(cell["center"][2] for cell in result["final_geometry"]) + assert math.isclose(negative, -0.4, abs_tol=1e-6) + assert math.isclose(positive, 0.4, abs_tol=1e-6) + + inherited = results["inherited_tilt"] + assert inherited["first_out_of_plane"]["stage"] == "initialization" + assert any( + stage["max_center_displacement_from_plane"] > 0.2 + for stage in inherited["stages"] + if stage["stage"] == "division" + ) + jitter = [stage for stage in inherited["stages"] if stage["stage"] == "geometry_edit"] + assert len(jitter) == 2 + assert all(stage["requested_direction_delta_z"] == 0 for stage in jitter) + assert any(stage["max_stage_direction_change_z"] > 1e-6 for stage in jitter) + confined = results["finite_height_constraints"] + default = confined["default_wall_penetration_by_pass"] + tight = confined["tight_wall_penetration_by_pass"] + assert math.isclose(default[0], 0.3, abs_tol=1e-6) + assert 1e-6 < default[-1] < 0.005 + assert tight[-1] < 1e-6 + assert all(after <= before + 1e-7 for before, after in pairwise(tight)) + final = confined["final_geometry"][0] + axial_z_extent = abs(final["direction"][2]) * final["length"] / 2 + final["radius"] + assert final["center"][2] + axial_z_extent <= 1 + 1e-6 + assert final["center"][2] > 0.49 # walls permit nonzero Z; they do not impose z=0 + flow = results["vertical_flow"] + assert flow["first_out_of_plane"]["stage"] == "flow_drift" + assert math.isclose(flow["final_geometry"][0]["center"][2], 1.004, abs_tol=1e-6) + + +def test_diagnostics_preserve_rng_and_restore_native_methods_on_failure() -> None: + model_path = ROOT / "examples/tutorials/biophysics.py" + traced, _ = build_model( + model_path, ModelContext(BackendKind.CPU, 0, seed=17, parameters={"scenario": "two_types"}) + ) + normal, _ = build_model( + model_path, ModelContext(BackendKind.CPU, 0, seed=17, parameters={"scenario": "two_types"}) + ) + assert isinstance(traced, NativeController) + assert isinstance(normal, NativeController) + trace = DIAGNOSTIC["Trace"](traced.simulation) + originals = { + name: getattr(Simulation, name) + for name in ( + "divide", + "divide_equal", + "remove_cell", + "step", + "apply_flow_drift", + "relax_cell_mechanics", + "set_cell_geometry", + ) + } + + with trace.instrument(): + for _ in range(5): + traced.step(0.02) + + for _ in range(5): + normal.step(0.02) + + assert dumps_scene(capture_scene(traced.simulation)) == dumps_scene( + capture_scene(normal.simulation) + ) + assert traced.controller_state() == normal.controller_state() + assert traced.simulation.cell_count > len(trace.initial["cells"]) + assert any(stage["stage"] == "geometry_edit" for stage in trace.events) + assert all(getattr(Simulation, name) is method for name, method in originals.items()) + + with pytest.raises(RuntimeError, match="probe"), trace.instrument(): + raise RuntimeError("probe") + + assert all(getattr(Simulation, name) is method for name, method in originals.items()) + assert trace.initial["cells"] + assert "constraints" in trace.report() diff --git a/python/tests/test_rates.py b/python/tests/test_rates.py index a9eccd8..3ace8db 100644 --- a/python/tests/test_rates.py +++ b/python/tests/test_rates.py @@ -20,6 +20,7 @@ def test_symbolic_species_plan_runs_on_every_available_backend(backend: BackendKind) -> None: if not backend_available(backend): pytest.skip("native backend is not built") + builder = RatePlanBuilder() x = builder.species(0) y = builder.species(1) @@ -51,6 +52,7 @@ def test_symbolic_species_plan_runs_on_every_available_backend(backend: BackendK def test_symbolic_coupled_plan_uses_geometry_and_signal_sources(backend: BackendKind) -> None: if not backend_available(backend): pytest.skip("native backend is not built") + builder = RatePlanBuilder() intracellular = builder.species(0) extracellular = builder.signal(0) @@ -82,9 +84,12 @@ def test_symbolic_coupled_plan_uses_geometry_and_signal_sources(backend: Backend def test_rate_builder_rejects_nonfinite_constants_and_mixed_graphs() -> None: first = RatePlanBuilder() second = RatePlanBuilder() + with pytest.raises(RatePlanError, match="finite"): first.constant(float("nan")) + with pytest.raises(RatePlanError, match="different builders"): _ = first.species(0) + second.species(0) + with pytest.raises(TypeError, match="Booleans"): bool(first.species(0)) diff --git a/python/tests/test_renderer_compatibility.py b/python/tests/test_renderer_compatibility.py index d256ff2..4ca2ead 100644 --- a/python/tests/test_renderer_compatibility.py +++ b/python/tests/test_renderer_compatibility.py @@ -60,6 +60,7 @@ def test_every_legacy_renderer_has_a_closed_disposition() -> None: classes = [name for family in families for name in cast(list[str], family["classes"])] assert len(classes) == len(set(classes)) assert set(classes) == _CLASSES + for family in families: assert set(family) == { "id", @@ -71,6 +72,7 @@ def test_every_legacy_renderer_has_a_closed_disposition() -> None: } assert family["disposition"] in {"replaced", "deliberately_retired"} assert isinstance(family["reason"], str) and family["reason"] + if family["disposition"] == "replaced": assert isinstance(family["replacement"], str) and family["replacement"] else: @@ -79,8 +81,10 @@ def test_every_legacy_renderer_has_a_closed_disposition() -> None: def test_pinned_legacy_renderer_source_and_call_sites() -> None: legacy_root_value = os.environ.get("CM_LEGACY_ROOT") + if legacy_root_value is None: pytest.skip("CM_LEGACY_ROOT is required to authenticate legacy renderer sources") + legacy_root = Path(legacy_root_value) document = _document() source = legacy_root / cast(str, document["source"]) @@ -94,6 +98,7 @@ def test_pinned_legacy_renderer_source_and_call_sites() -> None: examples = tuple((legacy_root / "Examples").rglob("*.py")) families = cast(list[dict[str, Any]], document["families"]) + for family in families: classes = cast(list[str], family["classes"]) call_sites = sum( diff --git a/python/tests/test_replay.py b/python/tests/test_replay.py new file mode 100644 index 0000000..029834e --- /dev/null +++ b/python/tests/test_replay.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any, cast + +import pytest +import rfc8785 +from microsimulator import ( + CellInit, + ChannelMetadata, + GridShape, + SignalGridSpec, + Simulation, + Vec3, + load_scene, + save_checkpoint, +) +from microsimulator.cli import main +from microsimulator.replay import ReplayExportError, export_replay + + +def lifecycle_checkpoints(directory: Path) -> list[Path]: + """Native growth/division/removal; names deliberately oppose lexical ordering.""" + simulation = Simulation(species_count=1) + cell = CellInit() + cell.length = 2.0 + cell.growth_rate = 0.5 + cell.species = [0.25] + parent = simulation.add_cell(cell) + paths: list[Path] = [] + + for ordinal, name in enumerate( + ("z-start.json", "a-growth.json", "m-division.json", "b-removal.json") + ): + if ordinal == 1: + simulation.step(0.2) + elif ordinal == 2: + simulation.divide_equal(parent) + elif ordinal == 3: + simulation.remove_cell(2) + simulation.step(0.1) + + path = directory / name + save_checkpoint( + simulation, + path, + channel_metadata=ChannelMetadata(species=("Reporter",)), + provenance={"model": {"path": "/missing/model-that-must-not-be-imported.py"}}, + ) + paths.append(path) + + return paths + + +def test_ordered_export_preserves_topology_labels_equal_times_and_input_files( + tmp_path: Path, +) -> None: + paths = lifecycle_checkpoints(tmp_path) + before = [path.read_bytes() for path in paths] + summary = export_replay(paths, tmp_path / "bundle") + assert summary.frame_count == 4 + manifest = json.loads((summary.output / "manifest.json").read_text()) + assert manifest["format"] == "microsimulator-replay" + assert manifest["version"] == 1 + recording = manifest["recording"] + assert ( + hashlib.sha256(rfc8785.dumps(recording)).hexdigest() == manifest["integrity"]["recording"] + ) + frames = [load_scene(summary.output / entry["file"]) for entry in recording["frames"]] + assert [len(frame.cells) for frame in frames] == [1, 1, 2, 1] + assert frames[1].cells[0].length > frames[0].cells[0].length + assert [cell.id for cell in frames[2].cells] == [2, 3] + assert frames[3].cells[0].id == 3 + assert frames[2].time == frames[1].time + + for ordinal, (entry, source) in enumerate(zip(recording["frames"], paths, strict=True)): + assert entry["ordinal"] == ordinal + assert entry["checkpoint_sha256"] == hashlib.sha256(source.read_bytes()).hexdigest() + encoded = (summary.output / entry["file"]).read_bytes() + assert entry["bytes"] == len(encoded) + assert entry["sha256"] == hashlib.sha256(encoded).hexdigest() + assert frames[ordinal].channel_metadata.species == ("Reporter",) + + assert [path.read_bytes() for path in paths] == before + + +def test_source_backend_preserved_while_exporting_without_original_device(tmp_path: Path) -> None: + paths = lifecycle_checkpoints(tmp_path) + # This is a provenance fixture, not evidence of a CUDA simulation run. + source = cast(dict[str, Any], json.loads(paths[0].read_text())) + source["source_backend"] = { + "kind": "cuda", + "name": "Recorded GPU", + "device": "Unavailable GPU", + "device_index": 7, + "native": True, + } + paths[0].write_text(json.dumps(source)) + result = export_replay(paths[:1], tmp_path / "cpu-export") + manifest = json.loads((result.output / "manifest.json").read_text()) + assert manifest["recording"]["export_backend"]["kind"] == "cpu" + entry = manifest["recording"]["frames"][0] + assert entry["source_backend"] == source["source_backend"] + scene = load_scene(result.output / entry["file"]) + assert scene.backend.kind == "cuda" + assert scene.backend.device_index == 7 + + +def test_failure_is_attributed_to_ordinal_and_leaves_no_partial_bundle(tmp_path: Path) -> None: + paths = lifecycle_checkpoints(tmp_path) + output = tmp_path / "bad-order" + + with pytest.raises(ReplayExportError, match=r"frame 1.*precedes previous"): + export_replay([paths[1], paths[0]], output) + + assert not output.exists() + assert not list(tmp_path.glob(".bad-order.*")) + paths[2].write_text("invalid") + + with pytest.raises(ReplayExportError, match=r"frame 2.*not valid"): + export_replay(paths, output) + + assert not output.exists() + + with pytest.raises(ReplayExportError, match=r"frame 0.*missing.json"): + export_replay([tmp_path / "missing.json"], output) + + output.mkdir() + + with pytest.raises(ReplayExportError, match="output already exists"): + export_replay(paths[:1], output) + + with pytest.raises(ReplayExportError, match="ordered checkpoints"): + export_replay([], tmp_path / "empty") + + +def test_export_rejects_checkpoint_digest_tampering(tmp_path: Path) -> None: + paths = lifecycle_checkpoints(tmp_path) + document = json.loads(paths[0].read_text()) + document["simulation"]["time"] = 100 + paths[0].write_text(json.dumps(document)) + + with pytest.raises(ReplayExportError, match="state digest does not match"): + export_replay(paths, tmp_path / "bad") + + +def test_cli_exports_exact_argument_order_and_reports_errors( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + paths = lifecycle_checkpoints(tmp_path) + output = tmp_path / "cli" + assert main(["export-replay", *(str(path) for path in paths), "--output", str(output)]) == 0 + assert "frames=4" in capsys.readouterr().out + assert ( + main( + ["export-replay", str(paths[1]), str(paths[0]), "--output", str(tmp_path / "backwards")] + ) + == 2 + ) + assert "frame 1" in capsys.readouterr().err + + +def test_signal_grid_changes_are_independent_frames(tmp_path: Path) -> None: + paths: list[Path] = [] + + for ordinal, size in enumerate((None, 1, 3)): + simulation = Simulation(species_count=0) + + if size is not None: + shape = GridShape() + shape.x, shape.y, shape.z = size, size, size + spec = SignalGridSpec() + spec.signal_count = 2 + spec.shape = shape + spec.spacing = Vec3(1, 1, 1) + spec.diffusion = [0, 0] + spec.advection = [Vec3(), Vec3()] + simulation.configure_signal_grid(spec, [1.0] * (2 * size**3)) + + path = tmp_path / f"{ordinal}.json" + save_checkpoint(simulation, path) + paths.append(path) + + result = export_replay(paths, tmp_path / "grid") + frames = [ + load_scene(result.output / f"frames/{ordinal:08d}.scene.json") for ordinal in range(3) + ] + assert frames[0].signal_grid is None + assert frames[1].signal_grid is not None and frames[1].signal_grid.shape == (1, 1, 1) + assert frames[2].signal_grid is not None and frames[2].signal_grid.shape == (3, 3, 3) + + +def test_export_bounds_and_parent_io_failures_are_actionable( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import microsimulator.replay as replay + + paths = lifecycle_checkpoints(tmp_path) + parent = tmp_path / "file-parent" + parent.write_text("preserve") + + with pytest.raises(ReplayExportError, match="could not prepare replay destination"): + export_replay(paths, parent / "bundle") + + assert parent.read_text() == "preserve" + monkeypatch.setattr(replay, "MAX_REPLAY_MANIFEST_BYTES", 10) + + with pytest.raises(ReplayExportError, match="manifest exceeds"): + export_replay(paths, tmp_path / "bounded") + + assert not (tmp_path / "bounded").exists() + assert not list(tmp_path.glob(".bounded.*")) + monkeypatch.setattr(replay, "MAX_CHECKPOINT_BYTES", 10) + + with pytest.raises(ReplayExportError, match=r"frame 0.*checkpoint exceeds"): + export_replay(paths, tmp_path / "input-limit") diff --git a/python/tests/test_run_manifest.py b/python/tests/test_run_manifest.py index 09d74e4..454e65d 100644 --- a/python/tests/test_run_manifest.py +++ b/python/tests/test_run_manifest.py @@ -31,6 +31,7 @@ def _write_model(path: Path, *, side_effect: Path | None = None) -> str: " return simulation\n", encoding="utf-8", ) + return hashlib.sha256(path.read_bytes()).hexdigest() @@ -61,6 +62,7 @@ def build(context): """, encoding="utf-8", ) + return hashlib.sha256(path.read_bytes()).hexdigest() @@ -240,6 +242,7 @@ def test_manifest_rejects_duplicate_ids_and_output_collisions(tmp_path: Path) -> manifest_path = tmp_path / "invalid.json" duplicate = _job(job_id="same", model_sha256=digest, output="first.json") _write_manifest(manifest_path, [duplicate, {**duplicate, "output": "second.json"}]) + with pytest.raises(RunManifestError, match="IDs must be unique"): load_run_manifest(manifest_path) @@ -256,18 +259,21 @@ def test_manifest_rejects_duplicate_ids_and_output_collisions(tmp_path: Path) -> checkpoint_every=3, ) _write_manifest(manifest_path, [first, second]) + with pytest.raises(RunManifestError, match="colliding periodic outputs"): load_run_manifest(manifest_path) second["checkpoint_every"] = 0 second["output"] = "runs/colony.step-00000002.json" _write_manifest(manifest_path, [first, second]) + with pytest.raises(RunManifestError, match="colliding final/periodic outputs"): load_run_manifest(manifest_path) first["output"] = "runs/legacy.cm2.json" second["output"] = "runs/legacy.step-00000002.cm2.json" _write_manifest(manifest_path, [first, second]) + with pytest.raises(RunManifestError, match="colliding final/periodic outputs"): load_run_manifest(manifest_path) @@ -299,6 +305,7 @@ def test_manifest_rejects_invalid_job_fields( def test_manifest_rejects_duplicate_keys_and_nonfinite_parameters(tmp_path: Path) -> None: path = tmp_path / "invalid.json" path.write_text('{"format":"first","format":"second"}') + with pytest.raises(RunManifestError, match="duplicate key"): load_run_manifest(path) @@ -306,5 +313,6 @@ def test_manifest_rejects_duplicate_keys_and_nonfinite_parameters(tmp_path: Path job = _job(job_id="nonfinite", model_sha256=digest, output="output.cm2.json") job["parameters"] = {"rate": float("inf")} _write_manifest(path, [job]) + with pytest.raises(RunManifestError, match="non-finite"): load_run_manifest(path) diff --git a/python/tests/test_runner.py b/python/tests/test_runner.py index 01b6fe9..a6ad9e9 100644 --- a/python/tests/test_runner.py +++ b/python/tests/test_runner.py @@ -130,9 +130,9 @@ def test_batch_library_is_deterministic_and_preflights_outputs(tmp_path: Path) - document = _document(output) assert document["provenance"]["model"]["seed"] == 1234 - assert document["provenance"]["model"]["sha256"] == hashlib.sha256( - model.read_bytes() - ).hexdigest() + assert ( + document["provenance"]["model"]["sha256"] == hashlib.sha256(model.read_bytes()).hexdigest() + ) assert document["provenance"]["run"] == { "completed_steps": 3, "dt": 0.1, @@ -143,8 +143,10 @@ def test_batch_library_is_deterministic_and_preflights_outputs(tmp_path: Path) - } original_bytes = output.read_bytes() + with pytest.raises(BatchError, match="already exists"): run_simulation(simulation, steps=0, dt=0.1, output=output) + assert output.read_bytes() == original_bytes second, second_provenance = build_model( @@ -354,6 +356,7 @@ def test_cell_count_threshold_can_finish_before_the_first_step( @pytest.mark.parametrize("threshold", [0, -1, True, 1 << 64]) def test_cell_count_threshold_must_be_positive_uint64(tmp_path: Path, threshold: int) -> None: simulation = ModelContext(BackendKind.CPU, 0, seed=0).simulation() + with pytest.raises(BatchError, match="positive uint64"): run_simulation( simulation, @@ -414,9 +417,10 @@ def test_cli_runs_models_resumes_and_lists_devices( assert status == 0 assert math.isclose(load_checkpoint(resumed).time, 1.0) resume_document = _document(resumed) - assert resume_document["provenance"]["resume"]["sha256"] == hashlib.sha256( - first.read_bytes() - ).hexdigest() + assert ( + resume_document["provenance"]["resume"]["sha256"] + == hashlib.sha256(first.read_bytes()).hexdigest() + ) capsys.readouterr() status = main(["devices", "--json"]) diff --git a/python/tests/test_sbml.py b/python/tests/test_sbml.py index fc5aa56..b1c9676 100644 --- a/python/tests/test_sbml.py +++ b/python/tests/test_sbml.py @@ -27,6 +27,7 @@ def _model_xml( reactant_id: str = "", ) -> str: reactant_id_attribute = f' id="{reactant_id}"' if reactant_id else "" + return f""" @@ -78,6 +79,7 @@ def _run_model(backend: BackendKind, *, local_rate: bool = False) -> tuple[float simulation.set_species_rate_plan(model.rate_plan) simulation.step(0.1) levels = simulation.cell(cell_id).species + return levels[0], levels[1] @@ -94,9 +96,12 @@ def test_sbml_compiles_to_native_rate_plan_on_available_backends() -> None: for backend in BackendKind: if not backend_available(backend): continue + probe = Simulation(backend) + if not probe.supports(BackendFeature.SPECIES): continue + substrate, product = _run_model(backend) assert math.isclose(substrate, 2.85, rel_tol=1.0e-6, abs_tol=1.0e-6) assert math.isclose(product, 0.3, rel_tol=1.0e-6, abs_tol=1.0e-6) @@ -179,11 +184,22 @@ def test_unsupported_sbml_semantics_fail_explicitly(source: str, message: str) - def test_malformed_or_empty_sbml_fails_explicitly() -> None: with pytest.raises(SBMLImportError, match="nonempty"): parse_sbml("") + with pytest.raises(SBMLImportError, match="invalid"): parse_sbml("") - level_two = _model_xml().replace('level="3" version="2"', 'level="2" version="5"').replace( - "/level3/version2/core", "/level2/version5" + level_two = ( + _model_xml() + .replace('level="3" version="2"', 'level="2" version="5"') + .replace("/level3/version2/core", "/level2/version5") ) + with pytest.raises(SBMLImportError, match="Level 3 Version 2"): parse_sbml(level_two) + + +def test_sbml_channel_metadata_uses_names_and_identifier_fallback() -> None: + source = _model_xml().replace('name="substrate"', 'name=""') + model = parse_sbml(source) + assert model.channel_metadata.species == ("A", "product") + assert model.channel_metadata.signals is None diff --git a/python/tests/test_scene.py b/python/tests/test_scene.py index 5b93d14..e461d7f 100644 --- a/python/tests/test_scene.py +++ b/python/tests/test_scene.py @@ -99,6 +99,7 @@ def _simulation(backend: BackendKind = BackendKind.CPU) -> Simulation: simulation.divide_equal(parent) simulation.step(0.125) + return simulation @@ -118,6 +119,7 @@ def _semantic_frame(frame: SceneFrame) -> SceneFrame: def _resign(document: dict[str, Any]) -> str: canonical = rfc8785.dumps(document["frame"]) document["integrity"]["frame"] = hashlib.sha256(canonical).hexdigest() + return json.dumps(document) @@ -163,9 +165,12 @@ def test_capture_scene_is_backend_neutral_and_complete() -> None: for backend in BackendKind: if backend is BackendKind.CPU or not backend_available(backend): continue + probe = Simulation(backend) + if not probe.supports(BackendFeature.SIGNALS): continue + assert _semantic_frame(capture_scene(_simulation(backend))) == _semantic_frame(reference) @@ -183,6 +188,7 @@ def test_scene_round_trip_is_exact_and_uses_decimal_identifiers( document["format"] = format_name assert parse_scene(json.dumps(document)) == frame document["frame"]["time"] += 1.0 + with pytest.raises(SceneError, match="digest"): parse_scene(json.dumps(document)) @@ -224,11 +230,13 @@ def test_scene_preserves_identifiers_outside_javascript_integer_range() -> None: def test_scene_rejects_tampering_unknown_fields_and_duplicate_keys() -> None: document = cast(dict[str, Any], json.loads(dumps_scene(capture_scene(_simulation())))) document["frame"]["time"] = 3.0 + with pytest.raises(SceneError, match="digest"): parse_scene(json.dumps(document)) document = cast(dict[str, Any], json.loads(dumps_scene(capture_scene(_simulation())))) document["frame"]["cells"][0]["color"] = [1.0, 0.0, 0.0] + with pytest.raises(SceneError, match=r"unknown keys.*color"): parse_scene(_resign(document)) @@ -242,11 +250,13 @@ def test_scene_rejects_invalid_geometry_and_grid_shape() -> None: frame, cells=(replace(frame.cells[0], direction=(2.0, 0.0, 0.0)), *frame.cells[1:]), ) + with pytest.raises(SceneError, match="normalized"): dumps_scene(bad_direction) assert frame.signal_grid is not None bad_levels = replace(frame, signal_grid=replace(frame.signal_grid, levels=(1.0,))) + with pytest.raises(SceneError, match="expected 8 values"): dumps_scene(bad_levels) diff --git a/python/tests/test_scene_channel_budget.py b/python/tests/test_scene_channel_budget.py new file mode 100644 index 0000000..05320e5 --- /dev/null +++ b/python/tests/test_scene_channel_budget.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import hashlib +import json +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace +from typing import cast + +import pytest +import rfc8785 +from microsimulator import ( + MAX_SCENE_CHANNELS, + ChannelMetadata, + SceneBackend, + SceneConstraints, + SceneError, + SceneFrame, + SceneGridBoundary, + SceneSignalGrid, + Simulation, + capture_scene, + dumps_scene, + load_scene, + parse_scene, + save_scene, +) + + +def _frame(species_count: int = 0, signal_count: int = 1) -> SceneFrame: + boundary = SceneGridBoundary("no_flux", ()) + grid = SceneSignalGrid( + signal_count, + (1, 1, 1), + (0.0, 0.0, 0.0), + (1.0, 1.0, 1.0), + boundary, + boundary, + boundary, + boundary, + boundary, + boundary, + (0.0,) * signal_count, + ) + + return SceneFrame( + 0.0, + SceneBackend("cpu", "CPU reference", "host", 0, False), + species_count, + (), + SceneConstraints((), (), (), ()), + grid, + ) + + +def _forbid_expansion( + self: ChannelMetadata, species_count: int, signal_count: int +) -> ChannelMetadata: + raise AssertionError("scene count must be bounded before label expansion") + + +@pytest.mark.parametrize("version", [2, 3, 4, 5]) +def test_scene_channel_budget_boundary_roundtrips(version: int, tmp_path: Path) -> None: + # Each group independently accepts the inclusive limit, including a scene + # without cells that still needs species selector labels. + frame = _frame(MAX_SCENE_CHANNELS, MAX_SCENE_CHANNELS) + document = json.loads(dumps_scene(frame)) + document["version"] = version + + if version < 4: + del document["frame"]["culture"] + + if version == 4: + document["frame"]["media"] = document["frame"].pop("culture") + + if version == 2: + del document["frame"]["channel_metadata"] + + document["integrity"]["frame"] = hashlib.sha256(rfc8785.dumps(document["frame"])).hexdigest() + encoded = json.dumps(document) + assert parse_scene(encoded) == frame + path = tmp_path / "boundary.scene.json" + path.write_text(encoded) + assert load_scene(path) == frame + save_scene(frame, path) + assert load_scene(path) == frame + assert frame.channel_metadata == ChannelMetadata( + species=(None,) * MAX_SCENE_CHANNELS, signals=(None,) * MAX_SCENE_CHANNELS + ) + + +@pytest.mark.parametrize("version", [2, 3, 4, 5]) +@pytest.mark.parametrize("group", ["species", "signals"]) +@pytest.mark.parametrize("count", [MAX_SCENE_CHANNELS + 1, (1 << 32) - 1]) +def test_scene_reader_rejects_tiny_oversized_claim_before_expansion( + version: int, group: str, count: int, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + document = json.loads(dumps_scene(_frame())) + document["version"] = version + + if version < 4: + del document["frame"]["culture"] + + if version == 4: + document["frame"]["media"] = document["frame"].pop("culture") + + if version == 2: + del document["frame"]["channel_metadata"] + + if group == "species": + document["frame"]["species_count"] = count + else: + document["frame"]["signal_grid"]["signal_count"] = count + + document["integrity"]["frame"] = hashlib.sha256(rfc8785.dumps(document["frame"])).hexdigest() + encoded = json.dumps(document) + assert len(encoded) < 2000 + monkeypatch.setattr(ChannelMetadata, "resolved", _forbid_expansion) + + with pytest.raises(SceneError, match="scene presentation channel budget of 4096"): + parse_scene(encoded) + + path = tmp_path / "oversized.scene.json" + path.write_text(encoded) + + with pytest.raises(SceneError, match="scene presentation channel budget of 4096"): + load_scene(path) + + +@pytest.mark.parametrize("group", ["species", "signals"]) +@pytest.mark.parametrize("count", [MAX_SCENE_CHANNELS + 1, (1 << 32) - 1]) +def test_construct_capture_and_write_bound_counts_before_copy_or_expansion( + group: str, count: int, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + frame = _frame() + assert frame.signal_grid is not None + grid = ( + replace(frame.signal_grid, signal_count=count) if group == "signals" else frame.signal_grid + ) + species_count = count if group == "species" else 0 + monkeypatch.setattr(ChannelMetadata, "resolved", _forbid_expansion) + + with pytest.raises(SceneError, match="scene presentation channel budget of 4096"): + replace(frame, species_count=species_count, signal_grid=grid) + + # No _checkpoint method: rejection must precede any native-state copy. + simulation = cast( + Simulation, SimpleNamespace(species_count=species_count, signal_count=grid.signal_count) + ) + + with pytest.raises(SceneError, match="scene presentation channel budget of 4096"): + capture_scene(simulation) + + # Encoders independently validate even objects restored without __init__. + object.__setattr__(frame, "species_count", species_count) + object.__setattr__(frame, "signal_grid", grid) + + with pytest.raises(SceneError, match="scene presentation channel budget of 4096"): + dumps_scene(frame) + + path = tmp_path / "rejected.scene.json" + + with pytest.raises(SceneError, match="scene presentation channel budget of 4096"): + save_scene(frame, path) + + assert not path.exists() diff --git a/python/tests/test_signals.py b/python/tests/test_signals.py index b624e8f..20d53a3 100644 --- a/python/tests/test_signals.py +++ b/python/tests/test_signals.py @@ -32,11 +32,13 @@ def _line_spec(length: int = 3) -> SignalGridSpec: spec.shape = shape spec.diffusion = [1.0] spec.advection = [Vec3()] + return spec def _assert_levels(actual: list[float], expected: list[float]) -> None: assert len(actual) == len(expected) + for left, right in zip(actual, expected, strict=True): assert math.isclose(left, right, rel_tol=1.0e-6, abs_tol=1.0e-6) @@ -46,6 +48,7 @@ def _instruction(operation: RateOp, *, first: int = 0, value: float = 0.0) -> Ra instruction.operation = operation instruction.first = first instruction.value = value + return instruction @@ -63,10 +66,13 @@ def test_cpu_signal_transport_sampling_and_stability() -> None: assert math.isclose(sum(simulation.signal_levels), 1.0) before = simulation.signal_levels before_time = simulation.time + with pytest.raises(ValueError, match="stability"): simulation.step(0.51) + assert simulation.signal_levels == before assert simulation.time == before_time + with pytest.raises(IndexError, match="outside"): simulation.sample_signals(Vec3(3.0, 0.0, 0.0)) @@ -121,6 +127,7 @@ def _uptake_removed(background: float, integration: SignalIntegrationKind) -> fl before = sum(simulation.signal_levels) simulation.step(0.02) + return (before - sum(simulation.signal_levels)) * spec.voxel_volume @@ -159,6 +166,7 @@ def test_a_source_below_the_field_noise_commits_a_converged_step() -> None: def test_cell_sources_match_their_declared_amount() -> None: cell_volume = math.pi * 0.5**2 * (2.6 + 2.0 * 0.5) expected = 0.02 * 0.667 * cell_volume + for integration in ( SignalIntegrationKind.FORWARD_EULER, SignalIntegrationKind.CRANK_NICOLSON, @@ -197,11 +205,14 @@ def test_gpu_signal_paths_are_native_or_fail_before_mutation() -> None: reference = Simulation() reference.configure_signal_grid(spec, [0.0, 1.0, 0.0]) reference.step(0.25) + for backend in (BackendKind.METAL, BackendKind.CUDA): if not backend_available(backend): continue + simulation = Simulation(backend) simulation.configure_signal_grid(spec, [0.0, 1.0, 0.0]) + if simulation.supports(BackendFeature.SIGNALS): simulation.step(0.25) _assert_levels(simulation.signal_levels, reference.signal_levels) @@ -209,6 +220,7 @@ def test_gpu_signal_paths_are_native_or_fail_before_mutation() -> None: else: with pytest.raises(RuntimeError, match="does not implement signal grid"): simulation.step(0.25) + assert simulation.time == 0.0 assert simulation.signal_levels == [0.0, 1.0, 0.0] @@ -217,6 +229,7 @@ def test_gpu_coupling_is_native_or_fails_before_growth() -> None: for backend in BackendKind: if not backend_available(backend): continue + simulation = Simulation(backend, species_count=1) simulation.configure_signal_grid(_line_spec(), [0.0, 1.0, 0.0]) cell = CellInit() @@ -236,6 +249,7 @@ def test_gpu_coupling_is_native_or_fails_before_growth() -> None: [1], ) ) + if simulation.supports(BackendFeature.COUPLED_RATES): simulation.step(0.25) assert math.isclose(simulation.cell(cell_id).species[0], 1.25) @@ -243,6 +257,7 @@ def test_gpu_coupling_is_native_or_fails_before_growth() -> None: else: with pytest.raises(RuntimeError, match="does not implement coupled rates"): simulation.step(0.25) + assert simulation.cell(cell_id).species == [1.0] assert simulation.signal_levels == [0.0, 1.0, 0.0] @@ -251,13 +266,16 @@ def test_native_coupling_handles_empty_grids_and_atomic_failure() -> None: for backend in BackendKind: if not backend_available(backend): continue + empty = Simulation(backend) empty.configure_signal_grid(_line_spec(), [0.0, 1.0, 0.0]) empty.set_coupled_rate_plan( CoupledRatePlan(0, 1, [_instruction(RateOp.CONSTANT, value=0.0)], [], [0]) ) + if not empty.supports(BackendFeature.COUPLED_RATES): continue + empty.step(0.25) _assert_levels(empty.signal_levels, [0.25, 0.5, 0.25]) @@ -282,8 +300,10 @@ def test_native_coupling_handles_empty_grids_and_atomic_failure() -> None: [1], ) ) + with pytest.raises(ValueError, match=r"negative|invalid"): failing.step(0.25) + assert failing.cell(cell_id).species == [2.0] assert failing.signal_levels == [0.0, 0.0, 0.0] assert failing.time == 0.0 diff --git a/python/tests/test_simulation.py b/python/tests/test_simulation.py index 068ffda..e063589 100644 --- a/python/tests/test_simulation.py +++ b/python/tests/test_simulation.py @@ -47,6 +47,7 @@ def rate_instruction( instruction.second = second instruction.third = third instruction.value = value + return instruction @@ -130,6 +131,7 @@ def test_asymmetric_division_rejects_invalid_fraction_atomically(fraction: float with pytest.raises(ValueError, match="fraction"): simulation.divide(parent, fraction) + assert simulation.cell_count == 1 assert simulation.cell(parent).length == 6.0 @@ -138,7 +140,9 @@ def test_asymmetric_division_rejects_invalid_fraction_atomically(fraction: float def test_unavailable_backend_fails_instead_of_falling_back(backend: BackendKind) -> None: if backend_available(backend): Simulation(backend) + return + with pytest.raises(RuntimeError, match=r"not implemented|unavailable"): Simulation(backend) @@ -148,6 +152,7 @@ def test_backend_device_selection_is_explicit() -> None: assert backend_available(BackendKind.CPU, 0) assert not backend_available(BackendKind.CPU, 1) assert Simulation(device_index=0).backend_info.device_index == 0 + with pytest.raises(IndexError, match="device index 0"): Simulation(device_index=1) @@ -155,12 +160,14 @@ def test_backend_device_selection_is_explicit() -> None: count = backend_device_count(backend) assert backend_available(backend) == (count > 0) assert not backend_available(backend, count) + if count > 0: assert Simulation(backend, device_index=0).backend_info.device_index == 0 def test_invalid_time_step_is_rejected() -> None: simulation = Simulation() + with pytest.raises(ValueError, match="time step"): simulation.step(-0.1) @@ -169,9 +176,12 @@ def test_invalid_time_step_is_rejected() -> None: def test_species_step_dilutes_then_evaluates_typed_rates(backend: BackendKind) -> None: if not backend_available(backend): pytest.skip("native backend is not built") + simulation = Simulation(backend, species_count=2) + if not simulation.supports(BackendFeature.SPECIES): pytest.skip("backend does not implement species integration") + cell = CellInit() cell.length = 2.0 cell.radius = 0.5 @@ -224,9 +234,12 @@ def test_species_plan_rejects_forward_references() -> None: def test_contact_graph_is_available_through_the_public_api(backend: BackendKind) -> None: if not backend_available(backend): pytest.skip("native backend is not built") + simulation = Simulation(backend) + if not simulation.supports(BackendFeature.CELL_CONTACTS): pytest.skip("backend does not implement cell contacts") + first = CellInit() first.length = 4.0 first.radius = 0.5 @@ -286,6 +299,7 @@ def test_cell_attributes_can_be_updated_by_stable_id() -> None: with pytest.raises(ValueError, match="finite"): simulation.set_cell_attributes(cell_id, growth_rate=math.nan, cell_type=8) + assert simulation.cell(cell_id).cell_type == 7 @@ -293,9 +307,12 @@ def test_cell_attributes_can_be_updated_by_stable_id() -> None: def test_plane_constraint_graph_is_typed_and_incident(backend: BackendKind) -> None: if not backend_available(backend): pytest.skip("native backend is not built") + simulation = Simulation(backend) + if not simulation.supports(BackendFeature.EXTERNAL_CONSTRAINTS): pytest.skip("backend does not implement external constraints") + cell = CellInit() cell.position = Vec3(0.0, 0.25, 0.0) cell.length = 2.0 @@ -342,9 +359,12 @@ def test_plane_constraint_graph_is_typed_and_incident(backend: BackendKind) -> N def test_constraints_participate_in_mechanical_relaxation(backend: BackendKind) -> None: if not backend_available(backend): pytest.skip("native backend is not built") + simulation = Simulation(backend) + if not simulation.supports(BackendFeature.EXTERNAL_CONSTRAINTS): pytest.skip("backend does not implement external constraints") + cell = CellInit() cell.position = Vec3(0.0, 0.4, 0.0) cell.length = 2.0 @@ -366,9 +386,12 @@ def test_constraints_participate_in_mechanical_relaxation(backend: BackendKind) def test_box_constraints_participate_in_mechanical_relaxation(backend: BackendKind) -> None: if not backend_available(backend): pytest.skip("native backend is not built") + simulation = Simulation(backend) + if not simulation.supports(BackendFeature.EXTERNAL_CONSTRAINTS): pytest.skip("backend does not implement external constraints") + cell = CellInit() cell.position = Vec3(1.4, 0.0, 0.0) cell.direction = Vec3(0.0, 1.0, 0.0) @@ -386,8 +409,7 @@ def test_box_constraints_participate_in_mechanical_relaxation(backend: BackendKi assert all(contact.constraint_id == constraint_id for contact in graph.contacts) assert all(contact.constraint_kind == ExternalConstraintKind.BOX for contact in graph.contacts) assert all( - math.isclose(contact.signed_separation, -0.1, abs_tol=1.0e-6) - for contact in graph.contacts + math.isclose(contact.signed_separation, -0.1, abs_tol=1.0e-6) for contact in graph.contacts ) result = simulation.relax_cell_mechanics() @@ -401,9 +423,12 @@ def test_box_constraints_participate_in_mechanical_relaxation(backend: BackendKi def test_finite_wall_detects_midspan_capsule_contact(backend: BackendKind) -> None: if not backend_available(backend): pytest.skip("native backend is not built") + simulation = Simulation(backend) + if not simulation.supports(BackendFeature.EXTERNAL_CONSTRAINTS): pytest.skip("backend does not implement external constraints") + cell = CellInit() cell.position = Vec3(0.0, 0.75, 0.0) cell.direction = Vec3(1.0, 0.0, 0.0) @@ -458,6 +483,7 @@ def test_native_growth_matches_cpu(backend: BackendKind) -> None: cpu = Simulation(BackendKind.CPU) native = Simulation(backend) + for index in range(33): cell = CellInit() cell.length = 1.0 + index * 0.1 @@ -472,9 +498,7 @@ def test_native_growth_matches_cpu(backend: BackendKind) -> None: assert math.isclose(cpu_cell.length, native_cell.length, abs_tol=1.0e-6) -def uniform_flow_grid( - *, origin: float, spacing: float, sites: int, speed: float -) -> SignalGridSpec: +def uniform_flow_grid(*, origin: float, spacing: float, sites: int, speed: float) -> SignalGridSpec: """A collapsed y/z lattice carrying a uniform x flow between fixed ends.""" shape = GridShape() @@ -495,18 +519,17 @@ def uniform_flow_grid( field.y_faces = [0.0] * (2 * sites) field.z_faces = [0.0] * (2 * sites) grid.velocity_field = field + return grid -@pytest.mark.parametrize( - ("origin", "spacing"), [(0.0, 1.0), (0.1, 0.3), (-97.5, 1.65), (0.7, 5.0)] -) +@pytest.mark.parametrize(("origin", "spacing"), [(0.0, 1.0), (0.1, 0.3), (-97.5, 1.65), (0.7, 5.0)]) def test_flow_drift_clamps_endpoints_on_any_lattice(origin: float, spacing: float) -> None: sites = 33 simulation = Simulation() - simulation.configure_signal_grid(uniform_flow_grid( - origin=origin, spacing=spacing, sites=sites, speed=2.0 - )) + simulation.configure_signal_grid( + uniform_flow_grid(origin=origin, spacing=spacing, sites=sites, speed=2.0) + ) cell = CellInit() cell.position = Vec3(origin + spacing * (sites - 1), 0.0, 0.0) cell.direction = Vec3(1.0, 0.0, 0.0) @@ -596,5 +619,6 @@ def test_a_cell_inside_a_wall_samples_no_flow_and_does_not_drift() -> None: simulation.apply_flow_drift(0.5) assert simulation.cell(buried_id).position.x == 2.0 + with pytest.raises(ValueError, match="inside a grid obstacle"): simulation.sample_signals(Vec3(2.0, 0.0, 0.0)) diff --git a/python/tests/test_stokes.py b/python/tests/test_stokes.py index cabc679..ff34da6 100644 --- a/python/tests/test_stokes.py +++ b/python/tests/test_stokes.py @@ -30,6 +30,7 @@ def _plane_poiseuille_error(nx: int) -> float: profile = np.asarray(field.y_faces).reshape(nx, 7, 1)[:, 3, 0] positions = (np.arange(nx) + 0.5) / nx exact = plane_poiseuille(positions) + return float(np.max(np.abs(profile - exact)) / np.max(exact)) @@ -45,6 +46,7 @@ def test_plane_poiseuille_profile_converges_at_second_order() -> None: def test_resolved_flow_uses_the_selected_native_backend(backend: BackendKind) -> None: if not backend_available(backend): pytest.skip(f"{backend.name} backend is unavailable") + spec = duct_grid(6, 5, 1, (1.0 / 6.0, 0.25, 1.0)) expected, _ = solve_stokes_field(spec, mean_inlet_speed=1.0) simulation = Simulation(backend) @@ -103,9 +105,11 @@ def test_zero_drag_recovers_pure_stokes() -> None: def test_stokes_field_is_engine_valid_and_conservative_around_a_pillar() -> None: spec = duct_grid(9, 12, 1, (1.0, 1.0, 1.0)) obstacles = [0] * (9 * 12) + for y in (5, 6): for x in (4, 5): obstacles[site_index(spec, x, y, 0)] = 1 + spec.obstacles = obstacles field, report = solve_stokes_field(spec, mean_inlet_speed=6.0, tolerance=1.0e-6) spec.velocity_field = field @@ -116,8 +120,10 @@ def y_face(x: int, fy: int) -> float: return field.y_faces[x * 13 + fy] fluxes = [sum(y_face(x, fy) for x in range(9)) for fy in range(13)] + for flux in fluxes[1:]: assert math.isclose(flux, fluxes[0], rel_tol=1.0e-5) + assert y_face(4, 6) == 0.0 assert y_face(1, 6) > 6.0 @@ -128,16 +134,16 @@ def test_thin_gap_stokes_depth_averages_to_the_hele_shaw_solution() -> None: nx, ny, nz = 6, 10, 6 spec = duct_grid(nx, ny, nz, (1.0, 1.0, 0.05)) obstacles = [0] * (nx * ny * nz) + for y in (4, 5): for x in (1, 2): for z in range(nz): obstacles[site_index(spec, x, y, z)] = 1 + spec.obstacles = obstacles stokes_field, _ = solve_stokes_field(spec, mean_inlet_speed=1.0, tolerance=1.0e-6) - hele_shaw_field, _ = solve_flow_field( - spec, mean_inlet_speed=1.0, mobility=gap_mobility(spec) - ) + hele_shaw_field, _ = solve_flow_field(spec, mean_inlet_speed=1.0, mobility=gap_mobility(spec)) def column_flux(field_values: list[float], x: int, fy: int) -> float: return sum(field_values[(x * (ny + 1) + fy) * nz + z] for z in range(nz)) @@ -147,6 +153,7 @@ def column_flux(field_values: list[float], x: int, fy: int) -> float: hele_shaw_split = [column_flux(hele_shaw_field.y_faces, x, mid) for x in range(nx)] stokes_total = sum(stokes_split) hele_shaw_total = sum(hele_shaw_split) + for x in range(nx): assert math.isclose( stokes_split[x] / stokes_total, @@ -157,20 +164,27 @@ def column_flux(field_values: list[float], x: int, fy: int) -> float: def test_ill_posed_stokes_problems_are_rejected() -> None: spec = duct_grid(4, 6, 1, (1.0, 1.0, 1.0)) + with pytest.raises(FlowError, match="one of x, y, z"): solve_stokes_field(spec, mean_inlet_speed=1.0, axis="w") + with pytest.raises(FlowError, match="finite and nonzero"): solve_stokes_field(spec, mean_inlet_speed=0.0) + with pytest.raises(FlowError, match="must be FIXED"): solve_stokes_field(spec, mean_inlet_speed=1.0, axis="x") + with pytest.raises(FlowError, match="one value per grid site"): solve_stokes_field(spec, mean_inlet_speed=1.0, drag=[1.0]) blocked = duct_grid(3, 4, 1, (1.0, 1.0, 1.0)) obstacles = [0] * 12 + for x in range(3): obstacles[site_index(blocked, x, 2, 0)] = 1 + blocked.obstacles = obstacles + with pytest.raises(FlowError, match="no through-flow"): solve_stokes_field(blocked, mean_inlet_speed=1.0) @@ -196,6 +210,7 @@ def __init__(self, x: float, y: float) -> None: assert 0.0 < empty < packed assert solid == 0.0 assert math.isclose(packed, 50.0 * 0.9**2 / (1.0 - 0.9) ** 3, rel_tol=1.0e-9) + with pytest.raises(FlowError, match="finite and non-negative"): colony_drag(spec, [], drag_coefficient=-1.0) @@ -212,18 +227,22 @@ def test_thin_gaps_over_predict_flux_until_they_are_resolved() -> None: lubrication = 1.0 / 16.0 errors: list[float] = [] + for thin in (1, 2, 4, 8): nz = thin + 1 + 4 * thin spec = duct_grid(1, 8, nz, (1.0, 1.0, 1.0)) obstacles = [0] * (8 * nz) + for y in range(8): obstacles[site_index(spec, 0, y, thin)] = 1 + spec.obstacles = obstacles field, report = solve_stokes_field(spec, mean_inlet_speed=1.0, tolerance=1.0e-6) profile = np.asarray(field.y_faces).reshape(1, 9, nz)[0, 4, :] ratio = float(profile[:thin].mean() / profile[thin + 1 :].mean()) errors.append(ratio / lubrication) assert report.min_gap_voxels == thin + assert errors[0] > 2.0 assert errors[1] < errors[0] assert errors[2] < 1.2 @@ -245,11 +264,13 @@ def test_reversed_and_transverse_flow_axes_solve() -> None: across.y_lower.values = [] across.y_upper.kind = GridBoundaryKind.NO_FLUX across.y_upper.values = [] + for name in ("x_lower", "x_upper"): boundary = getattr(across, name) boundary.kind = GridBoundaryKind.FIXED boundary.values = [0.0] setattr(across, name, boundary) + sideways, _ = solve_stokes_field(across, mean_inlet_speed=1.0, axis="x") across.velocity_field = sideways across.validate() @@ -262,8 +283,10 @@ def test_reversed_and_transverse_flow_axes_solve() -> None: def test_partly_blocked_inlets_and_walled_off_pockets_solve() -> None: spec = duct_grid(4, 6, 1, (1.0, 1.0, 1.0)) obstacles = [0] * 24 + for y in range(6): obstacles[site_index(spec, 0, y, 0)] = 1 + spec.obstacles = obstacles field, report = solve_stokes_field(spec, mean_inlet_speed=2.0, tolerance=1.0e-6) spec.velocity_field = field @@ -277,11 +300,14 @@ def test_partly_blocked_inlets_and_walled_off_pockets_solve() -> None: # A fluid site sealed off from the flow leaves the solve well posed. pocket = duct_grid(5, 6, 1, (1.0, 1.0, 1.0)) sealed = [0] * 30 + for y in (2, 4): for x in (3, 4): sealed[site_index(pocket, x, y, 0)] = 1 + for x in (3, 4): sealed[site_index(pocket, x, 3, 0)] = 0 + sealed[site_index(pocket, 2, 3, 0)] = 1 pocket.obstacles = sealed sealed_field, sealed_report = solve_stokes_field(pocket, mean_inlet_speed=1.0) diff --git a/python/tests/test_stokes_flow.py b/python/tests/test_stokes_flow.py new file mode 100644 index 0000000..4b881c1 --- /dev/null +++ b/python/tests/test_stokes_flow.py @@ -0,0 +1,176 @@ +"""Physical hydraulic units and port constraints, independent of signal species.""" + +import math + +import microsimulator as ms +import numpy as np +import pytest + + +def duct(n: int = 4, length: int = 8) -> ms.FluidGridSpec: + grid = ms.FluidGridSpec() + grid.shape.x, grid.shape.y, grid.shape.z = n, length, n + + return grid + + +def pressure_ports() -> list[ms.FlowPort]: + inlet, outlet = ms.FlowPort(), ms.FlowPort() + inlet.name, inlet.value = "feed", 0.01 + outlet.name, outlet.upper = "drain", True + + return [inlet, outlet] + + +def test_physical_flow_python_api() -> None: + grid = duct() + result = ms.StokesFlowSolver().solve(grid, ms.FluidProperties(), pressure_ports()) + assert grid.site_count == 128 + assert len(result.pressure_pa) == grid.site_count + assert len(result.field.y_faces) == 4 * 9 * 4 + assert result.ports[0].flow_rate_m3_s < 0 < result.ports[1].flow_rate_m3_s + assert result.ports[0].pressure_pa == 0.01 + assert math.isclose(result.ports[0].area_m2, 16e-12) + assert result.report.relative_residual <= 1e-5 + assert result.report.reynolds_number < 1 + + +def test_unit_conversion_does_not_change_physics() -> None: + solver, medium = ms.StokesFlowSolver(), ms.FluidProperties() + grid = duct() + a = solver.solve(grid, medium, pressure_ports()) + grid.spacing = 2 + grid.length_unit_m = 0.5e-6 + grid.time_unit_s = 3 + b = solver.solve(grid, medium, pressure_ports()) + np.testing.assert_allclose(b.pressure_pa, a.pressure_pa, atol=1e-8) + np.testing.assert_allclose(b.field.y_faces, np.array(a.field.y_faces) * 6, rtol=2e-5) + assert math.isclose(b.ports[0].flow_rate_m3_s, a.ports[0].flow_rate_m3_s, rel_tol=1e-5) + + +def test_disconnected_components_have_independent_pressure_gauges() -> None: + grid = duct(5) + grid.obstacles = [int(x == 2) for x in range(5) for _ in range(8 * 5)] + ports: list[ms.FlowPort] = [] + + for first_x, offset in [(0, 0), (3, 100)]: + for upper in (False, True): + p = ms.FlowPort() + p.name = f"{first_x}-{upper}" + p.upper = upper + p.value = offset + (0 if upper else 0.01) + p.sites = [ + x * 40 + (35 if upper else 0) + z + for x in range(first_x, first_x + 2) + for z in range(5) + ] + ports.append(p) + + parameters = ms.LinearSolveParameters() + parameters.relative_tolerance, parameters.absolute_tolerance = 1e-10, 1e-12 + result = ms.StokesFlowSolver().solve(grid, ms.FluidProperties(), ports, parameters) + assert math.isclose( + result.ports[0].flow_rate_m3_s, result.ports[2].flow_rate_m3_s, rel_tol=1e-8 + ) + assert math.isclose( + result.ports[1].flow_rate_m3_s, result.ports[3].flow_rate_m3_s, rel_tol=1e-8 + ) + + with pytest.raises(ValueError, match="connected fluid component"): + ms.StokesFlowSolver().solve(grid, ms.FluidProperties(), pressure_ports()) + + +def test_square_duct_poiseuille_refinement() -> None: + # Fully developed square-duct solution: Q = C * G * width**4 / mu. + # C follows by integrating the sine-series solution of -laplacian(u)=G/mu. + # Measure the central pressure gradient to exclude traction-port entrance effects. + coefficient = ( + 64 + / math.pi**6 + * sum( + 1 / (m * m * n * n * (m * m + n * n)) + for m in range(1, 100, 2) + for n in range(1, 100, 2) + ) + ) + errors: list[float] = [] + + for n in (4, 8): + grid = duct(n, 6 * n) + grid.spacing = 4 / n + parameters = ms.LinearSolveParameters() + parameters.relative_tolerance, parameters.absolute_tolerance = 1e-8, 1e-10 + result = ms.StokesFlowSolver().solve( + grid, ms.FluidProperties(), pressure_ports(), parameters + ) + pressure = np.array(result.pressure_pa).reshape(n, 6 * n, n) + gradient = float((pressure[:, 2 * n, :].mean() - pressure[:, 4 * n, :].mean()) / 8e-6) + expected = coefficient * gradient * (4e-6) ** 4 / 1e-3 + errors.append(abs(result.ports[1].flow_rate_m3_s / expected - 1)) + + assert errors[1] < 0.1 + assert errors[1] < errors[0] / 2.5 + + +def test_failure_does_not_contaminate_next_solve() -> None: + solver = ms.StokesFlowSolver() + parameters = ms.LinearSolveParameters() + parameters.max_iterations = 1 + + with pytest.raises(RuntimeError, match="converge"): + solver.solve(duct(), ms.FluidProperties(), pressure_ports(), parameters) + + assert solver.solve(duct(), ms.FluidProperties(), pressure_ports()).ports[1].flow_rate_m3_s > 0 + + +@pytest.mark.parametrize("value", [0, -1, float("nan"), float("inf")]) +def test_invalid_physical_units(value: float) -> None: + grid = duct() + grid.length_unit_m = value + + with pytest.raises(ValueError, match="unit scales"): + ms.StokesFlowSolver().solve(grid, ms.FluidProperties(), pressure_ports()) + + +def test_body_proposals_preserve_inputs_and_unit_quaternions() -> None: + grid = duct(12, 12) + body = ms.CapsuleBody() + body.position = (5.5, 5.5, 5.5) + body.radius = 2 + body.torque_nm = (0, 0, 1e-20) + solver = ms.StokesFlowSolver() + step = solver.propose_body_step(grid, ms.FluidProperties(), [], [body], 0.1) + assert body.orientation == (1, 0, 0, 0) + assert step.accepted_dt == 0.1 + assert step.bodies[0].orientation[3] > 0 + assert math.isclose(sum(q * q for q in step.bodies[0].orientation), 1, abs_tol=1e-12) + assert step.flow.bodies[0].marker_count > 0 + assert math.isclose(body.geometric_volume, 32 * math.pi / 3) + limits = ms.FluidBodyStepParameters() + limits.max_halvings = 0 + + with pytest.raises(RuntimeError, match="substep halvings"): + solver.propose_body_step( + grid, ms.FluidProperties(), [], [body], 1e4, step_parameters=limits + ) + + assert body.position == (5.5, 5.5, 5.5) + assert body.orientation == (1, 0, 0, 0) + + +def test_body_resolution_and_overlap_are_explicit_errors() -> None: + grid = duct(12, 12) + body = ms.CapsuleBody() + body.position = (5.5, 5.5, 5.5) + + with pytest.raises(ValueError, match="two grid spacings"): + ms.StokesFlowSolver().solve_bodies(grid, ms.FluidProperties(), [], [body]) + + body.radius = 2 + other = ms.CapsuleBody() + other.id = 2 + other.position = body.position + other.radius = body.radius + + with pytest.raises(ValueError, match="overlap"): + ms.StokesFlowSolver().solve_bodies(grid, ms.FluidProperties(), [], [body, other]) diff --git a/python/tests/test_transport_accuracy.py b/python/tests/test_transport_accuracy.py index 7c27bc1..977323a 100644 --- a/python/tests/test_transport_accuracy.py +++ b/python/tests/test_transport_accuracy.py @@ -20,6 +20,7 @@ def test_sampling_and_scatter_do_not_bridge_disconnected_corners(backend: BackendKind) -> None: if not backend_available(backend): pytest.skip("backend unavailable") + spec = SignalGridSpec() shape = GridShape() shape.x = shape.y = 2 @@ -50,21 +51,26 @@ def _periodic_error(backend: BackendKind, n: int) -> float: spec.shape, spec.signal_count = shape, 1 spec.spacing = Vec3(h, 1, 1) spec.diffusion, spec.advection = [diffusion], [Vec3(speed, 0, 0)] + for name in ["x_lower", "x_upper"]: boundary = getattr(spec, name) boundary.kind = GridBoundaryKind.PERIODIC setattr(spec, name, boundary) + initial = [1 + 0.25 * math.sin(2 * math.pi * i * h) for i in range(n)] simulation = Simulation(backend) simulation.configure_signal_grid(spec, initial) steps = math.ceil(duration / (0.12 * h * h / diffusion)) + for _ in range(steps): simulation.step(duration / steps) + values = simulation.signal_levels assert min(values) >= 0 assert math.isclose(sum(values) * h, sum(initial) * h, abs_tol=2e-6) amplitude = 0.25 * math.exp(-diffusion * (2 * math.pi) ** 2 * duration) exact = [1 + amplitude * math.sin(2 * math.pi * (i * h - speed * duration)) for i in range(n)] + return math.sqrt(sum((a - b) ** 2 for a, b in zip(values, exact, strict=True)) / n) @@ -72,6 +78,7 @@ def _periodic_error(backend: BackendKind, n: int) -> float: def test_periodic_advection_diffusion_converges_and_conserves(backend: BackendKind) -> None: if not backend_available(backend): pytest.skip("backend unavailable") + errors = [_periodic_error(backend, n) for n in (16, 32, 64)] assert errors[0] / errors[1] > 1.7 assert errors[1] / errors[2] > 1.7 diff --git a/python/tests/test_tutorial_commands.py b/python/tests/test_tutorial_commands.py new file mode 100644 index 0000000..635feb2 --- /dev/null +++ b/python/tests/test_tutorial_commands.py @@ -0,0 +1,469 @@ +"""Execute the published CLI examples through real shells, without rewriting them.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import math +import os +import platform +import re +import shlex +import shutil +import signal +import subprocess +import sys +from collections.abc import Iterator +from contextlib import suppress +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, cast +from urllib.parse import urlsplit + +import pytest +from aiohttp import ClientSession, WSMsgType +from microsimulator import load_checkpoint_bundle +from microsimulator.cli import _parser # pyright: ignore[reportPrivateUsage] + +ROOT = Path(__file__).resolve().parents[2] +GUIDE = ROOT / "docs/tutorials/commands.md" +BLOCKS = dict( + re.findall( + r"\s*```\w+\n(.*?)\n```", + GUIDE.read_text(encoding="utf-8"), + re.DOTALL, + ) +) +SHELLS = ("pwsh",) if sys.platform == "win32" else ("sh", "bash", "zsh", "pwsh") + + +def _same_time(actual: float, expected: float) -> bool: + # dt is stored in native float32; retain pytest.approx's intended tolerance. + return math.isclose(actual, expected, rel_tol=1e-6, abs_tol=1e-8) + + +def _kill_tree(pid: int) -> None: + # Only called for a subprocess created by this test. Killing just its shell + # leaves uv/the live server holding the inherited stdout pipe and port. + if sys.platform == "win32": + subprocess.run( + ["taskkill", "/PID", str(pid), "/T", "/F"], + capture_output=True, + check=False, + timeout=10, + ) + else: + with suppress(ProcessLookupError): + os.killpg(pid, signal.SIGKILL) + + +def _run_script( + arguments: list[str], + environment: dict[str, str], + cwd: Path, +) -> subprocess.CompletedProcess[str]: + with subprocess.Popen( + arguments, + cwd=cwd, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=sys.platform != "win32", + ) as process: + try: + stdout, stderr = process.communicate(timeout=120) + except subprocess.TimeoutExpired as error: + _kill_tree(process.pid) + stdout, stderr = process.communicate(timeout=10) + raise AssertionError(f"command timed out: {stdout} {stderr}") from error + + return subprocess.CompletedProcess(arguments, process.returncode, stdout, stderr) + + +@dataclass +class CommandShell: + name: str + executable: str + cwd: Path + records: list[dict[str, Any]] = field(default_factory=lambda: list[dict[str, Any]]()) + + def prepare(self, command: str) -> tuple[list[str], dict[str, str]]: + environment = dict(os.environ) + environment.pop("VIRTUAL_ENV", None) + environment["UV_PROJECT_ENVIRONMENT"] = sys.prefix + environment["PYTHONUNBUFFERED"] = "1" + environment["UV_OFFLINE"] = "1" + + if self.name == "pwsh": + script = self.cwd / "command.ps1" + script.write_text( + "$ErrorActionPreference = 'Stop'\n" + "$global:LASTEXITCODE = 0\n" + + BLOCKS["powershell-mode"] + + "\n" + + command + + "\nexit $LASTEXITCODE\n", + encoding="utf-8", + ) + arguments = [self.executable, "-NoLogo", "-NoProfile", "-File", str(script)] + else: + script = self.cwd / "command.sh" + script.write_text("set -e\n" + command + "\n", encoding="utf-8") + arguments = [self.executable, str(script)] + + return arguments, environment + + def run(self, identifier: str, *, success: bool = True) -> subprocess.CompletedProcess[str]: + command = BLOCKS[identifier] + arguments, environment = self.prepare(command) + print(f"Executing {self.name}: {identifier}", flush=True) + result = _run_script(arguments, environment, self.cwd) + self.records.append( + { + "id": identifier, + "command": command, + "exit_code": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + } + ) + assert (result.returncode == 0) is success, result.stdout + result.stderr + + return result + + +@pytest.fixture(params=SHELLS) +def shell(request: pytest.FixtureRequest, tmp_path: Path) -> Iterator[CommandShell]: + name = cast(str, request.param) + executable = shutil.which(name) + + if executable is None: + pytest.skip(f"{name} is not installed; no {name} coverage claimed") + + cwd = tmp_path / "repository with spaces" + cwd.mkdir() + shutil.copytree(ROOT / "examples", cwd / "examples") + (cwd / "pyproject.toml").write_text( + '[project]\nname="tutorial-command-test"\nversion="0.0.0"\n' + 'requires-python=">=3.12,<3.13"\n', + encoding="utf-8", + ) + # CLI lifecycle checks deliberately do not claim to test the browser bundle. + (cwd / "viewer/dist/assets").mkdir(parents=True) + (cwd / "viewer/dist/index.html").write_text("CLI test") + instance = CommandShell(name, executable, cwd) + version_args = ( + ["-NoLogo", "-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"] + if name == "pwsh" + else ["--version"] + if name != "sh" + else ["-c", "echo POSIX-sh"] + ) + version = subprocess.run( + [executable, *version_args], capture_output=True, text=True, timeout=60, check=True + ).stdout.strip() + + if name == "pwsh": + assert tuple(int(part) for part in version.split(".")[:2]) >= (7, 3) + + try: + yield instance + finally: + if destination := os.environ.get("MICROSIMULATOR_COMMAND_REPORT"): + directory = Path(destination) + directory.mkdir(parents=True, exist_ok=True) + node = cast(Any, request).node # pytest's request.node is untyped. + (directory / f"{node.name}.json").write_text( + json.dumps( + { + "platform": platform.platform(), + "python": sys.version, + "shell": name, + "shell_version": version, + "cwd_contains_spaces": True, + "commands": instance.records, + }, + indent=2, + ), + encoding="utf-8", + ) + + +def _document(path: Path) -> dict[str, Any]: + # Authenticate checkpoints as well as inspecting their human-readable fields. + load_checkpoint_bundle(path) + + return cast(dict[str, Any], json.loads(path.read_text(encoding="utf-8"))) + + +def test_documented_headless_commands(shell: CommandShell) -> None: + shell.run("devices") + devices = cast(list[dict[str, Any]], json.loads(shell.run("devices-json").stdout)) + assert {record["backend"] for record in devices} == {"cpu", "metal", "cuda"} + + for record in devices: + backend = record["backend"] + result = shell.run(f"trap-{backend}", success=record["available"]) + output = shell.cwd / f"results/tutorial runs/trap-{backend}.json" + + if not record["available"]: + assert f"backend {backend} device 0 is unavailable" in result.stderr + assert not output.exists() + continue + + document = _document(output) + assert document["source_backend"]["kind"] == backend + assert document["provenance"]["model"]["seed"] == 42 + assert document["provenance"]["model"]["parameters"] == {} + assert document["provenance"]["run"]["completed_steps"] == 100 + assert _same_time(document["simulation"]["time"], 2.0) + assert len(list(output.parent.glob(f"trap-{backend}.step-*.json"))) == 5 + + shell.run("resume-trap") + assert _same_time( + _document(shell.cwd / "results/tutorial runs/trap-cpu-resumed.json")["simulation"]["time"], + 4.0, + ) + + suffix = "powershell" if shell.name == "pwsh" else "posix" + shell.run(f"copy-{suffix}") + shell.run(f"basics-{suffix}") + shell.run("resume-basics") + initial = _document(shell.cwd / "results/tutorial runs/basics.json") + resumed = _document(shell.cwd / "results/tutorial runs/basics-resumed.json") + + for document in (initial, resumed): + provenance = document["provenance"]["model"] + assert provenance["seed"] == 42 + assert provenance["parameters"] == {"scenario": "basics"} + assert ( + provenance["sha256"] + == hashlib.sha256( + (shell.cwd / "results/tutorial models/biophysics.py").read_bytes() + ).hexdigest() + ) + + assert _same_time(initial["simulation"]["time"], 0.2) + assert _same_time(resumed["simulation"]["time"], 0.4) + assert ( + resumed["provenance"]["resume"]["sha256"] + == hashlib.sha256( + (shell.cwd / "results/tutorial runs/basics.json").read_bytes() + ).hexdigest() + ) + + # Independently establish the claimed continuation semantics, using the same + # installed CLI for a 20-step uninterrupted run. + subprocess.run( + [ + sys.executable, + "-m", + "microsimulator", + "run", + "--model", + str(shell.cwd / "results/tutorial models/biophysics.py"), + "--parameter", + 'scenario="basics"', + "--seed", + "42", + "--steps", + "20", + "--dt", + "0.02", + "--output", + str(shell.cwd / "uninterrupted.json"), + ], + check=True, + capture_output=True, + text=True, + timeout=60, + ) + uninterrupted = _document(shell.cwd / "uninterrupted.json") + assert resumed["simulation"] == uninterrupted["simulation"] + assert resumed["controller"] == uninterrupted["controller"] + + # The documented guard rejects overrides and edited source before executing + # it; neither failed resume should create an output or run injected code. + arguments, environment = shell.prepare(BLOCKS["resume-basics"] + " --parameter 'scenario=1'") + rejected = subprocess.run( + arguments, + env=environment, + cwd=shell.cwd, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + assert rejected.returncode != 0 + assert "do not pass --parameter" in rejected.stderr + model = shell.cwd / "results/tutorial models/biophysics.py" + model.write_text("raise RuntimeError('model executed before digest check')\n", encoding="utf-8") + rejected = shell.run("resume-basics", success=False) + assert "model digest does not match checkpoint" in rejected.stderr + assert "model executed before digest check" not in rejected.stderr + + +def test_documented_live_commands(shell: CommandShell) -> None: + suffix = "powershell" if shell.name == "pwsh" else "posix" + shell.run(f"copy-{suffix}") + shell.run(f"basics-{suffix}") + + async def exercise() -> None: + for identifier in ("live-trap", "live-basics", "live-resume"): + arguments, environment = shell.prepare(BLOCKS[identifier]) + process = await asyncio.create_subprocess_exec( + *arguments, + cwd=shell.cwd, + env=environment, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + start_new_session=sys.platform != "win32", + ) + stopped = False + + try: + assert process.stdout is not None + line = (await asyncio.wait_for(process.stdout.readline(), 60)).decode().strip() + assert line.startswith("MicroSimulator live viewer: "), line + url = urlsplit(line.split(": ", 1)[1]) + assert url.port == 8765 + + async with ( + ClientSession() as client, + client.ws_connect( + f"http://{url.netloc}/api/v1/session?{url.query}", + headers={"Origin": f"http://{url.netloc}"}, + ) as ws, + ): + frame = await ws.receive_json(timeout=10) + assert frame["playing"] is False + assert _same_time( + frame["scene"]["frame"]["time"], + 0.2 if identifier == "live-resume" else 0.0, + ) + + if identifier == "live-trap": + await ws.send_json({"type": "checkpoint"}) + saved = await ws.receive_json(timeout=10) + assert saved["type"] == "checkpoint" + _document(shell.cwd / "results/tutorial runs/live-trap.json") + + await ws.send_json({"type": "stop"}) + + while True: + message = await ws.receive(timeout=15) + + if message.type in {WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.ERROR}: + break + + if message.type == WSMsgType.TEXT: + stopped |= message.json() == {"type": "session", "state": "stopped"} + + stdout, stderr = await asyncio.wait_for(process.communicate(), 15) + shell.records.append( + { + "id": identifier, + "command": BLOCKS[identifier], + "exit_code": process.returncode, + "stopped": stopped, + "stdout": stdout.decode(), + "stderr": stderr.decode(), + } + ) + assert stopped + assert process.returncode == 0, stderr.decode() + finally: + if process.returncode is None: + await asyncio.to_thread(_kill_tree, process.pid) + await asyncio.wait_for(process.communicate(), 10) + + asyncio.run(exercise()) + + +def _assert_document_links(path: Path, text: str) -> None: + for target in re.findall(r"\[[^\]]+\]\(([^)]+)\)", text): + if re.match(r"[a-z]+://", target): + continue + + filename, _, anchor = target.partition("#") + destination = (path.parent / filename).resolve() if filename else path + assert destination.exists(), (path, target) + + if anchor and destination.suffix == ".md": + headings = re.findall( + r"^#+\s+(.+)$", destination.read_text(encoding="utf-8"), re.MULTILINE + ) + anchors = {re.sub(r"[^\w -]", "", h.lower()).replace(" ", "-") for h in headings} + assert anchor in anchors, (path, target) + + +def test_tutorial_flags_paths_and_links() -> None: + documents = [ + *sorted((ROOT / "docs/tutorials").glob("*.md")), + ROOT / "environments/metal/README.md", + ROOT / "environments/cuda/README.md", + ROOT / "viewer/README.md", + ROOT / "docs/development/tutorial-command-validation.md", + ] + parser = _parser() + checked = 0 + + for path in documents: + text = path.read_text(encoding="utf-8") + + _assert_document_links(path, text) + + if path.parent == ROOT / "docs/tutorials" and path != GUIDE: + assert "commands.md#" in text, path + + for language, block in re.findall(r"```(console|sh)\n(.*?)\n```", text, re.DOTALL): + del language + + for line in block.replace("\\\n", " ").splitlines(): + parts = shlex.split(line) + + if "microsimulator" not in parts or "--help" in parts: + continue + + # Exclude prose or output; only parse literal CLI invocations. + if parts[:2] != ["uv", "run"]: + continue + + arguments = parser.parse_args(parts[parts.index("microsimulator") + 1 :]) + + if (model := getattr(arguments, "model", None)) is not None: + model = cast(Path, model) + + if model != Path("results/tutorial models/biophysics.py"): + assert (ROOT / model).is_file(), (path, model) + + checked += 1 + + assert checked >= 35 + + +def test_failed_command_cleanup_drains_descendant_pipes() -> None: + # Regression for a failed live assertion hanging after only its shell died. + code = ( + "import subprocess, sys, time; " + "subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)']); " + "print('ready', flush=True); time.sleep(60)" + ) + + with subprocess.Popen( + [sys.executable, "-c", code], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + start_new_session=sys.platform != "win32", + ) as process: + try: + assert process.stdout is not None + assert process.stdout.readline().strip() == "ready" + finally: + _kill_tree(process.pid) + + process.communicate(timeout=10) + assert process.returncode is not None diff --git a/python/tests/test_tutorials.py b/python/tests/test_tutorials.py index c23cab9..dab84e9 100644 --- a/python/tests/test_tutorials.py +++ b/python/tests/test_tutorials.py @@ -20,33 +20,45 @@ _TUTORIALS = _ROOT / "examples" / "tutorials" _MODELS: tuple[tuple[str, dict[str, JSONValue], float], ...] = ( - *(("biophysics.py", {"scenario": scenario}, 0.001) for scenario in ( - "basics", - "two_types", - "short_cells", - "competition", - "box", - )), - *(("gene_expression.py", {"scenario": scenario}, 0.001) for scenario in ( - "constitutive", - "legacy_constitutive", - "dilution", - "derepression", - "oscillator", - )), - *(("signaling.py", {"scenario": scenario}, 0.001) for scenario in ( - "single_gene", - "communication", - "mutualism", - )), - *(("simbol_circuits.py", {"circuit": circuit}, 0.001) for circuit in ( - "bba_0001", - "bba_0002", - "bba_0003", - "bba_0004", - "bba_0005", - "bba_i5200", - )), + *( + ("biophysics.py", {"scenario": scenario}, 0.001) + for scenario in ( + "basics", + "two_types", + "short_cells", + "competition", + "box", + ) + ), + *( + ("gene_expression.py", {"scenario": scenario}, 0.001) + for scenario in ( + "constitutive", + "legacy_constitutive", + "dilution", + "derepression", + "oscillator", + ) + ), + *( + ("signaling.py", {"scenario": scenario}, 0.001) + for scenario in ( + "single_gene", + "communication", + "mutualism", + ) + ), + *( + ("simbol_circuits.py", {"circuit": circuit}, 0.001) + for circuit in ( + "bba_0001", + "bba_0002", + "bba_0003", + "bba_0004", + "bba_0005", + "bba_i5200", + ) + ), ("plasmid_segregation.py", {"copies_per_cell": 10}, 0.001), ("conjugation.py", {"transfer_probability": 0.1}, 0.001), ("danino_clock.py", {}, 0.001), @@ -90,6 +102,7 @@ def test_plasmid_tutorial_keeps_exact_copy_counts_and_published_fractions() -> N state = cast(dict[str, object], model.controller_state()) model_state = cast(dict[str, object], state["state"]) plasmids = cast(dict[str, dict[str, int]], model_state["plasmids"]) + for cell in model.simulation.cells(): counts = plasmids[str(cell.id)] assert counts["a"] + counts["b"] == 6 @@ -149,6 +162,7 @@ def test_pillar_channel_anchors_sheds_and_washes_out() -> None: ModelContext(BackendKind.CPU, 0, seed=7), ) assert isinstance(model, SimulationController) + # 250 steps crosses the Brinkman re-solve cadence at step 100 and sheds # daughters from every anchored lineage into the stream. for _ in range(250): @@ -160,13 +174,16 @@ def test_pillar_channel_anchors_sheds_and_washes_out() -> None: released = [cell for cell in cells if not cell.fixed] assert len(anchored) == 3 assert len(released) > 3 + for cell in anchored: nearest = min( math.hypot(cell.position.x - x, cell.position.y - y) for x, y in adhesion_sites ) assert nearest < 4.0 + # Released cells drift downstream of the anchors; the flow is doing work. assert any(cell.position.y > 30.0 for cell in released) + for cell in cells: assert cell.position.z == 0.0 assert abs(cell.position.x) < 40.0 @@ -182,6 +199,7 @@ def test_plasmid_tutorial_resume_is_exact(tmp_path: Path) -> None: ModelContext(BackendKind.CPU, 0, seed=37, parameters=parameters), ) assert isinstance(uninterrupted, SimulationController) + for _ in range(6): uninterrupted.step(0.1) @@ -204,6 +222,7 @@ def test_plasmid_tutorial_resume_is_exact(tmp_path: Path) -> None: checkpoint=load_checkpoint_bundle(midpoint), ) assert isinstance(resumed, SimulationController) + for _ in range(3): resumed.step(0.1) @@ -240,3 +259,114 @@ def test_plasmid_tutorial_resume_is_exact(tmp_path: Path) -> None: ) for cell in uninterrupted.simulation.cells() ] + + +_FOUNDER_MODELS: tuple[tuple[str, dict[str, JSONValue], float], ...] = ( + *_MODELS, + ("../culture_dish.py", {}, 0.001), + ("../microfluidic_trap.py", {}, 0.001), +) + + +def _founder_targets(model: SimulationController) -> dict[str, float]: + controller = cast(dict[str, JSONValue], model.controller_state()) + state = cast(dict[str, JSONValue], controller["state"]) + + if "division_targets" in state: + return cast(dict[str, float], state["division_targets"]) + + policy = cast(dict[str, JSONValue], state["length_division"]) + + return cast(dict[str, float], policy["targets"]) + + +@pytest.mark.parametrize("seed", (0, 7, 17, 71)) +@pytest.mark.parametrize(("filename", "parameters", "dt"), _FOUNDER_MODELS) +def test_tutorial_founders_do_not_divide_without_growth( + filename: str, + parameters: dict[str, JSONValue], + dt: float, + seed: int, +) -> None: + model, _ = build_model( + _TUTORIALS / filename, + ModelContext(BackendKind.CPU, 0, seed=seed, parameters=parameters), + ) + assert isinstance(model, SimulationController) + targets = _founder_targets(model) + ids = [cell.id for cell in model.simulation.cells()] + assert set(targets) == {str(cell_id) for cell_id in ids} + assert all(cell.length <= targets[str(cell.id)] for cell in model.simulation.cells()) + model.step(0.0) + assert [cell.id for cell in model.simulation.cells()] == ids + + # Existing strict comparison still divides each founder after its length grows. + for cell in model.simulation.cells(): + model.simulation.set_cell_geometry( + cell.id, + cell.position, + cell.direction, + targets[str(cell.id)] + 0.01, + ) + + model.step(0.0) + assert not set(ids) & {cell.id for cell in model.simulation.cells()} + + +@pytest.mark.parametrize(("filename", "parameters", "dt"), _FOUNDER_MODELS) +def test_tutorial_founder_initialization_is_deterministic_and_resume_does_not_cap( + filename: str, + parameters: dict[str, JSONValue], + dt: float, + tmp_path: Path, +) -> None: + context = ModelContext(BackendKind.CPU, 0, seed=71, parameters=parameters) + first, provenance = build_model(_TUTORIALS / filename, context) + second, _ = build_model( + _TUTORIALS / filename, + ModelContext(BackendKind.CPU, 0, seed=71, parameters=parameters), + ) + assert isinstance(first, SimulationController) + assert isinstance(second, SimulationController) + assert first.controller_state() == second.controller_state() + assert [cell.length for cell in first.simulation.cells()] == [ + cell.length for cell in second.simulation.cells() + ] + + for cell in first.simulation.cells(): + first.simulation.set_cell_geometry(cell.id, cell.position, cell.direction, 8.0) + + path = tmp_path / "oversized.cm2.json" + run_simulation(first, steps=0, dt=dt, output=path, provenance=provenance) + restored, _ = build_model( + _TUTORIALS / filename, + ModelContext(BackendKind.CPU, 0, seed=71, parameters=parameters), + checkpoint=load_checkpoint_bundle(path), + ) + assert isinstance(restored, SimulationController) + assert restored.controller_state() == first.controller_state() + assert all(cell.length == 8.0 for cell in restored.simulation.cells()) + + +def test_conjugation_rare_short_gaussian_target_is_not_resampled() -> None: + import random + + model, _ = build_model( + _TUTORIALS / "conjugation.py", + ModelContext(BackendKind.CPU, 0, seed=3103), + ) + assert isinstance(model, SimulationController) + expected = random.Random(3103) + targets = _founder_targets(model) + cells = model.simulation.cells() + # Native CellInit stores the requested 1.9 in single precision. + requested = 1.899999976158142 + + for cell in cells: + assert targets[str(cell.id)] == requested + expected.gauss(1.9, 0.45) + assert cell.length <= targets[str(cell.id)] + + assert cells[0].length < requested + ids = [cell.id for cell in cells] + model.step(0.0) + assert [cell.id for cell in model.simulation.cells()] == ids diff --git a/python/tests/test_viewer_server.py b/python/tests/test_viewer_server.py index f46e53e..21ec152 100644 --- a/python/tests/test_viewer_server.py +++ b/python/tests/test_viewer_server.py @@ -32,6 +32,7 @@ def _factory() -> tuple[Simulation, dict[str, JSONValue]]: cell.length = 2.0 cell.growth_rate = 0.5 simulation.add_cell(cell) + return simulation, {"model": {"name": "viewer-test"}} @@ -54,6 +55,7 @@ def controller_state(self) -> JSONValue: def _controller_factory() -> tuple[_TestController, dict[str, JSONValue]]: simulation, provenance = _factory() + return _TestController(simulation), provenance @@ -63,6 +65,7 @@ def _dist(path: Path) -> Path: assets.mkdir(parents=True) (dist / "index.html").write_text("test", encoding="utf-8") (assets / "app.js").write_text("", encoding="utf-8") + return dist @@ -89,6 +92,7 @@ def test_live_session_steps_resets_and_writes_only_configured_checkpoint( assert cast(dict[str, Any], reset["scene"])["frame"]["time"] == 0.0 disabled = LiveSession(_factory, dt=0.25) + with pytest.raises(LiveViewerError, match="not configured"): disabled.checkpoint() @@ -134,18 +138,22 @@ async def exercise() -> None: client = TestClient(TestServer(application)) await client.start_server() origin = str(client.make_url("/")).rstrip("/") + try: with pytest.raises(WSServerHandshakeError) as wrong_token: await client.ws_connect( "/api/v1/session?token=wrong", headers={"Origin": origin}, ) + assert wrong_token.value.status == 403 + with pytest.raises(WSServerHandshakeError) as wrong_origin: await client.ws_connect( f"/api/v1/session?token={token}", headers={"Origin": "https://attacker.invalid"}, ) + assert wrong_origin.value.status == 403 socket = await client.ws_connect( diff --git a/python/tests/test_viewer_shutdown.py b/python/tests/test_viewer_shutdown.py new file mode 100644 index 0000000..b91223f --- /dev/null +++ b/python/tests/test_viewer_shutdown.py @@ -0,0 +1,735 @@ +"""Shutdown regressions use real sockets and a controllably blocked worker.""" + +from __future__ import annotations + +import asyncio +import os +import signal +import socket +import subprocess +import sys +from contextlib import suppress +from pathlib import Path +from threading import Event +from threading import enumerate as threads +from typing import Any, cast +from urllib.parse import urlsplit + +import pytest +from aiohttp import ClientSession, ClientWebSocketResponse, WSMsgType, web +from aiohttp.test_utils import TestClient, TestServer +from microsimulator import BackendKind, CellInit, Simulation, Vec3, load_checkpoint +from microsimulator.checkpoint import JSONValue +from microsimulator.viewer_server import ( + _CONTROLLER_KEY, # pyright: ignore[reportPrivateUsage] + LiveCommand, + LiveController, + LiveSession, + LiveViewerError, + create_live_app, + parse_command, +) + + +def _factory() -> tuple[Simulation, dict[str, JSONValue]]: + simulation = Simulation(BackendKind.CPU) + simulation.add_cell(CellInit()) + + return simulation, {} + + +def _dist(path: Path) -> Path: + dist = path / "dist" + (dist / "assets").mkdir(parents=True) + (dist / "index.html").write_text("shutdown test") + + return dist + + +class _BlockedModel: + def __init__(self) -> None: + self.simulation, _ = _factory() + self.entered = Event() + self.release = Event() + + def controller_state(self) -> JSONValue: + return None + + def step(self, dt: float) -> None: + self.entered.set() + assert self.release.wait(10), "test did not release blocked step" + self.simulation.step(dt) + + +async def _entered(event: Event) -> None: + assert await asyncio.to_thread(event.wait, 5), "worker never reached blocking point" + + +@pytest.mark.parametrize("playing", [False, True]) +def test_stop_preempts_same_socket_batch_and_rejects_new_commands( + tmp_path: Path, + playing: bool, +) -> None: + async def exercise() -> None: + model = _BlockedModel() + session = LiveSession(lambda: (model, {}), dt=0.1) + app, token = create_live_app(session, _dist(tmp_path), frame_steps=10_000) + client = TestClient(TestServer(app)) + + try: + await client.start_server() + origin = str(client.make_url("/")).rstrip("/") + ws = await client.ws_connect( + f"/api/v1/session?token={token}", + headers={"Origin": origin}, + ) + await ws.receive_json(timeout=5) + await ws.send_json({"type": "play"} if playing else {"type": "step", "steps": 10_000}) + await _entered(model.entered) + await ws.send_json({"type": "stop"}) + await ws.send_json({"type": "stop"}) + + # Read until admission is closed while the current step is blocked. + while (await ws.receive_json(timeout=5)).get("type") != "session": + pass + + for name in ("play", "step", "reset", "checkpoint"): + await ws.send_json({"type": name}) + rejected = await ws.receive_json(timeout=5) + assert rejected["type"] == "error" + assert "stopping" in rejected["message"] + + assert session.completed_steps == 0 + model.release.set() + + while True: + message = await ws.receive_json(timeout=5) + + if message == {"type": "session", "state": "stopped"}: + break + + assert session.completed_steps == 1 + assert (await ws.receive(timeout=5)).type == WSMsgType.CLOSE + assert ws.close_code == 1000 + finally: + model.release.set() + await client.close() + + assert not any(t.name.startswith("microsimulator-live") for t in threads()) + + asyncio.run(exercise()) + + +def test_stop_waits_for_atomic_checkpoint_replace( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def exercise() -> None: + output = tmp_path / "run.json" + session = LiveSession(_factory, dt=0.1, checkpoint_output=output) + session.checkpoint() + original = output.read_bytes() + session.step() + entered, release = Event(), Event() + replace = os.replace + + def blocked_replace(source: str | Path, destination: str | Path) -> None: + entered.set() + assert release.wait(10), "test did not release checkpoint replace" + replace(source, destination) + + monkeypatch.setattr(os, "replace", blocked_replace) + controller = LiveController(session) + save = asyncio.create_task(controller.command(LiveCommand("checkpoint"))) + + try: + await _entered(entered) + await controller.command(LiveCommand("stop")) + assert not controller.stopped.is_set() + assert output.read_bytes() == original + + for name in ("play", "step", "reset", "checkpoint"): + with pytest.raises(LiveViewerError, match="stopping"): + await controller.command(parse_command('{"type":"' + name + '"}')) + + release.set() + assert await save == str(output.resolve()) + await asyncio.wait_for(controller.close(), 5) + assert abs(load_checkpoint(output).time - 0.1) < 1e-7 + assert list(tmp_path.iterdir()) == [output] + finally: + release.set() + await controller.close() + + asyncio.run(exercise()) + + +def test_cancelled_waiter_does_not_release_worker_early() -> None: + async def exercise() -> None: + model = _BlockedModel() + session = LiveSession(lambda: (model, {}), dt=0.1) + controller = LiveController(session) + operation = asyncio.create_task(controller.command(LiveCommand("step", 10_000))) + + try: + await _entered(model.entered) + operation.cancel() + controller.request_stop() + await asyncio.sleep(0) + assert not operation.done() + assert not controller.stopped.is_set() + model.release.set() + + with pytest.raises(asyncio.CancelledError): + await operation + + await asyncio.wait_for(controller.close(), 5) + assert session.completed_steps == 1 + finally: + model.release.set() + await controller.close() + + asyncio.run(exercise()) + + +@pytest.mark.parametrize("_attempt", range(10)) +def test_disconnect_pauses_without_stopping_and_close_is_idempotent( + tmp_path: Path, + _attempt: int, +) -> None: + async def exercise() -> None: + session = LiveSession(_factory, dt=0.1) + app, token = create_live_app(session, _dist(tmp_path)) + client = TestClient(TestServer(app)) + await client.start_server() + origin = str(client.make_url("/")).rstrip("/") + + try: + ws = await client.ws_connect( + f"/api/v1/session?token={token}", + headers={"Origin": origin}, + ) + await ws.receive_json(timeout=5) + await ws.send_json({"type": "play"}) + await ws.receive_json(timeout=5) + await ws.close() + ws2 = await client.ws_connect( + f"/api/v1/session?token={token}", + headers={"Origin": origin}, + ) + assert (await ws2.receive_json(timeout=5))["playing"] is False + await ws2.send_json({"type": "stop"}) + await asyncio.gather(ws2.close(), client.close()) + await client.close() + finally: + await client.close() + + asyncio.run(exercise()) + + +def test_stop_command_is_closed() -> None: + assert parse_command('{"type":"stop"}') == LiveCommand("stop") + + with pytest.raises(LiveViewerError, match="unknown fields"): + parse_command('{"type":"stop","force":true}') + + +def test_reconnect_observes_pause_before_disconnected_command_drains( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def exercise() -> None: + entered, release = Event(), Event() + reconnect_started = asyncio.Event() + frame_message = LiveSession.frame_message + connect = LiveController.connect + frames, connections = 0, 0 + + def blocked_frame(session: LiveSession, *, playing: bool) -> dict[str, JSONValue]: + nonlocal frames + frames += 1 + + if frames == 2: + entered.set() + assert release.wait(10), "test did not release frame capture" + + return frame_message(session, playing=playing) + + async def observe_connect( + controller: LiveController, + ws: web.WebSocketResponse, + transport: asyncio.Transport | None = None, + ) -> None: + nonlocal connections + connections += 1 + + if connections == 2: + reconnect_started.set() + + await connect(controller, ws, transport) + + monkeypatch.setattr(LiveSession, "frame_message", blocked_frame) + monkeypatch.setattr(LiveController, "connect", observe_connect) + app, token = create_live_app(LiveSession(_factory, dt=0.1), _dist(tmp_path)) + client = TestClient(TestServer(app)) + + try: + await client.start_server() + origin = str(client.make_url("/")).rstrip("/") + + async def open_socket() -> ClientWebSocketResponse: + return await client.ws_connect( + f"/api/v1/session?token={token}", + headers={"Origin": origin}, + ) + + ws = await open_socket() + await ws.receive_json(timeout=5) + await ws.send_json({"type": "play"}) + await _entered(entered) + # close() finishes the network handshake while the server's canceled + # frame capture is still holding its worker/serialization lock. + await ws.close() + reconnect = asyncio.create_task(open_socket()) + await asyncio.wait_for(reconnect_started.wait(), 5) + release.set() + ws2 = await reconnect + assert (await ws2.receive_json(timeout=5))["playing"] is False + await ws2.close() + finally: + release.set() + await client.close() + + asyncio.run(exercise()) + + +def test_stop_is_not_blocked_by_a_stalled_frame_send( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def exercise() -> None: + entered = asyncio.Event() + send = web.WebSocketResponse.send_str + frame_count = 0 + + async def stalled_send( + ws: web.WebSocketResponse, + data: str, + compress: int | None = None, + ) -> None: + nonlocal frame_count + + if data.startswith('{"type":"frame"'): + frame_count += 1 + + if frame_count > 1: + entered.set() + await asyncio.Event().wait() + + await send(ws, data, compress=compress) + + monkeypatch.setattr(web.WebSocketResponse, "send_str", stalled_send) + app, token = create_live_app(LiveSession(_factory, dt=0.1), _dist(tmp_path)) + client = TestClient(TestServer(app)) + + try: + await client.start_server() + origin = str(client.make_url("/")).rstrip("/") + ws = await client.ws_connect( + f"/api/v1/session?token={token}", + headers={"Origin": origin}, + ) + await ws.receive_json(timeout=5) + await ws.send_json({"type": "play"}) + await asyncio.wait_for(entered.wait(), 5) + await ws.send_json({"type": "stop"}) + assert await ws.receive_json(timeout=5) == {"type": "session", "state": "stopping"} + assert await ws.receive_json(timeout=5) == {"type": "session", "state": "stopped"} + await ws.close() + finally: + await client.close() + + asyncio.run(exercise()) + + +async def _open_stalled_connection( + port: int, +) -> tuple[socket.socket, asyncio.StreamReader, asyncio.StreamWriter]: + raw = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + + try: + raw.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4096) + raw.setblocking(False) + await asyncio.get_running_loop().sock_connect(raw, ("127.0.0.1", port)) + reader, stalled = await asyncio.open_connection(sock=raw) + except BaseException: + raw.close() + raise + + return raw, reader, stalled + + +async def _wait_for_backpressure( + blocked: asyncio.Transport, pump: asyncio.Task[None] | None +) -> None: + async with asyncio.timeout(15): + while blocked.get_write_buffer_size() <= 1_000_000: + assert not blocked.is_closing(), "probe closed before backpressure observed" + + if pump is not None and pump.done(): + await pump + raise AssertionError("8 real Frame requests did not cause backpressure") + + await asyncio.sleep(0.01) + + +def _task_await_chains() -> list[str]: + chains: list[str] = [] + + for task in list(asyncio.all_tasks())[:16]: + current = cast(Any, task.get_coro()) + chain: list[str] = [] + + for _ in range(16): + if current is None: + break + + code = getattr(current, "cr_code", None) + chain.append(code.co_name if code else type(current).__name__) + current = getattr(current, "cr_await", None) + + chains.append(" -> ".join(chain)) + + return chains + + +async def _assert_port_reusable(client: ClientSession, dist: Path, port: int, origin: str) -> None: + replacement, next_token = create_live_app(LiveSession(_factory, dt=0.1), dist) + next_server = TestServer(replacement, host="127.0.0.1", port=port) + await next_server.start_server() + + try: + async with client.ws_connect( + f"{origin}/api/v1/session?token={next_token}", + headers={"Origin": origin}, + ) as next_ws: + frame = cast(dict[str, Any], await next_ws.receive_json(timeout=5)) + assert len(frame["scene"]["frame"]["cells"]) == 1 + finally: + await asyncio.wait_for(next_server.close(), 5) + + +async def _interrupt_viewer_process(process: asyncio.subprocess.Process) -> None: + if sys.platform == "win32": + # A separate sender attaches to the isolated viewer + # console. Ignore the event in the sender only; the + # viewer receives the real Windows CTRL_C_EVENT. + sender = await asyncio.create_subprocess_exec( + sys.executable, + "-c", + "import ctypes, sys\n" + "kernel = ctypes.WinDLL('kernel32', use_last_error=True)\n" + "kernel.FreeConsole()\n" + "for operation, arguments in (\n" + " (kernel.AttachConsole, (int(sys.argv[1]),)),\n" + " (kernel.SetConsoleCtrlHandler, (None, True)),\n" + " (kernel.GenerateConsoleCtrlEvent, (0, 0)),\n" + "):\n" + " if not operation(*arguments):\n" + " raise ctypes.WinError(ctypes.get_last_error())\n", + str(process.pid), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + try: + _, sender_error = await asyncio.wait_for(sender.communicate(), 10) + assert sender.returncode == 0, sender_error.decode() + finally: + if sender.returncode is None: + sender.kill() + await sender.wait() + else: + process.send_signal(signal.SIGINT) + + +@pytest.mark.parametrize("termination", ["stop", "runner_cleanup"]) +def test_real_tcp_backpressure_releases_connections_and_reuses_port( + tmp_path: Path, termination: str +) -> None: + def large_factory() -> tuple[Simulation, dict[str, JSONValue]]: + simulation = Simulation(BackendKind.CPU) + cell = CellInit() + + for index in range(40_000): + cell.position = Vec3(index * 3.0, 0.0, 0.0) + simulation.add_cell(cell) + + return simulation, {} + + async def exercise() -> None: + dist = _dist(tmp_path) + app, token = create_live_app(LiveSession(large_factory, dt=0.1), dist) + controller = app[_CONTROLLER_KEY] + probe_transport: asyncio.Transport | None = None + probe_prepared = asyncio.Event() + + async def limit_probe_send_buffer( + request: web.Request, response: web.StreamResponse + ) -> None: + nonlocal probe_transport + + if request.headers.get("X-Backpressure-Probe") == "1": + assert response.status == 101 + probe_transport = request.transport + assert probe_transport is not None + peer = cast(socket.socket, probe_transport.get_extra_info("socket")) + # Bound kernel buffering too: Windows overlapped writes may + # otherwise accept the whole scene before the peer consumes it. + peer.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 16_384) + probe_prepared.set() + + app.on_response_prepare.append(limit_probe_send_buffer) + server = TestServer(app, host="127.0.0.1") + await server.start_server() + port = server.make_url("/").port + assert port is not None + origin = f"http://127.0.0.1:{port}" + stalled: asyncio.StreamWriter | None = None + cleanup: asyncio.Task[None] | None = None + receiver: asyncio.Task[None] | None = None + pump: asyncio.Task[None] | None = None + + try: + async with ClientSession() as client: + healthy = await client.ws_connect( + f"{origin}/api/v1/session?token={token}", + headers={"Origin": origin}, + max_msg_size=128 * 1024 * 1024, + ) + initial = cast(dict[str, Any], await healthy.receive_json(timeout=15)) + assert len(initial["scene"]["frame"]["cells"]) == 40_000 + notices: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + frames: asyncio.Queue[None] = asyncio.Queue() + + async def drain_healthy() -> None: + async for message in healthy: + assert message.type is WSMsgType.TEXT + body = cast(dict[str, Any], message.json()) + + if body.get("type") == "frame": + frames.put_nowait(None) + else: + notices.put_nowait(body) + + receiver = asyncio.create_task(drain_healthy()) + # Limit the receive window before TCP negotiation, then stop + # reading before sending the authenticated WebSocket upgrade. + # No send/close implementation or timeout is mocked. + raw, reader, stalled = await _open_stalled_connection(port) + + cast(asyncio.Transport, stalled.transport).pause_reading() + stalled.write( + ( + f"GET /api/v1/session?token={token} HTTP/1.1\r\n" + f"Host: 127.0.0.1:{port}\r\nOrigin: {origin}\r\n" + "Upgrade: websocket\r\nConnection: Upgrade\r\n" + "Sec-WebSocket-Key: MTIzNDU2Nzg5MDEyMzQ1Ng==\r\n" + "Sec-WebSocket-Version: 13\r\nX-Backpressure-Probe: 1\r\n\r\n" + ).encode("ascii") + ) + await stalled.drain() + # Observe the server's authenticated 101 response without + # allowing the client to prefetch any scene payload first. + await asyncio.wait_for(probe_prepared.wait(), 5) + # Synchronize on observed backpressure instead of assuming that + # a delay was long enough for the initial send to fill the queue. + assert probe_transport is not None + blocked = probe_transport + requests_sent = 0 + + async def fill_windows_loopback() -> None: + nonlocal requests_sent + + # Windows can accept the entire initial scene below the + # Proactor transport while the receiver is already paused. + # Exercise sustained real broadcasts on that platform; + # POSIX retains the stalled-initial-delivery scenario. + for _ in range(8): + if blocked.get_write_buffer_size() > 1_000_000: + return + + await healthy.send_json({"type": "frame"}) + requests_sent += 1 + # The enclosing 15-second setup budget owns this wait; + # native capture is not part of the network deadline. + await frames.get() + + if sys.platform == "win32": + pump = asyncio.create_task(fill_windows_loopback()) + + try: + await _wait_for_backpressure(blocked, pump) + except TimeoutError as error: + peer = cast(socket.socket, blocked.get_extra_info("socket")) + buffered = len(reader._buffer) # pyright: ignore[reportPrivateUsage] + chains = _task_await_chains() + + registered = len(controller._sockets) # pyright: ignore[reportPrivateUsage] + raise AssertionError( + f"no backpressure: transport={type(blocked).__name__}, " + f"queued={blocked.get_write_buffer_size()}, reader_bytes={buffered}, " + f"send_buffer={peer.getsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF)}, " + f"receive_buffer={raw.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF)}, " + f"registered={registered}, frame_requests={requests_sent}, " + f"await_chains={chains}" + ) from error + + assert blocked.get_write_buffer_size() > 1_000_000 + + if pump is not None: + # Only the test's request pump is canceled. Already-running + # server work is still drained by cooperative shutdown. + pump.cancel() + + with suppress(asyncio.CancelledError): + await pump + + if termination == "stop": + await healthy.send_json({"type": "stop"}) + else: + cleanup = asyncio.create_task(server.close()) + + assert await asyncio.wait_for(notices.get(), 5) == { + "type": "session", + "state": "stopping", + } + assert await asyncio.wait_for(notices.get(), 5) == { + "type": "session", + "state": "stopped", + } + await asyncio.wait_for(receiver, 5) + assert healthy.close_code == 1000 + await asyncio.wait_for(controller.stopped.wait(), 5) + await asyncio.wait_for(cleanup or server.close(), 5) + # close() alone may still flush indefinitely; abort() releases + # queued bytes without resuming the paused receiver. + assert blocked.is_closing() + assert blocked.get_write_buffer_size() == 0 + assert not any(t.name.startswith("microsimulator-live") for t in threads()) + await _assert_port_reusable(client, dist, port, origin) + finally: + tasks = [task for task in (pump, receiver) if task is not None] + + for task in tasks: + task.cancel() + + # Verification awaits failures above. Cleanup must still release + # real sockets if either helper had already failed. + await asyncio.gather(*tasks, return_exceptions=True) + + if stalled is not None: + stalled.transport.abort() + await asyncio.wait_for(stalled.wait_closed(), 5) + + if cleanup is not None: + await asyncio.wait_for(asyncio.shield(cleanup), 5) + + await asyncio.wait_for(server.close(), 5) + + asyncio.run(exercise()) + + +@pytest.mark.parametrize("termination", ["stop", "interrupt"]) +def test_cli_process_exits_and_reuses_port_for_another_model( + tmp_path: Path, + termination: str, +) -> None: + async def exercise() -> None: + dist = _dist(tmp_path) + + with socket.socket() as reservation: + reservation.bind(("127.0.0.1", 0)) + port = reservation.getsockname()[1] + + for index in range(3): + model = tmp_path / f"model {index}.py" + model.write_text( + "from microsimulator import CellInit\n" + "def build(context):\n" + " simulation = context.simulation()\n" + " cell = CellInit()\n" + f" cell.length = {2 + index}\n" + " simulation.add_cell(cell)\n" + " return simulation\n" + ) + process = await asyncio.create_subprocess_exec( + sys.executable, + "-m", + "microsimulator", + "view", + "--model", + str(model), + "--backend", + "cpu", + "--dt", + "0.1", + "--viewer-dist", + str(dist), + "--port", + str(port), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + # Keep the test runner outside the console receiving Ctrl+C. + # CREATE_NEW_PROCESS_GROUP would disable Ctrl+C in the child. + creationflags=( + getattr(subprocess, "CREATE_NEW_CONSOLE", 0) + if termination == "interrupt" and sys.platform == "win32" + else 0 + ), + ) + + try: + assert process.stdout is not None + line = (await asyncio.wait_for(process.stdout.readline(), 10)).decode().strip() + + if not line.startswith("MicroSimulator live viewer: "): + _, error = await asyncio.wait_for(process.communicate(), 5) + pytest.fail(f"viewer did not start: {line} {error.decode()}") + + url = urlsplit(line.split(": ", 1)[1]) + + async with ( + ClientSession() as client, + client.ws_connect( + f"http://{url.netloc}/api/v1/session?{url.query}", + headers={"Origin": f"http://{url.netloc}"}, + ) as ws, + ): + frame = cast(dict[str, Any], await ws.receive_json(timeout=5)) + assert frame["scene"]["frame"]["cells"][0]["length"] == 2 + index + + if termination == "interrupt": + await _interrupt_viewer_process(process) + else: + await ws.send_json({"type": "stop"}) + + assert await ws.receive_json(timeout=5) == { + "type": "session", + "state": "stopping", + } + assert await ws.receive_json(timeout=5) == { + "type": "session", + "state": "stopped", + } + assert (await ws.receive(timeout=5)).type == WSMsgType.CLOSE + + _, error = await asyncio.wait_for(process.communicate(), 10) + assert process.returncode == 0, error.decode() + assert not error, error.decode() + finally: + if process.returncode is None: + process.kill() + await process.wait() + + asyncio.run(exercise()) diff --git a/scripts/check_complexity.py b/scripts/check_complexity.py new file mode 100644 index 0000000..0d0f1fc --- /dev/null +++ b/scripts/check_complexity.py @@ -0,0 +1,74 @@ +"""Structural limits shared by native, Python, and viewer source.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +import lizard +from quality_files import NATIVE, ROOT, source_files + +MAX_LINES = 100 +MAX_COMPLEXITY = 15 + + +def analysis_name(path: Path) -> str | None: + if path.suffix in NATIVE: + return str(path.with_suffix(".mm" if path.suffix == ".mm" else ".cpp")) + + if path.suffix in {".py", ".pyi"}: + return str(path.with_suffix(".py")) + + if path.suffix in {".ts", ".tsx"}: + return str(path.with_suffix(".ts")) + + if path.suffix in {".js", ".mjs", ".cjs"}: + return str(path.with_suffix(".js")) + + return None + + +def violations(path: Path, code: str) -> list[str]: + name = analysis_name(path) + + if name is None: + return [] + + findings = [] + + if re.search(r"(?m)^\s*(?://|#|/\*|\*)\s*#?\s*lizard\s+forgiv", code): + findings.append(f"{path}: complexity suppressions are not permitted") + + result = lizard.analyze_file.analyze_source_code(name, code) + + for function in result.function_list: + if function.nloc > MAX_LINES or function.cyclomatic_complexity > MAX_COMPLEXITY: + findings.append( + f"{path}:{function.start_line}: {function.name}: " + f"{function.nloc}/{MAX_LINES} code lines, " + f"complexity {function.cyclomatic_complexity}/{MAX_COMPLEXITY}" + ) + + return findings + + +def check(paths: list[Path]) -> int: + findings = [] + count = 0 + + for path in paths: + if analysis_name(path) is not None: + count += 1 + findings.extend(violations(path, (ROOT / path).read_text(encoding="utf-8"))) + + for finding in findings: + print(finding) + + print(f"Complexity: {len(findings)} violations in {count} source files") + + return int(bool(findings)) + + +if __name__ == "__main__": + raise SystemExit(check(source_files(sys.argv[1:]))) diff --git a/scripts/code_paragraphs.py b/scripts/code_paragraphs.py new file mode 100644 index 0000000..47413aa --- /dev/null +++ b/scripts/code_paragraphs.py @@ -0,0 +1,147 @@ +"""Separate statement groups without changing tokens or guessing numerical intent.""" + +from __future__ import annotations + +import difflib +from functools import cache +from pathlib import Path + +import tree_sitter_cpp +import tree_sitter_javascript +import tree_sitter_python +import tree_sitter_typescript +from quality_files import NATIVE, ROOT +from tree_sitter import Language, Node, Parser + +BLOCKS = {"compound_statement", "statement_block", "block"} +CONTROL = { + "if_statement", + "for_statement", + "for_range_loop", + "for_in_statement", + "while_statement", + "do_statement", + "switch_statement", + "switch_expression", + "try_statement", + "with_statement", + "match_statement", +} +DEFINITIONS = {"function_definition", "class_definition", "function_declaration"} + + +@cache +def parser_for(suffix: str) -> Parser | None: + if suffix in NATIVE: + language = tree_sitter_cpp.language() + elif suffix in {".py", ".pyi"}: + language = tree_sitter_python.language() + elif suffix in {".ts", ".tsx"}: + language = ( + tree_sitter_typescript.language_tsx() + if suffix == ".tsx" + else tree_sitter_typescript.language_typescript() + ) + elif suffix in {".js", ".mjs", ".cjs"}: + language = tree_sitter_javascript.language() + else: + return None + + return Parser(Language(language)) + + +def needs_paragraph(previous: Node, current: Node) -> bool: + if previous.type == "ERROR" or current.type == "ERROR": + return False + + if previous.type in DEFINITIONS or current.type in DEFINITIONS: + return False + + return previous.type in CONTROL or current.type in CONTROL | {"return_statement"} + + +def block_boundaries(block: Node, lines: list[str]) -> set[int]: + boundaries = set() + previous = None + comments: list[Node] = [] + + for node in block.named_children: + if node.type == "comment": + comments.append(node) + continue + + if previous is not None and needs_paragraph(previous, node): + leading = [c for c in comments if c.start_point.row > previous.end_point.row] + row = (leading[0] if leading else node).start_point.row + + # A newline may only be inserted between complete physical lines. + # Never split one-line bodies, trailing comments, or multiline tokens. + if row > previous.end_point.row and row > 0 and lines[row - 1].strip(): + boundaries.add(row) + + previous = node + comments = [] + + return boundaries + + +def format_code(path: Path, code: str) -> str: + parser = parser_for(path.suffix) + + if parser is None: + return code + + lines = code.splitlines(keepends=True) + tree = parser.parse(code.encode("utf-8")) + pending = [tree.root_node] + boundaries = set() + + while pending: + node = pending.pop() + + # Unsupported Objective-C/CUDA syntax is opaque. It must not expose + # text inside an error recovery node as candidate statements. + if node.type == "ERROR" or node.type.startswith("preproc_"): + continue + + if node.type in BLOCKS: + boundaries.update(block_boundaries(node, lines)) + + pending.extend(node.named_children) + + return "".join(("\n" if i in boundaries else "") + line for i, line in enumerate(lines)) + + +def check(paths: list[Path], *, write: bool) -> int: + changed = 0 + + for path in paths: + if parser_for(path.suffix) is None: + continue + + original = (ROOT / path).read_text(encoding="utf-8") + formatted = format_code(path, original) + + if formatted == original: + continue + + changed += 1 + + if write: + (ROOT / path).write_text(formatted, encoding="utf-8") + else: + print( + "".join( + difflib.unified_diff( + original.splitlines(keepends=True), + formatted.splitlines(keepends=True), + fromfile=str(path), + tofile=f"{path} (logical spacing)", + ) + ), + end="", + ) + + print(f"Code paragraphs: {changed} files {'updated' if write else 'need formatting'}") + + return int(changed > 0 and not write) diff --git a/scripts/diagnose_planarity.py b/scripts/diagnose_planarity.py new file mode 100644 index 0000000..88f5fc3 --- /dev/null +++ b/scripts/diagnose_planarity.py @@ -0,0 +1,402 @@ +"""Record stage-local Z motion without changing the 3D solver or its random stream. + +Run in a dedicated diagnostic process: native Python entry points are temporarily +instrumented and restored on exit. No controller implementation is duplicated. +""" + +from __future__ import annotations + +import argparse +import json +import math +import random +from contextlib import contextmanager +from pathlib import Path + +from microsimulator import ( + BackendKind, + CellInit, + GridBoundary, + GridBoundaryKind, + GridShape, + MechanicsConfig, + ModelContext, + NativeController, + PlaneConstraintInit, + SignalGridSpec, + SignalGridVelocityField, + Simulation, + StepPlan, + UniformLengthDivision, + Vec3, + backend_available, + build_model, +) +from microsimulator.scene import capture_scene, dumps_scene + + +def cells(simulation): + return [ + dict( + id=c.id, + center=[c.position.x, c.position.y, c.position.z], + direction=[c.direction.x, c.direction.y, c.direction.z], + length=c.length, + radius=c.radius, + ) + for c in simulation.cells() + ] + + +class Trace: + def __init__(self, simulation, plane_z=0.0, tolerance=1e-6): + self.simulation = simulation + self.plane_z = plane_z + self.tolerance = tolerance + self.events = [] + self.first_event = None + self.initial = json.loads(dumps_scene(capture_scene(simulation)))["frame"] + self.record("initialization", [], cells(simulation)) + + def record(self, stage, before, after, requested_z=None): + entry = dict( + stage=stage, + time=self.simulation.time, + cells=len(after), + max_center_z=max((abs(c["center"][2]) for c in after), default=0.0), + max_center_displacement_from_plane=max( + (abs(c["center"][2] - self.plane_z) for c in after), default=0.0 + ), + max_direction_z=max((abs(c["direction"][2]) for c in after), default=0.0), + ) + previous = {c["id"]: c for c in before} + entry["max_stage_center_change_z"] = max( + ( + abs(c["center"][2] - previous[c["id"]]["center"][2]) + for c in after + if c["id"] in previous + ), + default=0.0, + ) + entry["max_stage_direction_change_z"] = max( + ( + abs(c["direction"][2] - previous[c["id"]]["direction"][2]) + for c in after + if c["id"] in previous + ), + default=0.0, + ) + + if requested_z is not None: + entry["requested_direction_delta_z"] = requested_z + + self.events.append(entry) + + if ( + self.first_event is None + and max(entry["max_center_displacement_from_plane"], entry["max_direction_z"]) + > self.tolerance + ): + self.first_event = dict(**entry, before=before, after=after) + + @contextmanager + def instrument(self): + names = { + "divide": "division", + "divide_equal": "division", + "remove_cell": "removal", + "step": "growth_and_chemistry", + "apply_flow_drift": "flow_drift", + "relax_cell_mechanics": "contact_and_constraint_relaxation", + "set_cell_geometry": "geometry_edit", + } + originals = {} + + def wrapped(name, original): + def call(simulation, *args, **kwargs): + if simulation is not self.simulation: + return original(simulation, *args, **kwargs) + + before = cells(simulation) + requested_z = None + + if name == "set_cell_geometry": + cell_id = args[0] if args else kwargs.get("cell_id") + direction = args[2] if len(args) >= 3 else kwargs.get("direction") + + if cell_id is not None and direction is not None: + previous = simulation.cell(cell_id) + requested_z = direction.z - previous.direction.z + + result = original(simulation, *args, **kwargs) + self.record(names[name], before, cells(simulation), requested_z) + + return result + + return call + + try: + for name in names: + originals[name] = getattr(Simulation, name) + setattr(Simulation, name, wrapped(name, originals[name])) + + yield + finally: + for name, original in originals.items(): + setattr(Simulation, name, original) + + def report(self): + return dict( + plane_z=self.plane_z, + tolerance=self.tolerance, + initial_geometry=self.initial["cells"], + constraints=self.initial["constraints"], + backend=self.initial["backend"], + first_out_of_plane=self.first_event, + final_geometry=cells(self.simulation), + stages=self.events, + ) + + +def add(simulation, position=(0, 0, 0), direction=(1, 0, 0), length=4.0, radius=0.5): + cell = CellInit() + cell.position, cell.direction = Vec3(*position), Vec3(*direction) + cell.length, cell.radius, cell.growth_rate = length, radius, 0.0 + + return simulation.add_cell(cell) + + +def fixtures(backend, seed, dt): + results = {} + + for name in ("separated_planar", "crossing", "coincident_parallel"): + simulation = Simulation(backend) + add(simulation) + add( + simulation, + position=(0, 3, 0) if name == "separated_planar" else (0, 0, 0), + direction=(0, 1, 0) if name == "crossing" else (1, 0, 0), + ) + normals = [ + [c.normal.x, c.normal.y, c.normal.z] for c in simulation.find_cell_contacts().contacts + ] + trace = Trace(simulation) + + with trace.instrument(): + simulation.relax_cell_mechanics(*MechanicsConfig().native_parameters()) + + results[name] = dict( + classification="expected 3D contact behavior", + mechanics=MechanicsConfig().to_json(), + contact_normals=normals, + **trace.report(), + ) + + for name, direction in (("planar_division", (1, 0, 0)), ("inherited_tilt", (1, 0, 0.2))): + simulation = Simulation(backend) + founder = add(simulation, direction=direction) + rng = random.Random(seed) + policy = UniformLengthDivision(3.0, 3.0, jitter_z=False) + state = {} + policy.initialize(state, rng, (founder,)) # intentionally oversized diagnostic founder + controller = NativeController( + simulation, + model_id="planarity-diagnostic", + model_version=1, + rng=rng, + state=state, + regulate=lambda step, policy=policy: StepPlan(divisions=policy.requests(step)), + on_division=policy.on_division, + ) + trace = Trace(simulation) + + with trace.instrument(): + controller.step(0.0) + + results[name] = dict( + classification="expected inherited geometry and normalized XY jitter", + controller_step_dt=0.0, + division_target=3.0, + jitter_z=False, + mechanics=MechanicsConfig().to_json(), + **trace.report(), + ) + + simulation = Simulation(backend) + add(simulation, position=(0, 0, 0.8)) + + for height, normal in ((-1.0, 1.0), (1.0, -1.0)): + plane = PlaneConstraintInit() + plane.point, plane.inward_normal = Vec3(0, 0, height), Vec3(0, 0, normal) + simulation.add_plane_constraint(plane) + + trace = Trace(simulation, plane_z=0.8) + + def relax_to_tolerance(config): + residuals = [] + + for _ in range(20): + residuals.append( + max( + ( + max(0.0, -contact.signed_separation) + for contact in simulation.find_external_contacts().contacts + ), + default=0.0, + ) + ) + + if residuals[-1] <= 1e-6 or (len(residuals) > 1 and residuals[-1] == residuals[-2]): + break + + simulation.relax_cell_mechanics(*config.native_parameters()) + + return residuals + + default_config = MechanicsConfig() + tight_config = MechanicsConfig(residual_rms_tolerance=1e-8) + + with trace.instrument(): + default_residuals = relax_to_tolerance(default_config) + default_geometry = cells(simulation) + tight_residuals = relax_to_tolerance(tight_config) + + results["finite_height_constraints"] = dict( + classification="expected 3D wall relaxation; finite height is not strict 2D", + default_mechanics=default_config.to_json(), + tight_mechanics=tight_config.to_json(), + default_wall_penetration_by_pass=default_residuals, + tight_wall_penetration_by_pass=tight_residuals, + default_final_geometry=default_geometry, + **trace.report(), + ) + simulation = Simulation(backend) + shape = GridShape() + shape.x, shape.y, shape.z = 1, 1, 3 + spec = SignalGridSpec() + spec.shape, spec.signal_count, spec.diffusion, spec.advection = shape, 1, [0.0], [Vec3()] + boundary = GridBoundary() + boundary.kind, boundary.values = GridBoundaryKind.FIXED, [0.0] + spec.z_lower, spec.z_upper = boundary, boundary + field = SignalGridVelocityField() + field.x_faces, field.y_faces, field.z_faces = [0.0] * 6, [0.0] * 6, [0.2] * 4 + spec.velocity_field = field + simulation.configure_signal_grid(spec) + add(simulation, position=(0, 0, 1), length=1.0) + trace = Trace(simulation, plane_z=1.0) + + with trace.instrument(): + simulation.apply_flow_drift(dt) + + results["vertical_flow"] = dict( + classification="expected prescribed 3D advection", + prescribed_velocity=[0.0, 0.0, 0.2], + drift_dt=dt, + **trace.report(), + ) + + return results + + +def model_parameters(args, parser): + parameters = {} + + for parameter in args.parameter: + try: + key, value = parameter.split("=", 1) + + if not key or key in parameters: + raise ValueError("parameter names must be nonempty and unique") + + parameters[key] = json.loads(value) + except (ValueError, json.JSONDecodeError) as error: + parser.error(f"invalid parameter {parameter!r}: {error}") + + if args.scenario is not None: + if "scenario" in parameters: + parser.error("provide scenario once, using --scenario or --parameter") + + parameters["scenario"] = args.scenario + + return parameters + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--backend", choices=("cpu", "metal", "cuda"), default="cpu") + parser.add_argument("--seed", type=int, default=17) + parser.add_argument("--dt", type=float, default=0.02) + parser.add_argument("--model", type=Path) + parser.add_argument("--scenario", help="Convenience alias for a JSON string scenario parameter") + parser.add_argument("--parameter", action="append", default=[], metavar="NAME=JSON") + parser.add_argument("--steps", type=int, default=1000) + parser.add_argument("--max-cells", type=int, default=128) + parser.add_argument("--plane-z", type=float, default=0.0) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + + if not math.isfinite(args.dt) or args.dt <= 0 or args.steps < 1 or args.max_cells < 1: + parser.error("dt, steps and max-cells must be positive and finite") + + if not math.isfinite(args.plane_z): + parser.error("plane-z must be finite") + + if not args.model and (args.scenario is not None or args.parameter): + parser.error("scenario and parameters require a model") + + backend = getattr(BackendKind, args.backend.upper()) + + if not backend_available(backend): + parser.error(f"{args.backend} backend unavailable; no fallback performed") + + result = dict( + diagnostic_version=1, + seed=args.seed, + dt=args.dt, + backend=args.backend, + fixtures=fixtures(backend, args.seed, args.dt), + ) + + if args.model: + parameters = model_parameters(args, parser) + + model, provenance = build_model( + args.model, + ModelContext(backend, 0, seed=args.seed, parameters=parameters), + ) + trace = Trace(model.simulation, args.plane_z) + completed = 0 + + with trace.instrument(): + for _ in range(args.steps): + if model.simulation.cell_count >= args.max_cells: + break + + model.step(args.dt) + completed += 1 + + result["tutorial"] = dict( + model=str(args.model), + parameters=parameters, + model_state=model.controller_state().get("model"), + controller_kind=model.controller_state().get("kind"), + mechanics=model.controller_state().get("mechanics"), + completed_steps=completed, + stop_reason="max_cells" if model.simulation.cell_count >= args.max_cells else "steps", + provenance=provenance, + requested_steps=args.steps, + max_cells=args.max_cells, + **trace.report(), + ) + + encoded = json.dumps(result, indent=2, allow_nan=False) + "\n" + + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(encoded) + else: + print(encoded, end="") + + +if __name__ == "__main__": + main() diff --git a/scripts/quality.py b/scripts/quality.py new file mode 100644 index 0000000..f9658b3 --- /dev/null +++ b/scripts/quality.py @@ -0,0 +1,110 @@ +"""Run the same pinned formatters and checks locally, in hooks, and in CI.""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +from collections import defaultdict +from pathlib import Path + +from check_complexity import check as check_complexity +from code_paragraphs import check as check_paragraphs +from quality_files import ROOT, formatter, source_files + + +def prettier() -> list[str]: + script = ROOT / "viewer/node_modules/prettier/bin/prettier.cjs" + + if not script.is_file(): + raise RuntimeError( + "Install viewer tools first: pnpm --dir viewer install --frozen-lockfile" + ) + + return ["node", str(script)] + + +def commands(kind: str, write: bool) -> list[list[str]]: + if kind == "native": + return [["clang-format", "-i"] if write else ["clang-format", "--dry-run", "--Werror"]] + + if kind == "python": + if write: + return [["ruff", "format"], ["ruff", "check", "--fix"], ["ruff", "format"]] + + return [ + ["ruff", "check"], + ["ruff", "format", "--check"], + ] + + if kind in {"web", "yaml"}: + return [ + [ + *prettier(), + "--write" if write else "--check", + *(["--parser", "yaml"] if kind == "yaml" else []), + ] + ] + + if kind == "toml": + return [["taplo", "fmt", *([] if write else ["--check"])]] + + if kind == "shell": + return [["shfmt", "-i", "2", "-w" if write else "-d"]] + + if kind == "cmake": + return [["cmake-format", "-i" if write else "--check"]] + + raise ValueError(f"unknown formatter: {kind}") + + +def run_formatters(paths: list[Path], write: bool) -> int: + groups: dict[str, list[str]] = defaultdict(list) + + for path in paths: + kind = formatter(path) + + if kind is not None: + groups[kind].append(str(path)) + + failed = False + + for kind, names in sorted(groups.items()): + for command in commands(kind, write): + if shutil.which(command[0]) is None: + raise RuntimeError( + f"Missing {command[0]}; run with the locked quality dependency group" + ) + + # Bound command size on Windows as well as Unix. Always use argv, never a shell. + for start in range(0, len(names), 50): + result = subprocess.run([*command, *names[start : start + 50]], cwd=ROOT) + failed |= result.returncode != 0 + + return int(failed) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=["format", "check", "complexity"]) + parser.add_argument("files", nargs="*") + args = parser.parse_args() + paths = source_files(args.files) + + if args.command == "complexity": + return check_complexity(paths) + + status = run_formatters(paths, write=args.command == "format") + status |= check_paragraphs(paths, write=args.command == "format") + + if args.command == "check": + status |= check_complexity(paths) + + return status + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (RuntimeError, ValueError) as error: + raise SystemExit(str(error)) from error diff --git a/scripts/quality_files.py b/scripts/quality_files.py new file mode 100644 index 0000000..8989549 --- /dev/null +++ b/scripts/quality_files.py @@ -0,0 +1,74 @@ +"""Shared source discovery for formatting and structural checks.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +NATIVE = {".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", ".cu", ".cuh", ".mm", ".metal"} +PYTHON = {".py", ".pyi"} +WEB = {".ts", ".tsx", ".js", ".mjs", ".cjs", ".html", ".css", ".md", ".json", ".yml", ".yaml"} +YAML_NAMES = {".clang-format", ".clang-tidy"} +EXCLUDED_PARTS = {".git", ".venv", "node_modules", "build", "dist", "__pycache__"} +LOCKFILES = {"uv.lock", "pnpm-lock.yaml"} + + +def maintained(path: Path) -> bool: + return not (set(path.parts) & EXCLUDED_PARTS) and path.name not in LOCKFILES + + +def source_files(names: list[str] | None = None) -> list[Path]: + if not names: + output = subprocess.check_output( + ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd=ROOT + ) + names = output.decode().rstrip("\0").split("\0") + + paths = set() + discovered = None + + for name in names: + path = Path(name) + + if path.is_absolute(): + path = path.relative_to(ROOT) + + if ".." in path.parts: + raise ValueError(f"path must be inside the repository: {path}") + + if maintained(path) and (ROOT / path).is_dir(): + if discovered is None: + discovered = source_files() + + paths.update(candidate for candidate in discovered if path in candidate.parents) + + if maintained(path) and (ROOT / path).is_file(): + paths.add(path) + + return sorted(paths) + + +def formatter(path: Path) -> str | None: + if path.suffix in NATIVE: + return "native" + + if path.suffix in PYTHON: + return "python" + + if path.name in YAML_NAMES: + return "yaml" + + if path.suffix in WEB: + return "web" + + if path.suffix == ".toml": + return "toml" + + if path.suffix == ".sh": + return "shell" + + if path.suffix == ".cmake" or path.name == "CMakeLists.txt": + return "cmake" + + return None diff --git a/scripts/record_legacy_trajectories.py b/scripts/record_legacy_trajectories.py index be41e2d..ce0814a 100644 --- a/scripts/record_legacy_trajectories.py +++ b/scripts/record_legacy_trajectories.py @@ -88,8 +88,10 @@ def _install_runtime_shims() -> None: def shape_compatible_set(array: Any, source: Any, *args: Any, **kwargs: Any) -> Any: host = np.asarray(source) + if host.size == array.size and host.shape != array.shape: host = host.reshape(array.shape) + return original_set(array, host, *args, **kwargs) cl_array.Array.set = shape_compatible_set @@ -110,21 +112,26 @@ def _legacy_commit(root: Path) -> str: capture_output=True, text=True, ) + return result.stdout.strip() def _source_digests(matrix_path: Path) -> tuple[str, dict[str, str]]: document = json.loads(matrix_path.read_text(encoding="utf-8")) + return document["legacy_commit"], {row["path"]: row["sha256"] for row in document["examples"]} def _signal_statistics(simulator: Any) -> list[dict[str, float]]: integrator = simulator.integ + if integrator is None or not hasattr(integrator, "signalLevel"): return [] + shape = tuple(int(value) for value in integrator.gridDim) levels = np.asarray(integrator.signalLevel, dtype=np.float64).reshape(shape) result: list[dict[str, float]] = [] + for channel in levels: result.append( { @@ -133,9 +140,24 @@ def _signal_statistics(simulator: Any) -> list[dict[str, float]]: "l2_norm": float(np.linalg.norm(channel)), } ) + return result +def _cell_topology(cells: list[Any]) -> tuple[dict[str, int], set[tuple[int, int]]]: + type_counts: dict[str, int] = {} + neighbor_pairs: set[tuple[int, int]] = set() + + for cell in cells: + key = str(int(cell.cellType)) + type_counts[key] = type_counts.get(key, 0) + 1 + + for neighbor in getattr(cell, "neighbours", []): + neighbor_pairs.add(tuple(sorted((int(cell.id), int(neighbor))))) + + return type_counts, neighbor_pairs + + def _frame(simulator: Any, step: int) -> dict[str, object]: cells = sorted(simulator.cellStates.values(), key=lambda cell: cell.id) positions = np.asarray( @@ -149,13 +171,8 @@ def _frame(simulator: Any, step: int) -> dict[str, object]: [[float(value) for value in getattr(cell, "species", ())] for cell in cells], dtype=np.float64, ).reshape((len(cells), species_count)) - type_counts: dict[str, int] = {} - neighbor_pairs: set[tuple[int, int]] = set() - for cell in cells: - key = str(int(cell.cellType)) - type_counts[key] = type_counts.get(key, 0) + 1 - for neighbor in getattr(cell, "neighbours", []): - neighbor_pairs.add(tuple(sorted((int(cell.id), int(neighbor))))) + type_counts, neighbor_pairs = _cell_topology(cells) + result: dict[str, object] = { "step": step, "cell_count": len(cells), @@ -174,9 +191,11 @@ def _frame(simulator: Any, step: int) -> dict[str, object]: "species_maximum": [float(value) for value in species.max(axis=0)], "signals": _signal_statistics(simulator), } + for value in _numbers(result): if not math.isfinite(value): raise RuntimeError(f"scenario produced a non-finite value at step {step}") + return result @@ -200,13 +219,16 @@ def _record_scenario( expected_digest: str, ) -> dict[str, object]: model = legacy_root / "Examples" / scenario.model + if _digest(model) != expected_digest: raise RuntimeError(f"legacy source digest mismatch for {scenario.model}") + random.seed(scenario.seed) np.random.seed(scenario.seed) from CellModeller.Simulator import Simulator quiet = io.StringIO() + with contextlib.redirect_stdout(quiet): simulator = Simulator( str(model), @@ -216,16 +238,20 @@ def _record_scenario( clDeviceNum=device_index, is_gui=False, ) + simulator.saveOutput = False frames: list[dict[str, object]] = [] final_step = scenario.sample_steps[-1] samples = set(scenario.sample_steps) + for step in range(final_step + 1): if step in samples: frames.append(_frame(simulator, step)) + if step != final_step: with contextlib.redirect_stdout(quiet): simulator.step() + return { "id": scenario.identifier, "role": scenario.role, @@ -250,6 +276,7 @@ def _parser() -> argparse.ArgumentParser: default=project_root / "compatibility" / "legacy-examples-v1.json", ) parser.add_argument("--output", type=Path, required=True) + return parser @@ -260,10 +287,12 @@ def main() -> int: legacy_root = arguments.legacy_root.resolve() expected_commit, digests = _source_digests(arguments.matrix) actual_commit = _legacy_commit(legacy_root) + if actual_commit != expected_commit: raise RuntimeError( f"legacy commit mismatch: expected {expected_commit}, found {actual_commit}" ) + platforms = cl.get_platforms() opencl_platform = platforms[arguments.platform_index] opencl_device = opencl_platform.get_devices()[arguments.device_index] @@ -304,6 +333,7 @@ def main() -> int: temporary.write_text(json.dumps(document, indent=2, sort_keys=True) + "\n", encoding="utf-8") temporary.replace(arguments.output) print(f"recorded {len(scenarios)} legacy trajectories: {arguments.output}") + return 0 diff --git a/scripts/run_flow_benchmarks.py b/scripts/run_flow_benchmarks.py index a8d55af..12a7370 100644 --- a/scripts/run_flow_benchmarks.py +++ b/scripts/run_flow_benchmarks.py @@ -54,6 +54,7 @@ def error(self) -> float: # error measure; the tolerance then bounds it absolutely. if self.reference == 0.0: return abs(self.computed) + return abs(self.computed - self.reference) / abs(self.reference) @property @@ -64,6 +65,7 @@ def passed(self) -> bool: def bench_plane_poiseuille_order(coarse: int, simulation: Simulation) -> list[Result]: results: list[Result] = [] errors: list[float] = [] + for n in (coarse, coarse * 2): start = time.perf_counter() spec = duct_grid(n, 6, 1, (1.0 / n, 0.25, 1.0)) @@ -86,6 +88,7 @@ def bench_plane_poiseuille_order(coarse: int, simulation: Simulation) -> list[Re time.perf_counter() - start, ) ) + order = math.log2(errors[0] / errors[1]) results.append( Result( @@ -98,6 +101,7 @@ def bench_plane_poiseuille_order(coarse: int, simulation: Simulation) -> list[Re 0.0, ) ) + return results @@ -111,6 +115,7 @@ def bench_square_duct(n: int, simulation: Simulation) -> Result: # Cell centers straddle the duct axis, so the peak is interpolated rather # than taken from the largest sample, which would understate it. ratio = centerline_value(cross) / float(cross.mean()) + return Result( "stokes", f"square duct (n={n})", @@ -126,14 +131,11 @@ def bench_two_layer_brinkman(coarse: int, simulation: Simulation) -> list[Result drag_value = 200.0 results: list[Result] = [] errors: list[float] = [] + for nz in (coarse, coarse * 2): start = time.perf_counter() spec = duct_grid(1, 6, nz, (1.0, 0.25, 1.0 / nz)) - drag = [ - 0.0 if (z + 0.5) / nz < 0.5 else drag_value - for _ in range(6) - for z in range(nz) - ] + drag = [0.0 if (z + 0.5) / nz < 0.5 else drag_value for _ in range(6) for z in range(nz)] field, _ = solve_stokes_field( spec, mean_inlet_speed=1.0, @@ -162,6 +164,7 @@ def bench_two_layer_brinkman(coarse: int, simulation: Simulation) -> list[Result time.perf_counter() - start, ) ) + results.append( Result( "stokes", @@ -173,6 +176,7 @@ def bench_two_layer_brinkman(coarse: int, simulation: Simulation) -> list[Result 0.0, ) ) + return results @@ -181,6 +185,7 @@ def bench_hele_shaw_duct(scale: int, simulation: Simulation) -> Result: spec = duct_grid(4 * scale, 8 * scale, 3 * scale, (1.0, 1.0, 1.0)) field, _ = solve_flow_field(spec, mean_inlet_speed=5.0, simulation=simulation) error = float(max(abs(v - 5.0) for v in field.y_faces)) + return Result( "hele-shaw", "uniform duct", @@ -196,15 +201,14 @@ def bench_hele_shaw_mobility_split(scale: int, simulation: Simulation) -> Result start = time.perf_counter() columns, rows = 2 * scale, 6 * scale spec = duct_grid(columns, rows, 1, (1.0, 1.0, 1.0)) - mobility = [ - 1.0 if x < columns // 2 else 3.0 for x in range(columns) for _ in range(rows) - ] + mobility = [1.0 if x < columns // 2 else 3.0 for x in range(columns) for _ in range(rows)] field, _ = solve_flow_field( spec, mean_inlet_speed=4.0, mobility=mobility, simulation=simulation ) middle = rows // 2 slow = field.y_faces[0 * (rows + 1) + middle] fast = field.y_faces[(columns - 1) * (rows + 1) + middle] + return Result( "hele-shaw", "parallel channels", @@ -221,10 +225,12 @@ def bench_cross_solver_consistency(scale: int, simulation: Simulation) -> Result nx, ny, nz = 6 * scale, 10 * scale, 6 * scale spec = duct_grid(nx, ny, nz, (1.0 / scale, 1.0 / scale, 0.05 / scale)) obstacles = [0] * (nx * ny * nz) + for y in range(4 * scale, 6 * scale): for x in range(scale, 3 * scale): for z in range(nz): obstacles[site_index(spec, x, y, z)] = 1 + spec.obstacles = obstacles stokes_field, _ = solve_stokes_field( spec, mean_inlet_speed=1.0, tolerance=1.0e-6, simulation=simulation @@ -238,12 +244,11 @@ def column_flux(values: list[float], x: int, fy: int) -> float: mid = ny // 2 stokes_split = np.array([column_flux(stokes_field.y_faces, x, mid) for x in range(nx)]) - hele_shaw_split = np.array( - [column_flux(hele_shaw_field.y_faces, x, mid) for x in range(nx)] - ) + hele_shaw_split = np.array([column_flux(hele_shaw_field.y_faces, x, mid) for x in range(nx)]) deviation = float( np.max(np.abs(stokes_split / stokes_split.sum() - hele_shaw_split / hele_shaw_split.sum())) ) + return Result( "cross-check", "thin-gap pillar", @@ -257,9 +262,7 @@ def column_flux(values: list[float], x: int, fy: int) -> float: def main() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--fine", action="store_true", help="double the benchmark resolutions" - ) + parser.add_argument("--fine", action="store_true", help="double the benchmark resolutions") parser.add_argument( "--backend", choices=("cpu", "metal", "cuda"), @@ -274,10 +277,12 @@ def main() -> int: "cuda": BackendKind.CUDA, } backend = backends[arguments.backend] + if arguments.device_index < 0 or not backend_available(backend, arguments.device_index): parser.error( f"backend {arguments.backend!r} has no device at index {arguments.device_index}" ) + simulation = Simulation(backend, device_index=arguments.device_index) scale = 2 if arguments.fine else 1 @@ -292,19 +297,26 @@ def main() -> int: width = max(len(r.benchmark) for r in results) info = simulation.backend_info print(f"backend: {info.name} ({info.device}), device index {info.device_index}") - print(f"{'solver':<11} {'benchmark':<{width}} {'computed':>10} {'reference':>10} " - f"{'error':>9} {'tol':>7} {'time':>7} status") + print( + f"{'solver':<11} {'benchmark':<{width}} {'computed':>10} {'reference':>10} " + f"{'error':>9} {'tol':>7} {'time':>7} status" + ) failures = 0 + for r in results: status = "pass" if r.passed else "FAIL" + if not r.passed: failures += 1 + print( f"{r.solver:<11} {r.benchmark:<{width}} {r.computed:>10.5f} " f"{r.reference:>10.5f} {r.error:>9.5f} {r.tolerance:>7.3g} " f"{r.seconds:>6.2f}s {status} [{r.metric}]" ) + print(f"\n{len(results) - failures}/{len(results)} benchmarks passed") + return 1 if failures else 0 diff --git a/scripts/run_legacy_example_matrix.py b/scripts/run_legacy_example_matrix.py index d473f82..ad706d3 100644 --- a/scripts/run_legacy_example_matrix.py +++ b/scripts/run_legacy_example_matrix.py @@ -41,11 +41,13 @@ def _parser() -> argparse.ArgumentParser: ) parser.add_argument("--seed", type=int, default=1729) parser.add_argument("--output", type=Path, required=True) + return parser def main() -> int: arguments = _parser().parse_args() + try: matrix = load_legacy_example_matrix(arguments.matrix) targets = enumerate_backend_targets(tuple(_BACKENDS[name] for name in arguments.backend)) @@ -58,11 +60,13 @@ def main() -> int: ) except LegacyExampleMatrixError as error: _parser().error(str(error)) + arguments.output.parent.mkdir(parents=True, exist_ok=True) temporary = arguments.output.with_name(f".{arguments.output.name}.tmp") temporary.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") temporary.replace(arguments.output) print(f"legacy example matrix {report['result']}: {arguments.output}") + return 0 if report["result"] == "pass" else 1 diff --git a/scripts/run_nutrient_benchmarks.py b/scripts/run_nutrient_benchmarks.py index af0ddf7..95e18da 100644 --- a/scripts/run_nutrient_benchmarks.py +++ b/scripts/run_nutrient_benchmarks.py @@ -55,6 +55,26 @@ class Result: growth_rate: list[float] +def _penetration_half(y, profile, h: float) -> float: + # Boundary sample follows the ghost-center discretization above. + # First downstream crossing, with no assumption of monotonicity farther on. + previous_y, previous_c = -h / 2, 1.0 + penetration = LENGTH + + for yy, cc in zip(y, profile, strict=True): + coordinate, concentration = float(yy), float(cc) + + if concentration <= 0.5: + penetration = previous_y + (coordinate - previous_y) * (previous_c - 0.5) / ( + previous_c - concentration + ) + break + + previous_y, previous_c = coordinate, concentration + + return penetration + + def run(h: float, dt: float, refresh: float, duration: float, backend: BackendKind) -> Result: nx, ny = round(WIDTH / h), round(LENGTH / h) spec = duct_grid(nx, ny, 1, (h, h, HEIGHT)) @@ -68,12 +88,14 @@ def run(h: float, dt: float, refresh: float, duration: float, backend: BackendKi spec.velocity_field = field sim.configure_signal_grid(spec, [0] * spec.site_count) ids: list[int] = [] + for x in (3, 6.5, 10, 13.5, 17): for y in (3, 7, 11, 15, 19, 23, 27, 31, 35): cell = CellInit() cell.position = Vec3(x, y, HEIGHT / 2) cell.length, cell.radius, cell.growth_rate, cell.fixed = 2, RADIUS, 0, True ids.append(sim.add_cell(cell)) + cells = sim.cells() initial = np.array([biomass_volume(c.length, c.radius) for c in cells], dtype=np.float64) # K_i integrates to one. The exact voxel-integrated physical kernel is @@ -91,11 +113,14 @@ def run(h: float, dt: float, refresh: float, duration: float, backend: BackendKi boundary_supply = 0.0 next_refresh = 0.0 steps = round(duration / dt) + if not math.isclose(steps * dt, duration, abs_tol=1e-7): raise ValueError("duration must be an integer number of steps") + for step in range(steps): time = step * dt cells = sim.cells() + if time + 1e-9 >= next_refresh: mobility = colony_mobility( spec, cells, drag_coefficient=DRAG, averaging_radius=AVERAGING_RADIUS @@ -105,6 +130,7 @@ def run(h: float, dt: float, refresh: float, duration: float, backend: BackendKi ) sim.set_velocity_field(field) next_refresh += refresh + old = np.asarray(sim.signal_levels, dtype=np.float64) mean = weights @ old cylinder = np.array([math.pi * c.radius**2 * c.length for c in cells]) @@ -116,9 +142,11 @@ def run(h: float, dt: float, refresh: float, duration: float, backend: BackendKi sim.step(dt) new = np.asarray(sim.signal_levels, dtype=np.float64) consumed = dt * coefficient * (weights @ new) + for cid, cell, amount in zip(ids, cells, consumed, strict=True): length = cell.length + YIELD * float(amount) / (math.pi * cell.radius**2) sim.set_cell_geometry(cid, cell.position, cell.direction, length) + # Exact discrete BE boundary flux convention used by engine transport: # boundary values are ghost-center concentrations, one h from a site. concentration = new.reshape(nx, ny) @@ -126,27 +154,19 @@ def run(h: float, dt: float, refresh: float, duration: float, backend: BackendKi influx = DIFFUSION / h * (1 - concentration[:, 0]) + y_faces[:, 0] outflux = DIFFUSION / h * concentration[:, -1] + y_faces[:, -1] * concentration[:, -1] boundary_supply += dt * h * HEIGHT * float((influx - outflux).sum()) + final_cells = sim.cells() final = np.array([biomass_volume(c.length, c.radius) for c in final_cells]) nutrient = np.asarray(sim.signal_levels, dtype=np.float64).reshape(nx, ny) profile = nutrient.mean(axis=0) y = (np.arange(ny) + 0.5) * h - # Boundary sample follows the ghost-center discretization above. - # First downstream crossing, with no assumption of monotonicity farther on. - previous_y, previous_c = -h / 2, 1.0 - penetration = LENGTH - for yy, cc in zip(y, profile, strict=True): - coordinate, concentration = float(yy), float(cc) - if concentration <= 0.5: - penetration = previous_y + (coordinate - previous_y) * (previous_c - 0.5) / ( - previous_c - concentration - ) - break - previous_y, previous_c = coordinate, concentration + penetration = _penetration_half(y, profile, h) + growth = (final - initial) / (duration * initial) gain = float((final - initial).sum()) nutrient_amount = float(nutrient.sum()) * volume balance = abs(nutrient_amount + gain / YIELD - boundary_supply) / max(boundary_supply, 1e-12) + return Result( h, dt, @@ -183,6 +203,7 @@ def main() -> None: (1.0, 0.04, 0.2), ] results: list[Result] = [] + for case in cases: result = run(*case, args.duration, backend) print( @@ -192,8 +213,10 @@ def main() -> None: flush=True, ) results.append(result) + base = results[1] comparisons: dict[str, dict[str, float]] = {} + for label, other in [ ("grid_2_to_1", results[0]), ("grid_1_to_half", results[2]), @@ -205,6 +228,7 @@ def main() -> None: / max(abs(getattr(base, metric)), 1e-12) for metric in ("biomass_gain", "penetration_half", "upstream_growth") } + checks: dict[str, bool] = { "mass_balance": max(r.balance_relative_error for r in results) < 2e-4, "spatial_growth": base.upstream_growth > 3 * base.downstream_growth, @@ -230,6 +254,7 @@ def main() -> None: } args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(payload, indent=2) + "\n") + if not all(checks.values()): raise SystemExit(f"Nutrient validation failed: {checks}") diff --git a/tests/conformance/README.md b/tests/conformance/README.md index a00f2d7..b7a8d23 100644 --- a/tests/conformance/README.md +++ b/tests/conformance/README.md @@ -22,6 +22,14 @@ The backend contract scenario constructs every enumerated device and requires it The flow scenario exercises two native domain operations. The depth-averaged case uses a 5-by-8-by-2 anisotropic grid with an internal obstacle and spatially varying mobility. The resolved case uses a 6-by-7-by-2 anisotropic grid with a two-layer Brinkman drag field. Every backend solves through its own matrix-free operator and Krylov kernels, reports convergence, and returns all face components. Velocity fields are compared with the CPU reference using absolute and relative tolerances of `8e-4`; the resolved divergence RMS must remain below `5e-5`, and the minimum transverse gap is exact. CPU-only execution validates the reference fixture, Metal conformance requires execution on an Apple GPU, and CUDA conformance requires execution on an NVIDIA GPU. +## Physical media flow + +The five `culture_*` targets cover multiport hydraulics, constrained capsule motion, geometric extracellular fragments, amount transport, and integrated simulation. Hydraulic tests check pressure/rate signs and budgets, disconnected-component gauges, pressure offsets, and CPU/GPU field agreement. Body tests check the six-point kernel identities, force balance, no-slip residuals, growth displacement, wall reactions, and equal/opposite pair-contact reactions. Geometry checks analytic capsule volume, face-area closure, refined surface overlaps, disconnected fluid regions, and daughter geometry. + +Transport checks a discrete diffusion eigenmode, first-order timestep convergence against its exact semidiscrete decay, signed reservoir exchange, moving-volume uniform concentration, and paired membrane uptake. Integrated tests check independent biochemical volume in native reaction plans, nutrient/biomass budgets, exact same-device restart, and failed-step rollback. Python tests additionally cover division, controller/RNG rollback, checkpoints, scenes, and analysis. Assertions use explicit runtime checks in the media fixtures, including optimized builds. + +Each numerical scenario executes every enumerated backend without a CPU fallback. Include `metal_runtime_gate` or `cuda_runtime_gate` with a GPU conformance run; compiling the CUDA sources does not establish NVIDIA runtime conformance. See [ADR 0026](../../docs/architecture/0026-resolved-fluid-culture.md) for precision, residual gates, host/device execution boundaries, and remaining physical-accuracy and scaling studies. + ## Species The species scenario uses 513 cells, three concentrations, heterogeneous geometry and cell types, and a typed plan that reads concentrations and cell attributes. It checks growth dilution, post-dilution rate evaluation, simultaneous explicit Euler updates, every declared instruction operation, zero-length time steps, stable identity, and cell-major schema preservation. Levels and lengths use absolute and relative tolerances of `2e-5`; identities and shapes are exact. @@ -38,6 +46,10 @@ The trajectory scenario composes coupled intracellular/extracellular rates, grow The signal-grid scenario uses two fields on a 9-by-7-by-5 anisotropic lattice, mixed diffusion and vector advection, periodic x boundaries, no-flux y boundaries, and distinct fixed reservoirs on the z faces. One native transport step and an interior trilinear sample are compared with the CPU reference using absolute and relative tolerances of `5e-6`. An available backend is skipped until it advertises native signal-grid support; after that advertisement this executable is its hardware conformance gate. +## Standalone cell occupancy + +The `occupancy_conformance` scenario explicitly selects every enumerated Metal/CUDA device. It checks capsule-union quadrature, binary walls, closing-component rejection, deterministic redistribution, accessible exchange weights, harmonic aperture, signed boundary/reaction/source ledgers, and 1000-step closed transport drift. Checks remain active in optimized builds. With no GPU the scenario is marked skipped, not passed; the existing platform runtime gates still require the selected platform. `python/tests/test_occupancy_native.py` compares against the independent float64 reference and adds rotated/translated geometry, division/removal, periodic edges, cutoff variation, invalid-input rejection, quadrature refinement, and separate spatial/timestep convergence. Concentrations use rtol `2e-4`/atol `2e-6`; normalized ledger residuals must stay below `5e-6`, and 1000-step relative drift below `5e-5`. These are standalone primitives, with no controller/checkpoint or displacement-flow integration claim. + ## Coupled rates The coupled scenario uses 513 heterogeneous cells, three intracellular species, and two signals on a 9-by-7-by-5 anisotropic lattice. It combines post-growth dilution, fractional old-field sampling, typed species and signal outputs, transport, and repeated trilinear scatter destinations in one step. Cell state uses an absolute tolerance of `2e-5` and grid state uses `1e-4` to allow backend-specific floating-point accumulation order. An available backend is skipped until it advertises the complete native coupled operation; after advertisement this executable becomes its hardware gate. diff --git a/tests/conformance/backend_contract_conformance_test.cpp b/tests/conformance/backend_contract_conformance_test.cpp index 4c1bd55..4678513 100644 --- a/tests/conformance/backend_contract_conformance_test.cpp +++ b/tests/conformance/backend_contract_conformance_test.cpp @@ -21,6 +21,7 @@ constexpr std::array required_features{ void require_complete_backend(cm::BackendKind backend, std::uint32_t device_index) { cm::Simulation simulation(backend, 0, 0, device_index); + for (const auto feature : required_features) { assert(simulation.supports(feature)); } @@ -30,5 +31,6 @@ void require_complete_backend(cm::BackendKind backend, std::uint32_t device_inde int main() { cm::test::for_each_backend_device(require_complete_backend); + return 0; } diff --git a/tests/conformance/backend_devices.hpp b/tests/conformance/backend_devices.hpp index 37b330a..62f9a1a 100644 --- a/tests/conformance/backend_devices.hpp +++ b/tests/conformance/backend_devices.hpp @@ -13,12 +13,15 @@ namespace cm::test { template void for_each_backend_device(Function&& function) { constexpr std::array backends{BackendKind::cpu, BackendKind::metal, BackendKind::cuda}; + for (const auto backend : backends) { const auto device_count = backend_device_count(backend); + for (std::size_t index = 0; index < device_count; ++index) { if (index > static_cast(std::numeric_limits::max())) { throw std::overflow_error("backend device index exceeds uint32"); } + function(backend, static_cast(index)); } } diff --git a/tests/conformance/capsule_body_conformance_test.cpp b/tests/conformance/capsule_body_conformance_test.cpp new file mode 100644 index 0000000..41b0cb8 --- /dev/null +++ b/tests/conformance/capsule_body_conformance_test.cpp @@ -0,0 +1,149 @@ +#include +#include +#include +#include +#include + +#include "backend_devices.hpp" +#include "cm/stokes_flow.hpp" +#include "core/capsule_bodies.hpp" + +namespace { +void check(bool value, const char* message) { + if (!value) { + throw std::runtime_error(message); + } +} + +void kernel_moments() { + const double K = 59.0 / 60 - std::sqrt(29.0) / 20; + double square = 0; + + for (unsigned j = 0; j <= 100; ++j) { + const double r = double(j) / 100; + const auto weights = cm::detail::immersed_delta_weights(r); + std::array moment{}; + double even = 0, squared = 0; + + for (unsigned i = 0; i < 6; ++i) { + check(weights[i] >= -1e-15, "IB kernel must be nonnegative"); + squared += weights[i] * weights[i]; + + if (i % 2 == 0) { + even += weights[i]; + } + + for (unsigned p = 0; p < 4; ++p) { + moment[p] += std::pow(r - 3 + i, p) * weights[i]; + } + } + + if (j == 0) { + square = squared; + } + + check(std::abs(moment[0] - 1) < 1e-14 && std::abs(moment[1]) < 1e-14 && + std::abs(moment[2] - K) < 1e-14 && std::abs(moment[3]) < 1e-14 && + std::abs(even - 0.5) < 1e-14 && std::abs(squared - square) < 1e-14, + "IB kernel moments and sum of squares"); + } +} + +void run(cm::BackendKind backend, std::uint32_t index) { + cm::FluidGridSpec grid; + grid.shape = {12, 12, 12}; + cm::CapsuleBody body; + body.position = {5.5, 5.5, 5.5}; + body.radius = 2; + body.force_n = {1e-14, 0, 0}; + cm::StokesFlowSolver solver(backend, index); + cm::LinearSolveParameters parameters; + parameters.relative_tolerance = 1e-5; + const auto result = solver.solve_bodies(grid, {}, {}, {body}, parameters); + const auto& response = result.bodies[0]; + std::cout << "body iterations " << result.report.iterations << ", velocity " + << response.velocity[0] << ", slip " << response.no_slip_rms_m_s << std::endl; + check(response.velocity[0] > 0, "force-driven body translation"); + check( + std::abs(response.velocity[1]) + std::abs(response.velocity[2]) < 1e-3 * response.velocity[0], + "sphere transverse symmetry"); + check(std::abs(response.hydrodynamic_force_n[0] + 1e-14) < 1e-18, "rigid-body force balance"); + check(response.no_slip_rms_m_s < 1e-4 * response.velocity[0] * grid.length_unit_m, + "rigid-body no-slip residual"); + + if (backend != cm::BackendKind::cpu) { + const auto oracle = cm::StokesFlowSolver{}.solve_bodies(grid, {}, {}, {body}, parameters); + check(std::abs(response.velocity[0] - oracle.bodies[0].velocity[0]) < + 2e-4 * oracle.bodies[0].velocity[0], + "rigid-body CPU/GPU parity"); + } + + body.fixed = true; + auto fixed = + solver.solve_bodies(grid, {}, {{.name = "in", .value = 0.01}, {.name = "out", .upper = true}}, + {body}, parameters); + check(fixed.bodies[0].velocity[0] == 0 && fixed.bodies[0].velocity[1] == 0, "fixed body motion"); + check(fixed.bodies[0].hydrodynamic_force_n[1] > 0, "fixed-body drag direction"); + body.length_rate = 0.01; + auto growing = solver.solve_bodies(grid, {}, {{.name = "in"}, {.name = "out", .upper = true}}, + {body}, parameters); + const double rate = std::numbers::pi * body.radius * body.radius * body.length_rate * 1e-18; + check(std::abs(growing.report.net_flow_rate_m3_s - rate) < 1e-4 * rate, + "growth displaces medium"); + check(std::abs(growing.report.source_volume_rate_m3_s - rate) < 1e-12 * rate, + "growth source volume"); + check(growing.report.continuity_rms_per_s < 1e-4 * growing.report.max_speed_m_s / 1e-6, + "growth continuity residual"); + bool rejected = false; + + try { + (void)solver.solve_bodies(grid, {}, {}, {body}, parameters); + } catch (const std::invalid_argument&) { + rejected = true; + } + + check(rejected, "sealed incompressible chamber must reject growth"); + body.fixed = false; + body.length_rate = 0; + body.position = {1.61, 5.5, 5.5}; + body.force_n = {-1e-14, 0, 0}; + cm::FluidBodyStepParameters stepping; + stepping.minimum_gap_m = 0.1e-6; + const auto step = solver.propose_body_step(grid, {}, {}, {body}, 100, parameters, stepping); + std::cout << "contact dt " << step.accepted_dt << " x " << step.bodies[0].position[0] << " vx " + << step.flow.bodies[0].velocity[0] << " count " << step.contacts.size() << std::endl; + check(step.bodies[0].position[0] - body.radius + 0.5 >= 0.1, "wall contact must not penetrate"); + check(body.position[0] == 1.61, "body proposal changed caller-owned inputs"); + check(!step.contacts.empty() && step.contacts[0].normal_force_n > 0, + "compressive contact reaction"); + check(std::abs(step.flow.bodies[0].hydrodynamic_force_n[0] + step.contacts[0].normal_force_n - + 1e-14) < 1e-18, + "contact and hydrodynamic force balance"); + grid.shape = {16, 12, 12}; + cm::CapsuleBody first, second; + first.id = 1; + second.id = 2; + first.radius = second.radius = 2; + first.position = {5.45, 5.5, 5.5}; + second.position = {9.55002, 5.5, 5.5}; + first.force_n = {1e-14, 0, 0}; + second.force_n = {-1e-14, 0, 0}; + const auto pair = + solver.propose_body_step(grid, {}, {}, {first, second}, 1, parameters, stepping); + check(pair.bodies[1].position[0] - pair.bodies[0].position[0] - 4 >= 0.1, + "pair contact must preserve minimum gap"); + check(pair.contacts.size() == 1 && pair.contacts[0].normal_force_n > 0, + "compressive pair contact reaction"); + const double reaction = pair.contacts[0].normal_force_n; + check(std::abs(pair.flow.bodies[0].hydrodynamic_force_n[0] + 1e-14 - reaction) < 1e-18 && + std::abs(pair.flow.bodies[1].hydrodynamic_force_n[0] - 1e-14 + reaction) < 1e-18, + "pair contact equal and opposite force balance"); + std::cout << "media body backend " << static_cast(backend) << " device " << index + << " passed\n"; +} +} // namespace + +int main() { + kernel_moments(); + cm::test::for_each_backend_device(run); +} diff --git a/tests/conformance/constraint_conformance_test.cpp b/tests/conformance/constraint_conformance_test.cpp index 8dc2b22..f0ce634 100644 --- a/tests/conformance/constraint_conformance_test.cpp +++ b/tests/conformance/constraint_conformance_test.cpp @@ -13,6 +13,7 @@ constexpr float relative_tolerance = 2.0e-5F; bool close(float actual, float expected) { const auto tolerance = absolute_tolerance + relative_tolerance * std::abs(expected); + return std::abs(actual - expected) <= tolerance; } @@ -142,6 +143,7 @@ void compare_graphs(const cm::ExternalContactGraph& actual, assert(actual.size() == expected.size()); const auto actual_contacts = actual.contacts(); const auto expected_contacts = expected.contacts(); + for (std::size_t index = 0; index < expected.size(); ++index) { const auto& left = actual_contacts[index]; const auto& right = expected_contacts[index]; @@ -159,10 +161,12 @@ void compare_graphs(const cm::ExternalContactGraph& actual, assert(close(left.signed_separation, right.signed_separation)); assert(close(left.weight, right.weight)); } + for (std::size_t slot = 0; slot < expected.cell_count(); ++slot) { const auto actual_incidence = actual.incident_contact_indices(static_cast(slot)); const auto expected_incidence = expected.incident_contact_indices(static_cast(slot)); assert(actual_incidence.size() == expected_incidence.size()); + for (std::size_t index = 0; index < expected_incidence.size(); ++index) { assert(actual_incidence[index] == expected_incidence[index]); } @@ -196,9 +200,11 @@ void run_empty_inputs(cm::BackendKind backend, std::uint32_t device_index) { int main() { cm::test::for_each_backend_device([](cm::BackendKind backend, std::uint32_t device_index) { cm::Simulation capability_probe(backend, 0, 0, device_index); + if (!capability_probe.supports(cm::BackendFeature::external_constraints)) { return; } + run_empty_inputs(backend, device_index); run_fixture(backend, device_index, populate_mixed_constraints); run_fixture(backend, device_index, populate_box_regions); @@ -210,5 +216,6 @@ int main() { run_fixture(backend, device_index, populate_centered_wall_crossing); run_fixture(backend, device_index, populate_midspan_cylinder); }); + return 0; } diff --git a/tests/conformance/constraint_mechanics_conformance_test.cpp b/tests/conformance/constraint_mechanics_conformance_test.cpp index 4302691..b692a8d 100644 --- a/tests/conformance/constraint_mechanics_conformance_test.cpp +++ b/tests/conformance/constraint_mechanics_conformance_test.cpp @@ -14,6 +14,7 @@ constexpr float relative_tolerance = 3.0e-4F; bool close(float actual, float expected) { const auto tolerance = absolute_tolerance + relative_tolerance * std::abs(expected); + return std::abs(actual - expected) <= tolerance; } @@ -172,6 +173,7 @@ void compare_results(const cm::MechanicsSolveResult& actual, assert(actual.report.breakdown == expected.report.breakdown); assert(close(actual.report.initial_residual_rms, expected.report.initial_residual_rms)); assert(actual.corrections.size() == expected.corrections.size()); + for (std::size_t index = 0; index < expected.corrections.size(); ++index) { const auto& left = actual.corrections[index]; const auto& right = expected.corrections[index]; @@ -189,6 +191,7 @@ void compare_cells(const cm::Simulation& actual, const cm::Simulation& expected) const auto actual_cells = actual.cells(); const auto expected_cells = expected.cells(); assert(actual_cells.size() == expected_cells.size()); + for (std::size_t index = 0; index < expected_cells.size(); ++index) { const auto& left = actual_cells[index]; const auto& right = expected_cells[index]; @@ -221,11 +224,13 @@ void reject_unsupported_backend(cm::BackendKind backend, std::uint32_t device_in cm::Simulation simulation(backend, 0, 0, device_index); populate_plane(simulation); bool rejected = false; + try { static_cast(simulation.solve_cell_mechanics()); } catch (const std::runtime_error&) { rejected = true; } + assert(rejected); } @@ -234,10 +239,13 @@ void reject_unsupported_backend(cm::BackendKind backend, std::uint32_t device_in int main() { cm::test::for_each_backend_device([](cm::BackendKind backend, std::uint32_t device_index) { cm::Simulation capability_probe(backend, 0, 0, device_index); + if (!capability_probe.supports(cm::BackendFeature::external_constraints)) { reject_unsupported_backend(backend, device_index); + return; } + run_fixture(backend, device_index, populate_plane); run_fixture(backend, device_index, populate_fixed_plane); run_fixture(backend, device_index, populate_outside_sphere); @@ -250,5 +258,6 @@ int main() { run_fixture(backend, device_index, populate_midspan_box); run_fixture(backend, device_index, populate_midspan_cylinder); }); + return 0; } diff --git a/tests/conformance/contact_conformance_test.cpp b/tests/conformance/contact_conformance_test.cpp index e85eece..417665b 100644 --- a/tests/conformance/contact_conformance_test.cpp +++ b/tests/conformance/contact_conformance_test.cpp @@ -13,6 +13,7 @@ constexpr float relative_tolerance = 2.0e-5F; bool close(float actual, float expected) { const auto tolerance = absolute_tolerance + relative_tolerance * std::abs(expected); + return std::abs(actual - expected) <= tolerance; } @@ -29,6 +30,7 @@ void add_capsule(cm::Simulation& simulation, cm::Vec3 center, cm::Vec3 axis, flo void compare_graphs(const cm::ContactGraph& actual, const cm::ContactGraph& expected) { assert(actual.cell_count() == expected.cell_count()); assert(actual.size() == expected.size()); + for (std::size_t index = 0; index < expected.size(); ++index) { const auto& actual_contact = actual.contacts()[index]; const auto& expected_contact = expected.contacts()[index]; @@ -46,10 +48,12 @@ void compare_graphs(const cm::ContactGraph& actual, const cm::ContactGraph& expe assert(close(actual_contact.signed_separation, expected_contact.signed_separation)); assert(close(actual_contact.weight, expected_contact.weight)); } + for (std::size_t slot = 0; slot < expected.cell_count(); ++slot) { const auto actual_indices = actual.incident_contact_indices(static_cast(slot)); const auto expected_indices = expected.incident_contact_indices(static_cast(slot)); assert(actual_indices.size() == expected_indices.size()); + for (std::size_t index = 0; index < expected_indices.size(); ++index) { assert(actual_indices[index] == expected_indices[index]); } @@ -90,10 +94,12 @@ void run_empty_and_single_cell(cm::BackendKind backend, std::uint32_t device_ind void run_dense_geometry(cm::BackendKind backend, std::uint32_t device_index) { cm::Simulation reference(cm::BackendKind::cpu, 31); cm::Simulation candidate(backend, 31, 0, device_index); + for (std::size_t index = 0; index < 31; ++index) { add_capsule(reference, {0.0F, 0.0F, 0.0F}, {1.0F, 0.0F, 0.0F}); add_capsule(candidate, {0.0F, 0.0F, 0.0F}, {1.0F, 0.0F, 0.0F}); } + const auto expected = reference.find_cell_contacts(); const auto actual = candidate.find_cell_contacts(); assert(actual.size() == 930); @@ -104,6 +110,7 @@ void run_dense_geometry(cm::BackendKind backend, std::uint32_t device_index) { void run_parameters_and_buffer_reuse(cm::BackendKind backend, std::uint32_t device_index) { cm::Simulation reference(cm::BackendKind::cpu); cm::Simulation candidate(backend, 0, 0, device_index); + for (auto* simulation : {&reference, &candidate}) { add_capsule(*simulation, {0.0F, 0.0F, 0.0F}, {1.0F, 0.0F, 0.0F}, 0.0F); add_capsule(*simulation, {1.005F, 0.0F, 0.0F}, {1.0F, 0.0F, 0.0F}, 0.0F); @@ -123,6 +130,7 @@ void run_parameters_and_buffer_reuse(cm::BackendKind backend, std::uint32_t devi void run_compacted_identity_geometry(cm::BackendKind backend, std::uint32_t device_index) { cm::Simulation reference(cm::BackendKind::cpu); cm::Simulation candidate(backend, 0, 0, device_index); + for (auto* simulation : {&reference, &candidate}) { cm::CellInit first; first.length = 4.0F; @@ -132,6 +140,7 @@ void run_compacted_identity_geometry(cm::BackendKind backend, std::uint32_t devi simulation->add_cell(second); simulation->divide_equal(parent); } + compare_graphs(candidate.find_cell_contacts(), reference.find_cell_contacts()); } @@ -140,14 +149,17 @@ void run_compacted_identity_geometry(cm::BackendKind backend, std::uint32_t devi int main() { cm::test::for_each_backend_device([](cm::BackendKind backend, std::uint32_t device_index) { cm::Simulation capability_probe(backend, 0, 0, device_index); + if (!capability_probe.supports(cm::BackendFeature::cell_contacts)) { return; } + run_empty_and_single_cell(backend, device_index); run_mixed_geometry(backend, device_index); run_dense_geometry(backend, device_index); run_parameters_and_buffer_reuse(backend, device_index); run_compacted_identity_geometry(backend, device_index); }); + return 0; } diff --git a/tests/conformance/coupled_rates_conformance_test.cpp b/tests/conformance/coupled_rates_conformance_test.cpp index 5bb0ec9..4d086bd 100644 --- a/tests/conformance/coupled_rates_conformance_test.cpp +++ b/tests/conformance/coupled_rates_conformance_test.cpp @@ -11,7 +11,7 @@ namespace { cm::RateInstruction operation(cm::RateOp op, std::uint32_t first = 0, std::uint32_t second = 0, - float value = 0.0F) { + float value = 0.0F) { return {.operation = op, .first = first, .second = second, .value = value}; } @@ -34,11 +34,14 @@ cm::Simulation make_reference(cm::SignalIntegrationKind integration, bool masked cm::SignalGridAffineReaction reaction; reaction.source_rates.resize(grid.level_count()); reaction.loss_rates.resize(grid.level_count()); + for (std::size_t index = 0; index < grid.level_count(); ++index) { reaction.source_rates[index] = 0.004F * static_cast(index % 7); reaction.loss_rates[index] = 0.003F * static_cast(index % 11); } + grid.reaction = std::move(reaction); + if (masked) { std::vector obstacles(grid.site_count(), 0); const auto solid_site = [&](std::uint32_t x, std::uint32_t y, std::uint32_t z) { @@ -47,6 +50,7 @@ cm::Simulation make_reference(cm::SignalIntegrationKind integration, bool masked }; obstacles[solid_site(4, 3, 2)] = 1; obstacles[solid_site(2, 1, 1)] = 1; + for (std::size_t signal = 0; signal < grid.signal_count; ++signal) { for (std::size_t site = 0; site < grid.site_count(); ++site) { if (obstacles[site] != 0) { @@ -55,14 +59,18 @@ cm::Simulation make_reference(cm::SignalIntegrationKind integration, bool masked } } } + grid.obstacles = std::move(obstacles); } + std::vector levels(grid.level_count()); + for (std::size_t index = 0; index < levels.size(); ++index) { levels[index] = grid.solid_site(index % grid.site_count()) ? 0.0F : 1.0F + (0.002F * static_cast(index % 113)); } + simulation.configure_signal_grid(grid, std::move(levels)); for (std::size_t index = 0; index < 513; ++index) { @@ -105,6 +113,7 @@ cm::Simulation make_reference(cm::SignalIntegrationKind integration, bool masked }; simulation.set_coupled_rate_plan( cm::CoupledRatePlan(3, 2, std::move(instructions), {8, 9, 10}, {11, 12})); + return simulation; } @@ -113,16 +122,20 @@ void assert_close(const cm::Simulation& actual, const cm::Simulation& expected) const auto actual_cells = actual.cells(); const auto expected_cells = expected.cells(); assert(actual_cells.size() == expected_cells.size()); + for (std::size_t cell = 0; cell < actual_cells.size(); ++cell) { assert(std::abs(actual_cells[cell].length - expected_cells[cell].length) <= 2.0e-6F); + for (std::size_t species = 0; species < actual_cells[cell].species.size(); ++species) { assert(std::abs(actual_cells[cell].species[species] - expected_cells[cell].species[species]) <= 2.0e-5F); } } + const auto actual_grid = actual.signal_levels(); const auto expected_grid = expected.signal_levels(); assert(actual_grid.size() == expected_grid.size()); + for (std::size_t index = 0; index < actual_grid.size(); ++index) { assert(std::abs(actual_grid[index] - expected_grid[index]) <= 1.0e-4F); } @@ -136,11 +149,14 @@ void run_case(cm::SignalIntegrationKind integration, float dt, bool masked = fal cm::test::for_each_backend_device([&](cm::BackendKind backend, std::uint32_t device_index) { cm::Simulation candidate(backend, checkpoint, device_index); + if (!candidate.supports(cm::BackendFeature::coupled_rates)) { std::cout << "backend " << static_cast(backend) << " does not advertise coupled rates; skipping\n"; + return; } + candidate.step(dt); assert_close(candidate, expected); assert(candidate.last_signal_solve_report().has_value()); @@ -156,5 +172,6 @@ int main() { run_case(cm::SignalIntegrationKind::backward_euler, 0.5F); run_case(cm::SignalIntegrationKind::forward_euler, 0.01F, true); run_case(cm::SignalIntegrationKind::crank_nicolson, 0.5F, true); + return 0; } diff --git a/tests/conformance/cuda_runtime_gate_test.cpp b/tests/conformance/cuda_runtime_gate_test.cpp index 34efe95..bde7e2a 100644 --- a/tests/conformance/cuda_runtime_gate_test.cpp +++ b/tests/conformance/cuda_runtime_gate_test.cpp @@ -11,6 +11,7 @@ namespace { int fail(const std::string& message) { std::cerr << "CUDA runtime gate failed: " << message << '\n'; + return 1; } @@ -18,6 +19,7 @@ int fail(const std::string& message) { int main() { const auto device_count = cm::backend_device_count(cm::BackendKind::cuda); + if (device_count == 0) { return fail("the CUDA-enabled build did not discover an NVIDIA device"); } @@ -27,17 +29,21 @@ int main() { if (index > static_cast(std::numeric_limits::max())) { return fail("the CUDA device index exceeds the backend index type"); } + const auto device_index = static_cast(index); + if (!cm::backend_available(cm::BackendKind::cuda, device_index)) { return fail("an enumerated CUDA device is not available through the backend"); } cm::Simulation simulation(cm::BackendKind::cuda, 0, device_index); const auto info = simulation.backend_info(); - if (info.kind != cm::BackendKind::cuda || !info.native || - info.device_index != device_index || info.name != "cuda" || info.device.empty()) { + + if (info.kind != cm::BackendKind::cuda || !info.native || info.device_index != device_index || + info.name != "cuda" || info.device.empty()) { return fail("the constructed backend did not identify the selected native CUDA device"); } + simulation.step(0.0F); simulation.validate(); } @@ -46,5 +52,6 @@ int main() { } std::cout << "validated " << device_count << " native CUDA device(s)\n"; + return 0; } diff --git a/tests/conformance/culture_growth_conformance_test.cpp b/tests/conformance/culture_growth_conformance_test.cpp new file mode 100644 index 0000000..6edbd8c --- /dev/null +++ b/tests/conformance/culture_growth_conformance_test.cpp @@ -0,0 +1,71 @@ +#include +#include +#include +#include + +#include "backend_devices.hpp" +#include "cm/growth.hpp" +#include "cm/simulation.hpp" + +void check(bool value, const char* message) { + if (!value) { + throw std::runtime_error(message); + } +} + +void run(cm::BackendKind backend, std::uint32_t device) { + cm::CellGrowthModel model; + model.cell_id = 1; + model.mu_max = 0.01; + model.biomass_density = 1; + model.requirements = {{0, 1, 2}}; + cm::GrowthExecutor executor(backend, device); + const auto e = executor.evaluate({model}, {{10, 5, {3}, {0.2}}}, 0.1)[0]; + check(std::abs(e.uptake_velocities[0] - 0.0025) < 1e-8, "native Monod coefficient"); + check(std::abs(e.biomass_gain - 0.4) < 1e-7, "native accepted uptake yield"); + cm::Simulation sim(backend, 1, 0, device); + cm::CellInit c; + c.position = {5.5, 5.5, 5.5}; + c.radius = 2; + c.length = 1; + c.fixed = true; + c.growth_rate = 0; + const auto id = sim.add_cell(c); + cm::CultureConfiguration config; + config.grid.shape = {12, 12, 12}; + config.ports = {{.name = "in"}, {.name = "out", .upper = true}}; + config.solutes = {{"substrate", 0.1}}; + config.reservoirs = {{"in", {0}, cm::ChemicalBoundaryKind::advective}, + {"out", {0}, cm::ChemicalBoundaryKind::advective}}; + model.cell_id = id; + config.growth = {model}; + config.maximum_retries = 0; + config.coupling_tolerance = 2e-6; + sim.configure_culture(config, {1}); + const auto before = *sim.culture_checkpoint(); + sim.step(0.01F); + const auto after = *sim.culture_checkpoint(); + const double gained = after.cells[0].biochemical_volume - before.cells[0].biochemical_volume; + check(gained > 0, "nutrient driven growth"); + check(std::abs(gained - 2 * after.cells[0].uptake_totals[0]) < 1e-10, "biomass uptake budget"); + double supplied = 0; + + for (const auto& r : after.reservoir_totals) { + supplied += r.amounts[0]; + } + + const double lost = + std::accumulate(before.extracellular_amounts.begin(), before.extracellular_amounts.end(), + 0.0) - + std::accumulate(after.extracellular_amounts.begin(), after.extracellular_amounts.end(), 0.0); + check(std::abs(lost + supplied - gained / 2) < 0.02, "whole culture chemical budget"); + cm::Simulation resumed(backend, sim.checkpoint(), device); + check(resumed.culture_checkpoint()->cells[0].biochemical_volume == + after.cells[0].biochemical_volume, + "growth checkpoint"); + std::cout << "culture growth backend " << static_cast(backend) << " gain=" << gained << '\n'; +} + +int main() { + cm::test::for_each_backend_device(run); +} diff --git a/tests/conformance/culture_simulation_conformance_test.cpp b/tests/conformance/culture_simulation_conformance_test.cpp new file mode 100644 index 0000000..7a1547d --- /dev/null +++ b/tests/conformance/culture_simulation_conformance_test.cpp @@ -0,0 +1,107 @@ +#include +#include +#include +#include + +#include "backend_devices.hpp" +#include "cm/simulation.hpp" + +namespace { +void check(bool value, const char* message) { + if (!value) { + throw std::runtime_error(message); + } +} + +void run(cm::BackendKind backend, std::uint32_t device) { + cm::Simulation simulation(backend, 1, 1, device); + cm::CellInit cell; + cell.position = {5.5, 5.5, 5.5}; + cell.radius = 2; + cell.length = 1; + cell.growth_rate = 0.01F; + cell.species = {3}; + cell.fixed = true; + const auto id = simulation.add_cell(cell); + cm::CultureConfiguration config; + config.grid.shape = {12, 12, 12}; + config.maximum_retries = 1; + config.ports = {{.name = "in"}, {.name = "out", .upper = true}}; + config.solutes = {{"nutrient", 0.1}}; + config.reservoirs = {{"in", {2}}, {"out", {2}}}; + config.exchange = {{id, 0, 0, 0.01, 0}}; + config.biomass_requirements = {{0, 2}}; + simulation.configure_culture(config, {2}, {80}); + const auto original = simulation.checkpoint(); + simulation.step(0.1F); + const auto state = *simulation.culture_checkpoint(); + check(simulation.time() > 0 && state.cells[0].biochemical_volume > 80, "media simulation growth"); + const double geometric_growth = + state.cells[0].body.geometric_volume() - original.culture->cells[0].body.geometric_volume(); + check(std::abs(state.cells[0].biochemical_volume - 80 - geometric_growth) < 1e-11, + "independent biochemical biomass increment"); + const double extracellular_initial = + std::accumulate(original.culture->extracellular_amounts.begin(), + original.culture->extracellular_amounts.end(), 0.0); + const double extracellular_final = + std::accumulate(state.extracellular_amounts.begin(), state.extracellular_amounts.end(), 0.0); + double reservoir = 0; + + for (const auto& t : state.reservoir_totals) { + reservoir += t.amounts[0]; + } + + const double initial_total = extracellular_initial + 240; + const double final_total = + extracellular_final + state.cells[0].species_amounts[0] + 2 * geometric_growth; + check(std::abs(final_total - initial_total - reservoir) < 0.02, + "fluid cell and biomass amount budget"); + cm::Simulation restart(backend, simulation.checkpoint(), device); + simulation.step(0.1F); + restart.step(0.1F); + const auto continued = *simulation.culture_checkpoint(), resumed = *restart.culture_checkpoint(); + check(continued.cells[0].body.position == resumed.cells[0].body.position && + continued.cells[0].species_amounts == resumed.cells[0].species_amounts && + continued.extracellular_amounts == resumed.extracellular_amounts, + "media restart continuation"); + auto kinetics_checkpoint = simulation.checkpoint(); + kinetics_checkpoint.culture->configuration.exchange.clear(); + kinetics_checkpoint.world.cells[0].growth_rate = 0; + kinetics_checkpoint.species_rate_plan = + cm::SpeciesRatePlan(1, {{.operation = cm::RateOp::cell_volume}}, {0}); + cm::Simulation kinetics(backend, kinetics_checkpoint, device); + const auto& old_cell = kinetics_checkpoint.culture->cells[0]; + kinetics.step(0.125F); + const auto kinetic_amount = kinetics.culture_checkpoint()->cells[0].species_amounts[0]; + const double expected_amount = old_cell.species_amounts[0] + + 0.125 * old_cell.biochemical_volume * old_cell.biochemical_volume; + check(std::abs(kinetic_amount / expected_amount - 1) < 2e-6, + "native kinetics uses explicit biochemical volume"); + const auto before = simulation.checkpoint(); + using enum cm::RateOp; + simulation.set_species_rate_plan(cm::SpeciesRatePlan( + 1, {{.operation = constant, .value = 0}, {.operation = divide, .first = 0, .second = 0}}, + {1})); + bool failed = false; + + try { + simulation.step(0.1F); + } catch (const std::exception&) { + failed = true; + } + + check(failed && simulation.time() == before.time && + simulation.culture_checkpoint()->extracellular_amounts == + before.culture->extracellular_amounts && + simulation.culture_checkpoint()->cells[0].body.length == + before.culture->cells[0].body.length, + "media failed interval rollback"); + simulation.restore_checkpoint(before); + check(simulation.time() == before.time, "media explicit restore"); + std::cout << "media simulation backend " << static_cast(backend) << " passed\n"; +} +} // namespace + +int main() { + cm::test::for_each_backend_device(run); +} diff --git a/tests/conformance/flow_conformance_test.cpp b/tests/conformance/flow_conformance_test.cpp index b881241..e9c4e0a 100644 --- a/tests/conformance/flow_conformance_test.cpp +++ b/tests/conformance/flow_conformance_test.cpp @@ -24,6 +24,7 @@ cm::SignalGridSpec duct(std::uint32_t nx, std::uint32_t ny, std::uint32_t nz, spec.advection = {{0.0F, 0.0F, 0.0F}}; spec.y_lower = {.kind = cm::GridBoundaryKind::fixed, .values = {0.0F}}; spec.y_upper = {.kind = cm::GridBoundaryKind::fixed, .values = {0.0F}}; + return spec; } @@ -35,6 +36,7 @@ std::size_t site_index(const cm::SignalGridSpec& spec, std::uint32_t x, std::uin bool close(float actual, float expected) { constexpr float absolute_tolerance = 8.0e-4F; constexpr float relative_tolerance = 8.0e-4F; + return std::abs(actual - expected) <= absolute_tolerance + relative_tolerance * std::abs(expected); } @@ -42,6 +44,7 @@ bool close(float actual, float expected) { void compare_component(std::span actual, std::span expected, std::string_view scenario, std::string_view component) { assert(actual.size() == expected.size()); + for (std::size_t index = 0; index < actual.size(); ++index) { if (!close(actual[index], expected[index])) { std::cerr << scenario << ' ' << component << " face " << index << ": actual=" << actual[index] @@ -61,10 +64,13 @@ void compare_fields(const cm::SignalGridVelocityField& actual, void run_depth_case(cm::BackendKind backend, std::uint32_t device_index) { auto spec = duct(5, 8, 2, {0.7F, 1.1F, 0.6F}); spec.obstacles.assign(spec.site_count(), 0); + for (std::uint32_t y = 3; y <= 4; ++y) { spec.obstacles[site_index(spec, 2, y, 1)] = 1; } + std::vector mobility(spec.site_count(), 1.0F); + for (std::uint32_t x = 0; x < spec.shape.x; ++x) { for (std::uint32_t y = 0; y < spec.shape.y; ++y) { for (std::uint32_t z = 0; z < spec.shape.z; ++z) { @@ -73,6 +79,7 @@ void run_depth_case(cm::BackendKind backend, std::uint32_t device_index) { } } } + cm::DepthAveragedFlowParameters parameters; parameters.mean_inlet_speed = 3.5F; parameters.relative_tolerance = 1.0e-6F; @@ -87,6 +94,7 @@ void run_depth_case(cm::BackendKind backend, std::uint32_t device_index) { void run_resolved_case(cm::BackendKind backend, std::uint32_t device_index) { auto spec = duct(6, 7, 2, {0.2F, 0.35F, 0.3F}); std::vector drag(spec.site_count(), 0.0F); + for (std::uint32_t x = 0; x < spec.shape.x; ++x) { for (std::uint32_t y = 0; y < spec.shape.y; ++y) { for (std::uint32_t z = 0; z < spec.shape.z; ++z) { @@ -96,6 +104,7 @@ void run_resolved_case(cm::BackendKind backend, std::uint32_t device_index) { } } } + cm::ResolvedFlowParameters parameters; parameters.mean_inlet_speed = 2.0F; parameters.relative_tolerance = 1.0e-6F; diff --git a/tests/conformance/fluid_geometry_test.cpp b/tests/conformance/fluid_geometry_test.cpp new file mode 100644 index 0000000..745733f --- /dev/null +++ b/tests/conformance/fluid_geometry_test.cpp @@ -0,0 +1,164 @@ +#include "cm/fluid_geometry.hpp" + +#include +#include +#include +#include + +#include "core/convex_polyhedron.hpp" + +namespace { +void check(bool value, const char* message) { + if (!value) { + throw std::runtime_error(message); + } +} + +void polyhedra() { + using namespace cm::detail; + const auto box = box_polyhedron({-1, -1, -1}, {1, 1, 1}); + check(std::abs(measure_polyhedron(box).volume - 8) < 1e-13, "box volume"); + const auto half = clip_polyhedron(box, {{1, 0, 0}, 0, 1}); + const auto measure = measure_polyhedron(half); + check(std::abs(measure.volume - 4) < 1e-13 && std::abs(measure.centroid[0] + 0.5) < 1e-13, + "half-box volume and centroid"); + cm::CapsuleBody body; + body.position = {12.3, -4.6, 1.4}; + body.radius = 1.7; + body.length = 3.9; + body.orientation = {std::cos(0.31), 0, std::sin(0.31), 0}; + double previous_error = 1; + + for (unsigned resolution : {8, 16, 32, 64}) { + auto surface = capsule_polyhedron(body, resolution); + const auto m = measure_polyhedron(surface.poly); + check(std::abs(m.volume / body.geometric_volume() - 1) < 1e-13, "capsule exact volume"); + + for (unsigned d = 0; d < 3; ++d) { + check(std::abs(m.centroid[d] - body.position[d]) < 1e-12, "capsule centroid"); + } + + check(surface.surface_error_bound < previous_error / 3, "capsule surface refinement"); + previous_error = surface.surface_error_bound; + + // Every surface vertex must be inside every halfspace: the volume-preserving + // corrections must retain convexity, including the cylinder/cap junctions. + for (const auto& plane : surface.planes) { + for (const auto& face : surface.poly.faces) { + for (const auto& point : face.vertices) { + check(body_dot(plane.normal, point) - plane.offset < 1e-12, "capsule convexity"); + } + } + } + + double clipped_volume = 0; + + for (int slab = -8; slab < 32; ++slab) { + auto clipped = clip_polyhedron(surface.poly, {{1, 0, 0}, 0.5 * (slab + 1), 1}); + clipped = clip_polyhedron(clipped, {{-1, 0, 0}, -0.5 * slab, 2}); + clipped_volume += measure_polyhedron(clipped).volume; + } + + check(std::abs(clipped_volume / body.geometric_volume() - 1) < 1e-13, + "cut-cell capsule volume partition"); + } +} + +void extracellular() { + cm::FluidGridSpec grid; + grid.shape = {8, 8, 8}; + cm::FluidGeometry empty(grid, {}); + check(empty.fragments().size() == 512 && empty.report().fluid_volume == 512 && + empty.report().component_count == 1, + "empty fluid mesh"); + cm::CapsuleBody body; + body.position = {3.31, 3.67, 3.48}; + body.radius = 1.2; + body.length = 1.3; + body.orientation = {std::cos(0.31), 0, std::sin(0.31), 0}; + cm::FluidGeometry geometry(grid, {body}); + check(std::abs(geometry.report().fluid_volume - (512 - body.geometric_volume())) < 1e-9, + "extracellular volume excludes exact capsule volume"); + check(geometry.report().component_count == 1, "capsule exterior connected"); + std::vector> closure(geometry.fragments().size()); + double membrane_area = 0; + + for (const auto& face : geometry.faces()) { + for (unsigned d = 0; d < 3; ++d) { + closure[face.first][d] += face.area * face.normal[d]; + + if (face.second != cm::fluid_boundary) { + closure[face.second][d] -= face.area * face.normal[d]; + } + } + + if (face.body_id) { + check(face.body_id == body.id, "membrane body id"); + membrane_area += face.area; + } + } + + for (const auto& sum : closure) { + for (double x : sum) { + check(std::abs(x) < 1e-9, "extracellular fragment face closure"); + } + } + + const auto surface = cm::detail::capsule_polyhedron(body, 32); + double analytic_mesh_area = 0; + + for (const auto& face : surface.poly.faces) { + analytic_mesh_area += cm::detail::polygon_area(face.vertices); + } + + check(std::abs(membrane_area / analytic_mesh_area - 1) < 1e-9, "membrane area partition"); + cm::FluidGeometryParameters refined_parameters; + refined_parameters.surface_resolution = 64; + const cm::FluidGeometry refined(grid, {body}, refined_parameters); + double overlap_volume = 0; + + for (const auto& overlap : geometry.overlaps(refined)) { + overlap_volume += overlap.volume; + } + + // Equal analytic body volumes do not make two surface approximations identical. + // Their common fluid region excludes the union of both polyhedra. + check(overlap_volume < geometry.report().fluid_volume - 1e-4 && + overlap_volume > geometry.report().fluid_volume - 0.1, + "surface refinement computes geometric overlap instead of an identity remap"); + std::cout << "extracellular fragments " << geometry.fragments().size() << " faces " + << geometry.faces().size() << " estimated bytes " + << geometry.report().estimated_memory_bytes << '\n'; + grid.obstacles.assign(512, 0); + + for (unsigned x = 0; x < 8; ++x) { + for (unsigned z = 0; z < 8; ++z) { + grid.obstacles[(x * 8 + 4) * 8 + z] = 1; + } + } + + cm::FluidGeometry split(grid, {}); + check(split.report().component_count == 2 && split.report().fluid_volume == 448, + "wall separates extracellular components"); + cm::FluidGridSpec divided_grid; + divided_grid.shape = {18, 12, 12}; + cm::CapsuleBody first, second; + first.position = second.position = {8.5, 5.5, 5.5}; + first.radius = second.radius = 2; + second.id = 2; + const double fraction = double(0.4F), cap = 8.0 / 3; + first.length = fraction * (8 + cap) - cap; + second.length = (1 - fraction) * (8 + cap) - cap; + const double separation = (first.length + second.length) / 2 + 4 + 0.10001; + first.position[0] -= (1 - fraction) * separation; + second.position[0] += fraction * separation; + const cm::FluidGeometry daughters(divided_grid, {first, second}); + check(std::abs(daughters.report().volume_error) < 1e-9, "daughter extracellular geometry volume"); +} +} // namespace + +int main() { + polyhedra(); + extracellular(); + std::cout << "media geometry passed\n"; +} diff --git a/tests/conformance/growth_conformance_test.cpp b/tests/conformance/growth_conformance_test.cpp index 8199e4f..61166f1 100644 --- a/tests/conformance/growth_conformance_test.cpp +++ b/tests/conformance/growth_conformance_test.cpp @@ -17,6 +17,7 @@ constexpr std::array time_steps{0.01F, 0.025F, 0.1F, 0.04F}; bool close(float actual, float expected) { const auto tolerance = absolute_tolerance + (relative_tolerance * std::abs(expected)); + return std::abs(actual - expected) <= tolerance; } @@ -30,6 +31,7 @@ void run_growth_scenario(cm::BackendKind backend, std::uint32_t device_index) { growth_rates.reserve(cell_count); simulation.step(0.0F); + for (std::size_t index = 0; index < cell_count; ++index) { cm::CellInit cell; cell.position = { @@ -50,9 +52,11 @@ void run_growth_scenario(cm::BackendKind backend, std::uint32_t device_index) { } double expected_time = 0.0; + for (const auto dt : time_steps) { simulation.step(dt); expected_time += static_cast(dt); + for (std::size_t index = 0; index < cell_count; ++index) { expected_lengths[index] += growth_rates[index] * expected_lengths[index] * dt; } @@ -60,6 +64,7 @@ void run_growth_scenario(cm::BackendKind backend, std::uint32_t device_index) { assert(simulation.cell_count() == cell_count); assert(std::abs(simulation.time() - expected_time) <= 1.0e-12); + for (std::size_t index = 0; index < cell_count; ++index) { const auto cell = simulation.cell(ids[index]); assert(cell.id == ids[index]); @@ -82,5 +87,6 @@ void run_growth_scenario(cm::BackendKind backend, std::uint32_t device_index) { int main() { cm::test::for_each_backend_device(run_growth_scenario); + return 0; } diff --git a/tests/conformance/lifecycle_conformance_test.cpp b/tests/conformance/lifecycle_conformance_test.cpp index 491a8e8..0e43c4c 100644 --- a/tests/conformance/lifecycle_conformance_test.cpp +++ b/tests/conformance/lifecycle_conformance_test.cpp @@ -13,16 +13,19 @@ constexpr float relative_tolerance = 1.0e-6F; bool close(float actual, float expected) { const auto tolerance = absolute_tolerance + (relative_tolerance * std::abs(expected)); + return std::abs(actual - expected) <= tolerance; } void assert_missing(const cm::Simulation& simulation, cm::CellId id) { bool rejected = false; + try { static_cast(simulation.cell(id)); } catch (const std::out_of_range&) { rejected = true; } + assert(rejected); } @@ -78,6 +81,7 @@ void run_lifecycle_scenario(cm::BackendKind backend, std::uint32_t device_index) assert(close(cells[2].length, 0.42921875F)); assert(close(cells[1].position.x, 2.665625F)); assert(close(cells[2].position.x, 4.084375F)); + for (const auto& cell : cells) { assert(cell.cell_type == 7); assert(cell.growth_rate == 0.25F); @@ -110,5 +114,6 @@ void run_lifecycle_scenario(cm::BackendKind backend, std::uint32_t device_index) int main() { cm::test::for_each_backend_device(run_lifecycle_scenario); + return 0; } diff --git a/tests/conformance/mechanics_conformance_test.cpp b/tests/conformance/mechanics_conformance_test.cpp index 21bca09..3a63a39 100644 --- a/tests/conformance/mechanics_conformance_test.cpp +++ b/tests/conformance/mechanics_conformance_test.cpp @@ -16,16 +16,18 @@ constexpr float relative_tolerance = 3.0e-4F; bool close(float actual, float expected) { const auto tolerance = absolute_tolerance + relative_tolerance * std::abs(expected); + return std::abs(actual - expected) <= tolerance; } cm::CellId add_capsule(cm::Simulation& simulation, cm::Vec3 center, cm::Vec3 axis, - float length = 4.0F, float radius = 0.5F) { + float length = 4.0F, float radius = 0.5F) { cm::CellInit cell; cell.position = center; cell.direction = axis; cell.length = length; cell.radius = radius; + return simulation.add_cell(cell); } @@ -41,6 +43,7 @@ void require_close(float actual, float expected, std::string_view scenario, std: if (close(actual, expected)) { return; } + std::cerr << scenario << " correction " << index << ' ' << field << ": actual=" << actual << " expected=" << expected << '\n'; std::abort(); @@ -53,6 +56,7 @@ void compare_corrections(const cm::MechanicsSolveResult& actual, require_close(actual.report.initial_residual_rms, expected.report.initial_residual_rms, scenario, 0, "initial_residual_rms"); assert(actual.corrections.size() == expected.corrections.size()); + for (std::size_t index = 0; index < expected.corrections.size(); ++index) { const auto& left = actual.corrections[index]; const auto& right = expected.corrections[index]; @@ -109,6 +113,7 @@ void run_empty_systems(cm::BackendKind backend, std::uint32_t device_index) { const auto separated_result = separated.solve_cell_mechanics(); assert(separated_result.report.status == cm::SolverStatus::converged); assert(separated_result.report.iterations == 0); + for (const auto& correction : separated_result.corrections) { assert(correction.translation.x == 0.0F); assert(correction.translation.y == 0.0F); @@ -126,6 +131,7 @@ void run_buffer_growth(cm::BackendKind backend, std::uint32_t device_index) { for (std::size_t index = 0; index < 7; ++index) { add_capsule(simulation, {0.1F * static_cast(index), 0.2F, 0.1F}, {1.0F, 0.1F, 0.0F}); } + const auto grown = simulation.solve_cell_mechanics(); assert(grown.report.status == cm::SolverStatus::converged); assert(grown.corrections.size() == 9); @@ -145,6 +151,7 @@ void run_integrated_relaxation(cm::BackendKind backend, std::uint32_t device_ind const auto expected_cells = reference.cells(); const auto actual_cells = candidate.cells(); assert(actual_cells.size() == expected_cells.size()); + for (std::size_t index = 0; index < expected_cells.size(); ++index) { assert(actual_cells[index].id == expected_cells[index].id); assert(close(actual_cells[index].position.x, expected_cells[index].position.x)); @@ -198,9 +205,11 @@ void run_fixed_cell_relaxation(cm::BackendKind backend, std::uint32_t device_ind int main() { cm::test::for_each_backend_device([](cm::BackendKind backend, std::uint32_t device_index) { cm::Simulation capability_probe(backend, 0, 0, device_index); + if (!capability_probe.supports(cm::BackendFeature::cell_mechanics)) { return; } + run_empty_systems(backend, device_index); run_converged_colony(backend, device_index); run_iteration_limit(backend, device_index); @@ -208,5 +217,6 @@ int main() { run_integrated_relaxation(backend, device_index); run_fixed_cell_relaxation(backend, device_index); }); + return 0; } diff --git a/tests/conformance/metal_runtime_gate_test.cpp b/tests/conformance/metal_runtime_gate_test.cpp index 8ed2785..0d2efed 100644 --- a/tests/conformance/metal_runtime_gate_test.cpp +++ b/tests/conformance/metal_runtime_gate_test.cpp @@ -11,6 +11,7 @@ namespace { int fail(const std::string& message) { std::cerr << "Metal runtime gate failed: " << message << '\n'; + return 1; } @@ -18,6 +19,7 @@ int fail(const std::string& message) { int main() { const auto device_count = cm::backend_device_count(cm::BackendKind::metal); + if (device_count == 0) { return fail("the Metal-enabled build did not discover an Apple GPU"); } @@ -27,17 +29,21 @@ int main() { if (index > static_cast(std::numeric_limits::max())) { return fail("the Metal device index exceeds the backend index type"); } + const auto device_index = static_cast(index); + if (!cm::backend_available(cm::BackendKind::metal, device_index)) { return fail("an enumerated Metal device is not available through the backend"); } cm::Simulation simulation(cm::BackendKind::metal, 0, device_index); const auto info = simulation.backend_info(); + if (info.kind != cm::BackendKind::metal || !info.native || info.device_index != device_index || info.name != "metal" || info.device.empty()) { return fail("the constructed backend did not identify the selected native Metal device"); } + simulation.step(0.0F); simulation.validate(); } @@ -46,5 +52,6 @@ int main() { } std::cout << "validated " << device_count << " native Metal device(s)\n"; + return 0; } diff --git a/tests/conformance/occupancy_conformance_test.cpp b/tests/conformance/occupancy_conformance_test.cpp new file mode 100644 index 0000000..6a3b280 --- /dev/null +++ b/tests/conformance/occupancy_conformance_test.cpp @@ -0,0 +1,92 @@ +#include +#include +#include +#include + +#include "cm/backend.hpp" +#include "cm/occupancy.hpp" + +namespace { +void near(double actual, double expected, double tolerance = 2e-6) { + if (!std::isfinite(actual) || std::abs(actual - expected) > tolerance) { + throw std::runtime_error("occupancy conformance mismatch"); + } +} + +void check(cm::BackendKind backend, std::uint32_t device) { + cm::OccupancySolver solver(backend, device); + const std::vector> centers{{0, 0, 0}, {1, 0, 0}, {2, 0, 0}}; + auto epsilon = solver.geometric_porosity(centers, {1, 1, 1}, {}, 8, {0, 1, 0}); + near(epsilon[0], 1); + near(epsilon[1], 0); + near(epsilon[2], 1); + const cm::OccupancyCapsule cell{{0, 0, 0}, {2, 1, 0}, 0.5F, 0.4F}; + const auto single = solver.geometric_porosity(centers, {1, 1, 1}, {cell}); + const auto overlap = solver.geometric_porosity(centers, {1, 1, 1}, {cell, cell}); + + if (single != overlap || single[0] >= 1) { + throw std::runtime_error("capsule union mismatch"); + } + + auto remapped = solver.remap_amounts({2, 3, 1}, {1, 1, 1}, {0, 0.25F, 0.75F}, {{0, 1}, {1, 2}}); + near(remapped[0], 0); + near(remapped[1], 3.5); + near(remapped[2], 2.5); + bool rejected = false; + + try { + static_cast(solver.remap_amounts({1, 0, 0}, {1, 0, 1}, {0, 0, 1}, {{0, 1}, {1, 2}})); + } catch (const std::invalid_argument&) { + rejected = true; + } + + if (!rejected) { + throw std::runtime_error("closed component was accepted"); + } + + auto weights = solver.exchange_weights({0.5F, 0.5F}, {0.25F, 0.75F}); + near(weights[0], 0.25); + near(weights[1], 0.75); + const auto face = solver.porosity_face(0, 1, 0.25F, 0.75F, 1, 1, 1); + near(face.conductance, 0.375); + auto amount = std::vector{4, 0}; + + for (int step = 0; step < 1000; ++step) { + const auto result = solver.backward_euler(amount, {0.5F, 1.5F}, {face}, 1); + near(result.balance.residual(), 0, 2e-5); + amount = result.amount; + } + + near(amount[0], 1, 2e-4); + near(amount[1], 3, 2e-4); + near(double(amount[0]) + amount[1], 4, 2e-4); + const auto open = + solver.backward_euler({0.5F, 3}, {0.25F, 0.75F}, {{0, 1, 0.1F, 0.05F}}, 0.2F, {0.75F, 2.25F}, + {0.3F, 0.7F}, {{0, 5, 0.2F, -0.1F}, {1, 0, 0, 0.1F}}); + near(open.balance.residual(), 0, 2e-5); + + if (!(open.balance.source > 0 && open.balance.reaction < 0 && open.balance.boundary > 0)) { + throw std::runtime_error("occupancy ledger signs differ"); + } + + std::cout << "occupancy backend " << static_cast(backend) << " device " << device + << " passed\n"; +} +} // namespace + +int main() { + std::size_t tested = 0; + + for (const auto backend : {cm::BackendKind::metal, cm::BackendKind::cuda}) { + for (std::uint32_t device = 0; device < cm::backend_device_count(backend); ++device) { + check(backend, device); + ++tested; + } + } + + if (!tested) { + std::cout << "No Metal or CUDA device available\n"; + + return 77; + } +} diff --git a/tests/conformance/signal_grid_conformance_test.cpp b/tests/conformance/signal_grid_conformance_test.cpp index 7be35e9..25ab44b 100644 --- a/tests/conformance/signal_grid_conformance_test.cpp +++ b/tests/conformance/signal_grid_conformance_test.cpp @@ -27,11 +27,14 @@ cm::SignalGridSpec make_spec() { cm::SignalGridAffineReaction reaction; reaction.source_rates.resize(spec.level_count()); reaction.loss_rates.resize(spec.level_count()); + for (std::size_t index = 0; index < spec.level_count(); ++index) { reaction.source_rates[index] = 0.01F * static_cast(index % 3); reaction.loss_rates[index] = 0.005F * static_cast(index % 5); } + spec.reaction = std::move(reaction); + return spec; } @@ -40,9 +43,11 @@ cm::SignalGridSpec make_masked_spec() { const auto solid = [&](std::uint32_t x, std::uint32_t y, std::uint32_t z) { const auto interior_block = x >= 3 && x <= 5 && y >= 2 && y <= 4 && z == 2; const auto periodic_edge = x == 0 && y == 1 && z == 1; + return interior_block || periodic_edge; }; std::vector obstacles(spec.site_count(), 0); + for (std::uint32_t x = 0; x < spec.shape.x; ++x) { for (std::uint32_t y = 0; y < spec.shape.y; ++y) { for (std::uint32_t z = 0; z < spec.shape.z; ++z) { @@ -52,6 +57,7 @@ cm::SignalGridSpec make_masked_spec() { } } } + for (std::size_t signal = 0; signal < spec.signal_count; ++signal) { for (std::size_t site = 0; site < spec.site_count(); ++site) { if (obstacles[site] != 0) { @@ -60,7 +66,9 @@ cm::SignalGridSpec make_masked_spec() { } } } + spec.obstacles = std::move(obstacles); + return spec; } @@ -71,6 +79,7 @@ cm::SignalGridSpec make_velocity_field_spec() { field.x_faces.resize(spec.x_face_count(), 0.0F); field.y_faces.resize(spec.y_face_count(), 0.0F); field.z_faces.resize(spec.z_face_count(), 0.0F); + for (std::uint32_t fx = 0; fx <= spec.shape.x; ++fx) { for (std::uint32_t y = 0; y < spec.shape.y; ++y) { for (std::uint32_t z = 0; z < spec.shape.z; ++z) { @@ -81,22 +90,27 @@ cm::SignalGridSpec make_velocity_field_spec() { } } } + spec.velocity_field = std::move(field); + return spec; } std::vector make_levels(const cm::SignalGridSpec& spec) { std::vector levels(spec.level_count()); + for (std::size_t index = 0; index < levels.size(); ++index) { levels[index] = spec.solid_site(index % spec.site_count()) ? 0.0F : 0.5F + (0.001F * static_cast((index * 37) % 211)); } + return levels; } bool close(float actual, float expected) { constexpr float tolerance = 5.0e-6F; + return std::abs(actual - expected) <= tolerance + (tolerance * std::max(std::abs(actual), std::abs(expected))); } @@ -106,11 +120,14 @@ void assert_matches(const cm::Simulation& actual, const cm::Simulation& expected assert(actual.signal_levels().size() == expected.signal_levels().size()); const auto actual_levels = actual.signal_levels(); const auto expected_levels = expected.signal_levels(); + for (std::size_t index = 0; index < actual_levels.size(); ++index) { assert(close(actual_levels[index], expected_levels[index])); } + const auto actual_sample = actual.sample_signals({-0.25F, -0.5F, 1.1F}); const auto expected_sample = expected.sample_signals({-0.25F, -0.5F, 1.1F}); + for (std::size_t signal = 0; signal < actual_sample.size(); ++signal) { assert(close(actual_sample[signal], expected_sample[signal])); } @@ -119,7 +136,7 @@ void assert_matches(const cm::Simulation& actual, const cm::Simulation& expected enum class SpecKind { plain, masked, velocity_field }; void run_case(cm::SignalIntegrationKind integration, float dt, SpecKind kind = SpecKind::plain) { - auto spec = kind == SpecKind::masked ? make_masked_spec() + auto spec = kind == SpecKind::masked ? make_masked_spec() : kind == SpecKind::velocity_field ? make_velocity_field_spec() : make_spec(); spec.integration = integration; @@ -131,9 +148,11 @@ void run_case(cm::SignalIntegrationKind integration, float dt, SpecKind kind = S cm::test::for_each_backend_device([&](cm::BackendKind backend, std::uint32_t device_index) { cm::Simulation candidate(backend, 0, 0, device_index); candidate.configure_signal_grid(spec, levels); + if (!candidate.supports(cm::BackendFeature::signals)) { return; } + candidate.step(dt); assert_matches(candidate, reference); assert(candidate.last_signal_solve_report().has_value()); diff --git a/tests/conformance/solute_transport_conformance_test.cpp b/tests/conformance/solute_transport_conformance_test.cpp new file mode 100644 index 0000000..ba334e5 --- /dev/null +++ b/tests/conformance/solute_transport_conformance_test.cpp @@ -0,0 +1,139 @@ +#include +#include +#include +#include +#include + +#include "backend_devices.hpp" +#include "cm/solute_transport.hpp" + +namespace { +void check(bool value, const char* message) { + if (!value) { + throw std::runtime_error(message); + } +} + +cm::SignalGridVelocityField zero_velocity(const cm::FluidGridSpec& g) { + cm::SignalGridVelocityField f; + f.x_faces.resize((g.shape.x + 1) * g.shape.y * g.shape.z); + f.y_faces.resize(g.shape.x * (g.shape.y + 1) * g.shape.z); + f.z_faces.resize(g.shape.x * g.shape.y * (g.shape.z + 1)); + + return f; +} + +void run(cm::BackendKind backend, std::uint32_t device) { + cm::FluidGridSpec grid; + grid.shape = {8, 8, 8}; + cm::FluidGeometry geometry(grid, {}); + cm::SoluteTransportSolver solver(backend, device); + const auto velocity = zero_velocity(grid); + std::vector initial; + + for (const auto& f : geometry.fragments()) { + initial.push_back(f.volume * + (2 + 0.5 * std::cos(std::numbers::pi * (f.centroid[0] + 0.5) / 8))); + } + + const double diffusion = 0.7, dt = 1.3; + const auto result = + solver.step(geometry, geometry, velocity, {}, {{"tracer", diffusion}}, {}, initial, dt); + const double damping = + 1 / (1 + dt * 4 * diffusion * std::pow(std::sin(std::numbers::pi / 16), 2)); + + for (std::size_t i = 0; i < initial.size(); ++i) { + const double exact = + 2 + 0.5 * damping * + std::cos(std::numbers::pi * (geometry.fragments()[i].centroid[0] + 0.5) / 8); + check(std::abs(result.concentrations[i] - exact) < 2e-4, "backward Euler diffusion eigenmode"); + } + + check(std::abs(result.report.mass_balance_error[0]) < 0.01, "closed diffusion amount ledger"); + // Compare timestep refinement against the exact semidiscrete diffusion + // solution, independently of the backward-Euler formula checked above. + const double eigenvalue = 4 * diffusion * std::pow(std::sin(std::numbers::pi / 16), 2); + double previous_error = INFINITY; + + for (unsigned steps : {1, 2, 4, 8}) { + auto amounts = initial; + + for (unsigned step = 0; step < steps; ++step) { + amounts = solver + .step(geometry, geometry, velocity, {}, {{"tracer", diffusion}}, {}, amounts, + 4.0 / steps) + .amounts; + } + + double error = 0; + + for (std::size_t i = 0; i < amounts.size(); ++i) { + const auto& fragment = geometry.fragments()[i]; + const double exact = 2 + 0.5 * std::exp(-4 * eigenvalue) * + std::cos(std::numbers::pi * (fragment.centroid[0] + 0.5) / 8); + error = std::max(error, std::abs(amounts[i] / fragment.volume - exact)); + } + + check(error < 0.65 * previous_error, "first-order transport timestep refinement"); + previous_error = error; + } + + auto channel = velocity; + std::fill(channel.x_faces.begin(), channel.x_faces.end(), 0.2F); + std::vector ports{{.name = "in", .axis = cm::FlowAxis::x}, + {.name = "out", .axis = cm::FlowAxis::x, .upper = true}}; + std::vector uniform(initial.size(), 2); + const auto perfusion = solver.step(geometry, geometry, channel, ports, {{"tracer", 0.1}}, + {{"in", {2}}, {"out", {2}}}, uniform, dt); + + for (double c : perfusion.concentrations) { + check(std::abs(c - 2) < 2e-4, "uniform perfusion concentration"); + } + + check(perfusion.reservoirs[0].amounts[0] > 0 && perfusion.reservoirs[1].amounts[0] < 0, + "reservoir ledger signs"); + cm::CapsuleBody body; + body.position = {3.31, 3.67, 3.48}; + body.radius = 1.2; + body.length = 1.3; + body.orientation = {std::cos(0.31), 0, std::sin(0.31), 0}; + const cm::FluidGeometry old(grid, {body}); + body.position[0] += 0.12; + const cm::FluidGeometry moved(grid, {body}); + uniform.clear(); + + for (const auto& f : old.fragments()) { + uniform.push_back(2 * f.volume); + } + + const auto moving = solver.step(old, moved, velocity, {}, {{"tracer", 0.1}}, {}, uniform, dt); + double maximum = 0; + + for (double c : moving.concentrations) { + maximum = std::max(maximum, std::abs(c - 2)); + } + + std::cout << "moving concentration error " << maximum << " GCL " + << moving.report.maximum_volume_residual << std::endl; + check(maximum < 4e-4, "moving capsule preserves uniform concentration"); + check(std::abs(moving.report.mass_balance_error[0]) < 0.02, "moving capsule amount ledger"); + const auto uptake = solver.step(old, old, velocity, {}, {{"tracer", 0.1}}, {}, uniform, dt, + {{.body_id = body.id, .solute = 0, .uptake_velocity = 0.05}}); + const double removed = std::accumulate(uniform.begin(), uniform.end(), 0.0) - + std::accumulate(uptake.amounts.begin(), uptake.amounts.end(), 0.0); + check(removed > 0 && uptake.cells[0].amounts[0] > 0, "surface uptake direction"); + check(std::abs(removed - uptake.cells[0].amounts[0]) < 0.01, + "surface uptake paired amount ledger"); + + for (double m : uptake.amounts) { + check(m >= 0, "nonnegative uptake amount"); + } + + std::cout << "media transport backend " << static_cast(backend) << " device " << device + << " passed\n"; +} +} // namespace + +int main() { + cm::test::for_each_backend_device(run); +} diff --git a/tests/conformance/species_conformance_test.cpp b/tests/conformance/species_conformance_test.cpp index ca2079d..a53f5b5 100644 --- a/tests/conformance/species_conformance_test.cpp +++ b/tests/conformance/species_conformance_test.cpp @@ -18,60 +18,63 @@ constexpr std::array time_steps{0.0F, 0.01F, 0.025F, 0.1F}; bool close(float actual, float expected) { constexpr float absolute_tolerance = 2.0e-5F; constexpr float relative_tolerance = 2.0e-5F; + return std::abs(actual - expected) <= absolute_tolerance + relative_tolerance * std::abs(expected); } cm::SpeciesRatePlan make_plan() { using enum cm::RateOp; + return cm::SpeciesRatePlan(species_count, - { - {.operation = species, .first = 0}, - {.operation = species, .first = 1}, - {.operation = species, .first = 2}, - {.operation = constant, .value = 0.25F}, - {.operation = position_x}, - {.operation = position_y}, - {.operation = position_z}, - {.operation = cell_length}, - {.operation = cell_radius}, - {.operation = growth_rate}, - {.operation = cell_type}, - {.operation = cell_volume}, - {.operation = cell_surface_area}, - {.operation = add, .first = 0, .second = 1}, - {.operation = subtract, .first = 13, .second = 2}, - {.operation = multiply, .first = 14, .second = 3}, - {.operation = constant, .value = 2.0F}, - {.operation = divide, .first = 15, .second = 16}, - {.operation = power, .first = 8, .second = 16}, - {.operation = minimum, .first = 17, .second = 18}, - {.operation = maximum, .first = 19, .second = 3}, - {.operation = negate, .first = 9}, - {.operation = exponential, .first = 21}, - {.operation = constant, .value = 1.0F}, - {.operation = add, .first = 0, .second = 23}, - {.operation = logarithm, .first = 24}, - {.operation = less, .first = 4, .second = 7}, - {.operation = less_equal, .first = 5, .second = 6}, - {.operation = greater, .first = 7, .second = 8}, - {.operation = greater_equal, .first = 10, .second = 3}, - {.operation = equal, .first = 10, .second = 16}, - {.operation = select, .first = 30, .second = 22, .third = 25}, - {.operation = add, .first = 20, .second = 31}, - {.operation = select, .first = 26, .second = 27, .third = 28}, - {.operation = select, .first = 29, .second = 33, .third = 30}, - {.operation = divide, .first = 12, .second = 11}, - {.operation = add, .first = 35, .second = 4}, - {.operation = add, .first = 36, .second = 5}, - {.operation = add, .first = 37, .second = 6}, - {.operation = add, .first = 38, .second = 18}, - }, - {32, 34, 39}); + { + {.operation = species, .first = 0}, + {.operation = species, .first = 1}, + {.operation = species, .first = 2}, + {.operation = constant, .value = 0.25F}, + {.operation = position_x}, + {.operation = position_y}, + {.operation = position_z}, + {.operation = cell_length}, + {.operation = cell_radius}, + {.operation = growth_rate}, + {.operation = cell_type}, + {.operation = cell_volume}, + {.operation = cell_surface_area}, + {.operation = add, .first = 0, .second = 1}, + {.operation = subtract, .first = 13, .second = 2}, + {.operation = multiply, .first = 14, .second = 3}, + {.operation = constant, .value = 2.0F}, + {.operation = divide, .first = 15, .second = 16}, + {.operation = power, .first = 8, .second = 16}, + {.operation = minimum, .first = 17, .second = 18}, + {.operation = maximum, .first = 19, .second = 3}, + {.operation = negate, .first = 9}, + {.operation = exponential, .first = 21}, + {.operation = constant, .value = 1.0F}, + {.operation = add, .first = 0, .second = 23}, + {.operation = logarithm, .first = 24}, + {.operation = less, .first = 4, .second = 7}, + {.operation = less_equal, .first = 5, .second = 6}, + {.operation = greater, .first = 7, .second = 8}, + {.operation = greater_equal, .first = 10, .second = 3}, + {.operation = equal, .first = 10, .second = 16}, + {.operation = select, .first = 30, .second = 22, .third = 25}, + {.operation = add, .first = 20, .second = 31}, + {.operation = select, .first = 26, .second = 27, .third = 28}, + {.operation = select, .first = 29, .second = 33, .third = 30}, + {.operation = divide, .first = 12, .second = 11}, + {.operation = add, .first = 35, .second = 4}, + {.operation = add, .first = 36, .second = 5}, + {.operation = add, .first = 37, .second = 6}, + {.operation = add, .first = 38, .second = 18}, + }, + {32, 34, 39}); } void populate(cm::Simulation& simulation) { simulation.set_species_rate_plan(make_plan()); + for (std::size_t index = 0; index < cell_count; ++index) { cm::CellInit cell; cell.position = {static_cast(index % 19) * 0.03F, static_cast(index % 7) * -0.02F, @@ -95,11 +98,13 @@ void compare(const cm::Simulation& actual, const cm::Simulation& expected) { assert(std::abs(actual.time() - expected.time()) <= 1.0e-12); const auto actual_cells = actual.cells(); const auto expected_cells = expected.cells(); + for (std::size_t cell = 0; cell < expected_cells.size(); ++cell) { assert(actual_cells[cell].id == expected_cells[cell].id); assert(actual_cells[cell].slot == expected_cells[cell].slot); assert(close(actual_cells[cell].length, expected_cells[cell].length)); assert(actual_cells[cell].species.size() == species_count); + for (std::size_t species = 0; species < species_count; ++species) { assert(close(actual_cells[cell].species[species], expected_cells[cell].species[species])); } @@ -111,10 +116,12 @@ void run_scenario(cm::BackendKind backend, std::uint32_t device_index) { cm::Simulation candidate(backend, cell_count, species_count, device_index); populate(reference); populate(candidate); + for (const auto dt : time_steps) { reference.step(dt); candidate.step(dt); } + compare(candidate, reference); candidate.validate(); } @@ -128,17 +135,19 @@ void run_non_finite_rejection(cm::BackendKind backend, std::uint32_t device_inde using enum cm::RateOp; simulation.set_species_rate_plan( cm::SpeciesRatePlan(1, - { - {.operation = constant, .value = 0.0F}, - {.operation = divide, .first = 0, .second = 0}, - }, - {1})); + { + {.operation = constant, .value = 0.0F}, + {.operation = divide, .first = 0, .second = 0}, + }, + {1})); bool rejected = false; + try { simulation.step(0.1F); } catch (const std::domain_error&) { rejected = true; } + assert(rejected); assert(simulation.time() == 0.0); assert(simulation.cell(id).species == cell.species); @@ -149,10 +158,12 @@ void run_non_finite_rejection(cm::BackendKind backend, std::uint32_t device_inde int main() { cm::test::for_each_backend_device([](cm::BackendKind backend, std::uint32_t device_index) { cm::Simulation probe(backend, 0, 0, device_index); + if (probe.supports(cm::BackendFeature::species)) { run_scenario(backend, device_index); run_non_finite_rejection(backend, device_index); } }); + return 0; } diff --git a/tests/conformance/stokes_flow_conformance_test.cpp b/tests/conformance/stokes_flow_conformance_test.cpp new file mode 100644 index 0000000..5c9270d --- /dev/null +++ b/tests/conformance/stokes_flow_conformance_test.cpp @@ -0,0 +1,162 @@ +#include +#include +#include +#include +#include + +#include "backend_devices.hpp" +#include "cm/stokes_flow.hpp" + +namespace { +void check(bool condition, const char* message) { + if (!condition) { + throw std::runtime_error(message); + } +} + +void close(double actual, double expected, double relative, double absolute, const char* label) { + if (std::abs(actual - expected) > absolute + relative * std::abs(expected)) { + std::cerr << label << ": " << actual << " != " << expected << '\n'; + throw std::runtime_error(label); + } +} + +template +void rejects(Function&& fn) { + try { + fn(); + } catch (const std::invalid_argument&) { + return; + } + + throw std::runtime_error("expected invalid argument"); +} + +cm::FluidGridSpec grid() { + cm::FluidGridSpec g; + g.shape = {4, 8, 4}; + + return g; +} + +std::vector ports() { + return {{.name = "in", .value = 0.01}, {.name = "out", .upper = true}}; +} + +void fields(const cm::FluidFlowResult& a, const cm::FluidFlowResult& b, double factor = 1) { + for (auto [x, y] : {std::pair{&a.field.x_faces, &b.field.x_faces}, + std::pair{&a.field.y_faces, &b.field.y_faces}, + std::pair{&a.field.z_faces, &b.field.z_faces}}) { + check(x->size() == y->size(), "field shape"); + + for (std::size_t i = 0; i < x->size(); ++i) { + close((*x)[i], factor * (*y)[i], 1e-3, 3e-5, "field parity"); + } + } +} + +void run(cm::BackendKind backend, std::uint32_t index) { + auto g = grid(); + cm::FluidProperties medium; + cm::StokesFlowSolver solver(backend, index), reference; + cm::LinearSolveParameters parameters; + parameters.relative_tolerance = 1e-6; + parameters.absolute_tolerance = 1e-8; + auto p = ports(); + const auto expected = reference.solve(g, medium, p, parameters); + const auto result = solver.solve(g, medium, p, parameters); + fields(result, expected); + + for (const auto axis : {cm::FlowAxis::x, cm::FlowAxis::z}) { + auto rotated_grid = g; + rotated_grid.shape = axis == cm::FlowAxis::x ? cm::GridShape{8, 4, 4} : cm::GridShape{4, 4, 8}; + auto rotated_ports = ports(); + + for (auto& port : rotated_ports) { + port.axis = axis; + } + + const auto rotated = solver.solve(rotated_grid, medium, rotated_ports, parameters); + close(rotated.ports[1].flow_rate_m3_s, result.ports[1].flow_rate_m3_s, 1e-4, 1e-25, + "axis covariance"); + } + + check(result.ports[0].flow_rate_m3_s < 0 && result.ports[1].flow_rate_m3_s > 0, "port signs"); + check(result.report.relative_residual < 1.1e-6, "true residual"); + close(result.report.net_flow_rate_m3_s, 0, 0, std::abs(result.ports[0].flow_rate_m3_s) * 1e-4, + "fluid conservation"); + check(result.report.divergence_rms_per_s < 1e-4 * result.report.max_speed_m_s / 1e-6, + "continuity"); + + for (auto& port : p) { + port.value += 37; + } + + const auto shifted = solver.solve(g, medium, p, parameters); + fields(shifted, result); + + for (std::size_t i = 0; i < shifted.pressure_pa.size(); ++i) { + close(shifted.pressure_pa[i], result.pressure_pa[i] + 37, 0, 1e-7, "pressure offset"); + } + + medium.viscosity_pa_s *= 2; + fields(solver.solve(g, medium, ports(), parameters), result, 0.5); + medium.viscosity_pa_s /= 2; + p = ports(); + p[0].kind = cm::FlowPortKind::flow_rate; + p[0].value = result.ports[0].flow_rate_m3_s; + const auto pumped = solver.solve(g, medium, p, parameters); + fields(pumped, result); + close(pumped.ports[0].pressure_pa, 0.01, 1e-4, 1e-8, "pump pressure"); + close(pumped.ports[0].flow_rate_m3_s, p[0].value, 1e-4, 1e-25, "pump rate"); + p[1].kind = cm::FlowPortKind::flow_rate; + p[1].value = -p[0].value; + fields(solver.solve(g, medium, p, parameters), result); + p[1].value *= 0.9; + rejects([&] { + (void)solver.solve(g, medium, p); + }); + p = ports(); + p.push_back(p[0]); + p.back().name = "duplicate"; + rejects([&] { + (void)solver.solve(g, medium, p); + }); + p = ports(); + p[0].sites = {4}; + rejects([&] { + (void)solver.solve(g, medium, p); + }); + auto zero = solver.solve(g, medium, {}); + check(zero.report.max_speed_m_s == 0, "sealed rest"); + p = ports(); + p[0].value = -p[0].value; + fields(solver.solve(g, medium, p, parameters), result, -1); + // The inlet is split into two independently prescribed reservoir patches. + p = ports(); + cm::FlowPort second = p[0]; + second.name = "second"; + + for (std::uint32_t x = 0; x < 4; ++x) { + for (std::uint32_t z = 0; z < 4; ++z) { + (x < 2 ? p[0].sites : second.sites).push_back(x * 8 * 4 + z); + } + } + + p.push_back(second); + auto multi = solver.solve(g, medium, p, parameters); + fields(multi, result); + close(multi.ports[0].flow_rate_m3_s, multi.ports[2].flow_rate_m3_s, 1e-4, 1e-25, + "junction symmetry"); + parameters.memory_limit_bytes = 1; + rejects([&] { + (void)solver.solve(g, medium, p, parameters); + }); + std::cout << "media flow backend " << static_cast(backend) << " device " << index + << " passed\n"; +} +} // namespace + +int main() { + cm::test::for_each_backend_device(run); +} diff --git a/tests/conformance/trajectory_conformance_test.cpp b/tests/conformance/trajectory_conformance_test.cpp index 647efc9..1c54782 100644 --- a/tests/conformance/trajectory_conformance_test.cpp +++ b/tests/conformance/trajectory_conformance_test.cpp @@ -16,7 +16,7 @@ constexpr float signal_tolerance = 5.0e-4F; constexpr std::array time_steps{0.01F, 0.015F, 0.02F}; cm::RateInstruction operation(cm::RateOp op, std::uint32_t first = 0, std::uint32_t second = 0, - float value = 0.0F) { + float value = 0.0F) { return {.operation = op, .first = first, .second = second, .value = value}; } @@ -31,9 +31,11 @@ cm::Simulation make_simulation(cm::BackendKind backend, std::uint32_t device_ind grid.diffusion = {0.02F}; grid.advection = {{0.01F, -0.005F, 0.0F}}; std::vector levels(grid.level_count()); + for (std::size_t index = 0; index < levels.size(); ++index) { levels[index] = 0.5F + 0.001F * static_cast(index % 37); } + simulation.configure_signal_grid(grid, std::move(levels)); const std::array positions{ @@ -46,6 +48,7 @@ cm::Simulation make_simulation(cm::BackendKind backend, std::uint32_t device_ind cm::Vec3{1.0F, 0.1F, 0.0F}, cm::Vec3{0.95F, -0.1F, 0.08F}, }; + for (std::size_t index = 0; index < positions.size(); ++index) { cm::CellInit cell; cell.position = positions[index]; @@ -81,8 +84,8 @@ cm::Simulation make_simulation(cm::BackendKind backend, std::uint32_t device_ind operation(constant, 0, 0, 0.02F), operation(multiply, 0, 8), }; - simulation.set_coupled_rate_plan( - cm::CoupledRatePlan(2, 1, std::move(instructions), {6, 7}, {9})); + simulation.set_coupled_rate_plan(cm::CoupledRatePlan(2, 1, std::move(instructions), {6, 7}, {9})); + return simulation; } @@ -94,6 +97,7 @@ void compare_cells(const cm::Simulation& actual, const cm::Simulation& expected) const auto actual_cells = actual.cells(); const auto expected_cells = expected.cells(); assert(actual_cells.size() == expected_cells.size()); + for (std::size_t index = 0; index < expected_cells.size(); ++index) { const auto& left = actual_cells[index]; const auto& right = expected_cells[index]; @@ -111,6 +115,7 @@ void compare_cells(const cm::Simulation& actual, const cm::Simulation& expected) assert(close(left.length, right.length, geometry_tolerance)); assert(left.radius == right.radius); assert(left.species.size() == right.species.size()); + for (std::size_t species = 0; species < right.species.size(); ++species) { assert(close(left.species[species], right.species[species], species_tolerance)); } @@ -121,6 +126,7 @@ void compare_signals(const cm::Simulation& actual, const cm::Simulation& expecte const auto actual_levels = actual.signal_levels(); const auto expected_levels = expected.signal_levels(); assert(actual_levels.size() == expected_levels.size()); + for (std::size_t index = 0; index < expected_levels.size(); ++index) { assert(close(actual_levels[index], expected_levels[index], signal_tolerance)); } @@ -174,5 +180,6 @@ void run_trajectory(cm::BackendKind backend, std::uint32_t device_index) { int main() { cm::test::for_each_backend_device(run_trajectory); + return 0; } diff --git a/tests/cpp/checkpoint_test.cpp b/tests/cpp/checkpoint_test.cpp index d8d275e..859be30 100644 --- a/tests/cpp/checkpoint_test.cpp +++ b/tests/cpp/checkpoint_test.cpp @@ -13,15 +13,16 @@ namespace { cm::SpeciesRatePlan make_rate_plan() { using enum cm::RateOp; + return cm::SpeciesRatePlan(2, - { - {.operation = species, .first = 0}, - {.operation = species, .first = 1}, - {.operation = constant, .value = 0.125F}, - {.operation = add, .first = 0, .second = 2}, - {.operation = negate, .first = 1}, - }, - {3, 4}); + { + {.operation = species, .first = 0}, + {.operation = species, .first = 1}, + {.operation = constant, .value = 0.125F}, + {.operation = add, .first = 0, .second = 2}, + {.operation = negate, .first = 1}, + }, + {3, 4}); } void assert_cells_equal(const cm::CellSnapshot& actual, const cm::CellSnapshot& expected) { @@ -44,10 +45,13 @@ void assert_rate_plans_equal(const cm::SpeciesRatePlan& actual, const cm::SpeciesRatePlan& expected) { assert(actual.species_count() == expected.species_count()); assert(actual.outputs().size() == expected.outputs().size()); + for (std::size_t index = 0; index < actual.outputs().size(); ++index) { assert(actual.outputs()[index] == expected.outputs()[index]); } + assert(actual.instructions().size() == expected.instructions().size()); + for (std::size_t index = 0; index < actual.instructions().size(); ++index) { const auto& left = actual.instructions()[index]; const auto& right = expected.instructions()[index]; @@ -66,17 +70,22 @@ void assert_checkpoints_equal(const cm::SimulationCheckpoint& actual, assert(actual.world.species_count == expected.world.species_count); assert(actual.world.next_id == expected.world.next_id); assert(actual.world.lineage.size() == expected.world.lineage.size()); + for (std::size_t index = 0; index < actual.world.lineage.size(); ++index) { assert(actual.world.lineage[index].child == expected.world.lineage[index].child); assert(actual.world.lineage[index].parent == expected.world.lineage[index].parent); } + assert(actual.world.cells.size() == expected.world.cells.size()); + for (std::size_t index = 0; index < actual.world.cells.size(); ++index) { assert_cells_equal(actual.world.cells[index], expected.world.cells[index]); } + assert(actual.constraints.next_id == expected.constraints.next_id); assert(actual.constraints.planes.size() == expected.constraints.planes.size()); assert(actual.constraints.spheres.size() == expected.constraints.spheres.size()); + for (std::size_t index = 0; index < actual.constraints.planes.size(); ++index) { const auto& left = actual.constraints.planes[index]; const auto& right = expected.constraints.planes[index]; @@ -89,6 +98,7 @@ void assert_checkpoints_equal(const cm::SimulationCheckpoint& actual, assert(left.inward_normal.z == right.inward_normal.z); assert(left.coefficient == right.coefficient); } + for (std::size_t index = 0; index < actual.constraints.spheres.size(); ++index) { const auto& left = actual.constraints.spheres[index]; const auto& right = expected.constraints.spheres[index]; @@ -100,6 +110,7 @@ void assert_checkpoints_equal(const cm::SimulationCheckpoint& actual, assert(left.coefficient == right.coefficient); assert(left.allowed_region == right.allowed_region); } + assert_rate_plans_equal(actual.species_rate_plan, expected.species_rate_plan); } @@ -108,11 +119,13 @@ void assert_resumed_step_close(const cm::Simulation& actual, const cm::Simulatio const auto actual_cells = actual.cells(); const auto expected_cells = expected.cells(); assert(actual_cells.size() == expected_cells.size()); + for (std::size_t index = 0; index < actual_cells.size(); ++index) { assert(actual_cells[index].id == expected_cells[index].id); assert(actual_cells[index].slot == expected_cells[index].slot); assert(std::abs(actual_cells[index].length - expected_cells[index].length) <= 1.0e-6F); assert(actual_cells[index].species.size() == expected_cells[index].species.size()); + for (std::size_t species = 0; species < actual_cells[index].species.size(); ++species) { assert(std::abs(actual_cells[index].species[species] - expected_cells[index].species[species]) <= 1.0e-5F); @@ -123,11 +136,13 @@ void assert_resumed_step_close(const cm::Simulation& actual, const cm::Simulatio template void assert_invalid(Function&& function) { bool rejected = false; + try { function(); } catch (const std::invalid_argument&) { rejected = true; } + assert(rejected); } @@ -203,17 +218,25 @@ int main() { auto invalid_version = saved; invalid_version.schema_version += 1; - assert_invalid([&] { cm::Simulation rejected(cm::BackendKind::cpu, invalid_version); }); + assert_invalid([&] { + cm::Simulation rejected(cm::BackendKind::cpu, invalid_version); + }); auto invalid_slot = saved; invalid_slot.world.cells.front().slot = 1; - assert_invalid([&] { cm::Simulation rejected(cm::BackendKind::cpu, invalid_slot); }); + assert_invalid([&] { + cm::Simulation rejected(cm::BackendKind::cpu, invalid_slot); + }); auto invalid_next_id = saved; invalid_next_id.world.next_id = daughter_b; - assert_invalid([&] { cm::Simulation rejected(cm::BackendKind::cpu, invalid_next_id); }); + assert_invalid([&] { + cm::Simulation rejected(cm::BackendKind::cpu, invalid_next_id); + }); auto invalid_plan = saved; invalid_plan.species_rate_plan = cm::SpeciesRatePlan::zero(1); - assert_invalid([&] { cm::Simulation rejected(cm::BackendKind::cpu, invalid_plan); }); + assert_invalid([&] { + cm::Simulation rejected(cm::BackendKind::cpu, invalid_plan); + }); } diff --git a/tests/cpp/constraint_geometry_test.cpp b/tests/cpp/constraint_geometry_test.cpp index 50c66a8..7562c89 100644 --- a/tests/cpp/constraint_geometry_test.cpp +++ b/tests/cpp/constraint_geometry_test.cpp @@ -18,6 +18,7 @@ cm::CellId add_capsule(cm::WorldState& state, cm::Vec3 center, cm::Vec3 axis, fl cell.direction = axis; cell.length = length; cell.radius = radius; + return state.add_cell(cell); } @@ -35,21 +36,25 @@ void test_constraint_ids_and_validation() { plane.coefficient = 0.0F; bool rejected = false; + try { static_cast(constraints.add_plane(plane)); } catch (const std::invalid_argument&) { rejected = true; } + assert(rejected); plane.coefficient = 1.0F; plane.inward_normal = {}; rejected = false; + try { static_cast(constraints.add_plane(plane)); } catch (const std::invalid_argument&) { rejected = true; } + assert(rejected); } @@ -62,11 +67,13 @@ void test_external_contact_graph_rejects_invalid_location_tag() { contact.normal = {1.0F, 0.0F, 0.0F}; bool rejected = false; + try { static_cast(cm::ExternalContactGraph(1, {contact})); } catch (const std::invalid_argument&) { rejected = true; } + assert(rejected); } @@ -82,6 +89,7 @@ void test_parallel_plane_contact_uses_two_weighted_endpoints() { const auto graph = cm::find_external_contacts_cpu(state, constraints); assert(graph.size() == 2); assert(graph.incident_contact_indices(0).size() == 2); + for (const auto& contact : graph.contacts()) { assert(contact.constraint_id == plane_id); assert(contact.constraint_kind == cm::ExternalConstraintKind::plane); @@ -117,6 +125,7 @@ void test_outside_and_inside_spheres_have_typed_orientation() { const auto outside_id = outside_constraints.add_sphere(outside); const auto outside_graph = cm::find_external_contacts_cpu(outside_state, outside_constraints); assert(outside_graph.size() == 2); + for (const auto& contact : outside_graph.contacts()) { assert(contact.constraint_id == outside_id); assert(contact.constraint_kind == cm::ExternalConstraintKind::sphere); @@ -135,6 +144,7 @@ void test_outside_and_inside_spheres_have_typed_orientation() { inside_constraints.add_sphere(inside); const auto inside_graph = cm::find_external_contacts_cpu(inside_state, inside_constraints); assert(inside_graph.size() == 2); + for (const auto& contact : inside_graph.contacts()) { assert(close(contact.signed_separation, -0.3F)); assert(close(contact.normal.x, 1.0F)); @@ -151,6 +161,7 @@ void test_degenerate_sphere_normal_is_finite_and_deterministic() { constraints.add_sphere(sphere); const auto graph = cm::find_external_contacts_cpu(state, constraints); assert(graph.size() == 2); + for (const auto& contact : graph.contacts()) { assert(close(cm::norm(contact.normal), 1.0F)); assert(close(contact.normal.x, -1.0F)); @@ -189,21 +200,25 @@ void test_box_ids_validation_and_checkpoint() { box.half_extents = {1.0F, 0.0F, 1.0F}; bool rejected = false; + try { static_cast(constraints.add_box(box)); } catch (const std::invalid_argument&) { rejected = true; } + assert(rejected); box.half_extents = {1.0F, 1.0F, 1.0F}; box.coefficient = -1.0F; rejected = false; + try { static_cast(constraints.add_box(box)); } catch (const std::invalid_argument&) { rejected = true; } + assert(rejected); const auto checkpoint = constraints.checkpoint(); @@ -223,6 +238,7 @@ void test_outside_box_face_contact_uses_two_weighted_endpoints() { const auto graph = cm::find_external_contacts_cpu(state, constraints); assert(graph.size() == 2); + for (const auto& contact : graph.contacts()) { assert(contact.constraint_id == box_id); assert(contact.constraint_kind == cm::ExternalConstraintKind::box); @@ -243,6 +259,7 @@ void test_outside_box_corner_contact_has_diagonal_normal() { const auto graph = cm::find_external_contacts_cpu(state, constraints); assert(graph.size() == 2); const auto diagonal = 1.0F / std::sqrt(2.0F); + for (const auto& contact : graph.contacts()) { assert(close(contact.signed_separation, 0.3F * std::sqrt(2.0F) - 0.5F)); assert(close(contact.normal.x, -diagonal)); @@ -261,6 +278,7 @@ void test_box_interior_endpoint_escapes_toward_nearest_face() { const auto graph = cm::find_external_contacts_cpu(state, constraints); assert(graph.size() == 2); + for (const auto& contact : graph.contacts()) { assert(close(contact.signed_separation, -0.9F)); assert(close(contact.normal.y, -1.0F)); @@ -279,6 +297,7 @@ void test_inside_box_confines_like_a_chamber() { const auto graph = cm::find_external_contacts_cpu(state, constraints); assert(graph.size() == 2); + for (const auto& contact : graph.contacts()) { assert(close(contact.signed_separation, -0.3F)); assert(close(contact.normal.x, 1.0F)); @@ -295,6 +314,7 @@ void test_box_center_degeneracy_is_finite_and_deterministic() { const auto graph = cm::find_external_contacts_cpu(state, constraints); assert(graph.size() == 2); + for (const auto& contact : graph.contacts()) { assert(close(cm::norm(contact.normal), 1.0F)); assert(close(contact.normal.x, -1.0F)); @@ -353,21 +373,25 @@ void test_cylinder_ids_validation_and_checkpoint() { cylinder.radius = 0.0F; bool rejected = false; + try { static_cast(constraints.add_cylinder(cylinder)); } catch (const std::invalid_argument&) { rejected = true; } + assert(rejected); cylinder.radius = 2.0F; cylinder.half_height = -1.0F; rejected = false; + try { static_cast(constraints.add_cylinder(cylinder)); } catch (const std::invalid_argument&) { rejected = true; } + assert(rejected); const auto checkpoint = constraints.checkpoint(); @@ -387,6 +411,7 @@ void test_outside_cylinder_barrel_contact_uses_two_weighted_endpoints() { const auto graph = cm::find_external_contacts_cpu(state, constraints); assert(graph.size() == 2); + for (const auto& contact : graph.contacts()) { assert(contact.constraint_id == cylinder_id); assert(contact.constraint_kind == cm::ExternalConstraintKind::cylinder); @@ -407,6 +432,7 @@ void test_outside_cylinder_rim_contact_has_blended_normal() { const auto graph = cm::find_external_contacts_cpu(state, constraints); assert(graph.size() == 2); const auto diagonal = 1.0F / std::sqrt(2.0F); + for (const auto& contact : graph.contacts()) { assert(close(contact.signed_separation, 0.3F * std::sqrt(2.0F) - 0.5F)); assert(close(contact.normal.x, -diagonal)); @@ -424,6 +450,7 @@ void test_outside_cylinder_cap_contact_points_axially() { const auto graph = cm::find_external_contacts_cpu(state, constraints); assert(graph.size() == 2); + for (const auto& contact : graph.contacts()) { assert(close(contact.signed_separation, -0.1F)); assert(close(contact.normal.z, -1.0F)); @@ -443,6 +470,7 @@ void test_inside_cylinder_confines_like_a_dish() { const auto graph = cm::find_external_contacts_cpu(state, constraints); assert(graph.size() == 2); + for (const auto& contact : graph.contacts()) { assert(close(contact.signed_separation, -0.3F)); assert(close(contact.normal.x, 1.0F)); @@ -459,6 +487,7 @@ void test_cylinder_axis_degeneracy_is_finite_and_radial() { const auto graph = cm::find_external_contacts_cpu(state, constraints); assert(graph.size() == 2); + for (const auto& contact : graph.contacts()) { assert(close(cm::norm(contact.normal), 1.0F)); assert(close(contact.normal.x, -1.0F)); @@ -529,5 +558,6 @@ int main() { test_cylinder_axis_degeneracy_is_finite_and_radial(); test_outside_cylinder_detects_midspan_capsule_contact(); test_simulation_exposes_cpu_constraint_graph(); + return 0; } diff --git a/tests/cpp/contact_geometry_test.cpp b/tests/cpp/contact_geometry_test.cpp index ea3276e..32a1620 100644 --- a/tests/cpp/contact_geometry_test.cpp +++ b/tests/cpp/contact_geometry_test.cpp @@ -14,13 +14,14 @@ bool close(float actual, float expected, float tolerance = 1.0e-5F) { return std::abs(actual - expected) <= tolerance; } -cm::CellId add_capsule(cm::WorldState& state, cm::Vec3 center, cm::Vec3 axis, - float length = 2.0F, float radius = 0.5F) { +cm::CellId add_capsule(cm::WorldState& state, cm::Vec3 center, cm::Vec3 axis, float length = 2.0F, + float radius = 0.5F) { cm::CellInit cell; cell.position = center; cell.direction = axis; cell.length = length; cell.radius = radius; + return state.add_cell(cell); } @@ -65,11 +66,13 @@ void test_parallel_overlap_uses_two_weighted_contacts() { assert(contacts[1].ordinal == 1); assert(close(contacts[0].point_on_first.x, -2.0F)); assert(close(contacts[1].point_on_first.x, 2.0F)); + for (const auto& contact : contacts) { assert(close(contact.signed_separation, -0.2F)); assert(close(contact.normal.y, 1.0F)); assert(close(contact.weight, std::sqrt(0.5F))); } + assert(graph.incident_contact_indices(0).size() == 2); assert(graph.incident_contact_indices(1).size() == 2); assert(graph.neighbor_ids(0).size() == 1); @@ -81,8 +84,7 @@ void test_parallel_overlap_uses_two_weighted_contacts() { void test_neighbors_use_sorted_stable_ids_after_division() { cm::WorldState state; const auto parent = add_capsule(state, {0.0F, 0.0F, 0.0F}, {1.0F, 0.0F, 0.0F}, 4.0F); - const auto neighbor = - add_capsule(state, {0.0F, 0.8F, 0.0F}, {1.0F, 0.0F, 0.0F}, 4.0F); + const auto neighbor = add_capsule(state, {0.0F, 0.8F, 0.0F}, {1.0F, 0.0F, 0.0F}, 4.0F); const auto [first_daughter, second_daughter] = state.divide_equal(parent); const auto graph = cm::find_cell_contacts_cpu(state); @@ -102,11 +104,13 @@ void test_neighbors_use_sorted_stable_ids_after_division() { void test_neighbor_lookup_checks_slot_bounds() { const cm::ContactGraph graph(0, {}); bool rejected = false; + try { static_cast(graph.neighbor_ids(0)); } catch (const std::out_of_range&) { rejected = true; } + assert(rejected); } @@ -125,6 +129,7 @@ void test_skew_and_coincident_contacts_have_finite_normals() { const auto graph = cm::find_cell_contacts_cpu(coincident); const auto contacts = graph.contacts(); assert(contacts.size() == 2); + for (const auto& contact : contacts) { assert(std::isfinite(contact.normal.x)); assert(std::isfinite(contact.normal.y)); @@ -137,6 +142,7 @@ void test_skew_and_coincident_contacts_have_finite_normals() { void test_contact_graph_has_no_per_cell_limit() { cm::WorldState state; constexpr std::size_t capsule_count = 31; + for (std::size_t index = 0; index < capsule_count; ++index) { add_capsule(state, {0.0F, 0.0F, 0.0F}, {1.0F, 0.0F, 0.0F}); } @@ -151,20 +157,24 @@ void test_contact_graph_has_no_per_cell_limit() { void test_sweep_and_prune_stages_only_overlapping_bounds() { cm::WorldState sparse; constexpr std::size_t capsule_count = 2048; + for (std::size_t index = 0; index < capsule_count; ++index) { - add_capsule(sparse, {static_cast(index) * 10.0F, 0.0F, 0.0F}, - {1.0F, 0.0F, 0.0F}); + add_capsule(sparse, {static_cast(index) * 10.0F, 0.0F, 0.0F}, {1.0F, 0.0F, 0.0F}); } + assert(cm::find_cell_contact_candidates(sparse).empty()); assert(cm::find_cell_contacts_cpu(sparse).empty()); cm::WorldState dense; + for (std::size_t index = 0; index < 31; ++index) { add_capsule(dense, {0.0F, 0.0F, 0.0F}, {1.0F, 0.0F, 0.0F}); } + const auto candidates = cm::find_cell_contact_candidates(dense); const auto geometry = dense.geometry_state(); assert(candidates.size() == 31 * 30 / 2); + for (const auto& candidate : candidates) { assert(geometry.ids[candidate.first_slot] < geometry.ids[candidate.second_slot]); } @@ -173,6 +183,7 @@ void test_sweep_and_prune_stages_only_overlapping_bounds() { void test_sweep_and_prune_matches_exhaustive_oracle() { cm::WorldState state; constexpr std::size_t capsule_count = 257; + for (std::size_t index = 0; index < capsule_count; ++index) { const auto x = static_cast((index * 37) % 101) * 0.21F; const auto y = static_cast((index * 53) % 89) * 0.19F; @@ -191,6 +202,7 @@ void test_sweep_and_prune_matches_exhaustive_oracle() { const auto actual = cm::find_cell_contacts_cpu(state, parameters); const auto expected = cm::find_cell_contacts_cpu_exhaustive(state, parameters); assert(actual.size() == expected.size()); + for (std::size_t index = 0; index < expected.size(); ++index) { assert(actual.contacts()[index].first_id == expected.contacts()[index].first_id); assert(actual.contacts()[index].second_id == expected.contacts()[index].second_id); @@ -210,6 +222,7 @@ void test_contacts_are_sorted_by_stable_identity() { const auto graph = cm::find_cell_contacts_cpu(state); const auto contacts = graph.contacts(); auto previous = std::tuple{cm::invalid_cell_id, cm::invalid_cell_id, std::uint8_t{0}}; + for (const auto& contact : contacts) { const auto key = std::tuple{contact.first_id, contact.second_id, contact.ordinal}; assert(contact.first_id < contact.second_id); @@ -230,6 +243,7 @@ void test_pair_order_reverses_the_contact_normal() { const auto reversed_graph = cm::find_cell_contacts_cpu(reversed); assert(forward_graph.size() == reversed_graph.size()); + for (std::size_t index = 0; index < forward_graph.size(); ++index) { const auto& forward_contact = forward_graph.contacts()[index]; const auto& reversed_contact = reversed_graph.contacts()[index]; @@ -246,11 +260,13 @@ void test_invalid_parameters_are_rejected() { cm::ContactParameters parameters; parameters.parallel_sine_threshold = 2.0F; bool rejected = false; + try { static_cast(cm::find_cell_contacts_cpu(state, parameters)); } catch (const std::invalid_argument&) { rejected = true; } + assert(rejected); } @@ -284,5 +300,6 @@ int main() { test_pair_order_reverses_the_contact_normal(); test_invalid_parameters_are_rejected(); test_simulation_exposes_the_backend_contact_contract(); + return 0; } diff --git a/tests/cpp/coupled_rates_test.cpp b/tests/cpp/coupled_rates_test.cpp index 3ef6285..7552ad6 100644 --- a/tests/cpp/coupled_rates_test.cpp +++ b/tests/cpp/coupled_rates_test.cpp @@ -12,13 +12,14 @@ bool close(float left, float right, float tolerance = 1.0e-6F) { } cm::SignalGridSpec grid_spec(cm::GridShape shape, cm::Vec3 spacing = {1.0F, 1.0F, 1.0F}, - float diffusion = 0.0F) { + float diffusion = 0.0F) { cm::SignalGridSpec spec; spec.signal_count = 1; spec.shape = shape; spec.spacing = spacing; spec.diffusion = {diffusion}; spec.advection = {{0.0F, 0.0F, 0.0F}}; + return spec; } @@ -33,11 +34,13 @@ cm::RateInstruction signal(std::uint32_t index) { template void assert_throws(Function&& function) { bool rejected = false; + try { function(); } catch (const Exception&) { rejected = true; } + assert(rejected); } @@ -57,10 +60,12 @@ void test_sample_and_scatter_share_trilinear_weights() { assert(close(simulation.cell(id).species[0], 12.0F)); float grid_amount = 0.0F; + for (const auto level : simulation.signal_levels()) { assert(close(level, 3.9375F)); grid_amount += level * 2.0F; } + assert(close(grid_amount, 63.0F)); assert(simulation.supports(cm::BackendFeature::coupled_rates)); } @@ -117,7 +122,9 @@ void test_invalid_position_is_rejected_before_growth() { const auto id = simulation.add_cell(cell); simulation.set_coupled_rate_plan(cm::CoupledRatePlan(1, 1, {constant(0.0F)}, {0}, {0})); - assert_throws([&] { simulation.step(0.25F); }); + assert_throws([&] { + simulation.step(0.25F); + }); assert(close(simulation.cell(id).length, 2.0F)); assert(close(simulation.cell(id).species[0], 3.0F)); assert(simulation.signal_levels() == std::vector({1.0F, 1.0F})); @@ -145,10 +152,12 @@ void test_coupled_plan_is_exact_checkpoint_state() { } void test_signal_inputs_are_reserved_for_coupled_plans() { - assert_throws( - [&] { static_cast(cm::SpeciesRatePlan(1, {signal(0)}, {0})); }); - assert_throws( - [&] { static_cast(cm::CoupledRatePlan(1, 1, {signal(1)}, {0}, {0})); }); + assert_throws([&] { + static_cast(cm::SpeciesRatePlan(1, {signal(0)}, {0})); + }); + assert_throws([&] { + static_cast(cm::CoupledRatePlan(1, 1, {signal(1)}, {0}, {0})); + }); } } // namespace @@ -160,5 +169,6 @@ int main() { test_invalid_position_is_rejected_before_growth(); test_coupled_plan_is_exact_checkpoint_state(); test_signal_inputs_are_reserved_for_coupled_plans(); + return 0; } diff --git a/tests/cpp/flow_test.cpp b/tests/cpp/flow_test.cpp index 8a5ee3a..480b674 100644 --- a/tests/cpp/flow_test.cpp +++ b/tests/cpp/flow_test.cpp @@ -21,6 +21,7 @@ cm::SignalGridSpec duct(std::uint32_t nx, std::uint32_t ny, std::uint32_t nz, spec.advection = {{0.0F, 0.0F, 0.0F}}; spec.y_lower = {.kind = cm::GridBoundaryKind::fixed, .values = {0.0F}}; spec.y_upper = {.kind = cm::GridBoundaryKind::fixed, .values = {0.0F}}; + return spec; } @@ -32,11 +33,13 @@ std::size_t y_face(const cm::SignalGridSpec& spec, std::uint32_t x, std::uint32_ template void assert_throws(Function&& function) { bool rejected = false; + try { function(); } catch (const Exception&) { rejected = true; } + assert(rejected); } @@ -50,14 +53,23 @@ int main() { std::vector drag(spec.site_count(), 0.3F); const cm::detail::ResolvedFlowSystem system(spec, drag, cm::FlowAxis::y); std::vector u(system.active().size()), v(u.size()), p(spec.site_count()); + for (std::size_t i = 0; i < u.size(); ++i) { u[i] = system.active()[i] ? std::sin(static_cast(i)) : 0; v[i] = system.active()[i] ? std::cos(static_cast(i) * 0.7) : 0; } - for (std::size_t i = 0; i < p.size(); ++i) p[i] = std::sin(static_cast(i) * 0.3); + + for (std::size_t i = 0; i < p.size(); ++i) { + p[i] = std::sin(static_cast(i) * 0.3); + } + const auto dot = [](const auto& a, const auto& b) { double sum = 0; - for (std::size_t i = 0; i < a.size(); ++i) sum += a[i] * b[i]; + + for (std::size_t i = 0; i < a.size(); ++i) { + sum += a[i] * b[i]; + } + return sum; }; const auto gp = system.gradient(p), du = system.divergence(u); @@ -76,22 +88,27 @@ int main() { assert(simulation.supports(cm::BackendFeature::depth_averaged_flow)); assert(result.report.iterations > 0); assert(result.report.relative_residual <= 1.0e-6F); - assert(std::ranges::all_of(result.field.y_faces, - [](float value) { return std::abs(value - 5.0F) <= 1.0e-5F; })); - assert(std::ranges::all_of(result.field.x_faces, - [](float value) { return std::abs(value) <= 2.0e-5F; })); - assert(std::ranges::all_of(result.field.z_faces, - [](float value) { return std::abs(value) <= 2.0e-5F; })); + assert(std::ranges::all_of(result.field.y_faces, [](float value) { + return std::abs(value - 5.0F) <= 1.0e-5F; + })); + assert(std::ranges::all_of(result.field.x_faces, [](float value) { + return std::abs(value) <= 2.0e-5F; + })); + assert(std::ranges::all_of(result.field.z_faces, [](float value) { + return std::abs(value) <= 2.0e-5F; + })); } { const auto spec = duct(2, 6, 1); std::vector mobility(spec.site_count()); + for (std::uint32_t x = 0; x < spec.shape.x; ++x) { for (std::uint32_t y = 0; y < spec.shape.y; ++y) { mobility[(static_cast(x) * spec.shape.y) + y] = x == 0 ? 1.0F : 3.0F; } } + const auto result = cm::solve_depth_averaged_flow_cpu( spec, mobility, {.mean_inlet_speed = 4.0F, .axis = cm::FlowAxis::y}); const auto slow = result.field.y_faces[y_face(spec, 0, 3, 0)]; @@ -107,12 +124,14 @@ int main() { spec, {}, {.mean_inlet_speed = 1.0F, .axis = cm::FlowAxis::y}); assert(simulation.supports(cm::BackendFeature::resolved_flow)); float max_error = 0.0F; + for (std::uint32_t x = 0; x < nx; ++x) { const auto position = (static_cast(x) + 0.5F) / static_cast(nx); const auto exact = 6.0F * position * (1.0F - position); max_error = std::max(max_error, std::abs(result.field.y_faces[y_face(spec, x, 3, 0)] - exact)); } + assert(max_error / 1.5F < 0.02F); assert(result.report.outer_iterations > 0); assert(result.report.inner_iterations > 0); @@ -123,12 +142,16 @@ int main() { { auto spec = duct(3, 4, 1); spec.obstacles.assign(spec.site_count(), 0); + for (std::uint32_t x = 0; x < spec.shape.x; ++x) { spec.obstacles[(static_cast(x) * spec.shape.y) + 2] = 1; } - assert_throws( - [&] { static_cast(cm::solve_depth_averaged_flow_cpu(spec, {})); }); - assert_throws( - [&] { static_cast(cm::solve_resolved_flow_cpu(spec, {})); }); + + assert_throws([&] { + static_cast(cm::solve_depth_averaged_flow_cpu(spec, {})); + }); + assert_throws([&] { + static_cast(cm::solve_resolved_flow_cpu(spec, {})); + }); } } diff --git a/tests/cpp/mechanics_integration_test.cpp b/tests/cpp/mechanics_integration_test.cpp index 1d693f6..062dd87 100644 --- a/tests/cpp/mechanics_integration_test.cpp +++ b/tests/cpp/mechanics_integration_test.cpp @@ -54,11 +54,13 @@ void test_validation_is_atomic_and_requires_convergence() { invalid.corrections = {cm::CellCorrection{}}; invalid.corrections[0].translation.x = std::numeric_limits::quiet_NaN(); bool rejected = false; + try { cm::integrate_mechanics_result(state, invalid); } catch (const std::invalid_argument&) { rejected = true; } + assert(rejected); assert(close(state.cell(id).position.x, 0.0F)); @@ -66,11 +68,13 @@ void test_validation_is_atomic_and_requires_convergence() { unconverged.corrections = {cm::CellCorrection{}}; unconverged.report.status = cm::SolverStatus::iteration_limit; rejected = false; + try { cm::integrate_mechanics_result(state, unconverged); } catch (const std::runtime_error&) { rejected = true; } + assert(rejected); assert(close(state.cell(id).position.x, 0.0F)); } @@ -144,5 +148,6 @@ int main() { test_validation_is_atomic_and_requires_convergence(); test_fixed_cell_integration_only_applies_declared_growth(); test_simulation_relaxation_reduces_penetration(); + return 0; } diff --git a/tests/cpp/mechanics_operator_test.cpp b/tests/cpp/mechanics_operator_test.cpp index b20cf39..21d2f89 100644 --- a/tests/cpp/mechanics_operator_test.cpp +++ b/tests/cpp/mechanics_operator_test.cpp @@ -14,35 +14,40 @@ bool close(float actual, float expected, float tolerance = 1.0e-5F) { return std::abs(actual - expected) <= tolerance; } -cm::CellId add_capsule(cm::WorldState& state, cm::Vec3 center, cm::Vec3 axis, - float length = 4.0F, float radius = 0.5F) { +cm::CellId add_capsule(cm::WorldState& state, cm::Vec3 center, cm::Vec3 axis, float length = 4.0F, + float radius = 0.5F) { cm::CellInit cell; cell.position = center; cell.direction = axis; cell.length = length; cell.radius = radius; + return state.add_cell(cell); } float correction_dot(const std::vector& left, const std::vector& right) { float result = 0.0F; + for (std::size_t index = 0; index < left.size(); ++index) { result += cm::dot(left[index].translation, right[index].translation); result += cm::dot(left[index].rotation, right[index].rotation); result += left[index].length * right[index].length; } + return result; } std::vector subtract(const std::vector& left, - const std::vector& right) { + const std::vector& right) { auto result = left; + for (std::size_t index = 0; index < result.size(); ++index) { result[index].translation = left[index].translation - right[index].translation; result[index].rotation = left[index].rotation - right[index].rotation; result[index].length = left[index].length - right[index].length; } + return result; } @@ -50,11 +55,11 @@ float residual_rms(const std::vector& residual) { if (residual.empty()) { return 0.0F; } + return std::sqrt(correction_dot(residual, residual) / static_cast(residual.size())); } -void assert_correction_close(const cm::CellCorrection& actual, - const cm::CellCorrection& expected) { +void assert_correction_close(const cm::CellCorrection& actual, const cm::CellCorrection& expected) { assert(close(actual.translation.x, expected.translation.x)); assert(close(actual.translation.y, expected.translation.y)); assert(close(actual.translation.z, expected.translation.z)); @@ -242,18 +247,19 @@ void test_invalid_inputs_are_rejected() { cm::MechanicsParameters parameters; parameters.gamma = 0.0F; bool rejected = false; + try { static_cast(cm::solve_cell_mechanics_cpu(state, cm::ContactGraph(1, {}), parameters)); } catch (const std::invalid_argument&) { rejected = true; } + assert(rejected); } void test_fixed_cells_are_projected_out_of_cpu_mechanics() { cm::WorldState state; - const auto fixed_id = - add_capsule(state, {0.0F, 0.0F, 0.0F}, {1.0F, 0.0F, 0.0F}); + const auto fixed_id = add_capsule(state, {0.0F, 0.0F, 0.0F}, {1.0F, 0.0F, 0.0F}); add_capsule(state, {0.0F, 0.8F, 0.0F}, {1.0F, 0.0F, 0.0F}); state.set_cell_fixed(fixed_id, true); const auto contacts = cm::find_cell_contacts_cpu(state); @@ -310,5 +316,6 @@ int main() { test_invalid_inputs_are_rejected(); test_fixed_cells_are_projected_out_of_cpu_mechanics(); test_simulation_exposes_cpu_mechanics_capability(); + return 0; } diff --git a/tests/cpp/signal_grid_test.cpp b/tests/cpp/signal_grid_test.cpp index c46df86..828ca85 100644 --- a/tests/cpp/signal_grid_test.cpp +++ b/tests/cpp/signal_grid_test.cpp @@ -16,564 +16,648 @@ cm::SignalGridSpec line_spec(std::uint32_t length) { spec.shape = {.x = length, .y = 1, .z = 1}; spec.diffusion = {1.0F}; spec.advection = {{0.0F, 0.0F, 0.0F}}; + return spec; } template void assert_throws(Function&& function) { bool rejected = false; + try { function(); } catch (const Exception&) { rejected = true; } + assert(rejected); } void assert_close(float actual, float expected, const std::source_location location = std::source_location::current()) { - if (std::abs(actual - expected) > 1.0e-6F) - std::fprintf(stderr, "line %u: actual %.9g, expected %.9g\n", location.line(), actual, expected); + if (std::abs(actual - expected) > 1.0e-6F) { + std::fprintf(stderr, "line %u: actual %.9g, expected %.9g\n", location.line(), actual, + expected); + } + assert(std::abs(actual - expected) <= 1.0e-6F); } } // namespace -int main() { - { - auto spec = line_spec(1); - spec.diffusion = {0.0F}; - spec.reaction = cm::SignalGridAffineReaction{ - .source_rates = {2.0F}, - .loss_rates = {0.5F}, - }; - cm::SignalGrid grid(spec, {1.0F}); - static_cast(cm::advance_signal_grid_cpu(grid, 0.5F)); - assert_close(grid.levels()[0], 1.75F); - - auto unstable = spec; - unstable.reaction->loss_rates = {2.0F}; - cm::SignalGrid unstable_grid(unstable, {1.0F}); - assert_throws( - [&] { static_cast(cm::advance_signal_grid_cpu(unstable_grid, 0.51F)); }); - } +namespace { - { - auto spec = line_spec(1); - spec.diffusion = {0.0F}; - spec.integration = cm::SignalIntegrationKind::crank_nicolson; - spec.reaction = cm::SignalGridAffineReaction{ - .source_rates = {2.0F}, - .loss_rates = {0.5F}, - }; - cm::SignalGrid grid(spec, {1.0F}); - const auto report = cm::advance_signal_grid_cpu(grid, 1.0F); - assert(report.converged); - assert_close(grid.levels()[0], 2.2F); - } +void test_affine_euler_stability() { + auto spec = line_spec(1); + spec.diffusion = {0.0F}; + spec.reaction = cm::SignalGridAffineReaction{ + .source_rates = {2.0F}, + .loss_rates = {0.5F}, + }; + cm::SignalGrid grid(spec, {1.0F}); + static_cast(cm::advance_signal_grid_cpu(grid, 0.5F)); + assert_close(grid.levels()[0], 1.75F); + + auto unstable = spec; + unstable.reaction->loss_rates = {2.0F}; + cm::SignalGrid unstable_grid(unstable, {1.0F}); + assert_throws([&] { + static_cast(cm::advance_signal_grid_cpu(unstable_grid, 0.51F)); + }); +} - { - auto invalid = line_spec(2); - invalid.reaction = cm::SignalGridAffineReaction{ - .source_rates = {1.0F}, - .loss_rates = {0.0F, 0.0F}, - }; - assert_throws([&] { invalid.validate(); }); - invalid.reaction->source_rates = {1.0F, -1.0F}; - assert_throws([&] { invalid.validate(); }); - invalid.reaction->source_rates = {1.0F, 1.0F}; - invalid.reaction->loss_rates = {0.0F, -1.0F}; - assert_throws([&] { invalid.validate(); }); - } +void test_affine_crank_nicolson() { + auto spec = line_spec(1); + spec.diffusion = {0.0F}; + spec.integration = cm::SignalIntegrationKind::crank_nicolson; + spec.reaction = cm::SignalGridAffineReaction{ + .source_rates = {2.0F}, + .loss_rates = {0.5F}, + }; + cm::SignalGrid grid(spec, {1.0F}); + const auto report = cm::advance_signal_grid_cpu(grid, 1.0F); + assert(report.converged); + assert_close(grid.levels()[0], 2.2F); +} - { - cm::SignalGrid grid(line_spec(3), {0.0F, 1.0F, 0.0F}); - static_cast(cm::advance_signal_grid_cpu(grid, 0.25F)); - const auto levels = grid.levels(); - assert_close(levels[0], 0.25F); - assert_close(levels[1], 0.5F); - assert_close(levels[2], 0.25F); - assert_close(levels[0] + levels[1] + levels[2], 1.0F); - - const std::vector before(levels.begin(), levels.end()); - assert_throws( - [&] { static_cast(cm::advance_signal_grid_cpu(grid, 0.51F)); }); - assert(std::vector(grid.levels().begin(), grid.levels().end()) == before); - } +void test_reaction_validation() { + auto invalid = line_spec(2); + invalid.reaction = cm::SignalGridAffineReaction{ + .source_rates = {1.0F}, + .loss_rates = {0.0F, 0.0F}, + }; + assert_throws([&] { + invalid.validate(); + }); + invalid.reaction->source_rates = {1.0F, -1.0F}; + assert_throws([&] { + invalid.validate(); + }); + invalid.reaction->source_rates = {1.0F, 1.0F}; + invalid.reaction->loss_rates = {0.0F, -1.0F}; + assert_throws([&] { + invalid.validate(); + }); +} - { - auto spec = line_spec(3); - spec.integration = cm::SignalIntegrationKind::crank_nicolson; - spec.solver.absolute_tolerance = 1.0e-7F; - spec.solver.relative_tolerance = 1.0e-6F; - cm::SignalGrid grid(spec, {0.0F, 1.0F, 0.0F}); - const auto report = cm::advance_signal_grid_cpu(grid, 1.0F); - assert(report.converged); - assert(report.iterations > 0); - assert(report.residual_rms <= 2.0e-6F); - assert_close(grid.levels()[0], 0.4F); - assert_close(grid.levels()[1], 0.2F); - assert_close(grid.levels()[2], 0.4F); - } +void test_explicit_diffusion_conservation() { + cm::SignalGrid grid(line_spec(3), {0.0F, 1.0F, 0.0F}); + static_cast(cm::advance_signal_grid_cpu(grid, 0.25F)); + const auto levels = grid.levels(); + assert_close(levels[0], 0.25F); + assert_close(levels[1], 0.5F); + assert_close(levels[2], 0.25F); + assert_close(levels[0] + levels[1] + levels[2], 1.0F); + + const std::vector before(levels.begin(), levels.end()); + assert_throws([&] { + static_cast(cm::advance_signal_grid_cpu(grid, 0.51F)); + }); + assert(std::vector(grid.levels().begin(), grid.levels().end()) == before); +} - { - auto spec = line_spec(9); - spec.integration = cm::SignalIntegrationKind::crank_nicolson; - spec.solver.max_iterations = 1; - spec.solver.absolute_tolerance = 1.0e-12F; - spec.solver.relative_tolerance = 0.0F; - cm::SignalGrid grid(spec, {0.0F, 0.0F, 0.0F, 0.0F, 1.0F, 0.0F, 0.0F, 0.0F, 0.0F}); - const auto result = cm::signal_grid_crank_nicolson_candidate(grid, 2.0F); - assert(!result.report.converged); - assert(result.report.iterations == 1); - assert_throws( - [&] { static_cast(cm::advance_signal_grid_cpu(grid, 2.0F)); }); - } +void test_implicit_diffusion() { + auto spec = line_spec(3); + spec.integration = cm::SignalIntegrationKind::crank_nicolson; + spec.solver.absolute_tolerance = 1.0e-7F; + spec.solver.relative_tolerance = 1.0e-6F; + cm::SignalGrid grid(spec, {0.0F, 1.0F, 0.0F}); + const auto report = cm::advance_signal_grid_cpu(grid, 1.0F); + assert(report.converged); + assert(report.iterations > 0); + assert(report.residual_rms <= 2.0e-6F); + assert_close(grid.levels()[0], 0.4F); + assert_close(grid.levels()[1], 0.2F); + assert_close(grid.levels()[2], 0.4F); +} - { - auto spec = line_spec(2); - spec.x_lower.kind = cm::GridBoundaryKind::fixed; - spec.x_lower.values = {2.0F}; - cm::SignalGrid grid(spec, {0.0F, 0.0F}); - static_cast(cm::advance_signal_grid_cpu(grid, 0.25F)); - assert_close(grid.levels()[0], 0.5F); - assert_close(grid.levels()[1], 0.0F); - } +void test_implicit_nonconvergence() { + auto spec = line_spec(9); + spec.integration = cm::SignalIntegrationKind::crank_nicolson; + spec.solver.max_iterations = 1; + spec.solver.absolute_tolerance = 1.0e-12F; + spec.solver.relative_tolerance = 0.0F; + cm::SignalGrid grid(spec, {0.0F, 0.0F, 0.0F, 0.0F, 1.0F, 0.0F, 0.0F, 0.0F, 0.0F}); + const auto result = cm::signal_grid_crank_nicolson_candidate(grid, 2.0F); + assert(!result.report.converged); + assert(result.report.iterations == 1); + assert_throws([&] { + static_cast(cm::advance_signal_grid_cpu(grid, 2.0F)); + }); +} - { - auto spec = line_spec(4); - spec.diffusion = {0.0F}; - spec.advection = {{1.0F, 0.0F, 0.0F}}; - spec.x_lower.kind = cm::GridBoundaryKind::periodic; - spec.x_upper.kind = cm::GridBoundaryKind::periodic; - cm::SignalGrid grid(spec, {1.0F, 0.0F, 0.0F, 0.0F}); - static_cast(cm::advance_signal_grid_cpu(grid, 0.5F)); - assert_close(grid.levels()[0], 0.5F); - assert_close(grid.levels()[1], 0.5F); - assert_close(grid.levels()[2], 0.0F); - assert_close(grid.levels()[3], 0.0F); - } +void test_fixed_reservoir_diffusion() { + auto spec = line_spec(2); + spec.x_lower.kind = cm::GridBoundaryKind::fixed; + spec.x_lower.values = {2.0F}; + cm::SignalGrid grid(spec, {0.0F, 0.0F}); + static_cast(cm::advance_signal_grid_cpu(grid, 0.25F)); + assert_close(grid.levels()[0], 0.5F); + assert_close(grid.levels()[1], 0.0F); +} - { - auto spec = line_spec(4); - spec.diffusion = {0.0F}; - spec.advection = {{1.0F, 0.0F, 0.0F}}; - cm::SignalGrid grid(spec, {0.0F, 0.0F, 0.0F, 1.0F}); - static_cast(cm::advance_signal_grid_cpu(grid, 0.5F)); - assert_close(grid.levels()[0], 0.0F); - assert_close(grid.levels()[1], 0.0F); - assert_close(grid.levels()[2], 0.0F); - assert_close(grid.levels()[3], 1.0F); - } +void test_periodic_advection() { + auto spec = line_spec(4); + spec.diffusion = {0.0F}; + spec.advection = {{1.0F, 0.0F, 0.0F}}; + spec.x_lower.kind = cm::GridBoundaryKind::periodic; + spec.x_upper.kind = cm::GridBoundaryKind::periodic; + cm::SignalGrid grid(spec, {1.0F, 0.0F, 0.0F, 0.0F}); + static_cast(cm::advance_signal_grid_cpu(grid, 0.5F)); + assert_close(grid.levels()[0], 0.5F); + assert_close(grid.levels()[1], 0.5F); + assert_close(grid.levels()[2], 0.0F); + assert_close(grid.levels()[3], 0.0F); +} - { - cm::SignalGridSpec spec; - spec.signal_count = 1; - spec.shape = {.x = 2, .y = 2, .z = 2}; - spec.diffusion = {0.0F}; - spec.advection = {{0.0F, 0.0F, 0.0F}}; - cm::SignalGrid grid(spec, {0.0F, 1.0F, 2.0F, 3.0F, 4.0F, 5.0F, 6.0F, 7.0F}); - assert_close(grid.sample({0.5F, 0.5F, 0.5F})[0], 3.5F); - assert_close(grid.sample({1.0F, 1.0F, 1.0F})[0], 7.0F); - assert_throws([&] { static_cast(grid.sample({1.01F, 0.0F, 0.0F})); }); - - cm::SignalGrid reduced(line_spec(2), {2.0F, 4.0F}); - assert_close(reduced.sample({0.5F, 99.0F, -37.0F})[0], 3.0F); - } +void test_closed_boundary_advection() { + auto spec = line_spec(4); + spec.diffusion = {0.0F}; + spec.advection = {{1.0F, 0.0F, 0.0F}}; + cm::SignalGrid grid(spec, {0.0F, 0.0F, 0.0F, 1.0F}); + static_cast(cm::advance_signal_grid_cpu(grid, 0.5F)); + assert_close(grid.levels()[0], 0.0F); + assert_close(grid.levels()[1], 0.0F); + assert_close(grid.levels()[2], 0.0F); + assert_close(grid.levels()[3], 1.0F); +} - { - auto invalid = line_spec(3); - invalid.x_lower.kind = cm::GridBoundaryKind::periodic; - assert_throws([&] { invalid.validate(); }); - invalid.x_upper.kind = cm::GridBoundaryKind::periodic; - invalid.diffusion = {-1.0F}; - assert_throws([&] { invalid.validate(); }); - } +void test_trilinear_sampling() { + cm::SignalGridSpec spec; + spec.signal_count = 1; + spec.shape = {.x = 2, .y = 2, .z = 2}; + spec.diffusion = {0.0F}; + spec.advection = {{0.0F, 0.0F, 0.0F}}; + cm::SignalGrid grid(spec, {0.0F, 1.0F, 2.0F, 3.0F, 4.0F, 5.0F, 6.0F, 7.0F}); + assert_close(grid.sample({0.5F, 0.5F, 0.5F})[0], 3.5F); + assert_close(grid.sample({1.0F, 1.0F, 1.0F})[0], 7.0F); + assert_throws([&] { + static_cast(grid.sample({1.01F, 0.0F, 0.0F})); + }); + + cm::SignalGrid reduced(line_spec(2), {2.0F, 4.0F}); + assert_close(reduced.sample({0.5F, 99.0F, -37.0F})[0], 3.0F); +} - { - cm::Simulation simulation; - const auto spec = line_spec(3); - simulation.configure_signal_grid(spec, {0.0F, 1.0F, 0.0F}); - assert(simulation.has_signal_grid()); - assert(simulation.signal_count() == 1); - assert(simulation.supports(cm::BackendFeature::signals)); - simulation.step(0.25F); - assert_close(simulation.signal_levels()[0], 0.25F); - assert(simulation.time() == 0.25); - const auto checkpoint = simulation.checkpoint(); - assert(checkpoint.signal_grid.has_value()); - assert(checkpoint.signal_grid->levels == simulation.signal_levels()); - - cm::Simulation restored(cm::BackendKind::cpu, checkpoint); - assert(restored.signal_levels() == simulation.signal_levels()); - assert_close(restored.sample_signals({1.0F, 0.0F, 0.0F})[0], 0.5F); - - cm::CellInit cell; - restored.add_cell(cell); - assert_throws([&] { restored.configure_signal_grid(spec); }); - } +void test_periodic_boundary_validation() { + auto invalid = line_spec(3); + invalid.x_lower.kind = cm::GridBoundaryKind::periodic; + assert_throws([&] { + invalid.validate(); + }); + invalid.x_upper.kind = cm::GridBoundaryKind::periodic; + invalid.diffusion = {-1.0F}; + assert_throws([&] { + invalid.validate(); + }); +} - { - auto spec = line_spec(3); - spec.advection = {{0.5F, 0.0F, 0.0F}}; - spec.obstacles = {0, 1, 0}; - cm::SignalGrid grid(spec, {1.0F, 0.0F, 0.5F}); - static_cast(cm::advance_signal_grid_cpu(grid, 0.25F)); - assert_close(grid.levels()[0], 1.0F); - assert_close(grid.levels()[1], 0.0F); - assert_close(grid.levels()[2], 0.5F); - } +void test_simulation_signals_checkpoint() { + cm::Simulation simulation; + const auto spec = line_spec(3); + simulation.configure_signal_grid(spec, {0.0F, 1.0F, 0.0F}); + assert(simulation.has_signal_grid()); + assert(simulation.signal_count() == 1); + assert(simulation.supports(cm::BackendFeature::signals)); + simulation.step(0.25F); + assert_close(simulation.signal_levels()[0], 0.25F); + assert(simulation.time() == 0.25); + const auto checkpoint = simulation.checkpoint(); + assert(checkpoint.signal_grid.has_value()); + assert(checkpoint.signal_grid->levels == simulation.signal_levels()); + + cm::Simulation restored(cm::BackendKind::cpu, checkpoint); + assert(restored.signal_levels() == simulation.signal_levels()); + assert_close(restored.sample_signals({1.0F, 0.0F, 0.0F})[0], 0.5F); + + cm::CellInit cell; + restored.add_cell(cell); + assert_throws([&] { + restored.configure_signal_grid(spec); + }); +} - { - auto spec = line_spec(4); - spec.obstacles = {0, 0, 1, 0}; - cm::SignalGrid grid(spec, {2.0F, 0.0F, 0.0F, 5.0F}); - static_cast(cm::advance_signal_grid_cpu(grid, 0.25F)); - assert_close(grid.levels()[0], 1.5F); - assert_close(grid.levels()[1], 0.5F); - assert_close(grid.levels()[2], 0.0F); - assert_close(grid.levels()[3], 5.0F); - assert_close(grid.levels()[0] + grid.levels()[1], 2.0F); - } +void test_blocked_advection() { + auto spec = line_spec(3); + spec.advection = {{0.5F, 0.0F, 0.0F}}; + spec.obstacles = {0, 1, 0}; + cm::SignalGrid grid(spec, {1.0F, 0.0F, 0.5F}); + static_cast(cm::advance_signal_grid_cpu(grid, 0.25F)); + assert_close(grid.levels()[0], 1.0F); + assert_close(grid.levels()[1], 0.0F); + assert_close(grid.levels()[2], 0.5F); +} - { - auto spec = line_spec(4); - spec.integration = cm::SignalIntegrationKind::crank_nicolson; - spec.obstacles = {0, 0, 1, 0}; - cm::SignalGrid grid(spec, {2.0F, 0.0F, 0.0F, 5.0F}); - const auto report = cm::advance_signal_grid_cpu(grid, 1.0F); - assert(report.converged); - assert(std::abs(grid.levels()[0] + grid.levels()[1] - 2.0F) <= 1.0e-4F); - assert(grid.levels()[2] == 0.0F); - assert(std::abs(grid.levels()[3] - 5.0F) <= 1.0e-4F); - } +void test_obstacle_diffusion() { + auto spec = line_spec(4); + spec.obstacles = {0, 0, 1, 0}; + cm::SignalGrid grid(spec, {2.0F, 0.0F, 0.0F, 5.0F}); + static_cast(cm::advance_signal_grid_cpu(grid, 0.25F)); + assert_close(grid.levels()[0], 1.5F); + assert_close(grid.levels()[1], 0.5F); + assert_close(grid.levels()[2], 0.0F); + assert_close(grid.levels()[3], 5.0F); + assert_close(grid.levels()[0] + grid.levels()[1], 2.0F); +} - { - auto spec = line_spec(2); - spec.obstacles = {0, 1}; - cm::SignalGrid grid(spec, {3.0F, 0.0F}); - assert_close(grid.sample({0.5F, 0.0F, 0.0F})[0], 3.0F); - assert_throws( - [&] { static_cast(grid.sample({1.0F, 0.0F, 0.0F})); }); - } +void test_obstacle_implicit_diffusion() { + auto spec = line_spec(4); + spec.integration = cm::SignalIntegrationKind::crank_nicolson; + spec.obstacles = {0, 0, 1, 0}; + cm::SignalGrid grid(spec, {2.0F, 0.0F, 0.0F, 5.0F}); + const auto report = cm::advance_signal_grid_cpu(grid, 1.0F); + assert(report.converged); + assert(std::abs(grid.levels()[0] + grid.levels()[1] - 2.0F) <= 1.0e-4F); + assert(grid.levels()[2] == 0.0F); + assert(std::abs(grid.levels()[3] - 5.0F) <= 1.0e-4F); +} + +void test_obstacle_sampling() { + auto spec = line_spec(2); + spec.obstacles = {0, 1}; + cm::SignalGrid grid(spec, {3.0F, 0.0F}); + assert_close(grid.sample({0.5F, 0.0F, 0.0F})[0], 3.0F); + assert_throws([&] { + static_cast(grid.sample({1.0F, 0.0F, 0.0F})); + }); +} - { - auto invalid = line_spec(2); - invalid.obstacles = {1}; - assert_throws([&] { invalid.validate(); }); - invalid.obstacles = {0, 2}; - assert_throws([&] { invalid.validate(); }); - invalid.obstacles = {0, 1}; +void test_obstacle_validation() { + auto invalid = line_spec(2); + invalid.obstacles = {1}; + assert_throws([&] { invalid.validate(); - assert_throws( - [&] { cm::SignalGrid grid(invalid, {0.0F, 1.0F}); }); - invalid.reaction = cm::SignalGridAffineReaction{ - .source_rates = {0.0F, 1.0F}, - .loss_rates = {0.0F, 0.0F}, - }; - assert_throws([&] { invalid.validate(); }); - } + }); + invalid.obstacles = {0, 2}; + assert_throws([&] { + invalid.validate(); + }); + invalid.obstacles = {0, 1}; + invalid.validate(); + assert_throws([&] { + cm::SignalGrid grid(invalid, {0.0F, 1.0F}); + }); + invalid.reaction = cm::SignalGridAffineReaction{ + .source_rates = {0.0F, 1.0F}, + .loss_rates = {0.0F, 0.0F}, + }; + assert_throws([&] { + invalid.validate(); + }); +} - { - auto spec = line_spec(3); - spec.diffusion = {0.0F}; - spec.x_lower.kind = cm::GridBoundaryKind::fixed; - spec.x_lower.values = {2.0F}; - spec.x_upper.kind = cm::GridBoundaryKind::fixed; - spec.x_upper.values = {0.0F}; - spec.velocity_field = cm::SignalGridVelocityField{ - .x_faces = {1.0F, 1.0F, 1.0F, 1.0F}, - .y_faces = std::vector(6, 0.0F), - .z_faces = std::vector(6, 0.0F), - }; - cm::SignalGrid grid(spec, {0.0F, 0.0F, 0.0F}); - static_cast(cm::advance_signal_grid_cpu(grid, 0.5F)); - assert_close(grid.levels()[0], 1.0F); - assert_close(grid.levels()[1], 0.0F); - static_cast(cm::advance_signal_grid_cpu(grid, 0.5F)); - assert_close(grid.levels()[0], 1.5F); - assert_close(grid.levels()[1], 0.5F); - assert_close(grid.levels()[2], 0.0F); - } +void test_face_velocity_advection() { + auto spec = line_spec(3); + spec.diffusion = {0.0F}; + spec.x_lower.kind = cm::GridBoundaryKind::fixed; + spec.x_lower.values = {2.0F}; + spec.x_upper.kind = cm::GridBoundaryKind::fixed; + spec.x_upper.values = {0.0F}; + spec.velocity_field = cm::SignalGridVelocityField{ + .x_faces = {1.0F, 1.0F, 1.0F, 1.0F}, + .y_faces = std::vector(6, 0.0F), + .z_faces = std::vector(6, 0.0F), + }; + cm::SignalGrid grid(spec, {0.0F, 0.0F, 0.0F}); + static_cast(cm::advance_signal_grid_cpu(grid, 0.5F)); + assert_close(grid.levels()[0], 1.0F); + assert_close(grid.levels()[1], 0.0F); + static_cast(cm::advance_signal_grid_cpu(grid, 0.5F)); + assert_close(grid.levels()[0], 1.5F); + assert_close(grid.levels()[1], 0.5F); + assert_close(grid.levels()[2], 0.0F); +} - { - cm::SignalGridSpec spec; - spec.signal_count = 1; - spec.shape = {.x = 3, .y = 3, .z = 1}; - spec.diffusion = {0.1F}; - spec.advection = {{0.0F, 0.0F, 0.0F}}; - spec.y_lower.kind = cm::GridBoundaryKind::periodic; - spec.y_upper.kind = cm::GridBoundaryKind::periodic; - std::vector y_faces(12, 0.0F); - for (std::uint32_t x = 0; x < 3; ++x) { - for (std::uint32_t fy = 0; fy < 4; ++fy) { - y_faces[(x * 4) + fy] = 0.5F * static_cast(x + 1); - } - } - spec.velocity_field = cm::SignalGridVelocityField{ - .x_faces = std::vector(12, 0.0F), - .y_faces = y_faces, - .z_faces = std::vector(18, 0.0F), - }; - std::vector levels(9); - float total_before = 0.0F; - for (std::size_t index = 0; index < levels.size(); ++index) { - levels[index] = 0.5F + 0.1F * static_cast(index); - total_before += levels[index]; - } - cm::SignalGrid grid(spec, levels); - static_cast(cm::advance_signal_grid_cpu(grid, 0.2F)); - float total_after = 0.0F; - for (const auto level : grid.levels()) { - total_after += level; +void test_planar_face_velocity_conservation() { + cm::SignalGridSpec spec; + spec.signal_count = 1; + spec.shape = {.x = 3, .y = 3, .z = 1}; + spec.diffusion = {0.1F}; + spec.advection = {{0.0F, 0.0F, 0.0F}}; + spec.y_lower.kind = cm::GridBoundaryKind::periodic; + spec.y_upper.kind = cm::GridBoundaryKind::periodic; + std::vector y_faces(12, 0.0F); + + for (std::uint32_t x = 0; x < 3; ++x) { + for (std::uint32_t fy = 0; fy < 4; ++fy) { + y_faces[(x * 4) + fy] = 0.5F * static_cast(x + 1); } - assert(std::abs(total_after - total_before) <= 1.0e-4F); } - { - auto spec = line_spec(3); - spec.integration = cm::SignalIntegrationKind::crank_nicolson; - spec.x_lower.kind = cm::GridBoundaryKind::fixed; - spec.x_lower.values = {2.0F}; - spec.x_upper.kind = cm::GridBoundaryKind::fixed; - spec.x_upper.values = {0.0F}; - spec.velocity_field = cm::SignalGridVelocityField{ - .x_faces = {1.0F, 1.0F, 1.0F, 1.0F}, - .y_faces = std::vector(6, 0.0F), - .z_faces = std::vector(6, 0.0F), - }; - cm::SignalGrid grid(spec, {0.0F, 0.0F, 0.0F}); - const auto report = cm::advance_signal_grid_cpu(grid, 1.0F); - assert(report.converged); - assert(grid.levels()[0] > grid.levels()[1]); - assert(grid.levels()[1] > grid.levels()[2]); + spec.velocity_field = cm::SignalGridVelocityField{ + .x_faces = std::vector(12, 0.0F), + .y_faces = y_faces, + .z_faces = std::vector(18, 0.0F), + }; + std::vector levels(9); + float total_before = 0.0F; + + for (std::size_t index = 0; index < levels.size(); ++index) { + levels[index] = 0.5F + 0.1F * static_cast(index); + total_before += levels[index]; } - { - auto invalid = line_spec(3); - invalid.velocity_field = cm::SignalGridVelocityField{ - .x_faces = {1.0F}, - .y_faces = std::vector(6, 0.0F), - .z_faces = std::vector(6, 0.0F), - }; - assert_throws([&] { invalid.validate(); }); + cm::SignalGrid grid(spec, levels); + static_cast(cm::advance_signal_grid_cpu(grid, 0.2F)); + float total_after = 0.0F; - invalid.velocity_field = cm::SignalGridVelocityField{ - .x_faces = {1.0F, 1.0F, 1.0F, 1.0F}, - .y_faces = std::vector(6, 0.0F), - .z_faces = std::vector(6, 0.0F), - }; - assert_throws([&] { invalid.validate(); }); + for (const auto level : grid.levels()) { + total_after += level; + } + + assert(std::abs(total_after - total_before) <= 1.0e-4F); +} - invalid.x_lower.kind = cm::GridBoundaryKind::fixed; - invalid.x_lower.values = {0.0F}; - invalid.x_upper.kind = cm::GridBoundaryKind::fixed; - invalid.x_upper.values = {0.0F}; +void test_implicit_face_advection() { + auto spec = line_spec(3); + spec.integration = cm::SignalIntegrationKind::crank_nicolson; + spec.x_lower.kind = cm::GridBoundaryKind::fixed; + spec.x_lower.values = {2.0F}; + spec.x_upper.kind = cm::GridBoundaryKind::fixed; + spec.x_upper.values = {0.0F}; + spec.velocity_field = cm::SignalGridVelocityField{ + .x_faces = {1.0F, 1.0F, 1.0F, 1.0F}, + .y_faces = std::vector(6, 0.0F), + .z_faces = std::vector(6, 0.0F), + }; + cm::SignalGrid grid(spec, {0.0F, 0.0F, 0.0F}); + const auto report = cm::advance_signal_grid_cpu(grid, 1.0F); + assert(report.converged); + assert(grid.levels()[0] > grid.levels()[1]); + assert(grid.levels()[1] > grid.levels()[2]); +} + +void test_face_velocity_validation() { + auto invalid = line_spec(3); + invalid.velocity_field = cm::SignalGridVelocityField{ + .x_faces = {1.0F}, + .y_faces = std::vector(6, 0.0F), + .z_faces = std::vector(6, 0.0F), + }; + assert_throws([&] { + invalid.validate(); + }); + + invalid.velocity_field = cm::SignalGridVelocityField{ + .x_faces = {1.0F, 1.0F, 1.0F, 1.0F}, + .y_faces = std::vector(6, 0.0F), + .z_faces = std::vector(6, 0.0F), + }; + assert_throws([&] { invalid.validate(); + }); - invalid.advection = {{0.5F, 0.0F, 0.0F}}; - assert_throws([&] { invalid.validate(); }); - } + invalid.x_lower.kind = cm::GridBoundaryKind::fixed; + invalid.x_lower.values = {0.0F}; + invalid.x_upper.kind = cm::GridBoundaryKind::fixed; + invalid.x_upper.values = {0.0F}; + invalid.validate(); - { - auto spec = line_spec(3); - spec.diffusion = {0.0F}; - spec.x_lower.kind = cm::GridBoundaryKind::fixed; - spec.x_lower.values = {0.0F}; - spec.x_upper.kind = cm::GridBoundaryKind::fixed; - spec.x_upper.values = {0.0F}; - spec.velocity_field = cm::SignalGridVelocityField{ - .x_faces = {1.0F, 1.0F, 1.0F, 1.0F}, - .y_faces = std::vector(6, 0.0F), - .z_faces = std::vector(6, 0.0F), - }; - cm::Simulation simulation; - simulation.configure_signal_grid(spec); - cm::CellInit mover; - mover.position = {1.0F, 0.0F, 0.0F}; - mover.length = 0.0F; - mover.radius = 0.4F; - const auto mover_id = simulation.add_cell(mover); - cm::CellInit anchored = mover; - anchored.position = {0.5F, 0.0F, 0.0F}; - anchored.fixed = true; - const auto anchored_id = simulation.add_cell(anchored); - - simulation.apply_flow_drift(0.25F); - assert_close(simulation.cell(mover_id).position.x, 1.25F); - assert_close(simulation.cell(anchored_id).position.x, 0.5F); - } + invalid.advection = {{0.5F, 0.0F, 0.0F}}; + assert_throws([&] { + invalid.validate(); + }); +} - { - cm::SignalGridSpec spec; - spec.signal_count = 1; - spec.shape = {.x = 3, .y = 3, .z = 1}; - spec.diffusion = {0.0F}; - spec.advection = {{0.0F, 0.0F, 0.0F}}; - spec.x_lower.kind = cm::GridBoundaryKind::fixed; - spec.x_lower.values = {0.0F}; - spec.x_upper.kind = cm::GridBoundaryKind::fixed; - spec.x_upper.values = {0.0F}; - std::vector x_faces(12, 0.0F); - for (std::uint32_t fx = 0; fx < 4; ++fx) { - for (std::uint32_t y = 0; y < 3; ++y) { - x_faces[(fx * 3) + y] = 0.5F * static_cast(y); - } - } - spec.velocity_field = cm::SignalGridVelocityField{ - .x_faces = x_faces, - .y_faces = std::vector(12, 0.0F), - .z_faces = std::vector(18, 0.0F), - }; - cm::Simulation simulation; - simulation.configure_signal_grid(spec); - cm::CellInit rod; - rod.position = {1.0F, 1.0F, 0.0F}; - rod.direction = {0.0F, 1.0F, 0.0F}; - rod.length = 1.0F; - rod.radius = 0.3F; - const auto rod_id = simulation.add_cell(rod); +void test_translational_flow_drift() { + auto spec = line_spec(3); + spec.diffusion = {0.0F}; + spec.x_lower.kind = cm::GridBoundaryKind::fixed; + spec.x_lower.values = {0.0F}; + spec.x_upper.kind = cm::GridBoundaryKind::fixed; + spec.x_upper.values = {0.0F}; + spec.velocity_field = cm::SignalGridVelocityField{ + .x_faces = {1.0F, 1.0F, 1.0F, 1.0F}, + .y_faces = std::vector(6, 0.0F), + .z_faces = std::vector(6, 0.0F), + }; + cm::Simulation simulation; + simulation.configure_signal_grid(spec); + cm::CellInit mover; + mover.position = {1.0F, 0.0F, 0.0F}; + mover.length = 0.0F; + mover.radius = 0.4F; + const auto mover_id = simulation.add_cell(mover); + cm::CellInit anchored = mover; + anchored.position = {0.5F, 0.0F, 0.0F}; + anchored.fixed = true; + const auto anchored_id = simulation.add_cell(anchored); + + simulation.apply_flow_drift(0.25F); + assert_close(simulation.cell(mover_id).position.x, 1.25F); + assert_close(simulation.cell(anchored_id).position.x, 0.5F); +} - simulation.apply_flow_drift(0.1F); - const auto drifted = simulation.cell(rod_id); - assert(drifted.position.x > 1.0F); - assert(drifted.direction.x > 0.04F); - assert_close(cm::norm(drifted.direction), 1.0F); +void test_rotational_flow_drift() { + cm::SignalGridSpec spec; + spec.signal_count = 1; + spec.shape = {.x = 3, .y = 3, .z = 1}; + spec.diffusion = {0.0F}; + spec.advection = {{0.0F, 0.0F, 0.0F}}; + spec.x_lower.kind = cm::GridBoundaryKind::fixed; + spec.x_lower.values = {0.0F}; + spec.x_upper.kind = cm::GridBoundaryKind::fixed; + spec.x_upper.values = {0.0F}; + std::vector x_faces(12, 0.0F); + + for (std::uint32_t fx = 0; fx < 4; ++fx) { + for (std::uint32_t y = 0; y < 3; ++y) { + x_faces[(fx * 3) + y] = 0.5F * static_cast(y); + } } - { - auto spec = line_spec(3); - spec.diffusion = {0.0F}; - spec.x_lower.kind = cm::GridBoundaryKind::fixed; - spec.x_lower.values = {0.0F}; - spec.x_upper.kind = cm::GridBoundaryKind::fixed; - spec.x_upper.values = {0.0F}; - - cm::SignalGrid grid(spec); - assert_throws( - [&grid] { (void)grid.sample_velocity({1.5F, 0.0F, 0.0F}); }); + spec.velocity_field = cm::SignalGridVelocityField{ + .x_faces = x_faces, + .y_faces = std::vector(12, 0.0F), + .z_faces = std::vector(18, 0.0F), + }; + cm::Simulation simulation; + simulation.configure_signal_grid(spec); + cm::CellInit rod; + rod.position = {1.0F, 1.0F, 0.0F}; + rod.direction = {0.0F, 1.0F, 0.0F}; + rod.length = 1.0F; + rod.radius = 0.3F; + const auto rod_id = simulation.add_cell(rod); + + simulation.apply_flow_drift(0.1F); + const auto drifted = simulation.cell(rod_id); + assert(drifted.position.x > 1.0F); + assert(drifted.direction.x > 0.04F); + assert_close(cm::norm(drifted.direction), 1.0F); +} + +void test_flow_drift_failure_atomicity() { + auto spec = line_spec(3); + spec.diffusion = {0.0F}; + spec.x_lower.kind = cm::GridBoundaryKind::fixed; + spec.x_lower.values = {0.0F}; + spec.x_upper.kind = cm::GridBoundaryKind::fixed; + spec.x_upper.values = {0.0F}; + + cm::SignalGrid grid(spec); + assert_throws([&grid] { + (void)grid.sample_velocity({1.5F, 0.0F, 0.0F}); + }); + grid.set_velocity_field(cm::SignalGridVelocityField{ + .x_faces = std::vector(4, 2.0F), + .y_faces = std::vector(6, 0.0F), + .z_faces = std::vector(6, 0.0F), + }); + assert_close(grid.sample_velocity({1.5F, 0.0F, 0.0F}).x, 2.0F); + assert_throws([&grid] { grid.set_velocity_field(cm::SignalGridVelocityField{ - .x_faces = std::vector(4, 2.0F), + .x_faces = std::vector(5, 0.0F), .y_faces = std::vector(6, 0.0F), .z_faces = std::vector(6, 0.0F), }); - assert_close(grid.sample_velocity({1.5F, 0.0F, 0.0F}).x, 2.0F); - assert_throws([&grid] { - grid.set_velocity_field(cm::SignalGridVelocityField{ - .x_faces = std::vector(5, 0.0F), - .y_faces = std::vector(6, 0.0F), - .z_faces = std::vector(6, 0.0F), - }); - }); - assert_close(grid.sample_velocity({1.5F, 0.0F, 0.0F}).x, 2.0F); - grid.set_velocity_field(std::nullopt); - assert_throws( - [&grid] { (void)grid.sample_velocity({1.5F, 0.0F, 0.0F}); }); - - cm::Simulation simulation; - simulation.configure_signal_grid(spec); - cm::CellInit drifter; - drifter.position = {1.5F, 0.0F, 0.0F}; - drifter.direction = {1.0F, 0.0F, 0.0F}; - drifter.length = 1.0F; - drifter.radius = 0.3F; - const auto drifter_id = simulation.add_cell(drifter); - assert_throws([&simulation] { simulation.apply_flow_drift(0.1F); }); - assert_close(simulation.cell(drifter_id).position.x, 1.5F); - simulation.set_velocity_field(cm::SignalGridVelocityField{ - .x_faces = std::vector(4, 2.0F), - .y_faces = std::vector(6, 0.0F), - .z_faces = std::vector(6, 0.0F), - }); - simulation.apply_flow_drift(0.1F); - assert_close(simulation.cell(drifter_id).position.x, 1.7F); - - cm::Simulation bare; - assert_throws([&bare] { bare.set_velocity_field(std::nullopt); }); - - // A rod whose tip pokes past the outermost site center drifts by sampling - // the nearest in-grid point instead of erroring. - cm::CellInit poking; - poking.position = {2.5F, 0.0F, 0.0F}; - poking.direction = {1.0F, 0.0F, 0.0F}; - poking.length = 1.5F; - poking.radius = 0.3F; - const auto poking_id = simulation.add_cell(poking); + }); + assert_close(grid.sample_velocity({1.5F, 0.0F, 0.0F}).x, 2.0F); + grid.set_velocity_field(std::nullopt); + assert_throws([&grid] { + (void)grid.sample_velocity({1.5F, 0.0F, 0.0F}); + }); + + cm::Simulation simulation; + simulation.configure_signal_grid(spec); + cm::CellInit drifter; + drifter.position = {1.5F, 0.0F, 0.0F}; + drifter.direction = {1.0F, 0.0F, 0.0F}; + drifter.length = 1.0F; + drifter.radius = 0.3F; + const auto drifter_id = simulation.add_cell(drifter); + assert_throws([&simulation] { simulation.apply_flow_drift(0.1F); - assert_close(simulation.cell(poking_id).position.x, 2.7F); - } + }); + assert_close(simulation.cell(drifter_id).position.x, 1.5F); + simulation.set_velocity_field(cm::SignalGridVelocityField{ + .x_faces = std::vector(4, 2.0F), + .y_faces = std::vector(6, 0.0F), + .z_faces = std::vector(6, 0.0F), + }); + simulation.apply_flow_drift(0.1F); + assert_close(simulation.cell(drifter_id).position.x, 1.7F); + + cm::Simulation bare; + assert_throws([&bare] { + bare.set_velocity_field(std::nullopt); + }); + + // A rod whose tip pokes past the outermost site center drifts by sampling + // the nearest in-grid point instead of erroring. + cm::CellInit poking; + poking.position = {2.5F, 0.0F, 0.0F}; + poking.direction = {1.0F, 0.0F, 0.0F}; + poking.length = 1.5F; + poking.radius = 0.3F; + const auto poking_id = simulation.add_cell(poking); + simulation.apply_flow_drift(0.1F); + assert_close(simulation.cell(poking_id).position.x, 2.7F); +} - { - // Endpoint clamping works in lattice coordinates, so an origin and spacing - // with no exact float representation still admits a rod poking past the - // outermost site center. - auto spec = line_spec(33); - spec.origin = {0.1F, 0.0F, 0.0F}; - spec.spacing = {0.3F, 1.0F, 1.0F}; - spec.diffusion = {0.0F}; - spec.x_lower.kind = cm::GridBoundaryKind::fixed; - spec.x_lower.values = {0.0F}; - spec.x_upper.kind = cm::GridBoundaryKind::fixed; - spec.x_upper.values = {0.0F}; - spec.velocity_field = cm::SignalGridVelocityField{ - .x_faces = std::vector(34, 2.0F), - .y_faces = std::vector(66, 0.0F), - .z_faces = std::vector(66, 0.0F), - }; - cm::Simulation simulation; - simulation.configure_signal_grid(spec); - cm::CellInit poking; - poking.position = {9.7F, 0.0F, 0.0F}; - poking.direction = {1.0F, 0.0F, 0.0F}; - poking.length = 2.0F; - poking.radius = 0.3F; - const auto poking_id = simulation.add_cell(poking); - simulation.apply_flow_drift(0.1F); - assert_close(simulation.cell(poking_id).position.x, 9.9F); - } +void test_flow_drift_endpoint_clamping() { + // Endpoint clamping works in lattice coordinates, so an origin and spacing + // with no exact float representation still admits a rod poking past the + // outermost site center. + auto spec = line_spec(33); + spec.origin = {0.1F, 0.0F, 0.0F}; + spec.spacing = {0.3F, 1.0F, 1.0F}; + spec.diffusion = {0.0F}; + spec.x_lower.kind = cm::GridBoundaryKind::fixed; + spec.x_lower.values = {0.0F}; + spec.x_upper.kind = cm::GridBoundaryKind::fixed; + spec.x_upper.values = {0.0F}; + spec.velocity_field = cm::SignalGridVelocityField{ + .x_faces = std::vector(34, 2.0F), + .y_faces = std::vector(66, 0.0F), + .z_faces = std::vector(66, 0.0F), + }; + cm::Simulation simulation; + simulation.configure_signal_grid(spec); + cm::CellInit poking; + poking.position = {9.7F, 0.0F, 0.0F}; + poking.direction = {1.0F, 0.0F, 0.0F}; + poking.length = 2.0F; + poking.radius = 0.3F; + const auto poking_id = simulation.add_cell(poking); + simulation.apply_flow_drift(0.1F); + assert_close(simulation.cell(poking_id).position.x, 9.9F); +} - { - // The angular limit controls internal substeps, not the total motion. - cm::SignalGridSpec spec; - spec.signal_count = 1; - spec.shape = {.x = 3, .y = 3, .z = 1}; - spec.diffusion = {0.0F}; - spec.advection = {{0.0F, 0.0F, 0.0F}}; - spec.x_lower.kind = cm::GridBoundaryKind::fixed; - spec.x_lower.values = {0.0F}; - spec.x_upper.kind = cm::GridBoundaryKind::fixed; - spec.x_upper.values = {0.0F}; - std::vector x_faces(12, 0.0F); - for (std::uint32_t fx = 0; fx < 4; ++fx) { - for (std::uint32_t y = 0; y < 3; ++y) { - x_faces[(fx * 3) + y] = static_cast(y); - } +void test_flow_drift_angular_substeps() { + // The angular limit controls internal substeps, not the total motion. + cm::SignalGridSpec spec; + spec.signal_count = 1; + spec.shape = {.x = 3, .y = 3, .z = 1}; + spec.diffusion = {0.0F}; + spec.advection = {{0.0F, 0.0F, 0.0F}}; + spec.x_lower.kind = cm::GridBoundaryKind::fixed; + spec.x_lower.values = {0.0F}; + spec.x_upper.kind = cm::GridBoundaryKind::fixed; + spec.x_upper.values = {0.0F}; + std::vector x_faces(12, 0.0F); + + for (std::uint32_t fx = 0; fx < 4; ++fx) { + for (std::uint32_t y = 0; y < 3; ++y) { + x_faces[(fx * 3) + y] = static_cast(y); } - spec.velocity_field = cm::SignalGridVelocityField{ - .x_faces = x_faces, - .y_faces = std::vector(12, 0.0F), - .z_faces = std::vector(18, 0.0F), - }; - - cm::CellInit rod; - rod.position = {1.0F, 1.0F, 0.0F}; - rod.direction = {0.0F, 1.0F, 0.0F}; - rod.length = 2.0F; - rod.radius = 0.3F; - - cm::Simulation capped; - capped.configure_signal_grid(spec); - const auto capped_id = capped.add_cell(rod); - capped.apply_flow_drift(1.0F); - const auto limit = cm::MechanicsIntegrationParameters{}.max_rotation_radians; - assert(capped.cell(capped_id).direction.x > 3 * std::sin(limit)); - assert_close(cm::norm(capped.cell(capped_id).direction), 1.0F); - - cm::Simulation frozen; - frozen.configure_signal_grid(spec); - const auto frozen_id = frozen.add_cell(rod); - frozen.apply_flow_drift(1.0F, cm::MechanicsIntegrationParameters{.max_rotation_radians = 0.0F}); - assert_close(frozen.cell(frozen_id).direction.x, 0.0F); - assert_close(frozen.cell(frozen_id).direction.y, 1.0F); } + + spec.velocity_field = cm::SignalGridVelocityField{ + .x_faces = x_faces, + .y_faces = std::vector(12, 0.0F), + .z_faces = std::vector(18, 0.0F), + }; + + cm::CellInit rod; + rod.position = {1.0F, 1.0F, 0.0F}; + rod.direction = {0.0F, 1.0F, 0.0F}; + rod.length = 2.0F; + rod.radius = 0.3F; + + cm::Simulation capped; + capped.configure_signal_grid(spec); + const auto capped_id = capped.add_cell(rod); + capped.apply_flow_drift(1.0F); + const auto limit = cm::MechanicsIntegrationParameters{}.max_rotation_radians; + assert(capped.cell(capped_id).direction.x > 3 * std::sin(limit)); + assert_close(cm::norm(capped.cell(capped_id).direction), 1.0F); + + cm::Simulation frozen; + frozen.configure_signal_grid(spec); + const auto frozen_id = frozen.add_cell(rod); + frozen.apply_flow_drift(1.0F, cm::MechanicsIntegrationParameters{.max_rotation_radians = 0.0F}); + assert_close(frozen.cell(frozen_id).direction.x, 0.0F); + assert_close(frozen.cell(frozen_id).direction.y, 1.0F); +} + +} // namespace + +int main() { + test_affine_euler_stability(); + test_affine_crank_nicolson(); + test_reaction_validation(); + test_explicit_diffusion_conservation(); + test_implicit_diffusion(); + test_implicit_nonconvergence(); + test_fixed_reservoir_diffusion(); + test_periodic_advection(); + test_closed_boundary_advection(); + test_trilinear_sampling(); + test_periodic_boundary_validation(); + test_simulation_signals_checkpoint(); + test_blocked_advection(); + test_obstacle_diffusion(); + test_obstacle_implicit_diffusion(); + test_obstacle_sampling(); + test_obstacle_validation(); + test_face_velocity_advection(); + test_planar_face_velocity_conservation(); + test_implicit_face_advection(); + test_face_velocity_validation(); + test_translational_flow_drift(); + test_rotational_flow_drift(); + test_flow_drift_failure_atomicity(); + test_flow_drift_endpoint_clamping(); + test_flow_drift_angular_substeps(); } diff --git a/tests/cpp/species_test.cpp b/tests/cpp/species_test.cpp index 4a175bf..5502942 100644 --- a/tests/cpp/species_test.cpp +++ b/tests/cpp/species_test.cpp @@ -73,20 +73,24 @@ void test_invalid_species_contracts_fail_explicitly() { cm::CellInit cell; cell.species = {1.0F}; bool rejected = false; + try { static_cast(simulation.add_cell(cell)); } catch (const std::invalid_argument&) { rejected = true; } + assert(rejected); rejected = false; + try { const std::vector instructions{species(0)}; static_cast(cm::SpeciesRatePlan(2, instructions, {1, 0})); } catch (const std::invalid_argument&) { rejected = true; } + assert(rejected); } @@ -96,5 +100,6 @@ int main() { test_dilution_precedes_simultaneous_euler_update(); test_division_preserves_concentration_and_schema(); test_invalid_species_contracts_fail_explicitly(); + return 0; } diff --git a/tests/cpp/world_state_test.cpp b/tests/cpp/world_state_test.cpp index 3d062f5..c748155 100644 --- a/tests/cpp/world_state_test.cpp +++ b/tests/cpp/world_state_test.cpp @@ -58,11 +58,13 @@ void test_division_reuses_slot_but_not_identity() { assert(simulation.lineage_parent(second) == parent); bool parent_is_gone = false; + try { static_cast(simulation.cell(parent)); } catch (const std::out_of_range&) { parent_is_gone = true; } + assert(parent_is_gone); simulation.validate(); } @@ -89,10 +91,9 @@ void test_asymmetric_division_preserves_capsule_extent() { assert(second_cell.species == initial.species); assert(close(first_cell.position.x - (first_cell.length * 0.5F), -1.0F)); assert(close(second_cell.position.x + (second_cell.length * 0.5F), 5.0F)); - assert(close( - (second_cell.position.x - (second_cell.length * 0.5F)) - - (first_cell.position.x + (first_cell.length * 0.5F)), - 1.0F)); + assert(close((second_cell.position.x - (second_cell.length * 0.5F)) - + (first_cell.position.x + (first_cell.length * 0.5F)), + 1.0F)); assert(simulation.lineage_parent(first) == parent); assert(simulation.lineage_parent(second) == parent); simulation.validate(); @@ -104,19 +105,21 @@ void test_invalid_division_fraction_is_atomic() { initial.length = 6.0F; const auto parent = simulation.add_cell(initial); - for (const auto fraction : {0.0F, 1.0F, -0.25F, 1.25F, - std::numeric_limits::quiet_NaN()}) { + for (const auto fraction : {0.0F, 1.0F, -0.25F, 1.25F, std::numeric_limits::quiet_NaN()}) { bool rejected = false; + try { static_cast(simulation.divide(parent, fraction)); } catch (const std::invalid_argument&) { rejected = true; } + assert(rejected); assert(simulation.cell_count() == 1); assert(simulation.cell(parent).slot == 0); assert(close(simulation.cell(parent).length, 6.0F)); } + simulation.validate(); } @@ -126,19 +129,23 @@ void test_invalid_state_fails_explicitly() { invalid.radius = 0.0F; bool rejected = false; + try { static_cast(simulation.add_cell(invalid)); } catch (const std::invalid_argument&) { rejected = true; } + assert(rejected); rejected = false; + try { simulation.step(-0.1F); } catch (const std::invalid_argument&) { rejected = true; } + assert(rejected); } @@ -153,11 +160,13 @@ void test_mutable_cell_attributes_keep_stable_identity() { assert(updated.cell_type == 7); bool rejected = false; + try { simulation.set_cell_attributes(id, std::numeric_limits::quiet_NaN(), 8); } catch (const std::invalid_argument&) { rejected = true; } + assert(rejected); assert(close(simulation.cell(id).growth_rate, 2.5F)); assert(simulation.cell(id).cell_type == 7); @@ -237,11 +246,13 @@ void test_removal_backfills_slots_and_keeps_lineage() { assert(restored.lineage_parent(daughters.second) == first); bool rejected = false; + try { state.remove_cell(second); } catch (const std::exception&) { rejected = true; } + assert(rejected); state.validate(); } @@ -251,11 +262,13 @@ void test_unavailable_backends_do_not_fall_back() { assert(cm::backend_available(cm::BackendKind::cpu, 0)); assert(!cm::backend_available(cm::BackendKind::cpu, 1)); bool invalid_cpu_device_rejected = false; + try { cm::Simulation simulation(cm::BackendKind::cpu, 0, 0, 1); } catch (const std::out_of_range&) { invalid_cpu_device_rejected = true; } + assert(invalid_cpu_device_rejected); for (const auto backend : {cm::BackendKind::metal, cm::BackendKind::cuda}) { @@ -266,12 +279,15 @@ void test_unavailable_backends_do_not_fall_back() { assert(cm::backend_device_count(backend) >= 1); continue; } + bool rejected = false; + try { cm::Simulation simulation(backend); } catch (const std::runtime_error&) { rejected = true; } + assert(rejected); } } @@ -289,5 +305,6 @@ int main() { test_fixed_state_is_mutable_persistent_and_inherited(); test_removal_backfills_slots_and_keeps_lineage(); test_unavailable_backends_do_not_fall_back(); + return 0; } diff --git a/tests/quality/test_quality.py b/tests/quality/test_quality.py new file mode 100644 index 0000000..ccc6597 --- /dev/null +++ b/tests/quality/test_quality.py @@ -0,0 +1,168 @@ +"""Behavioral coverage for source discovery, paragraph spacing, and strict limits.""" + +from __future__ import annotations + +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scripts")) + +import quality_files +from check_complexity import violations +from code_paragraphs import format_code + + +class ParagraphTests(unittest.TestCase): + def test_setup_guard_computation_and_return(self) -> None: + code = """int count(int n) { + int result = 0; + for (int i = 0; i < n; ++i) { + const int x = i * i; + if (x == 0) { + continue; + } + result += x; + } + return result; +} +""" + result = format_code(Path("kernel.cu"), code) + self.assertIn("result = 0;\n\n for", result) + self.assertIn("i * i;\n\n if", result) + self.assertIn("continue;\n }\n\n result", result) + self.assertIn("}\n\n return", result) + self.assertEqual(format_code(Path("kernel.cu"), result), result) + self.assertEqual("".join(code.split()), "".join(result.split())) + + def test_comments_chains_and_string_literals(self) -> None: + code = """int f(int x) { + const char* s = R"tag(for (;;) { + if (x) { return x; } + })tag"; + // Keep this comment attached to the guard. + if (x) { + return 1; + } else { + return 2; + } +} +""" + result = format_code(Path("source.cpp"), code) + self.assertIn('})tag";\n\n // Keep', result) + self.assertIn("guard.\n if", result) + self.assertIn("} else {", result) + self.assertIn("if (x) { return x; }", result) + + def test_preprocessor_and_empty_bodies_are_opaque(self) -> None: + code = "#define LOOP(x) do { x++; if (x) break; } while (0)\nvoid f() {}\n" + self.assertEqual(format_code(Path("header.hpp"), code), code) + + def test_python_and_typescript(self) -> None: + cases = { + "source.py": "def f(x):\n y = x + 1\n if y:\n y += 1\n return y\n", + "source.ts": ( + "function f(x: number) {\n let y = x + 1;\n" + " if (y) {\n y++;\n }\n return y;\n}\n" + ), + } + + for name, code in cases.items(): + with self.subTest(name=name): + result = format_code(Path(name), code) + self.assertGreater(result.count("\n\n"), 1) + self.assertEqual(format_code(Path(name), result), result) + + +class ComplexityTests(unittest.TestCase): + def test_line_boundary(self) -> None: + for count, fails in [(98, False), (99, True)]: + # Python definition and return each contribute one code line. + code = "def f():\n" + " x = 1\n" * count + " return x\n" + self.assertEqual(bool(violations(Path("source.py"), code)), fails) + + def test_complexity_boundary_and_gpu_extensions(self) -> None: + for extension in ["cpp", "hpp", "cu", "cuh", "metal", "mm"]: + for branches, fails in [(14, False), (15, True)]: + with self.subTest(extension=extension, branches=branches): + code = "int f(int x) {\n" + "if (x) { x--; }\n" * branches + "return x;\n}\n" + self.assertEqual(bool(violations(Path(f"source.{extension}"), code)), fails) + + def test_templates_lambdas_and_suppressions(self) -> None: + code = "template T f(T x) { return [](T y) { return y; }(x); }" + self.assertEqual(violations(Path("source.hpp"), code), []) + self.assertTrue(violations(Path("source.py"), "# lizard forgives\ndef f(): pass\n")) + + +class DiscoveryTests(unittest.TestCase): + def test_tracked_untracked_and_ignored_files(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + subprocess.run(["git", "init", "-q", str(root)], check=True) + (root / ".gitignore").write_text("ignored.py\n") + + for name in ["tracked.cpp", "new file.metal", "ignored.py", "uv.lock"]: + (root / name).write_text("\n") + + subprocess.run(["git", "add", "tracked.cpp"], cwd=root, check=True) + (root / "src").mkdir() + (root / "src/child.cpp").write_text("\n") + (root / "src/ignored.py").write_text("\n") + + with patch.object(quality_files, "ROOT", root): + files = quality_files.source_files() + self.assertIn(Path("tracked.cpp"), files) + self.assertIn(Path("new file.metal"), files) + self.assertNotIn(Path("ignored.py"), files) + self.assertNotIn(Path("uv.lock"), files) + self.assertEqual( + quality_files.source_files(["new file.metal"]), [Path("new file.metal")] + ) + self.assertEqual(quality_files.source_files(["src"]), [Path("src/child.cpp")]) + self.assertEqual(quality_files.source_files(["."]), files) + + +class CommandTests(unittest.TestCase): + def test_check_is_read_only_and_format_is_repeatable(self) -> None: + root = quality_files.ROOT + + with tempfile.TemporaryDirectory(dir=root) as directory: + source = Path(directory) / "example.cpp" + source.write_text("int f(int x){int y=x+1;if(y){y++;}return y;}\n") + original = source.read_bytes() + command = [sys.executable, str(root / "scripts/quality.py")] + selected = str(source.relative_to(root)) + check = subprocess.run( + [*command, "check", selected], cwd=root, capture_output=True, text=True + ) + self.assertNotEqual(check.returncode, 0) + self.assertEqual(source.read_bytes(), original) + + previous = None + + for _ in range(2): + result = subprocess.run( + [*command, "format", selected], cwd=root, capture_output=True, text=True + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + formatted = source.read_bytes() + + if previous is not None: + self.assertEqual(formatted, previous) + + previous = formatted + self.assertIn(b"\n\n if", formatted) + check = subprocess.run( + [*command, "check", selected], cwd=root, capture_output=True, text=True + ) + self.assertEqual(check.returncode, 0, check.stdout + check.stderr) + self.assertEqual(source.read_bytes(), formatted) + + self.assertEqual(source.read_bytes(), formatted) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index 0f364d7..f38528d 100644 --- a/uv.lock +++ b/uv.lock @@ -70,61 +70,46 @@ wheels = [ ] [[package]] -name = "microsimulator" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "numpy" }, - { name = "rfc8785" }, -] - -[package.optional-dependencies] -analysis = [ - { name = "polars" }, - { name = "pyarrow" }, - { name = "zarr" }, -] -sbml = [ - { name = "python-libsbml" }, -] -viewer = [ - { name = "aiohttp" }, +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445 }, ] -[package.dev-dependencies] -dev = [ - { name = "aiohttp" }, - { name = "polars" }, - { name = "pyarrow" }, - { name = "pyright" }, - { name = "pytest" }, - { name = "python-libsbml" }, - { name = "ruff" }, - { name = "zarr" }, +[[package]] +name = "clang-format" +version = "19.1.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ee/71d017fe603c06b83d6720df6b3f6f07f03abf330f39beee3fee2a067c56/clang_format-19.1.7.tar.gz", hash = "sha256:bd6fc5272a41034a7844149203461d1f311bece9ed100d22eb3eebd952a25f49", size = 11122 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/c3/2f1c53bc298c1740d0c9f8dc2d9b7030be4826b6f2aa8a04f07ef25a3d9b/clang_format-19.1.7-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:a09f34d2c89d176581858ff718c327eebc14eb6415c176dab4af5bfd8582a999", size = 1428184 }, + { url = "https://files.pythonhosted.org/packages/8e/9d/7c246a3d08105de305553d14971ed6c16cde06d20ab12d6ce7f243cf66f0/clang_format-19.1.7-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:776f89c7b056c498c0e256485bc031cbf514aaebe71e929ed54e50c478524b65", size = 1398224 }, + { url = "https://files.pythonhosted.org/packages/b1/7d/002aa5571351ee7f00f87aae5104cdd30cad1a46f25936226f7d2aed06bf/clang_format-19.1.7-py2.py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dac394c83a9233ab6707f66e1cdbd950f8b014b58604142a5b6f7998bf0bcc8c", size = 1730962 }, + { url = "https://files.pythonhosted.org/packages/1c/fe/24b7c13af432e609d65dc32c47c61f0a6c3b80d78eb7b3df37daf0395c56/clang_format-19.1.7-py2.py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bbd4f94d929edf6d8d81e990dfaafc22bb10deaefcb2762150a136f281b01c00", size = 1908820 }, + { url = "https://files.pythonhosted.org/packages/7d/a8/86595ffd6ea0bf3a3013aad94e3d55be32ef987567781eddf4621e316d09/clang_format-19.1.7-py2.py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bdcda63fffdbe2aac23b54d46408a6283ad16676a5230a95b3ed49eacd99129b", size = 2622838 }, + { url = "https://files.pythonhosted.org/packages/48/d1/731ebf78c5d5cc043c20b0755c89239350b8e75ac5d667b99689e8110bc7/clang_format-19.1.7-py2.py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c13a5802da986b1400afbee97162c29f841890ab9e20a0be7ede18189219f5f1", size = 1723352 }, + { url = "https://files.pythonhosted.org/packages/3c/e7/0e526915a3a4a23100cc721c24226a192fa0385d394019d06920dc83fe6c/clang_format-19.1.7-py2.py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4906fb463dd2033032978f56962caab268c9428a384126b9400543eb667f11c", size = 1740347 }, + { url = "https://files.pythonhosted.org/packages/52/04/ed8e2af6b3e29655a858b3aad145f3f0539df0dd1c77815b95f578260bd3/clang_format-19.1.7-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ffca915c09aed9137f8c649ad7521bd5ce690c939121db1ba54af2ba63ac8374", size = 2675802 }, + { url = "https://files.pythonhosted.org/packages/9a/ab/7874a6f45c167f4cc4d02f517b85d14b6b5fa8412f6e9c7482588d00fccb/clang_format-19.1.7-py2.py3-none-musllinux_1_2_i686.whl", hash = "sha256:fc011dc7bbe3ac8a32e0caa37ab8ba6c1639ceef6ecd04feea8d37360fc175e4", size = 2977872 }, + { url = "https://files.pythonhosted.org/packages/46/b5/c87b6c46eb7e9d0f07e2bd56cd0a62bf7e679f146b4e1447110cfae4bd01/clang_format-19.1.7-py2.py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:afdfb11584f5a6f15127a7061673a7ea12a0393fe9ee8d2ed84e74bb191ffc3b", size = 3125795 }, + { url = "https://files.pythonhosted.org/packages/22/3e/7ea08aba446c1e838367d3c0e13eb3d2e482b23e099a25149d4f7f6b8c75/clang_format-19.1.7-py2.py3-none-musllinux_1_2_s390x.whl", hash = "sha256:6ce81d5b08e0169dc52037d3ff1802eafcaf86c281ceb8b38b8359ba7b6b7bdc", size = 3069663 }, + { url = "https://files.pythonhosted.org/packages/f5/f9/6ce7fe8ff52ded01d02a568358f2ddf993347e44202b6506b039a583b7ed/clang_format-19.1.7-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d27ac1a5a8783c9271d41cd5851766ca547ea003efa4e3764f880f319b2d3ed3", size = 2763172 }, + { url = "https://files.pythonhosted.org/packages/82/fa/77fe5636bb6b6252918bf129226a248506af218a2256deece3a9d95af850/clang_format-19.1.7-py2.py3-none-win32.whl", hash = "sha256:5dfde0be33f038114af89efb917144c2f766f8b7f3a3d3e4cb9c25f76d71ef81", size = 1243262 }, + { url = "https://files.pythonhosted.org/packages/e4/32/0b44f3582b9df0b8f90266ef43975e37ec8ad52bae4f85b71552f264d5a2/clang_format-19.1.7-py2.py3-none-win_amd64.whl", hash = "sha256:3e3c75fbdf8827bbb7277226b3057fc3785dabe7284d3a9d15fceb250f68f529", size = 1441132 }, ] -[package.metadata] -requires-dist = [ - { name = "aiohttp", marker = "extra == 'viewer'", specifier = ">=3.14.3,<4" }, - { name = "numpy", specifier = ">=2.1,<3" }, - { name = "polars", marker = "extra == 'analysis'", specifier = ">=1.43,<2" }, - { name = "pyarrow", marker = "extra == 'analysis'", specifier = ">=25,<26" }, - { name = "python-libsbml", marker = "extra == 'sbml'", specifier = ">=5.21.1,<6" }, - { name = "rfc8785", specifier = ">=0.1.4,<0.2" }, - { name = "zarr", marker = "extra == 'analysis'", specifier = ">=3.2.1,<4" }, +[[package]] +name = "cmakelang" +version = "0.6.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, ] -provides-extras = ["analysis", "sbml", "viewer"] - -[package.metadata.requires-dev] -dev = [ - { name = "aiohttp", specifier = ">=3.14.3,<4" }, - { name = "polars", specifier = ">=1.43,<2" }, - { name = "pyarrow", specifier = ">=25,<26" }, - { name = "pyright", specifier = ">=1.1.390,<2" }, - { name = "pytest", specifier = ">=8.3,<9" }, - { name = "python-libsbml", specifier = ">=5.21.1,<6" }, - { name = "ruff", specifier = ">=0.9,<1" }, - { name = "zarr", specifier = ">=3.2.1,<4" }, +sdist = { url = "https://files.pythonhosted.org/packages/74/c0/75d4806cf21dcb4198e9fba02f4d2fa61c8db919b7db788862d9cd5f4433/cmakelang-0.6.13.tar.gz", hash = "sha256:03982e87b00654d024d73ef972d9d9bb0e5726cdb6b8a424a15661fb6278e67f", size = 123111 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/a8/c4676cac062d133c6b909d7def80a3194162597968953a3291b309878721/cmakelang-0.6.13-py3-none-any.whl", hash = "sha256:764b9467195c7c36453d60a829f30229720d26c7dffd41cb516b99bd9c7daf4e", size = 159803 }, ] [[package]] @@ -136,6 +121,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628 }, +] + [[package]] name = "donfig" version = "0.8.1.post1" @@ -148,6 +142,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/d5/c5db1ea3394c6e1732fb3286b3bd878b59507a8f77d32a2cebda7d7b7cd4/donfig-0.8.1.post1-py3-none-any.whl", hash = "sha256:2a3175ce74a06109ff9307d90a230f81215cbac9a751f4d1c6194644b8204f9d", size = 21592 }, ] +[[package]] +name = "filelock" +version = "4.0.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/f5/14097cca69f53794270d8c7970b48321636302affe3154c7e0ba114eeff9/filelock-4.0.7.tar.gz", hash = "sha256:da5915714a70b55d167fdc7e251ad91302b0a36816fb574dfafae8f4f2c9bb21", size = 248489 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/cf/1731c0dd182ee35f80a39e63afb3d03056ea0dd561b7498932b10b2e34d9/filelock-4.0.7-py3-none-any.whl", hash = "sha256:a93c4d93269b339a6af4848342c7e940d0f9928ad95eff64764699e5f1bf8a6a", size = 109242 }, +] + [[package]] name = "frozenlist" version = "1.8.0" @@ -186,6 +189,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437 }, ] +[[package]] +name = "identify" +version = "2.6.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/35/d70c0006c7cee65999ea94a6273e60b2094f600a3d8b71b04318253fc643/identify-2.6.20.tar.gz", hash = "sha256:ad729860a923858d26917c2f4fb0a1d83d27a75b1e090c06440c573f048f3285", size = 99681 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/70/fffc9613501877c0a10a1ef73a165e6ded3e53f6e7502227fef56d973933/identify-2.6.20-py2.py3-none-any.whl", hash = "sha256:6a16b69b93187244e0548cbfd25b3e4a6f9a7a2ad784625c3bec2b8d27b81aaa", size = 99506 }, +] + [[package]] name = "idna" version = "3.18" @@ -204,6 +216,105 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, ] +[[package]] +name = "lizard" +version = "1.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathspec" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/c9/97837b967a1a6bb64acb0e1738e19a154393ae1a9a448b9958176be5b10a/lizard-1.24.0.tar.gz", hash = "sha256:2e88a7af9d23a98d3f4a30767134361bd4b7dde02c410209c6c72732a43bbb65", size = 102329 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/c1/1da7937391a7e67edfb33bb3cd6d3426a7027cf3dd904ecb29c2b4416e78/lizard-1.24.0-py2.py3-none-any.whl", hash = "sha256:a688bc607a891ff4a7836826f25742dc9c1bf648da3075dbd495e199e8848602", size = 111418 }, +] + +[[package]] +name = "microsimulator" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "numpy" }, + { name = "rfc8785" }, +] + +[package.optional-dependencies] +analysis = [ + { name = "polars" }, + { name = "pyarrow" }, + { name = "zarr" }, +] +sbml = [ + { name = "python-libsbml" }, +] +viewer = [ + { name = "aiohttp" }, +] + +[package.dev-dependencies] +dev = [ + { name = "aiohttp" }, + { name = "polars" }, + { name = "pyarrow" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "python-libsbml" }, + { name = "ruff" }, + { name = "zarr" }, +] +quality = [ + { name = "clang-format" }, + { name = "cmakelang" }, + { name = "lizard" }, + { name = "pre-commit" }, + { name = "ruff" }, + { name = "shfmt-py" }, + { name = "taplo" }, + { name = "tree-sitter" }, + { name = "tree-sitter-cpp" }, + { name = "tree-sitter-javascript" }, + { name = "tree-sitter-python" }, + { name = "tree-sitter-typescript" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", marker = "extra == 'viewer'", specifier = ">=3.14.3,<4" }, + { name = "numpy", specifier = ">=2.1,<3" }, + { name = "polars", marker = "extra == 'analysis'", specifier = ">=1.43,<2" }, + { name = "pyarrow", marker = "extra == 'analysis'", specifier = ">=25,<26" }, + { name = "python-libsbml", marker = "extra == 'sbml'", specifier = ">=5.21.1,<6" }, + { name = "rfc8785", specifier = ">=0.1.4,<0.2" }, + { name = "zarr", marker = "extra == 'analysis'", specifier = ">=3.2.1,<4" }, +] +provides-extras = ["analysis", "sbml", "viewer"] + +[package.metadata.requires-dev] +dev = [ + { name = "aiohttp", specifier = ">=3.14.3,<4" }, + { name = "polars", specifier = ">=1.43,<2" }, + { name = "pyarrow", specifier = ">=25,<26" }, + { name = "pyright", specifier = ">=1.1.390,<2" }, + { name = "pytest", specifier = ">=8.3,<9" }, + { name = "python-libsbml", specifier = ">=5.21.1,<6" }, + { name = "ruff", specifier = "==0.16.3" }, + { name = "zarr", specifier = ">=3.2.1,<4" }, +] +quality = [ + { name = "clang-format", specifier = "==19.1.7" }, + { name = "cmakelang", specifier = "==0.6.13" }, + { name = "lizard", specifier = "==1.24.0" }, + { name = "pre-commit", specifier = "==4.1.0" }, + { name = "ruff", specifier = "==0.16.3" }, + { name = "shfmt-py", specifier = "==4.2.0" }, + { name = "taplo", specifier = "==0.9.3" }, + { name = "tree-sitter", specifier = "==0.25.2" }, + { name = "tree-sitter-cpp", specifier = "==0.23.4" }, + { name = "tree-sitter-javascript", specifier = "==0.25.0" }, + { name = "tree-sitter-python", specifier = "==0.25.0" }, + { name = "tree-sitter-typescript", specifier = "==0.23.2" }, +] + [[package]] name = "multidict" version = "6.7.1" @@ -285,6 +396,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956 }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328 }, +] + +[[package]] +name = "platformdirs" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/17/c8/721b3855fe457da514fe249247d404b9b39c5d16532278f70ebaa6acf18b/platformdirs-4.12.2.tar.gz", hash = "sha256:eab5f70271a490ef74618bb314fbb86e3c7e82fa3b9c922c2ea0e0a1a155d329", size = 59353 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/89/446044f33aba0348d35e433f56d12206d010a5281a1df54054d4cfb82388/platformdirs-4.12.2-py3-none-any.whl", hash = "sha256:29dbf06d96c500bc6bdbce75fb0a14d63279c93b1842f97e72a135b33e856983", size = 32457 }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -322,6 +451,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/8d/60a50c3f36c85218a7ffcb48c6fe2ce1f7bec799152d68b8658ebed2179c/polars_runtime_32-1.43.2-cp310-abi3-win_arm64.whl", hash = "sha256:350a4868cae85bf8b3f81b33ba47927c15256bd9264dfc8c0753f1b927eac9d3", size = 46582513 }, ] +[[package]] +name = "pre-commit" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/13/b62d075317d8686071eb843f0bb1f195eb332f48869d3c31a4c6f1e063ac/pre_commit-4.1.0.tar.gz", hash = "sha256:ae3f018575a588e30dfddfab9a05448bfbd6b73d78709617b5a2b853549716d4", size = 193330 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/b3/df14c580d82b9627d173ceea305ba898dca135feb360b6d84019d0803d3b/pre_commit-4.1.0-py2.py3-none-any.whl", hash = "sha256:d29e7cb346295bcc1cc75fc3e92e343495e3ea0196c9ec6ba53f49f10ab6ae7b", size = 220560 }, +] + [[package]] name = "propcache" version = "0.5.2" @@ -401,6 +546,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750 }, ] +[[package]] +name = "python-discovery" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/57/250bd238b966cece44328235eb85290045d059265fdaf7527a3a958123db/python_discovery-1.6.1.tar.gz", hash = "sha256:cf87d3627dfb4412437fdd5b13eae402607722998d21567993aedbc59b23c15e", size = 84338 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/7d/e9ffbadfbf89c93848412d04594135c4ae8c1d37d9e053b9c3ed718fabc4/python_discovery-1.6.1-py3-none-any.whl", hash = "sha256:d43fcdef879fe795352bd13ccf8d185ba5a9f86f36cfcd00529f596e737442b3", size = 38664 }, +] + [[package]] name = "python-libsbml" version = "5.21.1" @@ -467,6 +624,119 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302 }, ] +[[package]] +name = "shfmt-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/91/0ae8bbc703ac6779427fcdb40da7784b844278032f7896dbe54cb7e11e0e/shfmt_py-4.2.0.tar.gz", hash = "sha256:cf7842d69e9f787ce97503c1280e65417f2c08014d594018dda245fcba96ff51", size = 30005 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/87/b07a16ffebc4f04ad7b7f1a6928edbe0c1d024be089e8e37bcf9bb963091/shfmt_py-4.2.0-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:aaf9e195c2e115309c32ef0d8d05b42afc4a23468b61230a09a8a4bdac95d027", size = 1552980 }, + { url = "https://files.pythonhosted.org/packages/e3/39/3b5c4061a4fa7dcfab88ca4bd17566132a82225aad78d6d5b42cab4beee7/shfmt_py-4.2.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:7b7fa81b120fdccebbe60b9c3411ce1b549fa2828f2809e8fa5e231a9bac65dd", size = 1425340 }, + { url = "https://files.pythonhosted.org/packages/53/32/9448afcca6739c64810143d4dacae6a817308aab71d083d39e91b13b31ee/shfmt_py-4.2.0-py2.py3-none-manylinux2014_aarch64.whl", hash = "sha256:e615cfcfe3e184f59e3ac2574895542976995a46f7506b327c8af71ae02de84c", size = 1399402 }, + { url = "https://files.pythonhosted.org/packages/01/8a/03246179f8bafea1c4f0b664ef5fcad1814f2cf55af6acb2f614463eca20/shfmt_py-4.2.0-py2.py3-none-manylinux2014_x86_64.whl", hash = "sha256:867b55792952d4e4aa27a1b2d578e63b745a77e11b7742d2f6258ef123a9c8f2", size = 1558575 }, + { url = "https://files.pythonhosted.org/packages/bd/4a/755b7c7cde56dd7306cea2a50e75a8a6a9281df52e124a5bb0b03474c6ae/shfmt_py-4.2.0-py2.py3-none-win_amd64.whl", hash = "sha256:566f46c036cb475ac84ac952d83122b67c9557418c24b04628c5782f9f57eccf", size = 1662632 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "taplo" +version = "0.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/79/513513960377e1212a28446acb323cf77dfce162e825a822f035b02a422d/taplo-0.9.3.tar.gz", hash = "sha256:6b73b45b9adbd20189d8981ac9055d5465227c58bbe1b0646a7588a1a5c07a1a", size = 102556 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/42/a93c18ebb7cf3ee2a7a30dd2fda654aca458956c3b64bdfb9d82b2c42679/taplo-0.9.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1c3db689406d538420c64aa779ac8694cf44c13a46e158d6df406de65980b9c7", size = 4248497 }, + { url = "https://files.pythonhosted.org/packages/82/d2/f5b6e4a4f474f9fe613b5b91012520c3f62e46748a6ce9fd61fc2fb52fa2/taplo-0.9.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1e7782f33f97e7aa658d18788748bce5cf3ce440eeb419cf5861cf542740e610", size = 4044421 }, + { url = "https://files.pythonhosted.org/packages/7d/32/4ac46ff15bb9d060f50ad31fb3a80aa8ee1e6ca500104ca8569f6fbdee3d/taplo-0.9.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29d4d7abfcc10bd536e5a43fe6ec2c1931507c1433e79df03ea22e1030611cb6", size = 4334420 }, + { url = "https://files.pythonhosted.org/packages/ef/cc/656aed22a59cf4c50dcaaa66aaa570d4a1412acdd8ea429120a6bb00f336/taplo-0.9.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f12648f273478d7330cb3529c82f48f388501e1122e0bea78bce5ff5972b8b", size = 4468935 }, + { url = "https://files.pythonhosted.org/packages/21/15/d8db1db6382b444122fa1a66fe5fe0dd5b04bfbe68c74bdb5345aec11eb2/taplo-0.9.3-py3-none-win32.whl", hash = "sha256:9ab7df76a3facc6d0dd2fe2dae3e8eb52fa458d31d27878d5eac14f5cbc0abac", size = 3482612 }, + { url = "https://files.pythonhosted.org/packages/42/3c/df6641d7e2e84a6dd4de3b3a4426db7f6a7270c05bbdeadd523645c9c45f/taplo-0.9.3-py3-none-win_amd64.whl", hash = "sha256:7d80b630b93fb43cee99d1e1ee07b616236dc5615efaf7cd51074b4cffc33bab", size = 3985843 }, +] + +[[package]] +name = "tree-sitter" +version = "0.25.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/7c/0350cfc47faadc0d3cf7d8237a4e34032b3014ddf4a12ded9933e1648b55/tree-sitter-0.25.2.tar.gz", hash = "sha256:fe43c158555da46723b28b52e058ad444195afd1db3ca7720c59a254544e9c20", size = 177961 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/9e/20c2a00a862f1c2897a436b17edb774e831b22218083b459d0d081c9db33/tree_sitter-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ddabfff809ffc983fc9963455ba1cecc90295803e06e140a4c83e94c1fa3d960", size = 146941 }, + { url = "https://files.pythonhosted.org/packages/ef/04/8512e2062e652a1016e840ce36ba1cc33258b0dcc4e500d8089b4054afec/tree_sitter-0.25.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c0c0ab5f94938a23fe81928a21cc0fac44143133ccc4eb7eeb1b92f84748331c", size = 137699 }, + { url = "https://files.pythonhosted.org/packages/47/8a/d48c0414db19307b0fb3bb10d76a3a0cbe275bb293f145ee7fba2abd668e/tree_sitter-0.25.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd12d80d91d4114ca097626eb82714618dcdfacd6a5e0955216c6485c350ef99", size = 607125 }, + { url = "https://files.pythonhosted.org/packages/39/d1/b95f545e9fc5001b8a78636ef942a4e4e536580caa6a99e73dd0a02e87aa/tree_sitter-0.25.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b43a9e4c89d4d0839de27cd4d6902d33396de700e9ff4c5ab7631f277a85ead9", size = 635418 }, + { url = "https://files.pythonhosted.org/packages/de/4d/b734bde3fb6f3513a010fa91f1f2875442cdc0382d6a949005cd84563d8f/tree_sitter-0.25.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbb1706407c0e451c4f8cc016fec27d72d4b211fdd3173320b1ada7a6c74c3ac", size = 631250 }, + { url = "https://files.pythonhosted.org/packages/46/f2/5f654994f36d10c64d50a192239599fcae46677491c8dd53e7579c35a3e3/tree_sitter-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:6d0302550bbe4620a5dc7649517c4409d74ef18558276ce758419cf09e578897", size = 127156 }, + { url = "https://files.pythonhosted.org/packages/67/23/148c468d410efcf0a9535272d81c258d840c27b34781d625f1f627e2e27d/tree_sitter-0.25.2-cp312-cp312-win_arm64.whl", hash = "sha256:0c8b6682cac77e37cfe5cf7ec388844957f48b7bd8d6321d0ca2d852994e10d5", size = 113984 }, +] + +[[package]] +name = "tree-sitter-cpp" +version = "0.23.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/2c/4dd63d705a8933543cad9b92ff31be849b164fec91a6eb63475ebc9ce668/tree_sitter_cpp-0.23.4.tar.gz", hash = "sha256:6a59c4cebb1ad1dc2e8d586cf8a72b39d21b8108b7b139d089719e81a339e41d", size = 940358 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/ac/11d56670f7b048362db872ca866fd00ba2002a322ab179f047b7c0fb2910/tree_sitter_cpp-0.23.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:aacb1759f0efd9dbc25bd8ee88184a340483018869f75412d9c3bc32c039a520", size = 287861 }, + { url = "https://files.pythonhosted.org/packages/12/1c/0337c016bdc00a77a3326d12f10ee836401dd28f27db6fd5b7734bfb21ed/tree_sitter_cpp-0.23.4-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc3c404d9f0cbd87951213a85440afbf4c31e718f8d907fa9ee12bea4b8d276f", size = 315513 }, + { url = "https://files.pythonhosted.org/packages/b3/7b/dd38c049b10ed7fda118b903a1d28a8b55a36b98c30606ef90e8f374c6de/tree_sitter_cpp-0.23.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc43ddf1279d5d5a4ef190373f4cb16522801bec4492bcd4754edf2aeba2b7b", size = 334813 }, + { url = "https://files.pythonhosted.org/packages/6a/4d/23e390234d2acd351f5563b1079c515d7c1fe13ddb7392cee543be74dda3/tree_sitter_cpp-0.23.4-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:773d2cafc08bbc0f998687fa33f42f378c1a371cdb582870c4d13abb06092706", size = 316110 }, + { url = "https://files.pythonhosted.org/packages/32/c7/b94a7e0e803af9d3bd4608fb4f0cfb2e9e233abaf0a38c928bfb0b1a025d/tree_sitter_cpp-0.23.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:247d127f0eb6574b0f6b30c0151e0bd0774e2e7acf9c558bdf9fbb8adc2e80c0", size = 308242 }, + { url = "https://files.pythonhosted.org/packages/37/7e/909e52b3dec09c475140b0e175511e275d0d00ba2dbd7c68102d377ae0f6/tree_sitter_cpp-0.23.4-cp39-abi3-win_amd64.whl", hash = "sha256:68606a45bea92669d155399e1239f771a7767d8683cd8f8e30e7d813107030ca", size = 290997 }, + { url = "https://files.pythonhosted.org/packages/d4/6a/65435d4d1f4c735be7ffe52d7c2e7b8a7f7c2790343a2719c60c548611c8/tree_sitter_cpp-0.23.4-cp39-abi3-win_arm64.whl", hash = "sha256:712f84f18be94cbe2a148fa4fdf40fcf4a8c25a8f7670efb9f8a47ddec2fc281", size = 288203 }, +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/e0/e63103c72a9d3dfd89a31e02e660263ad84b7438e5f44ee82e443e65bbde/tree_sitter_javascript-0.25.0.tar.gz", hash = "sha256:329b5414874f0588a98f1c291f1b28138286617aa907746ffe55adfdcf963f38", size = 132338 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/df/5106ac250cd03661ebc3cc75da6b3d9f6800a3606393a0122eca58038104/tree_sitter_javascript-0.25.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b70f887fb269d6e58c349d683f59fa647140c410cfe2bee44a883b20ec92e3dc", size = 64052 }, + { url = "https://files.pythonhosted.org/packages/b1/8f/6b4b2bc90d8ab3955856ce852cc9d1e82c81d7ab9646385f0e75ffd5b5d3/tree_sitter_javascript-0.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8264a996b8845cfce06965152a013b5d9cbb7d199bc3503e12b5682e62bb1de1", size = 66440 }, + { url = "https://files.pythonhosted.org/packages/5f/c4/7da74ecdcd8a398f88bd003a87c65403b5fe0e958cdd43fbd5fd4a398fcf/tree_sitter_javascript-0.25.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9dc04ba91fc8583344e57c1f1ed5b2c97ecaaf47480011b92fbeab8dda96db75", size = 99728 }, + { url = "https://files.pythonhosted.org/packages/96/c8/97da3af4796495e46421e9344738addb3602fa6426ea695be3fcbadbee37/tree_sitter_javascript-0.25.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:199d09985190852e0912da2b8d26c932159be314bc04952cf917ed0e4c633e6b", size = 106072 }, + { url = "https://files.pythonhosted.org/packages/13/be/c964e8130be08cc9bd6627d845f0e4460945b158429d39510953bbcb8fcc/tree_sitter_javascript-0.25.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dfcf789064c58dc13c0a4edb550acacfc6f0f280577f1e7a00de3e89fc7f8ddc", size = 104388 }, + { url = "https://files.pythonhosted.org/packages/ee/89/9b773dee0f8961d1bb8d7baf0a204ab587618df19897c1ef260916f318ec/tree_sitter_javascript-0.25.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1b852d3aee8a36186dbcc32c798b11b4869f9b5041743b63b65c2ef793db7a54", size = 98377 }, + { url = "https://files.pythonhosted.org/packages/3b/dc/d90cb1790f8cec9b4878d278ad9faf7c8f893189ce0f855304fd704fc274/tree_sitter_javascript-0.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:e5ed840f5bd4a3f0272e441d19429b26eedc257abe5574c8546da6b556865e3c", size = 62975 }, + { url = "https://files.pythonhosted.org/packages/2e/1f/f9eba1038b7d4394410f3c0a6ec2122b590cd7acb03f196e52fa57ebbe72/tree_sitter_javascript-0.25.0-cp310-abi3-win_arm64.whl", hash = "sha256:622a69d677aa7f6ee2931d8c77c981a33f0ebb6d275aa9d43d3397c879a9bb0b", size = 61668 }, +] + +[[package]] +name = "tree-sitter-python" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/8b/c992ff0e768cb6768d5c96234579bf8842b3a633db641455d86dd30d5dac/tree_sitter_python-0.25.0.tar.gz", hash = "sha256:b13e090f725f5b9c86aa455a268553c65cadf325471ad5b65cd29cac8a1a68ac", size = 159845 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/64/a4e503c78a4eb3ac46d8e72a29c1b1237fa85238d8e972b063e0751f5a94/tree_sitter_python-0.25.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:14a79a47ddef72f987d5a2c122d148a812169d7484ff5c75a3db9609d419f361", size = 73790 }, + { url = "https://files.pythonhosted.org/packages/e6/1d/60d8c2a0cc63d6ec4ba4e99ce61b802d2e39ef9db799bdf2a8f932a6cd4b/tree_sitter_python-0.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:480c21dbd995b7fe44813e741d71fed10ba695e7caab627fb034e3828469d762", size = 76691 }, + { url = "https://files.pythonhosted.org/packages/aa/cb/d9b0b67d037922d60cbe0359e0c86457c2da721bc714381a63e2c8e35eba/tree_sitter_python-0.25.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:86f118e5eecad616ecdb81d171a36dde9bef5a0b21ed71ea9c3e390813c3baf5", size = 108133 }, + { url = "https://files.pythonhosted.org/packages/40/bd/bf4787f57e6b2860f3f1c8c62f045b39fb32d6bac4b53d7a9e66de968440/tree_sitter_python-0.25.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be71650ca2b93b6e9649e5d65c6811aad87a7614c8c1003246b303f6b150f61b", size = 110603 }, + { url = "https://files.pythonhosted.org/packages/5d/25/feff09f5c2f32484fbce15db8b49455c7572346ce61a699a41972dea7318/tree_sitter_python-0.25.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e6d5b5799628cc0f24691ab2a172a8e676f668fe90dc60468bee14084a35c16d", size = 108998 }, + { url = "https://files.pythonhosted.org/packages/75/69/4946da3d6c0df316ccb938316ce007fb565d08f89d02d854f2d308f0309f/tree_sitter_python-0.25.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:71959832fc5d9642e52c11f2f7d79ae520b461e63334927e93ca46cd61cd9683", size = 107268 }, + { url = "https://files.pythonhosted.org/packages/ed/a2/996fc2dfa1076dc460d3e2f3c75974ea4b8f02f6bc925383aaae519920e8/tree_sitter_python-0.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:9bcde33f18792de54ee579b00e1b4fe186b7926825444766f849bf7181793a76", size = 76073 }, + { url = "https://files.pythonhosted.org/packages/07/19/4b5569d9b1ebebb5907d11554a96ef3fa09364a30fcfabeff587495b512f/tree_sitter_python-0.25.0-cp310-abi3-win_arm64.whl", hash = "sha256:0fbf6a3774ad7e89ee891851204c2e2c47e12b63a5edbe2e9156997731c128bb", size = 74169 }, +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/fc/bb52958f7e399250aee093751e9373a6311cadbe76b6e0d109b853757f35/tree_sitter_typescript-0.23.2.tar.gz", hash = "sha256:7b167b5827c882261cb7a50dfa0fb567975f9b315e87ed87ad0a0a3aedb3834d", size = 773053 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/95/4c00680866280e008e81dd621fd4d3f54aa3dad1b76b857a19da1b2cc426/tree_sitter_typescript-0.23.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3cd752d70d8e5371fdac6a9a4df9d8924b63b6998d268586f7d374c9fba2a478", size = 286677 }, + { url = "https://files.pythonhosted.org/packages/8f/2f/1f36fda564518d84593f2740d5905ac127d590baf5c5753cef2a88a89c15/tree_sitter_typescript-0.23.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:c7cc1b0ff5d91bac863b0e38b1578d5505e718156c9db577c8baea2557f66de8", size = 302008 }, + { url = "https://files.pythonhosted.org/packages/96/2d/975c2dad292aa9994f982eb0b69cc6fda0223e4b6c4ea714550477d8ec3a/tree_sitter_typescript-0.23.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b1eed5b0b3a8134e86126b00b743d667ec27c63fc9de1b7bb23168803879e31", size = 351987 }, + { url = "https://files.pythonhosted.org/packages/49/d1/a71c36da6e2b8a4ed5e2970819b86ef13ba77ac40d9e333cb17df6a2c5db/tree_sitter_typescript-0.23.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e96d36b85bcacdeb8ff5c2618d75593ef12ebaf1b4eace3477e2bdb2abb1752c", size = 344960 }, + { url = "https://files.pythonhosted.org/packages/7f/cb/f57b149d7beed1a85b8266d0c60ebe4c46e79c9ba56bc17b898e17daf88e/tree_sitter_typescript-0.23.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8d4f0f9bcb61ad7b7509d49a1565ff2cc363863644a234e1e0fe10960e55aea0", size = 340245 }, + { url = "https://files.pythonhosted.org/packages/8b/ab/dd84f0e2337296a5f09749f7b5483215d75c8fa9e33738522e5ed81f7254/tree_sitter_typescript-0.23.2-cp39-abi3-win_amd64.whl", hash = "sha256:3f730b66396bc3e11811e4465c41ee45d9e9edd6de355a58bbbc49fa770da8f9", size = 278015 }, + { url = "https://files.pythonhosted.org/packages/9f/e4/81f9a935789233cf412a0ed5fe04c883841d2c8fb0b7e075958a35c65032/tree_sitter_typescript-0.23.2-cp39-abi3-win_arm64.whl", hash = "sha256:05db58f70b95ef0ea126db5560f3775692f609589ed6f8dd0af84b7f19f1cbb7", size = 274052 }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -476,6 +746,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571 }, ] +[[package]] +name = "virtualenv" +version = "21.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/67/b5d37693e5e666b68100db8fe34f00db28279330db55f739a3c799ff2449/virtualenv-21.14.1.tar.gz", hash = "sha256:719b189804e66678017d9f63bbfc590c44f6b96ab4829513806394f9b375929c", size = 5462838 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/a8/22be2d14ef0d7b1e9e7dc1d839a85073fc20917be1747d6f1347d1cfc8c3/virtualenv-21.14.1-py3-none-any.whl", hash = "sha256:6fd04089fc0dc33549e7abdff70fc3b63d4e15799f2dbf3281f80d13b9fce522", size = 5486534 }, +] + [[package]] name = "yarl" version = "1.24.5" diff --git a/viewer/README.md b/viewer/README.md index 1eea13e..13a045e 100644 --- a/viewer/README.md +++ b/viewer/README.md @@ -2,6 +2,8 @@ The MicroSimulator viewer displays cells, device walls, and signal fields so you can inspect a population in its microfluidic environment. Use it to explore saved scenes or follow a live simulation with growth-rate coloring, nutrient slices, and individual-cell inspection. +See [tutorial commands by backend and shell](../docs/tutorials/commands.md#live-view-stop-and-restart) for copyable CPU/Metal/CUDA selection, PowerShell quoting, paths with spaces, checkpoint resume, and Stop/restart. Multiline commands below use POSIX shell backslashes. + The viewer is a TypeScript and Three.js client for `microsimulator-scene` documents. Standalone mode reads scene files; live mode sends typed controls to a Python-owned engine session and verifies every returned scene document. Python owns the model, simulation clock, backend, and checkpoint writer. ## Run locally @@ -18,6 +20,18 @@ Open the local URL printed by Vite and load `viewer-demo.scene.json`. The viewer ## Run a live session +Start with the [mother-machine example](../examples/mother_machine.py). Six narrow, closed-ended growth channels open into a perfusion channel. Each starts with seven rod-shaped cells. Cells grow, divide, and push their neighbors toward the opening; descendants entering the perfusion channel move downstream and are removed near the outlet. Every cell is movable. Device walls and cell contacts retain the closed-end lineage. + +The growth channels use the 25 × 1.5 × 1.4 µm dimensions reported by Wang et al. (2010). The perfusion channel and array layout are compact modeling choices. A native shallow-flow solve supplies a steady velocity field, and native transport carries dye and nutrient through the device. Nutrient availability regulates growth and cells consume nutrient as they elongate. See the [mother-machine guide](../docs/tutorials/mother-machine.md) for the experimental source, parameters, lineage tracking, and numerical limits. + +The command below explicitly accelerates growth for interactive viewing. Without the `growth_rate` override, the maximum elongation rate is ln(2)/1800 per second. Colors under **Cell type** distinguish the tracked closed-end lineages (type 0) from other cells (type 1). Choose **Nutrient** to inspect substrate delivery or **Growth rate** to inspect cell growth. Add `--parameter cells=false` for a transport-only run. + +```console +uv run --no-sync microsimulator view --model examples/mother_machine.py --parameter growth_rate=0.3 --backend cpu --seed 42 --dt 0.025 --frame-steps 2 --fps 20 --viewer-dist viewer/dist --open +``` + +Click **Play**, keep **Show signal grid** and **Show device geometry** enabled, and select **XY plane (Z)** at slice **1** (the growth-channel height). Dyed and clear medium alternate every three seconds: flow carries the pulses along the perfusion channel, and diffusion carries dye into the dead ends. **Reset** restores the seeded channels and restarts the pulses. The viewer displays dye transport; it does not currently draw velocity arrows or streamlines. + From the repository root: ```console @@ -33,13 +47,34 @@ uv run microsimulator view \ --open ``` -Without `--open`, open the tokenized loopback URL printed by `microsimulator`. The live transport can play, pause, advance one step, rebuild the original model, and write to the configured checkpoint destination. Camera position, display mapping, grid slice, and selected-cell identity survive frame updates. +Without `--open`, open the tokenized loopback URL printed by `microsimulator`. The live transport can play, pause, advance one step, rebuild the original model, write to the configured checkpoint destination, and stop the session. Camera position, display mapping, grid slice, and selected-cell identity survive frame updates. + +### Stop one model and start another + +Click **Stop session** or press **Ctrl+C** once in the terminal running the server. The current individual step or checkpoint write finishes, the browser displays **Stopped**, and the command returns to the prompt. A large playback batch does not have to finish. Start another `microsimulator view` command using the same port and open the new printed URL. Reset rebuilds the current model; Pause keeps its process available; closing the browser pauses it and allows reconnection. Stop does not automatically save a checkpoint: use Checkpoint first if you need restartable state. + +For example, after stopping the trap model above, launch a different model on the same default port: + +```console +uv run microsimulator view --model examples/tutorials/biophysics.py --backend cpu --seed 42 --dt 0.02 --port 8765 --open +``` + +The command is a single line and also works in PowerShell 7.3+ where the Python/native build is available; configure [Standard argument passing](../docs/tutorials/commands.md#choose-a-shell) for JSON-valued parameters. To distinguish Windows console behavior from browser behavior, use this manual verification procedure in an attached PowerShell or Command Prompt console: + +1. Record the Windows version, terminal application/version, Python version, and exact launch command. Start the command above and click Stop while paused. Confirm the prompt returns, then start the second model on port 8765. +2. Repeat with Play active and `--frame-steps 10000`. Confirm Stopping transitions to Stopped without finishing the entire batch. +3. Repeat using Ctrl+C once, both paused and playing. Confirm the prompt returns without `taskkill`, then immediately start another model on the same port. +4. Close only the browser tab during Play, then reopen the printed URL. Confirm the session remains available and paused. + +The automated `python/tests/test_viewer_shutdown.py` suite covers same-socket Stop, checkpoint completion, worker cleanup, and repeated real subprocess restarts. It sends SIGINT on POSIX. On Windows it starts each viewer in an isolated console and uses a separate attached sender to deliver a real [Windows CTRL_C_EVENT](https://learn.microsoft.com/en-us/windows/console/generateconsolectrlevent), leaving the test runner unaffected. Both paths verify orderly browser notifications, clean process exit, and three different models reusing the same port. This exercises the operating-system interruption path; use the manual procedure above to check a particular interactive terminal application and keyboard configuration. + +The `Windows CLI and live-session checks` GitHub Actions job builds the CPU extension on `windows-2025` and runs the server and shutdown tests, including isolated-console Ctrl+C, with dependencies from `uv.lock`. Its uploaded report records Windows, PowerShell, Python, backend availability, and individual test results. ## Capabilities - SHA-256 verification over the Python writer's RFC 8785 canonical frame; - strict scene v2 structural and numerical validation; -- instanced cylinder and sphere rendering for exact spherocylinder geometry; +- instanced open cylinders and matching hemispheres for continuous capsule surfaces; - device walls rendered from plane, sphere, box, and cylinder constraints; - orbit, pan, zoom, colony framing, raycast picking, and selection highlighting; - a draggable camera-synchronized flat-corner view cube with readable labels, shortest-path single-click snapping, and double-click label leveling; @@ -54,6 +89,8 @@ Cell IDs remain decimal strings throughout the browser because their unsigned 64 ## Validate +`pnpm --dir viewer format` applies the shared repository formatter to the viewer. `format:check` checks formatting, logical paragraph spacing, lint, and function limits without rewriting files; see [CONTRIBUTING.md](../CONTRIBUTING.md). + ```console pnpm --dir viewer format:check pnpm --dir viewer check @@ -62,3 +99,69 @@ pnpm --dir viewer build ``` The unit suite includes a Python-authored scene fixture whose digest contains floating-point values that ordinary Python and JavaScript JSON serializers spell differently. Passing that test is the cross-language integrity gate. + +## Dataset presentation lifecycle + +Opening a scene file, live session, or recording begins a new dataset. Call `DatasetPresentationState.beginDataset()` and `ColonyViewer.beginDataset()` once, then present its first frame with `setFrame(frame, true)` to fit the camera. Ordinary updates, a reset of the same live model, and recording seeks use `setFrame(frame)` without beginning a dataset. Neither simulation time returning to zero nor a changed signal-grid shape identifies a new dataset. + +`DatasetPresentationState.datasetId` scopes numerical channel identities; display labels are not identities. Retained preferences are separate from the effective values returned by `forFrame()`. Temporarily absent channels or smaller grids use valid display indices without erasing the user's selections, signal visibility, or chosen slice. The first available signal grid initializes a default slice once. Feature-specific display state should reset only in the explicit `newDataset` block in `presentScene()`. + +The ground reference grid is separate from the scientific signal lattice. Its square extent and origin come from the first frame's finite device geometry (boxes, spheres, and cylinders), or from the initial cell capsule bounds when no finite device exists. Infinite plane constraints are excluded. The extent is at least 10 scene distance units with 20 equal divisions; the grid plane is 0.01 units below the lesser of the initial lower Z bound and zero. An initially empty dataset uses a 10-unit grid centered on the world origin. These values remain fixed even when cells or device geometry appear later, the colony expands beyond the grid, or all cells disappear. Opening another dataset initializes a new reference grid; camera Fit never changes its geometry. + +`browser/reference-grid.mjs` verifies the reference grid and presentation lifecycle in Chromium against a running Vite server. It uses Playwright (`@playwright/test`) and its installed Chromium; a shared installation can be supplied through `MICROSIMULATOR_PLAYWRIGHT_MODULE` as an absolute module filename. Set `VIEWER_URL` if the server is not on `http://127.0.0.1:4320`, and `EVIDENCE_DIR` to choose the screenshot directory. The test observes renderer transforms through test-only request instrumentation and introduces no production debug interface. + +## Concentration color ranges + +Species coloring and signal slices each offer Automatic and Fixed color ranges. Automatic uses the current frame's species extrema or the selected signal slice's extrema. Constant automatic data uses the midpoint color and a uniform legend; empty automatic data shows “no values” without numerical bounds. Fixed uses the entered minimum and maximum across frames and slices. Values outside that interval use endpoint colors; the underlying concentrations and inspector values remain unchanged. + +Switching to Fixed starts from the current extrema (with finite padding for constant data), or restores that channel's previously entered fixed bounds. Edit both bounds and choose Apply range or press Enter. Bounds accept finite decimal numbers, including negative numbers and scientific notation, with minimum strictly less than maximum. Invalid or incomplete edits show an explanation and leave the last valid range active. Legends always describe the active range rather than unsubmitted text. + +Settings belong to the numerical species or signal channel within the current dataset. They survive temporarily missing channels/grids, live updates, same-model reset, and frame seeking; opening another dataset restores automatic defaults. The shared `resolveScalarRange()` and `normalizeScalar()` APIs reject non-finite data explicitly, retain zero-valued and negative data, and avoid overflowing the difference between extreme finite bounds. They produce display intensity only and do not modify model data. + +`browser/scalar-ranges.mjs` checks actual cell instance colors, signal texture pixels, legends, validation messages, keyboard interaction, and dataset transitions in Chromium. Run it with a Vite server on port 4315, or set `VIEWER_URL`, using the same optional Playwright module and evidence-directory environment variables as the reference-grid test. + +## Channel labels + +Model-defined species and signal names appear in channel selectors, the species legend, and cell inspection. Duplicate names include their channel indices; unnamed channels retain `Channel N`. Names are presentation text; indices continue to identify selected channels. Current readers accept scene v2, v3, and v4, while writers emit v4. See the [authoring guide](../docs/models/channel-labels.md) and [scene v4 schema](../docs/formats/scene-v4.md). + +### Device geometry visibility + +Use **Show device geometry** in the Scene panel to hide all mechanical constraint meshes and their outlines. The control is disabled when the current frame contains no geometry, while its preference is retained for later frames. Visibility persists through live updates, reset, and replay seeks, and defaults to enabled on opening another dataset. Cells, selection, the reference grid, signal slices, camera pose, and the existing Fit bounds policy are independent of this display setting. No simulation constraint or transport obstacle is changed. + +`browser/device-visibility.mjs` verifies all four constraint types, outlines, keyboard toggling, cell picking, sibling visibility, frame/reset retention, missing geometry, camera and Fit invariance, and new-dataset defaults against Vite on port 4323. It uses the same Playwright module and evidence-directory options as the reference-grid browser test. + +## Composite species colors + +Choose Species composite to display several intracellular channels together. The first two available channels initially use red and green and are enabled; additional channels start disabled. Enable or disable each channel with its checkbox. Display settings exposes its tint (a six-digit sRGB hexadecimal color), the same Automatic/Fixed range editor used by single-species coloring, and controls for reordering the list. Tint or range changes apply when submitted, and invalid edits preserve the active value. + +For each cell, every enabled channel is independently normalized with its selected range. Tints are decoded from sRGB into linear RGB; normalized intensity multiplies each linear tint, the contributions are added, and each summed component is clipped to one. The result is encoded back to sRGB for the existing renderer interface, which converts its instance colors to linear RGB. Channel-list order has no effect on the result. Full red and full green therefore produce yellow. The legend lists enabled channel names, tints, and active bounds. Three.js uses a small approximation in its sRGB encoding function; conversion tests bound the resulting error below 0.00001, well below an 8-bit color step. + +When all channels are disabled or unavailable, cells use neutral gray and the legend says no channels are active. With active channels and fixed zero-based bounds, zero intensity is black. Automatic constant data uses the same midpoint convention as single-species coloring, including a constant zero field; choose fixed zero-based bounds when zero should mean no displayed contribution. These colors are a presentation mapping, not calibrated fluorescence measurements. Lighting, tone mapping, and selection highlighting can further affect the final pixel appearance. + +Tints, visibility, list order, and ranges remain associated with numerical channel identity when labels change or data temporarily disappears. Single-species and composite views share each species channel's range. Same-model reset and frame seeking retain settings; opening another dataset restores defaults. The cell inspector, picking, selection highlights, and lineage values continue using the original cell state. + +`browser/composite-species.mjs` exercises red-only, green-only, co-expressing, and zero-expression cells in a moving colony, verifies the rendered instance colors, and checks controls, ordering, picking, highlighting, lineage, and dataset transitions. It uses a Vite server on port 4319 (or `VIEWER_URL`) and the same Playwright/evidence environment variables as the other browser checks. + +## Replay a recording + +Record periodic checkpoints using the short native growth/division/removal example, then list the checkpoint paths in the order they should play: + +```sh +uv run microsimulator run --model examples/replay_demo.py --backend cpu --seed 17 --steps 5 --dt 0.2 --checkpoint-every 1 --output run/replay.json +uv run microsimulator export-replay run/replay.step-00000001.json run/replay.step-00000002.json run/replay.step-00000003.json run/replay.step-00000004.json run/replay.step-00000005.json --output run/replay-bundle +pnpm --dir viewer dev +``` + +Open the displayed viewer URL, choose **Open recording**, and select the `run/replay-bundle` folder. Select the folder itself, containing `manifest.json` and `frames`, rather than one frame file. The standalone viewer reads the selected local files; no simulation server or source GPU is required. + +Use Play/Pause, Previous/Next, the frame slider and Frames/s. Slider arrow keys seek one recorded frame; the buttons also work with keyboard focus. Manual seeking pauses playback. The transport displays a one-based frame position and recorded simulation time, while the manifest uses zero-based ordinals. Equal-time frames remain individually selectable. Playback stops at the end; Play then restarts from frame one. Opening a static scene ends the recording session. + +Source paths are used exactly in command-line order; avoid relying on shell globs to establish chronological ordering. The final `run/replay.json` duplicates the last periodic state in this example and is intentionally omitted. Decreasing times cause an error. The exporter refuses existing destinations; choose a new bundle directory for another export. Model parameters and source are unnecessary for export, and no callbacks execute during playback. + +The reader loads frames on demand through a bounded three-frame/64 MiB accounting-budget LRU cache; it does not decode the whole recording. Oversized frames are uncached, and renderer/current-load allocations exist outside that cache. See the [replay format](../docs/formats/replay-v1.md) for integrity, provenance, resource bounds and failure behavior. This first implementation imports checkpoint sequences; live recording, video export and timeline-based simulation restart are separate features. + +For browser regression checks, generate native fixtures with `.venv/bin/python viewer/browser/replay-fixtures.py /tmp/replay-fixtures`, run the viewer on port 4326, then run `viewer/browser/replay.mjs` with `REPLAY_FIXTURES=/tmp/replay-fixtures` and `MICROSIMULATOR_PLAYWRIGHT_MODULE` pointing to an installed Playwright module. This uses the existing shared browser harness and adds no production debug API. + +Capsule geometry tests verify the scene's cylindrical centerline length, constant radius, spherical ends, zero-length sphere case, arbitrary orientation, outward topology, exact equator positions/normals, and ray picking. The selected-cell overlay uses the same geometry with radius increased by 8%; the cap centers retain the original centerline length. Three instanced draw calls represent the colony, independent of cell count. Replacing frames disposes both geometry and instance buffers. + +For visual and GPU-resource checks, see [the capsule browser regression](browser/README.md). Mesh tessellation and pixel aliasing can still affect silhouettes at distant zoom levels; the shared tangent joins specifically remove overlapping end disks and mismatched sphere/cylinder boundaries. The viewer does not smooth or modify simulated cell motion. diff --git a/viewer/browser/README.md b/viewer/browser/README.md new file mode 100644 index 0000000..a4efe02 --- /dev/null +++ b/viewer/browser/README.md @@ -0,0 +1,39 @@ +# Capsule rendering verification + +`capsules.mjs` exercises the real viewer renderer through a development-server-only test injection. It records the isolated rod and dense colony at near/far camera distances, 48 frames of prescribed colony motion, stable-ID selection through frame reordering/removal, colors, pointer picking, selection geometry, and a selected zero-length cell. The generated fixtures use arbitrary 3D rod directions; no model or scientific output is modified. + +Start this worktree's Vite server and run the script from the repository root with an existing Playwright installation: + +```console +pnpm --dir viewer install --frozen-lockfile +pnpm --dir viewer dev --host 127.0.0.1 --port 4321 +``` + +In another terminal: + +```console +MICROSIMULATOR_PLAYWRIGHT_MODULE=/absolute/path/to/@playwright/test/index.mjs CAPSULE_MODE=corrected EVIDENCE_DIR=/tmp/capsule-corrected node viewer/browser/capsules.mjs +``` + +`VIEWER_URL` selects another server. For a before/after comparison, run the same script against a separate checkout of `b69193b9` served on another port, using `CAPSULE_MODE=baseline` and a different evidence directory. The script changes only the browser's fetched development module to observe the renderer; it does not patch the checkout. Baseline mode records rather than asserts the corrected GPU-resource and highlight invariants. Both runs use the same camera, lighting, colors, timestep sequence, and fixture parameters. + +The performance fixture renders 512 cells after ten warmup frames, measuring 60 complete frame replacements (transform upload, coloring, rendering, and `gl.finish()` GPU synchronization). It reports median/p95 wall times, browser/WebGL renderer, three draw calls, per-mesh vertices/triangles, and geometry/buffer counts. Timings include CPU work and synchronization and are local regression evidence, not a cross-device benchmark. It tracks actual `createBuffer`/`deleteBuffer` calls to detect instance-buffer leaks that `renderer.info.memory.geometries` alone misses. Corrected mode requires stable buffer counts across replacements and zero tracked buffers after an empty frame and viewer disposal. + +Inspect the PNGs and recorded WebM listed in `results.json`. Compare the tangent joins under identical lighting, distinguishing the reproduced ring seams from silhouette tessellation, pixel aliasing, and the fixture's deliberately changing orientation/position. Do not use test/build success alone to declare the visual artifact resolved. + +## Combined feedback campaign + +The validation branch includes `feedback-fixtures.py` and `feedback-integration.mjs` to exercise the completed issue stack together. The native exporter produces a growth/division/removal recording and constant-concentration presentation snapshots with missing/reappearing signal grids and device geometry. The browser check verifies fixed species/signal ranges, composite tints/order/enabled state, device visibility, stable-ID selection, camera/reference-grid retention, reverse/rapid seeking, damaged-frame recovery, keyboard navigation and the supported 880px layout. These synthetic presentation transitions are not claims about evolving native device geometry. + +```console +uv run --no-sync python viewer/browser/feedback-fixtures.py /tmp/microsimulator-feedback/fixtures-v3 +pnpm --dir viewer dev --host 127.0.0.1 --port 4330 +``` + +In another POSIX terminal, run: + +```console +MICROSIMULATOR_PLAYWRIGHT_MODULE=/absolute/path/to/@playwright/test/index.mjs EVIDENCE_DIR=/tmp/microsimulator-feedback node viewer/browser/feedback-integration.mjs +``` + +Use a new fixture/output directory for another export. Existing issue browser hooks accept Vite's cache-busting module query strings so hot reload during combined validation does not disable test-only observation. These scripts never install production debug globals. diff --git a/viewer/browser/capsules.mjs b/viewer/browser/capsules.mjs new file mode 100644 index 0000000..3744fd4 --- /dev/null +++ b/viewer/browser/capsules.mjs @@ -0,0 +1,381 @@ +// Vite browser regression: same fixture/camera/light for baseline and corrected meshes. +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; + +const { chromium } = await import( + process.env.MICROSIMULATOR_PLAYWRIGHT_MODULE ?? "@playwright/test" +); +const url = process.env.VIEWER_URL ?? "http://127.0.0.1:4321"; +const mode = process.env.CAPSULE_MODE ?? "corrected"; +const evidence = + process.env.EVIDENCE_DIR ?? `/tmp/microsimulator-capsules-${mode}`; +await mkdir(evidence, { recursive: true }); +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext({ + viewport: { width: 1440, height: 960 }, + recordVideo: { dir: evidence, size: { width: 1440, height: 960 } }, +}); +const page = await context.newPage(); +const errors = []; +page.on("pageerror", (error) => { + errors.push(error.message); + console.error(error.message); +}); +page.on("console", (message) => { + if (message.type() === "error") console.error(message.text()); +}); +page.on("requestfailed", (request) => + console.error(request.url(), request.failure()), +); +await page.route("**/src/colony-viewer.ts*", async (route) => { + const response = await route.fetch(); + const source = await response.text(); + const marker = "this.onSelection = onSelection;"; + assert.equal(source.split(marker).length, 2); + await route.fulfill({ + response, + body: source.replace(marker, `${marker}\nglobalThis.__testViewer = this;`), + }); +}); + +try { + await page.goto(url); + await page.waitForFunction(() => globalThis.__testViewer !== undefined); + await page.evaluate(() => { + const v = globalThis.__testViewer; + v.renderer.setAnimationLoop(null); + v.controls.enableDamping = false; + v.grid.visible = false; + const gl = v.renderer.getContext(); + const create = gl.createBuffer.bind(gl); + const remove = gl.deleteBuffer.bind(gl); + const buffers = new Set(); + gl.createBuffer = () => { + const b = create(); + buffers.add(b); + + return b; + }; + gl.deleteBuffer = (b) => { + buffers.delete(b); + + return remove(b); + }; + globalThis.__liveBuffers = buffers; + globalThis.__fixture = (count = 1, time = 0) => { + const columns = count === 1 ? 1 : Math.ceil(Math.sqrt(count * 2)); + const rows = Math.ceil(count / columns); + + return { + time, + backend: { + kind: "cpu", + name: "synthetic geometry fixture", + device: "host", + deviceIndex: 0, + native: true, + }, + speciesCount: 0, + signalGrid: null, + constraints: { boxes: [], cylinders: [], planes: [], spheres: [] }, + cells: Array.from({ length: count }, (_, i) => { + const angle = 0.4 + 0.09 * Math.sin(i * 0.7 + time); + const tilt = 0.15 * Math.cos(i * 0.9 + time); + + return { + id: String(i + 1), + parentId: null, + slot: i, + position: [ + ((i % columns) - (columns - 1) / 2) * 1.9 + + 0.04 * Math.sin(time + i), + (Math.floor(i / columns) - (rows - 1) / 2) * 1.05, + 0, + ], + direction: [ + Math.cos(angle) * Math.cos(tilt), + Math.sin(angle) * Math.cos(tilt), + Math.sin(tilt), + ], + length: count === 1 ? 3 : 1.05, + radius: count === 1 ? 0.6 : 0.4, + growthRate: 0, + cellType: i % 3, + fixed: false, + species: [], + }; + }), + }; + }; + globalThis.__present = (count, time, distance) => { + const frame = globalThis.__fixture(count, time); + v.setFrame(frame, false); + v.grid.visible = false; + v.setCellColors( + frame.cells.map((cell) => + cell.cellType === 0 + ? [0.65, 0.85, 0.72] + : cell.cellType === 1 + ? [0.85, 0.55, 0.3] + : [0.45, 0.65, 0.9], + ), + ); + + if (distance !== undefined) { + v.camera.position.set(0, -distance, distance * 0.65); + v.camera.up.set(0, 0, 1); + v.controls.target.set(0, 0, 0); + v.camera.lookAt(v.controls.target); + v.controls.update(); + } + + v.renderer.render(v.scene, v.camera); + }; + document.querySelector("#empty-state").hidden = true; + }); + + for (const [name, count, distance] of [ + ["isolated-near", 1, 6], + ["isolated-far", 1, 18], + ["colony-near", 64, 12], + ["colony-far", 64, 32], + ]) { + await page.evaluate( + ([n, d]) => globalThis.__present(n, 0, d), + [count, distance], + ); + await page + .locator("#canvas-host") + .screenshot({ path: `${evidence}/${name}.png` }); + } + + for (let frame = 0; frame < 48; frame++) { + await page.evaluate((time) => { + globalThis.__present(64, time, 12); + + return new Promise(requestAnimationFrame); + }, frame / 24); + + if (frame % 12 === 0) { + await page + .locator("#canvas-host") + .screenshot({ path: `${evidence}/moving-${frame}.png` }); + } + } + + const metrics = await page.evaluate(async () => { + const v = globalThis.__testViewer; + const samples = []; + const resources = []; + + for (let frame = 0; frame < 70; frame++) { + await new Promise(requestAnimationFrame); + const start = performance.now(); + globalThis.__present(512, frame / 24, 60); + // Complete GPU work so timings compare actual rendering, not only enqueue time. + v.renderer.getContext().finish(); + + if (frame >= 10) samples.push(performance.now() - start); + + if (frame === 10 || frame === 69) { + resources.push({ + geometries: v.renderer.info.memory.geometries, + buffers: globalThis.__liveBuffers.size, + }); + } + } + + const info = { ...v.renderer.info.render }; + const gl = v.renderer.getContext(); + const debug = gl.getExtension("WEBGL_debug_renderer_info"); + const device = debug + ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) + : gl.getParameter(gl.RENDERER); + const vertices = v.cellMeshes.map( + (mesh) => mesh.geometry.getAttribute("position").count, + ); + const triangles = v.cellMeshes.map((mesh) => mesh.geometry.index.count / 3); + samples.sort((a, b) => a - b); + v.setFrame(globalThis.__fixture(0), false); + v.renderer.render(v.scene, v.camera); + + return { + samples: samples.length, + device, + medianMilliseconds: samples[30], + p95Milliseconds: samples[57], + render: info, + vertices, + triangles, + resources, + emptyBuffers: globalThis.__liveBuffers.size, + }; + }); + + const behavior = await page.evaluate(() => { + const v = globalThis.__testViewer; + globalThis.__present(2, 0, 6); + v.selectCell(1); + const selected = v.selectedCellId; + const frame = globalThis.__fixture(2, 1); + frame.cells.reverse(); + frame.cells = frame.cells.map((cell, slot) => ({ ...cell, slot })); + v.setFrame(frame, false); + v.setCellColors([ + [1, 0, 0], + [0, 1, 0], + ]); + const retained = v.selectedCellId; + const highlights = v.highlight.children.map((mesh) => { + mesh.updateMatrixWorld(true); + + return { matrix: mesh.matrixWorld.elements.slice() }; + }); + const colors = v.cellMeshes.map((mesh) => + Array.from(mesh.instanceColor.array), + ); + v.selectCell(null); + const center = v.camera.position + .clone() + .fromArray(frame.cells[0].position) + .project(v.camera); + const canvas = v.renderer.domElement.getBoundingClientRect(); + v.renderer.render(v.scene, v.camera); + + return { + selected, + retained, + highlights, + colors, + point: { + x: canvas.x + ((center.x + 1) * canvas.width) / 2, + y: canvas.y + ((1 - center.y) * canvas.height) / 2, + }, + }; + }); + assert.equal(behavior.selected, "2"); + assert.equal(behavior.retained, "2"); + + for (const colors of behavior.colors) + assert.deepEqual(colors, [1, 0, 0, 0, 1, 0]); + + await page.mouse.click(behavior.point.x, behavior.point.y); + assert.equal( + await page.evaluate(() => globalThis.__testViewer.selectedCellId), + "2", + ); + const highlightError = await page.evaluate(() => { + const v = globalThis.__testViewer; + const selected = v.cells.find((cell) => cell.id === v.selectedCellId); + const center = v.camera.position.clone().fromArray(selected.position); + const axis = center.clone().fromArray(selected.direction).normalize(); + let error = 0; + + for (const mesh of v.highlight.children) { + mesh.updateMatrixWorld(true); + const positions = mesh.geometry.getAttribute("position"); + + for (let i = 0; i < positions.count; i++) { + const point = center + .clone() + .fromBufferAttribute(positions, i) + .applyMatrix4(mesh.matrixWorld); + const along = point.clone().sub(center).dot(axis); + const nearest = center + .clone() + .addScaledVector( + axis, + Math.max( + -selected.length / 2, + Math.min(selected.length / 2, along), + ), + ); + error = Math.max( + error, + Math.abs(point.distanceTo(nearest) - selected.radius * 1.08), + ); + } + } + + v.renderer.render(v.scene, v.camera); + + return error; + }); + + if (mode === "corrected") assert.ok(highlightError < 1e-6); + + await page + .locator("#canvas-host") + .screenshot({ path: `${evidence}/selected.png` }); + const removed = await page.evaluate(() => { + const v = globalThis.__testViewer; + v.setFrame(globalThis.__fixture(1), false); + + return { selected: v.selectedCellId, visible: v.highlight.visible }; + }); + assert.deepEqual(removed, { selected: null, visible: false }); + + if (mode === "corrected") { + const zero = await page.evaluate(() => { + const v = globalThis.__testViewer; + const frame = globalThis.__fixture(1); + frame.cells[0].length = 0; + v.setFrame(frame, false); + v.setCellColors([[0.6, 0.8, 0.7]]); + v.selectCell(0); + v.renderer.render(v.scene, v.camera); + + return v.highlight.children.every((mesh) => + mesh.matrix.elements.every(Number.isFinite), + ); + }); + assert.equal(zero, true, "zero-length selection has finite transforms"); + await page + .locator("#canvas-host") + .screenshot({ path: `${evidence}/zero-length-selected.png` }); + } + + if (mode === "corrected") { + assert.equal( + metrics.render.calls, + 3, + "cell rendering stays at three instanced draws", + ); + assert.equal( + metrics.resources[0].buffers, + metrics.resources[1].buffers, + "frame replacement must free old instance buffers", + ); + assert.equal( + metrics.emptyBuffers, + 0, + "empty frame must free cell GPU buffers", + ); + } + + assert.deepEqual(errors, []); + const disposedBuffers = await page.evaluate(() => { + globalThis.__testViewer.dispose(); + + return globalThis.__liveBuffers.size; + }); + + if (mode === "corrected") assert.equal(disposedBuffers, 0); + + const report = { + mode, + browser: browser.version(), + fixture: + "512 cells; 10 warmup + 60 measured replacement/color/render/gl.finish frames; fixed camera and lighting; 64-cell prescribed motion video", + metrics, + behavior, + highlightError, + disposedBuffers, + video: await page.video().path(), + }; + await writeFile(`${evidence}/results.json`, JSON.stringify(report, null, 2)); + console.log(JSON.stringify(report, null, 2)); +} finally { + await context.close(); + await browser.close(); +} diff --git a/viewer/browser/channel-labels.mjs b/viewer/browser/channel-labels.mjs new file mode 100644 index 0000000..5145222 --- /dev/null +++ b/viewer/browser/channel-labels.mjs @@ -0,0 +1,149 @@ +// Uses the shared external Playwright harness; no application debug API is shipped. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir, readFile } from "node:fs/promises"; +import canonicalize from "canonicalize"; + +const { chromium, expect } = await import( + process.env.MICROSIMULATOR_PLAYWRIGHT_MODULE ?? "@playwright/test" +); +const url = process.env.VIEWER_URL ?? "http://127.0.0.1:4318"; +const evidence = + process.env.EVIDENCE_DIR ?? "/tmp/microsimulator-channel-labels"; +await mkdir(evidence, { recursive: true }); +const browser = await chromium.launch({ headless: true }); +try { + const page = await browser.newPage({ + viewport: { width: 1440, height: 960 }, + }); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.route("**/src/colony-viewer.ts*", async (route) => { + const response = await route.fetch(); + const source = await response.text(); + const marker = "this.onSelection = onSelection;"; + assert.equal(source.split(marker).length, 2); + await route.fulfill({ + response, + body: source.replace( + marker, + `${marker}\nglobalThis.__testViewer = this;`, + ), + }); + }); + let socket; + await page.routeWebSocket("**/api/v1/session?*", (connection) => { + socket = connection; + }); + await page.goto(`${url}/?token=fixture`); + await expect.poll(() => socket !== undefined).toBe(true); + const document = JSON.parse( + await readFile( + new URL("../tests/fixtures/channels-v3.scene.json", import.meta.url), + "utf8", + ), + ); + let revision = 0; + async function send() { + document.integrity.frame = createHash("sha256") + .update(canonicalize(document.frame)) + .digest("hex"); + socket.send( + JSON.stringify({ + type: "frame", + revision: revision++, + completed_steps: revision, + playing: false, + checkpoint_enabled: false, + scene: document, + }), + ); + } + await send(); + await expect(page.locator("#species-channel option")).toHaveText([ + "α 🧪 [0]", + "α 🧪 [1]", + ]); + await expect(page.locator("#signal-channel option")).toHaveText([ + "Channel 0", + "Channel 1", + ]); + await page.selectOption("#color-mode", "species"); + await page.selectOption("#species-channel", "1"); + await page.selectOption("#signal-channel", "1"); + await expect(page.locator("#legend-title")).toHaveText("α 🧪 [1]"); + await page.evaluate(() => globalThis.__testViewer.selectCell(0)); + await expect(page.locator("#species-values li span")).toHaveText([ + "α 🧪 [0]", + "α 🧪 [1]", + ]); + await expect( + page.locator("#species-values b, #species-channel b, #legend-title b"), + ).toHaveCount(0); + document.frame.channel_metadata = { + species: ["Green reporter", "Red reporter"], + signals: ["Nutrient", "Extracellular cue"], + }; + document.frame.time = 1; + await send(); + await expect(page.locator("#species-channel")).toHaveValue("1"); + await expect(page.locator("#signal-channel")).toHaveValue("1"); + await expect(page.locator("#species-channel option")).toHaveText([ + "Green reporter", + "Red reporter", + ]); + await expect(page.locator("#signal-channel option")).toHaveText([ + "Nutrient", + "Extracellular cue", + ]); + await expect(page.locator("#legend-title")).toHaveText("Red reporter"); + await expect(page.locator("#species-values li span")).toHaveText([ + "Green reporter", + "Red reporter", + ]); + document.frame.time = 0; + await send(); + await expect(page.locator("#time-chip")).toHaveText("t = 0"); + await expect(page.locator("#species-channel")).toHaveValue("1"); + // Literal labels may imitate automatically generated duplicate suffixes. + document.frame.species_count = 3; + + for (const cell of document.frame.cells) cell.species.push(0.25); + + document.frame.channel_metadata.species = ["GFP", "GFP", "GFP [0]"]; + await send(); + const indexed = ["GFP [0]", "GFP [1]", "GFP [0] [2]"]; + await expect(page.locator("#species-channel option")).toHaveText(indexed); + await expect(page.locator("#species-values li span")).toHaveText(indexed); + await expect(page.locator("#species-channel")).toHaveValue("1"); + await page.selectOption("#species-channel", "2"); + await expect(page.locator("#legend-title")).toHaveText("GFP [0] [2]"); + document.frame.channel_metadata.species = [ + " GFP\tname ", + "GFP name", + "GFP\t name [0]", + ]; + await send(); + const normalized = ["GFP name [0]", "GFP name [1]", "GFP name [0] [2]"]; + await expect(page.locator("#species-channel option")).toHaveText(normalized); + assert.deepEqual( + await page + .locator("#species-channel option") + .evaluateAll((options) => options.map((option) => option.label)), + normalized, + ); + await expect(page.locator("#species-channel")).toHaveValue("2"); + await expect(page.locator("#legend-title")).toHaveText("GFP name [0] [2]"); + await page.screenshot({ path: `${evidence}/named-channels.png` }); + assert.deepEqual(errors, []); + console.log( + JSON.stringify({ + status: "passed", + assertions: + "selector names, duplicate, whitespace and generated-suffix disambiguation, HTML text safety, Unicode, inspector, legend, index-stable renamed frames, reset time", + screenshot: `${evidence}/named-channels.png`, + }), + ); +} finally { + await browser.close(); +} diff --git a/viewer/browser/composite-species.mjs b/viewer/browser/composite-species.mjs new file mode 100644 index 0000000..eca5e78 --- /dev/null +++ b/viewer/browser/composite-species.mjs @@ -0,0 +1,349 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir } from "node:fs/promises"; +import canonicalize from "canonicalize"; +const { chromium, expect } = await import( + process.env.MICROSIMULATOR_PLAYWRIGHT_MODULE ?? "@playwright/test" +); +const url = process.env.VIEWER_URL ?? "http://127.0.0.1:4319"; +const evidence = + process.env.EVIDENCE_DIR ?? "/tmp/microsimulator-composite-species"; +await mkdir(evidence, { recursive: true }); +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); +const errors = []; +page.on("pageerror", (error) => errors.push(error.message)); +await page.route("**/src/colony-viewer.ts*", async (route) => { + const response = await route.fetch(); + const source = await response.text(); + const marker = "this.onSelection = onSelection;"; + assert.equal(source.split(marker).length, 2); + await route.fulfill({ + response, + body: source.replace(marker, `${marker}\nglobalThis.__testViewer = this;`), + }); +}); +let socket; +await page.routeWebSocket("**/api/v1/session?*", (connection) => { + socket = connection; +}); +const base = { + backend: { + kind: "cpu", + name: "CPU fixture", + device: "host", + device_index: 0, + native: true, + }, + time: 0, + species_count: 2, + channel_metadata: { + species: ["Red reporter", "Green reporter"], + signals: [], + }, + cells: [ + [1, 0], + [0, 1], + [1, 1], + [0, 0], + ].map((species, slot) => ({ + id: String(slot + 1), + parent_id: slot === 2 ? "1" : null, + slot, + position: [slot % 2 === 0 ? -2 : 2, slot < 2 ? -2 : 2, 0.6], + direction: [1, 0, 0], + length: 1.5, + radius: 0.45, + growth_rate: 0.1, + cell_type: slot, + fixed: false, + species, + })), + constraints: { boxes: [], cylinders: [], planes: [], spheres: [] }, + signal_grid: null, +}; +function scene(frame) { + return { + format: "microsimulator-scene", + version: 3, + producer: { name: "microsimulator", version: "test" }, + integrity: { + algorithm: "sha256", + frame: createHash("sha256").update(canonicalize(frame)).digest("hex"), + }, + frame, + }; +} +let revision = 0; +async function send(frame) { + socket.send( + JSON.stringify({ + type: "frame", + revision: revision++, + completed_steps: revision, + playing: false, + checkpoint_enabled: false, + scene: scene(frame), + }), + ); + await expect(page.locator("#time-chip")).toHaveText(`t = ${frame.time}`); +} +const row = (index) => + page.locator(`.composite-channel[data-channel="${index}"]`); +async function fixed(index, minimum, maximum) { + const group = row(index); + + if (!(await group.locator("details").evaluate((details) => details.open))) + await group.locator("summary").click(); + + await group + .getByRole("combobox", { name: "Species range mode", exact: true }) + .selectOption("fixed"); + await group + .getByRole("textbox", { name: "Species minimum", exact: true }) + .fill(String(minimum)); + await group + .getByRole("textbox", { name: "Species maximum", exact: true }) + .fill(String(maximum)); + await group + .getByRole("textbox", { name: "Species maximum", exact: true }) + .press("Enter"); +} +async function colors() { + return page.evaluate(() => + Array.from( + globalThis.__testViewer.cellMeshes[0]?.instanceColor?.array ?? [], + ), + ); +} +function close(actual, expected, tolerance = 1e-5) { + assert.equal(actual.length, expected.length); + actual.forEach((value, index) => + assert.ok( + Math.abs(value - expected[index]) < tolerance, + `component ${index}: ${value} != ${expected[index]}`, + ), + ); +} +async function clickCell(index) { + const position = await page.evaluate((index) => { + const v = globalThis.__testViewer; + v.camera.updateMatrixWorld(true); + const point = v.camera.position + .clone() + .fromArray(v.cells[index].position) + .project(v.camera); + const bounds = v.renderer.domElement.getBoundingClientRect(); + + return { + x: bounds.x + ((point.x + 1) * bounds.width) / 2, + y: bounds.y + ((1 - point.y) * bounds.height) / 2, + }; + }, index); + await page.mouse.click(position.x, position.y); +} +try { + await page.goto(`${url}/?token=composite-test`); + await expect.poll(() => socket !== undefined).toBe(true); + await send(base); + await page.locator("#color-mode").selectOption("composite"); + await fixed(0, 0, 1); + await fixed(1, 0, 1); + const redGreenYellowBlack = [1, 0, 0, 0, 1, 0, 1, 1, 0, 0, 0, 0]; + close(await colors(), redGreenYellowBlack); + await expect(page.locator("#composite-legend")).toContainText( + "Red reporter · #ff0000 · Fixed 0 to 1", + ); + await expect(page.locator("#composite-legend")).toContainText( + "Green reporter · #00ff00 · Fixed 0 to 1", + ); + await row(0).locator("summary").click(); + await row(1).locator("summary").click(); + await page.evaluate(() => { + const v = globalThis.__testViewer; + v.controls.enableDamping = false; + v.camera.position + .sub(v.controls.target) + .multiplyScalar(1.25) + .add(v.controls.target); + v.controls.update(); + }); + await page.screenshot({ path: `${evidence}/expression-patterns.png` }); + await clickCell(2); + await expect(page.locator("#selection-title")).toHaveText("Cell 3"); + await expect( + page + .locator("#cell-details > div") + .filter({ has: page.locator("dt", { hasText: "Parent" }) }) + .locator("dd"), + ).toHaveText("1"); + await expect(page.locator("#species-values code")).toHaveText(["1", "1"]); + assert.equal( + await page.evaluate(() => globalThis.__testViewer.highlight.visible), + true, + ); + await page.screenshot({ path: `${evidence}/selected-patterns.png` }); + + for (let time = 1; time <= 3; time += 1) { + await send({ + ...base, + time, + cells: base.cells.map((cell) => ({ + ...cell, + position: [ + cell.position[0] + time / 3, + cell.position[1] - time / 4, + cell.position[2], + ], + })), + }); + close(await colors(), redGreenYellowBlack); + await expect(page.locator("#selection-title")).toHaveText("Cell 3"); + assert.equal( + await page.evaluate(() => globalThis.__testViewer.highlight.visible), + true, + ); + } + + await page.screenshot({ path: `${evidence}/moving-patterns.png` }); + await row(1) + .getByRole("checkbox", { name: "Enable Green reporter" }) + .uncheck(); + close(await colors(), [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0]); + await row(0).getByRole("checkbox", { name: "Enable Red reporter" }).uncheck(); + await expect(page.locator("#composite-legend")).toContainText( + "No channels active", + ); + const neutral = await page.evaluate(async () => { + const { COMPOSITE_NEUTRAL } = await import("/src/composite-color.ts"); + const { Color, SRGBColorSpace } = + await import("/node_modules/.vite/deps/three.js"); + const value = new Color().setRGB(...COMPOSITE_NEUTRAL, SRGBColorSpace); + + return [value.r, value.g, value.b]; + }); + close(await colors(), [...neutral, ...neutral, ...neutral, ...neutral]); + await row(0).getByRole("checkbox").check(); + await row(1).getByRole("checkbox").check(); + close(await colors(), redGreenYellowBlack); + await row(1).locator("summary").click(); + const beforeOrder = await colors(); + await row(1).getByRole("button", { name: "Move Green reporter up" }).click(); + assert.deepEqual( + await page + .locator(".composite-channel") + .evaluateAll((rows) => rows.map((row) => row.dataset.channel)), + ["1", "0"], + ); + assert.deepEqual(await colors(), beforeOrder); + await expect( + row(1).getByRole("textbox", { name: "Species maximum", exact: true }), + ).toHaveValue("1"); + + await fixed(0, 0, 2); + await page.locator("#color-mode").selectOption("species"); + await page.locator("#species-channel").selectOption("0"); + await expect( + page + .locator("#species-range") + .getByRole("textbox", { name: "Species maximum", exact: true }), + ).toHaveValue("2"); + await page + .locator("#species-range") + .getByRole("textbox", { name: "Species maximum", exact: true }) + .fill("3"); + await page + .locator("#species-range") + .getByRole("textbox", { name: "Species maximum", exact: true }) + .press("Enter"); + await page.locator("#color-mode").selectOption("composite"); + await expect( + row(0).getByRole("textbox", { name: "Species maximum", exact: true }), + ).toHaveValue("3"); + await fixed(0, 0, 1); + await row(0).getByRole("textbox", { name: "Channel tint" }).fill("#808080"); + await row(0).getByRole("textbox", { name: "Channel tint" }).press("Enter"); + await send({ + ...base, + time: 4, + cells: [{ ...base.cells[0], species: [0.5, 0] }, ...base.cells.slice(1)], + }); + const decode = (value) => ((value + 0.055) / 1.055) ** 2.4; + close((await colors()).slice(0, 3), Array(3).fill(0.5 * decode(128 / 255))); + const validTintColors = await colors(); + await row(0).getByRole("textbox", { name: "Channel tint" }).fill("invalid"); + await row(0).getByRole("textbox", { name: "Channel tint" }).press("Enter"); + await expect( + row(0).locator(".composite-tint-form [role=status]"), + ).toContainText("six-digit"); + assert.deepEqual(await colors(), validTintColors); + await row(0).getByRole("textbox", { name: "Channel tint" }).fill("#ff0000"); + await row(0).getByRole("textbox", { name: "Channel tint" }).press("Enter"); + await send({ + ...base, + time: 5, + cells: [{ ...base.cells[0], species: [-1, 2] }, ...base.cells.slice(1)], + }); + close((await colors()).slice(0, 3), [0, 1, 0]); + await clickCell(0); + await expect(page.locator("#species-values code")).toHaveText(["-1", "2"]); + + await row(1).getByRole("checkbox").uncheck(); + await send({ + ...base, + time: 6, + species_count: 0, + channel_metadata: { species: [], signals: [] }, + cells: [], + }); + await expect(page.locator("#selection-title")).toHaveText("No cell selected"); + await send(base); // Backward seek / live reset contract. + await expect(page.locator("#color-mode")).toHaveValue("composite"); + await expect(row(1).getByRole("checkbox")).not.toBeChecked(); + await expect( + row(0).getByRole("textbox", { name: "Species maximum", exact: true }), + ).toHaveValue("1"); + assert.deepEqual( + await page + .locator(".composite-channel") + .evaluateAll((rows) => rows.map((row) => row.dataset.channel)), + ["1", "0"], + ); + + await page.goto(url); + const open = async (frame) => { + await page.locator("#scene-file").setInputFiles({ + name: "named.scene.json", + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify(scene(frame))), + }); + await expect(page.locator("#time-chip")).toHaveText(`t = ${frame.time}`); + }; + await open(base); + await page.locator("#color-mode").selectOption("composite"); + await row(0).getByRole("checkbox").uncheck(); + await fixed(1, 0, 20); + await open({ ...base, time: 99 }); + await page.locator("#color-mode").selectOption("composite"); + await expect(row(0).getByRole("checkbox")).toBeChecked(); + await row(1).locator("summary").click(); + await expect( + row(1).getByRole("combobox", { name: "Species range mode", exact: true }), + ).toHaveValue("automatic"); + assert.deepEqual(errors, []); + console.log( + JSON.stringify( + { + result: "passed", + browser: browser.version(), + evidence, + coverage: + "linear RGB/colorspace, coexpression/motion, disable/neutral, reorder, shared ranges, tint validation, clipping, picking/highlight/lineage, absent channels/reset/new dataset", + }, + null, + 2, + ), + ); +} finally { + await browser.close(); +} diff --git a/viewer/browser/device-visibility.mjs b/viewer/browser/device-visibility.mjs new file mode 100644 index 0000000..6838cc2 --- /dev/null +++ b/viewer/browser/device-visibility.mjs @@ -0,0 +1,277 @@ +// Run against the Vite development server. No production debug hooks are added. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir } from "node:fs/promises"; +import canonicalize from "canonicalize"; + +const { chromium, expect } = await import( + process.env.MICROSIMULATOR_PLAYWRIGHT_MODULE ?? "@playwright/test" +); +const url = process.env.VIEWER_URL ?? "http://127.0.0.1:4323"; +const evidence = + process.env.EVIDENCE_DIR ?? "/tmp/microsimulator-device-visibility"; +await mkdir(evidence, { recursive: true }); +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }); +const errors = []; +page.on("pageerror", (error) => errors.push(error.message)); + +// Observe the real application instance rather than replacing its renderer. +await page.route("**/src/colony-viewer.ts*", async (route) => { + const response = await route.fetch(); + const source = await response.text(); + const marker = "this.onSelection = onSelection;"; + assert.equal(source.split(marker).length, 2); + await route.fulfill({ + response, + body: source.replace(marker, `${marker}\nglobalThis.__testViewer = this;`), + }); +}); +let socket; +await page.routeWebSocket("**/api/v1/session?*", (connection) => { + socket = connection; +}); +const boundary = { kind: "no_flux", values: [] }; +function signalGrid(shape = [5, 7, 9], signalCount = 3) { + return { + signal_count: signalCount, + shape, + origin: [-2, -3, 0], + spacing: [1, 1, 1], + boundaries: { + x_lower: boundary, + x_upper: boundary, + y_lower: boundary, + y_upper: boundary, + z_lower: boundary, + z_upper: boundary, + }, + levels: Array(shape.reduce((a, b) => a * b, signalCount)).fill(1), + }; +} +const cell = { + id: "1", + parent_id: null, + slot: 0, + position: [0, 0, 0.6], + direction: [1, 0, 0], + length: 2, + radius: 0.5, + growth_rate: 0.1, + cell_type: 0, + fixed: false, + species: [1, 2, 3], +}; +const base = { + backend: { + kind: "cpu", + name: "CPU fixture", + device: "host", + device_index: 0, + native: true, + }, + time: 0, + species_count: 3, + cells: [cell], + constraints: { boxes: [], cylinders: [], planes: [], spheres: [] }, + signal_grid: signalGrid(), +}; +function scene(frame) { + return { + format: "microsimulator-scene", + version: 2, + producer: { name: "microsimulator", version: "test" }, + integrity: { + algorithm: "sha256", + frame: createHash("sha256").update(canonicalize(frame)).digest("hex"), + }, + frame, + }; +} +let revision = 0; +async function send(frame) { + socket.send( + JSON.stringify({ + type: "frame", + revision: revision++, + completed_steps: revision, + playing: false, + checkpoint_enabled: false, + scene: scene(frame), + }), + ); + await expect(page.locator("#time-chip")).toHaveText(`t = ${frame.time}`); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); +} +const constraints = { + boxes: [ + { + id: "1", + center: [4, 0, 1], + half_extents: [1, 2, 1], + allowed_region: "outside", + coefficient: 1, + }, + ], + spheres: [ + { + id: "2", + center: [-4, 0, 1], + radius: 1.5, + allowed_region: "outside", + coefficient: 1, + }, + ], + cylinders: [ + { + id: "3", + center: [0, 4, 1], + radius: 1, + half_height: 2, + allowed_region: "outside", + coefficient: 1, + }, + ], + planes: [ + { id: "4", point: [0, 0, -0.5], inward_normal: [0, 0, 1], coefficient: 1 }, + ], +}; +const all = { ...base, constraints }; +async function snapshot() { + return page.evaluate(() => { + const v = globalThis.__testViewer; + v.grid.updateMatrixWorld(true); + + return { + camera: [ + ...v.camera.position.toArray(), + ...v.camera.quaternion.toArray(), + ], + bounds: [v.sceneBounds.min.toArray(), v.sceneBounds.max.toArray()], + grid: v.grid.matrixWorld.elements.slice(), + visible: v.device.visible, + deviceChildren: v.device.children.length, + cells: v.colony.visible && v.cellMeshes.length > 0, + highlight: v.highlight.visible, + signal: v.signal.visible && v.signal.children.length > 0, + }; + }); +} +function assertSnapshot(actual, expected, message) { + actual.camera.forEach((value, index) => + assert.ok(Math.abs(value - expected.camera[index]) < 1e-9, message), + ); + assert.deepEqual( + { ...actual, camera: [] }, + { ...expected, camera: [] }, + message, + ); +} +async function pickCell() { + const point = await page.evaluate(() => { + const v = globalThis.__testViewer; + const rect = v.renderer.domElement.getBoundingClientRect(); + const p = v.camera.position.clone().set(0, 0, 0.6).project(v.camera); + + return [ + rect.left + ((p.x + 1) * rect.width) / 2, + rect.top + ((1 - p.y) * rect.height) / 2, + ]; + }); + await page.mouse.click(...point); + await expect(page.locator("#selection-title")).toHaveText("Cell 1"); +} +try { + await page.goto(`${url}/?token=device-test`); + await expect.poll(() => socket !== undefined).toBe(true); + await send(all); + const toggle = page.getByLabel("Show device geometry", { exact: true }); + await expect(toggle).toBeEnabled(); + await expect(toggle).toBeChecked(); + await pickCell(); + const initial = await snapshot(); + assert.equal( + initial.deviceChildren, + 6, + "all four meshes plus box/cylinder outlines", + ); + assert.ok(initial.cells && initial.signal && initial.highlight); + await page.screenshot({ path: `${evidence}/visible.png` }); + await toggle.focus(); + await page.keyboard.press("Space"); + await expect(toggle).not.toBeChecked(); + const hidden = await snapshot(); + assertSnapshot(hidden, { ...initial, visible: false }); + await page.screenshot({ path: `${evidence}/hidden.png` }); + await page.locator("#clear-selection").click(); + await pickCell(); + + for (const time of [1, 20, 3, 0]) { + await send({ ...all, time }); + await expect(toggle).not.toBeChecked(); + const current = await snapshot(); + assertSnapshot( + current, + hidden, + "updates/reset/reverse-time retain display state", + ); + } + + await send({ ...base, time: 21 }); + await expect(toggle).toBeDisabled(); + await expect(toggle).not.toBeChecked(); + await send({ ...all, time: 22 }); + await expect(toggle).toBeEnabled(); + await expect(toggle).not.toBeChecked(); + await page.locator("#fit-button").click(); + await expect + .poll(async () => + page.evaluate(() => globalThis.__testViewer.cameraTransition === null), + ) + .toBe(true); + const fittedHidden = await snapshot(); + await toggle.focus(); + await page.keyboard.press("Space"); + const shown = await snapshot(); + assertSnapshot(shown, { ...fittedHidden, visible: true }); + await page.locator("#fit-button").click(); + await expect + .poll(async () => + page.evaluate(() => globalThis.__testViewer.cameraTransition === null), + ) + .toBe(true); + assertSnapshot( + await snapshot(), + shown, + "Fit bounds and pose are visibility independent", + ); + // A separate standalone file opening starts a new dataset and restores enabled. + await page.goto(url); + const file = { + name: "device.json", + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify(scene(all))), + }; + await page.locator("#scene-file").setInputFiles(file); + await expect(toggle).toBeChecked(); + await toggle.uncheck(); + await page.locator("#scene-file").setInputFiles(file); + await expect(toggle).toBeChecked(); + await pickCell(); + assert.deepEqual(errors, []); + console.log( + JSON.stringify({ + result: "passed", + browser: browser.version(), + assertions: + "device meshes/outlines, sibling visibility, keyboard, picking, camera, Fit, live updates/reset, missing geometry, new dataset", + }), + ); +} finally { + await browser.close(); +} diff --git a/viewer/browser/feedback-fixtures.py b/viewer/browser/feedback-fixtures.py new file mode 100644 index 0000000..c1911c0 --- /dev/null +++ b/viewer/browser/feedback-fixtures.py @@ -0,0 +1,77 @@ +"""Generate browser fixtures using the public model, checkpoint and exporter APIs.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from microsimulator import ( + BackendKind, + BoxConstraintInit, + CellInit, + ChannelMetadata, + ConstraintRegion, + GridShape, + ModelContext, + SignalGridSpec, + Simulation, + Vec3, + build_model, + run_simulation, + save_checkpoint, +) +from microsimulator.replay import export_replay + +parser = argparse.ArgumentParser() +parser.add_argument("output", type=Path) +root = parser.parse_args().output +root.mkdir(parents=True, exist_ok=True) +model, provenance = build_model("examples/replay_demo.py", ModelContext(BackendKind.CPU, 0, 17)) +summary = run_simulation( + model, + steps=5, + dt=0.2, + output=root / "lifecycle.json", + checkpoint_every=1, + provenance=provenance, +) +export_replay(summary.periodic_checkpoints, root / "lifecycle") +paths = [] +# Data-only compatible scene states exercise unavailable grids and clamping. +# Distinct CPU simulations produce these grid fixtures; no biology claim is made. +for ordinal, size in enumerate((3, None, 1, 3)): + simulation = Simulation(species_count=2) + cell = CellInit() + cell.species = [0.25, 0.75] + cell.growth_rate = 0 # presentation fixture: keep concentrations comparable across time + + if size is not None: + shape = GridShape() + shape.x, shape.y, shape.z = size, size, size + spec = SignalGridSpec() + spec.signal_count = 2 + spec.shape = shape + spec.spacing = Vec3(1, 1, 1) + spec.diffusion = [0, 0] + spec.advection = [Vec3(), Vec3()] + simulation.configure_signal_grid(spec, [0.25] * size**3 + [0.75] * size**3) + + if ordinal != 1: + box = BoxConstraintInit() + box.half_extents = Vec3(10, 10, 2) + box.allowed_region = ConstraintRegion.INSIDE + simulation.add_box_constraint(box) + + simulation.add_cell(cell) + simulation.step(ordinal * 0.2) + path = root / f"grid-{ordinal}.json" + save_checkpoint( + simulation, + path, + channel_metadata=ChannelMetadata( + species=("Green", "Red"), signals=("Nutrient", "Cue") if size else () + ), + ) + paths.append(path) +export_replay(paths, root / "grids") +print(root) diff --git a/viewer/browser/feedback-integration.mjs b/viewer/browser/feedback-integration.mjs new file mode 100644 index 0000000..987a738 --- /dev/null +++ b/viewer/browser/feedback-integration.mjs @@ -0,0 +1,341 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { cp, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import canonicalize from "canonicalize"; + +const { chromium, expect } = await import( + process.env.MICROSIMULATOR_PLAYWRIGHT_MODULE ?? "@playwright/test" +); +const url = process.env.VIEWER_URL ?? "http://127.0.0.1:4330"; +const evidence = process.env.EVIDENCE_DIR ?? "/tmp/microsimulator-feedback"; +const fixtures = process.env.REPLAY_FIXTURES ?? `${evidence}/fixtures-v3`; +await mkdir(evidence, { recursive: true }); +const browser = await chromium.launch({ headless: true }); +try { + const page = await browser.newPage({ + viewport: { width: 1440, height: 960 }, + }); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.route("**/src/colony-viewer.ts*", async (route) => { + const response = await route.fetch(); + const source = await response.text(); + const marker = "this.onSelection = onSelection;"; + assert.equal(source.split(marker).length, 2); + await route.fulfill({ + response, + body: source.replace( + marker, + `${marker}\nglobalThis.__testViewer = this;`, + ), + }); + }); + await page.route("**/src/replay-bundle.ts*", async (route) => { + const response = await route.fetch(); + const source = await response.text(); + const marker = "const bytes = await this.read(file, signal);"; + assert.equal(source.split(marker).length, 2); + await route.fulfill({ + response, + body: source.replace( + marker, + `await globalThis.__delayReplayLoad?.(ordinal);\n${marker}`, + ), + }); + }); + await page.goto(url); + async function open(name, count) { + await page + .locator("#recording-folder") + .setInputFiles(path.resolve(fixtures, name)); + await expect(page.locator("#replay-position")).toContainText( + `1 / ${count}`, + ); + await expect(page.locator("#replay-message")).toHaveText(""); + } + async function seek(ordinal, count) { + await page.locator("#replay-timeline").fill(String(ordinal)); + await page.locator("#replay-timeline").dispatchEvent("input"); + await expect(page.locator("#replay-position")).toContainText( + `${ordinal + 1} / ${count}`, + ); + await expect(page.locator("#replay-message")).toHaveText(""); + } + const snapshot = () => + page.evaluate(() => { + const viewer = globalThis.__testViewer; + viewer.grid.updateMatrixWorld(true); + + return { + camera: viewer.camera.position.toArray(), + target: viewer.controls.target.toArray(), + grid: viewer.grid.matrixWorld.elements.slice(), + }; + }); + function assertStationary(actual, expected) { + assert.deepEqual(actual.grid, expected.grid); + + for (const key of ["camera", "target"]) + actual[key].forEach((value, index) => { + assert.ok( + Math.abs(value - expected[key][index]) < 1e-9, + `${key}[${index}] remains stationary`, + ); + }); + } + await open("lifecycle", 5); + await expect(page.locator("#time-chip")).toHaveText("t = 0.2"); + await page.evaluate(() => { + const v = globalThis.__testViewer; + v.selectCell(0); + v.controls.enableDamping = false; + v.camera.position.multiplyScalar(2); + v.controls.update(); + }); + const initial = await snapshot(); + await expect(page.locator("#selection-title")).toHaveText("Cell 1"); + await seek(1, 5); + await expect(page.locator("#selection-title")).toHaveText("No cell selected"); + await expect(page.locator("#cell-count")).toHaveText("2"); + await page.evaluate(() => globalThis.__testViewer.selectCell(1)); + await expect(page.locator("#selection-title")).toHaveText("Cell 3"); + await seek(2, 5); + await expect(page.locator("#selection-title")).toHaveText("Cell 3"); + await expect(page.locator("#cell-count")).toHaveText("1"); + await expect(page.locator("#cell-details")).toContainText("Slot0"); + await seek(1, 5); + await expect(page.locator("#cell-details")).toContainText("Slot1"); + assertStationary(await snapshot(), initial); + await page.selectOption("#color-mode", "species"); + await expect(page.locator("#legend-title")).toHaveText("Reporter"); + // Delay an uncached frame and supersede it while its decode worker is occupied. + await page.evaluate(() => { + globalThis.__delayReplayLoad = (ordinal) => + ordinal === 4 + ? new Promise((resolve) => { + globalThis.__releaseReplay = resolve; + }) + : undefined; + }); + await page.locator("#replay-timeline").fill("4"); + await page.locator("#replay-timeline").dispatchEvent("input"); + await expect + .poll(() => page.evaluate(() => typeof globalThis.__releaseReplay)) + .toBe("function"); + await page.locator("#replay-timeline").fill("3"); + await page.locator("#replay-timeline").dispatchEvent("input"); + await page.evaluate(() => { + globalThis.__releaseReplay(); + globalThis.__delayReplayLoad = undefined; + }); + await expect(page.locator("#replay-position")).toContainText("4 / 5"); + await expect(page.locator("#time-chip")).toHaveText("t = 0.8"); + await expect(page.locator("#legend-title")).toHaveText("Reporter"); + // Playing while an uncached seek is pending must preserve its destination. + await seek(0, 5); + await seek(1, 5); + await seek(4, 5); + await page.locator("#replay-fps").fill("1"); + await page.locator("#replay-fps").dispatchEvent("change"); + await page.evaluate(() => { + globalThis.__delayReplayLoad = (ordinal) => + ordinal === 2 + ? new Promise((resolve) => { + globalThis.__releasePending = resolve; + }) + : undefined; + }); + await page.locator("#replay-timeline").fill("2"); + await page.locator("#replay-timeline").dispatchEvent("input"); + await expect + .poll(() => page.evaluate(() => typeof globalThis.__releasePending)) + .toBe("function"); + await page.locator("#replay-play").click(); + await expect(page.locator("#replay-timeline")).toHaveValue("2"); + await page.evaluate(() => { + globalThis.__releasePending(); + globalThis.__delayReplayLoad = undefined; + }); + await expect(page.locator("#replay-position")).toContainText("3 / 5"); + await expect(page.locator("#replay-play")).toHaveText("Pause"); + await page.locator("#replay-play").click(); + await seek(0, 5); + await page.locator("#replay-fps").fill("20"); + await page.locator("#replay-fps").dispatchEvent("change"); + await page.locator("#replay-play").click(); + await expect(page.locator("#replay-position")).toContainText("5 / 5"); + await expect(page.locator("#replay-play")).toHaveText("Play"); + await page.locator("#replay-previous").focus(); + await page.keyboard.press("Enter"); + await expect(page.locator("#replay-position")).toContainText("4 / 5"); + assertStationary(await snapshot(), initial); + await page.screenshot({ path: `${evidence}/lifecycle-replay.png` }); + await open("grids", 4); + async function fixed(root, kind, minimum, maximum) { + await root.getByLabel(`${kind} range mode`).selectOption("fixed"); + await root + .getByLabel(`${kind} minimum`, { exact: true }) + .fill(String(minimum)); + await root + .getByLabel(`${kind} maximum`, { exact: true }) + .fill(String(maximum)); + await root + .getByRole("button", { name: "Apply range", exact: true }) + .click(); + } + await page.selectOption("#color-mode", "species"); + await fixed(page.locator("#species-range"), "Species", 0, 1); + await page.selectOption("#species-channel", "1"); + await fixed(page.locator("#species-range"), "Species", 0, 2); + await page.selectOption("#signal-channel", "1"); + await fixed(page.locator("#signal-color-range"), "Signal", 0, 2); + await page.locator("#signal-slice").fill("2"); + await page.locator("#signal-slice").dispatchEvent("input"); + await page.locator("#signal-visible").focus(); + await page.keyboard.press("Space"); + await page.getByLabel("Show device geometry", { exact: true }).focus(); + await page.keyboard.press("Space"); + await page.selectOption("#color-mode", "composite"); + const blue = page.locator('.composite-channel[data-channel="1"]'); + await blue.locator("summary").click(); + await blue.getByLabel("Channel tint").fill("#0000ff"); + await blue.getByRole("button", { name: "Apply tint", exact: true }).click(); + await blue.getByRole("button", { name: "Move Red up", exact: true }).click(); + const colors = () => + page.evaluate(() => + Array.from(globalThis.__testViewer.cellMeshes[0].instanceColor.array), + ); + const composite = await colors(); + assert.ok(Math.abs(composite[0] - 0.25) < 1e-5); + assert.equal(composite[1], 0); + assert.ok(Math.abs(composite[2] - 0.375) < 1e-5); + await page.getByLabel("Enable Green", { exact: true }).uncheck(); + const retainedColors = await colors(); + assert.equal(retainedColors[0], 0); + await page.evaluate(() => { + const v = globalThis.__testViewer; + v.selectCell(0); + v.controls.enableDamping = false; + v.camera.position.multiplyScalar(1.3); + v.controls.update(); + }); + const gridInitial = await snapshot(); + await seek(1, 4); + await expect(page.locator("#signal-section")).toBeHidden(); + await expect(page.locator("#device-visible")).toBeDisabled(); + await seek(2, 4); + await expect(page.locator("#signal-slice")).toHaveValue("0"); + await seek(3, 4); + await expect(page.locator("#signal-slice")).toHaveValue("2"); + await expect(page.locator("#signal-channel")).toHaveValue("1"); + await expect(page.locator("#species-channel")).toHaveValue("1"); + await expect(page.locator("#signal-visible")).not.toBeChecked(); + await expect(page.locator("#device-visible")).not.toBeChecked(); + await expect(page.locator("#device-visible")).toBeEnabled(); + await expect(page.locator("#selection-title")).toHaveText("Cell 1"); + await expect(page.locator("#composite-legend")).toContainText( + "Red · #0000ff · Fixed 0 to 2", + ); + await expect( + page.getByLabel("Enable Green", { exact: true }), + ).not.toBeChecked(); + assert.deepEqual(await colors(), retainedColors); + assert.deepEqual( + await page + .locator(".composite-channel") + .evaluateAll((rows) => rows.map((row) => row.dataset.channel)), + ["1", "0"], + ); + assert.equal( + await page.evaluate(() => globalThis.__testViewer.device.visible), + false, + ); + await expect( + page + .locator("#signal-color-range") + .getByLabel("Signal maximum", { exact: true }), + ).toHaveValue("2"); + await seek(0, 4); + assert.deepEqual(await colors(), retainedColors); + assertStationary(await snapshot(), gridInitial); + await page.selectOption("#color-mode", "species"); + await expect( + page.locator("#species-range").getByLabel("Species range mode"), + ).toHaveValue("fixed"); + await expect( + page + .locator("#species-range") + .getByLabel("Species maximum", { exact: true }), + ).toHaveValue("2"); + await page.selectOption("#color-mode", "composite"); + assert.deepEqual(await colors(), retainedColors); + await page.screenshot({ path: `${evidence}/grid-replay.png` }); + await page.setViewportSize({ width: 880, height: 720 }); + + for (const id of [ + "#replay-play", + "#replay-timeline", + "#replay-fps", + "#recording-open", + ]) { + const bounds = await page.locator(id).boundingBox(); + assert.ok( + bounds && bounds.x >= 0 && bounds.x + bounds.width <= 880, + `${id} fits the supported narrow layout`, + ); + } + + await expect(page.locator("#replay-position")).toBeVisible(); + await page.screenshot({ path: `${evidence}/narrow-replay.png` }); + await page.setViewportSize({ width: 1440, height: 960 }); + // A damaged frame is reported persistently while retaining the last good frame. + const malformed = `${evidence}/malformed`; + await cp(`${fixtures}/lifecycle`, malformed, { + recursive: true, + force: true, + }); + const manifest = JSON.parse( + await readFile(`${malformed}/manifest.json`, "utf8"), + ); + const broken = Buffer.from("not a scene"); + const entry = manifest.recording.frames[2]; + entry.bytes = broken.byteLength; + entry.sha256 = createHash("sha256").update(broken).digest("hex"); + await writeFile(`${malformed}/${entry.file}`, broken); + manifest.integrity.recording = createHash("sha256") + .update(canonicalize(manifest.recording)) + .digest("hex"); + await writeFile(`${malformed}/manifest.json`, JSON.stringify(manifest)); + await page.locator("#recording-folder").setInputFiles(malformed); + await expect(page.locator("#replay-position")).toContainText("1 / 5"); + await page.locator("#replay-timeline").fill("2"); + await page.locator("#replay-timeline").dispatchEvent("input"); + await expect(page.locator("#replay-message")).toContainText( + "frame 2 (frames/00000002.scene.json)", + ); + await expect(page.locator("#replay-position")).toContainText("1 / 5"); + await seek(1, 5); + // Opening a static scene closes replay; delayed recording work cannot replace it. + await page + .locator("#scene-file") + .setInputFiles(`${fixtures}/grids/frames/00000000.scene.json`); + await expect(page.locator("#replay-transport")).toBeHidden(); + await expect(page.locator("#color-mode")).toHaveValue("cell-type"); + await expect(page.locator("#species-channel")).toHaveValue("0"); + await expect(page.locator("#device-visible")).toBeChecked(); + await page.selectOption("#color-mode", "species"); + await expect( + page.locator("#species-range").getByLabel("Species range mode"), + ).toHaveValue("automatic"); + assert.deepEqual(errors, []); + console.log( + JSON.stringify({ + status: "passed", + coverage: + "combined fixed species/signal ranges, composite tint/order/enabled state, device visibility, native exported topology, reverse steps, stable ID selection, time, rapid seeks, playback fps/end, metadata, missing/changing grids, camera/grid/preferences, malformed frame attribution/recovery, keyboard, narrow layout, new dataset reset", + }), + ); +} finally { + await browser.close(); +} diff --git a/viewer/browser/live-shutdown.mjs b/viewer/browser/live-shutdown.mjs new file mode 100644 index 0000000..7c454d0 --- /dev/null +++ b/viewer/browser/live-shutdown.mjs @@ -0,0 +1,183 @@ +// Real Python server + browser, including process exit and same-port restart. +// Run from the repository root after building this worktree's Python/viewer. +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createInterface } from "node:readline"; + +const { chromium, expect } = await import( + process.env.MICROSIMULATOR_PLAYWRIGHT_MODULE ?? "@playwright/test" +); +const evidence = + process.env.EVIDENCE_DIR ?? "/tmp/microsimulator-live-shutdown"; +const python = + process.env.MICROSIMULATOR_PYTHON ?? path.resolve(".venv/bin/python"); +const port = process.env.VIEWER_PORT ?? "4327"; +const temporary = await mkdtemp(path.join(tmpdir(), "microsimulator-stop-")); +await mkdir(evidence, { recursive: true }); +await writeFile( + path.join(temporary, "model.py"), + ` +import time +from microsimulator import CellInit +class Model: + def __init__(self, context): + self.simulation = context.simulation() + self.simulation.add_cell(CellInit()) + def step(self, dt): + time.sleep(0.02) + self.simulation.step(dt) + def controller_state(self): + return {"kind": "shutdown-browser-fixture"} +def build(context): + return Model(context) +`, +); + +const browser = await chromium.launch({ headless: true }); +let processUnderTest; +const errors = []; +const results = []; +try { + for (const mode of ["paused", "playing", "reconnect", "interrupt"]) { + if (mode === "interrupt" && process.platform === "win32") continue; + + const child = spawn( + python, + [ + "-m", + "microsimulator", + "view", + "--model", + path.join(temporary, "model.py"), + "--backend", + "cpu", + "--dt", + "0.01", + "--frame-steps", + "10000", + "--viewer-dist", + path.resolve("viewer/dist"), + "--port", + port, + "--checkpoint-output", + path.join(temporary, "checkpoint.json"), + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + processUnderTest = child; + let stderr = ""; + child.stderr.on("data", (data) => { + stderr += data.toString(); + }); + const exit = new Promise((resolve) => + child.once("exit", (code, signal) => resolve({ code, signal })), + ); + const lines = createInterface({ input: child.stdout }); + const url = await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("server startup timed out")), + 10000, + ); + lines.on("line", (line) => { + if (line.startsWith("MicroSimulator live viewer: ")) { + clearTimeout(timer); + resolve(line.slice("MicroSimulator live viewer: ".length)); + } + }); + child.once("exit", () => { + clearTimeout(timer); + reject(new Error(stderr)); + }); + child.once("error", reject); + }); + let page = await browser.newPage({ + viewport: { width: 1440, height: 960 }, + }); + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(url); + await expect(page.locator("#live-label")).toHaveText("Paused"); + await expect(page.locator("#live-stop")).toBeEnabled(); + + if (mode === "reconnect") { + await page.close(); + // The existing viewer deliberately requires a minimum width of 880px. + page = await browser.newPage({ viewport: { width: 880, height: 844 } }); + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(url); + await expect(page.locator("#live-label")).toHaveText("Paused"); + assert.equal(child.exitCode, null); + await page.locator("#live-checkpoint").click(); + await expect(page.locator("#status")).toContainText("Checkpoint saved"); + assert.ok( + (await readFile(path.join(temporary, "checkpoint.json"))).length > 100, + ); + } + + if (mode === "playing") { + await page.locator("#live-play").click(); + await expect(page.locator("#live-label")).toHaveText("Running"); + } + + const start = performance.now(); + + if (mode === "interrupt") { + child.kill("SIGINT"); + } else { + await page.locator("#live-stop").focus(); + await page.keyboard.press("Enter"); + } + + await expect(page.locator("#live-label")).toHaveText("Stopped", { + timeout: 5000, + }); + const toolbar = await page.locator("#live-transport").boundingBox(); + const viewport = await page.locator("#canvas-host").boundingBox(); + assert.ok(toolbar.x >= viewport.x); + assert.ok(toolbar.x + toolbar.width <= viewport.x + viewport.width); + await expect(page.locator("#status")).toContainText("Session stopped"); + + for (const id of ["play", "step", "reset", "checkpoint", "stop"]) { + await expect(page.locator(`#live-${id}`)).toBeDisabled(); + } + + const stopped = await Promise.race([ + exit, + new Promise((_, reject) => { + const timer = setTimeout( + () => reject(new Error("server did not exit")), + 5000, + ); + timer.unref(); + }), + ]); + assert.deepEqual(stopped, { code: 0, signal: null }); + assert.equal(stderr, ""); + await page.screenshot({ path: `${evidence}/${mode}-stopped.png` }); + results.push({ + mode, + stopMilliseconds: performance.now() - start, + exitCode: stopped.code, + }); + await page.close(); + lines.close(); + processUnderTest = undefined; + } + + assert.deepEqual(errors, []); + const result = { + result: "passed", + browser: browser.version(), + platform: process.platform, + port, + results, + }; + await writeFile(`${evidence}/results.json`, JSON.stringify(result, null, 2)); + console.log(JSON.stringify(result, null, 2)); +} finally { + processUnderTest?.kill("SIGKILL"); + await browser.close(); + await rm(temporary, { recursive: true, force: true }); +} diff --git a/viewer/browser/reference-grid.mjs b/viewer/browser/reference-grid.mjs new file mode 100644 index 0000000..b97965f --- /dev/null +++ b/viewer/browser/reference-grid.mjs @@ -0,0 +1,388 @@ +// Run against the Vite development server. No production debug hooks are added. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir } from "node:fs/promises"; +import canonicalize from "canonicalize"; + +const { chromium, expect } = await import( + process.env.MICROSIMULATOR_PLAYWRIGHT_MODULE ?? "@playwright/test" +); +const url = process.env.VIEWER_URL ?? "http://127.0.0.1:4320"; +const evidence = + process.env.EVIDENCE_DIR ?? "/tmp/microsimulator-reference-grid"; +await mkdir(evidence, { recursive: true }); +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }); +const errors = []; +page.on("pageerror", (error) => errors.push(error.message)); + +// Observe the real application instance rather than replacing its renderer. +await page.route("**/src/colony-viewer.ts*", async (route) => { + const response = await route.fetch(); + const source = await response.text(); + const marker = "this.onSelection = onSelection;"; + assert.equal(source.split(marker).length, 2); + await route.fulfill({ + response, + body: source.replace(marker, `${marker}\nglobalThis.__testViewer = this;`), + }); +}); +let socket; +await page.routeWebSocket("**/api/v1/session?*", (connection) => { + socket = connection; +}); +const boundary = { kind: "no_flux", values: [] }; +function signalGrid(shape = [5, 7, 9], signalCount = 3) { + return { + signal_count: signalCount, + shape, + origin: [-2, -3, 0], + spacing: [1, 1, 1], + boundaries: { + x_lower: boundary, + x_upper: boundary, + y_lower: boundary, + y_upper: boundary, + z_lower: boundary, + z_upper: boundary, + }, + levels: Array(shape.reduce((a, b) => a * b, signalCount)).fill(1), + }; +} +const cell = { + id: "1", + parent_id: null, + slot: 0, + position: [0, 0, 0.6], + direction: [1, 0, 0], + length: 2, + radius: 0.5, + growth_rate: 0.1, + cell_type: 0, + fixed: false, + species: [1, 2, 3], +}; +const base = { + backend: { + kind: "cpu", + name: "CPU fixture", + device: "host", + device_index: 0, + native: true, + }, + time: 0, + species_count: 3, + cells: [cell], + constraints: { boxes: [], cylinders: [], planes: [], spheres: [] }, + signal_grid: signalGrid(), +}; +function scene(frame) { + return { + format: "microsimulator-scene", + version: 2, + producer: { name: "microsimulator", version: "test" }, + integrity: { + algorithm: "sha256", + frame: createHash("sha256").update(canonicalize(frame)).digest("hex"), + }, + frame, + }; +} +let revision = 0; +async function send(frame) { + socket.send( + JSON.stringify({ + type: "frame", + revision: revision++, + completed_steps: revision, + playing: false, + checkpoint_enabled: false, + scene: scene(frame), + }), + ); + await expect(page.locator("#time-chip")).toHaveText(`t = ${frame.time}`); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); +} +async function snapshot() { + return page.evaluate(() => { + const v = globalThis.__testViewer; + v.grid.updateMatrixWorld(true); + v.camera.updateMatrixWorld(true); + const point = v.camera.position + .clone() + .set(0, 0, 0) + .applyMatrix4(v.grid.matrixWorld) + .project(v.camera); + + return { + grid: v.grid.matrixWorld.elements.slice(), + camera: v.camera.position.toArray(), + target: v.controls.target.toArray(), + pixelOrigin: point.toArray(), + }; + }); +} +function assertStationary(actual, expected, message) { + assert.deepEqual(actual.grid, expected.grid, message); + + for (const key of ["camera", "target", "pixelOrigin"]) { + actual[key].forEach((value, index) => { + assert.ok( + Math.abs(value - expected[key][index]) < 1e-9, + `${message}: ${key}[${index}]`, + ); + }); + } +} +try { + await page.goto(`${url}/?token=reference-grid-test`); + await expect.poll(() => socket !== undefined).toBe(true); + await send(base); + await page.locator("#signal-visible").focus(); + await page.keyboard.press("Space"); + await page.evaluate(() => { + const v = globalThis.__testViewer; + v.controls.enableDamping = false; + v.camera.position.multiplyScalar(4); + v.controls.update(); + }); + const initial = await snapshot(); + await page.screenshot({ path: `${evidence}/initial.png` }); + await send({ ...base, time: 1, cells: [{ ...cell, length: 14 }] }); + assertStationary( + await snapshot(), + initial, + "growth must preserve world grid and stationary-camera projection", + ); + await page.screenshot({ path: `${evidence}/grown.png` }); + await send({ + ...base, + time: 2, + cells: [ + { ...cell, position: [5, -8, -20] }, + { ...cell, id: "2", slot: 1, position: [-5, 2, 3] }, + ], + }); + assertStationary( + await snapshot(), + initial, + "translation and division must preserve grid and camera", + ); + await send({ ...base, time: 3, cells: [] }); + assertStationary( + await snapshot(), + initial, + "empty frames must preserve grid and camera", + ); + await send(base); + assertStationary( + await snapshot(), + initial, + "live reset must preserve grid and camera", + ); + + // Exercise UI preferences through a missing grid and reduced channel counts. + await page.locator("#color-mode").selectOption("species"); + await page.locator("#species-channel").selectOption("2"); + await page.locator("#signal-channel").selectOption("2"); + await page.locator("#signal-slice").focus(); + await page.keyboard.press("End"); + await page.locator("#signal-axis").selectOption("x"); + await expect(page.locator("#signal-slice")).toHaveValue("4"); + await page.locator("#signal-axis").selectOption("z"); + await expect(page.locator("#signal-slice")).toHaveValue("8"); + await send({ + ...base, + time: 4, + species_count: 0, + cells: [], + signal_grid: null, + }); + await send({ + ...base, + time: 5, + species_count: 1, + cells: [], + signal_grid: signalGrid([2, 2, 2], 1), + }); + await expect(page.locator("#signal-slice")).toHaveValue("1"); + await send({ ...base, time: 6 }); + await expect(page.locator("#species-channel")).toHaveValue("2"); + await expect(page.locator("#signal-channel")).toHaveValue("2"); + await expect(page.locator("#signal-slice")).toHaveValue("8"); + await expect(page.locator("#signal-visible")).not.toBeChecked(); + + const canvas = page.locator("#canvas-host canvas"); + const bounds = await canvas.boundingBox(); + const x = bounds.x + bounds.width / 2; + const y = bounds.y + bounds.height / 2; + + for (const button of ["left", "right"]) { + await page.mouse.move(x, y); + await page.mouse.down({ button }); + await page.mouse.move(x + 80, y + 40, { steps: 8 }); + await page.mouse.up({ button }); + assert.deepEqual( + (await snapshot()).grid, + initial.grid, + `${button} drag changes camera only`, + ); + } + + await page.mouse.wheel(0, 150); + assert.deepEqual( + (await snapshot()).grid, + initial.grid, + "zoom changes camera only", + ); + await page.locator("#fit-button").click(); + await expect + .poll(async () => + page.evaluate(() => globalThis.__testViewer.cameraTransition === null), + ) + .toBe(true); + assert.deepEqual( + (await snapshot()).grid, + initial.grid, + "Fit changes camera only", + ); + + // Explicit new dataset, including the initially empty case, in the real renderer. + const emptyThenDevice = await page.evaluate(() => { + const v = globalThis.__testViewer; + const empty = { + time: 0, + backend: { + kind: "cpu", + name: "CPU", + device: "host", + deviceIndex: 0, + native: true, + }, + speciesCount: 0, + cells: [], + constraints: { + boxes: [], + cylinders: [], + planes: [ + { + id: "1", + point: [1e30, 0, 0], + inwardNormal: [1, 0, 0], + coefficient: 1, + }, + ], + spheres: [], + }, + signalGrid: null, + }; + v.beginDataset(); + v.setFrame(empty, true); + const before = { + position: v.grid.position.toArray(), + scale: v.grid.scale.toArray(), + }; + const device = { + ...empty, + constraints: { + ...empty.constraints, + boxes: [ + { + id: "2", + center: [30, 40, -5], + halfExtents: [50, 25, 3], + coefficient: 1, + allowedRegion: "inside", + }, + ], + }, + }; + v.setFrame(device); + const retained = { + position: v.grid.position.toArray(), + scale: v.grid.scale.toArray(), + }; + v.beginDataset(); + v.setFrame(device, true); + + return { + before, + retained, + reopened: { + position: v.grid.position.toArray(), + scale: v.grid.scale.toArray(), + }, + }; + }); + assert.deepEqual(emptyThenDevice.before, { + position: [0, 0, -0.01], + scale: [0.5, 0.5, 0.5], + }); + assert.deepEqual(emptyThenDevice.retained, emptyThenDevice.before); + assert.deepEqual(emptyThenDevice.reopened, { + position: [30, 40, -8.01], + scale: [5, 5, 5], + }); + // Opening files goes through the application's explicit dataset reset path. + await page.goto(url); + const openFile = async (frame) => { + await page.locator("#scene-file").setInputFiles({ + name: "same-name.scene.json", + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify(scene(frame))), + }); + await expect(page.locator("#time-chip")).toHaveText(`t = ${frame.time}`); + }; + await openFile(base); + await page.locator("#color-mode").selectOption("species"); + await page.locator("#species-channel").selectOption("2"); + await page.locator("#signal-visible").focus(); + await page.keyboard.press("Space"); + await openFile({ + ...base, + time: 11, + constraints: { + ...base.constraints, + boxes: [ + { + id: "1", + center: [30, 40, -5], + half_extents: [50, 25, 3], + coefficient: 1, + allowed_region: "inside", + }, + ], + }, + }); + await expect(page.locator("#color-mode")).toHaveValue("cell-type"); + await expect(page.locator("#species-channel")).toHaveValue("0"); + await expect(page.locator("#signal-visible")).toBeChecked(); + assert.deepEqual( + await page.evaluate(() => ({ + position: globalThis.__testViewer.grid.position.toArray(), + scale: globalThis.__testViewer.grid.scale.toArray(), + })), + { position: [30, 40, -8.01], scale: [5, 5, 5] }, + ); + assert.deepEqual(errors, []); + console.log( + JSON.stringify( + { + result: "passed", + browser: browser.version(), + assertions: + "grid world/projected coordinates; camera orbit/pan/zoom/Fit; growth/XYZ/division/removal/reset; missing channels/grid; axis round trip; initially empty/new dataset", + evidence, + }, + null, + 2, + ), + ); +} finally { + await browser.close(); +} diff --git a/viewer/browser/replay-fixtures.py b/viewer/browser/replay-fixtures.py new file mode 100644 index 0000000..7c595f2 --- /dev/null +++ b/viewer/browser/replay-fixtures.py @@ -0,0 +1,68 @@ +"""Generate browser fixtures using the public model, checkpoint and exporter APIs.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from microsimulator import ( + BackendKind, + CellInit, + ChannelMetadata, + GridShape, + ModelContext, + SignalGridSpec, + Simulation, + Vec3, + build_model, + run_simulation, + save_checkpoint, +) +from microsimulator.replay import export_replay + +parser = argparse.ArgumentParser() +parser.add_argument("output", type=Path) +root = parser.parse_args().output +root.mkdir(parents=True, exist_ok=True) +model, provenance = build_model("examples/replay_demo.py", ModelContext(BackendKind.CPU, 0, 17)) +summary = run_simulation( + model, + steps=5, + dt=0.2, + output=root / "lifecycle.json", + checkpoint_every=1, + provenance=provenance, +) +export_replay(summary.periodic_checkpoints, root / "lifecycle") +paths = [] +# Data-only compatible scene states exercise unavailable grids and clamping. +# Distinct CPU simulations produce these grid fixtures; no biology claim is made. +for ordinal, size in enumerate((3, None, 1, 3)): + simulation = Simulation(species_count=2) + cell = CellInit() + cell.species = [0.25, 0.75] + + if size is not None: + shape = GridShape() + shape.x, shape.y, shape.z = size, size, size + spec = SignalGridSpec() + spec.signal_count = 2 + spec.shape = shape + spec.spacing = Vec3(1, 1, 1) + spec.diffusion = [0, 0] + spec.advection = [Vec3(), Vec3()] + simulation.configure_signal_grid(spec, [0.25] * size**3 + [0.75] * size**3) + + simulation.add_cell(cell) + simulation.step(ordinal * 0.2) + path = root / f"grid-{ordinal}.json" + save_checkpoint( + simulation, + path, + channel_metadata=ChannelMetadata( + species=("Green", "Red"), signals=("Nutrient", "Cue") if size else () + ), + ) + paths.append(path) +export_replay(paths, root / "grids") +print(root) diff --git a/viewer/browser/replay.mjs b/viewer/browser/replay.mjs new file mode 100644 index 0000000..6024d7a --- /dev/null +++ b/viewer/browser/replay.mjs @@ -0,0 +1,229 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { cp, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import canonicalize from "canonicalize"; + +const { chromium, expect } = await import( + process.env.MICROSIMULATOR_PLAYWRIGHT_MODULE ?? "@playwright/test" +); +const url = process.env.VIEWER_URL ?? "http://127.0.0.1:4326"; +const evidence = process.env.EVIDENCE_DIR ?? "/tmp/microsimulator-replay"; +const fixtures = process.env.REPLAY_FIXTURES ?? `${evidence}/fixtures-v2`; +await mkdir(evidence, { recursive: true }); +const browser = await chromium.launch({ headless: true }); +try { + const page = await browser.newPage({ + viewport: { width: 1440, height: 960 }, + }); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.route("**/src/colony-viewer.ts*", async (route) => { + const response = await route.fetch(); + const source = await response.text(); + const marker = "this.onSelection = onSelection;"; + assert.equal(source.split(marker).length, 2); + await route.fulfill({ + response, + body: source.replace( + marker, + `${marker}\nglobalThis.__testViewer = this;`, + ), + }); + }); + await page.route("**/src/replay-bundle.ts*", async (route) => { + const response = await route.fetch(); + const source = await response.text(); + const marker = "const bytes = await this.read(file, signal);"; + assert.equal(source.split(marker).length, 2); + await route.fulfill({ + response, + body: source.replace( + marker, + `await globalThis.__delayReplayLoad?.(ordinal);\n${marker}`, + ), + }); + }); + await page.goto(url); + async function open(name, count) { + await page + .locator("#recording-folder") + .setInputFiles(path.resolve(fixtures, name)); + await expect(page.locator("#replay-position")).toContainText( + `1 / ${count}`, + ); + await expect(page.locator("#replay-message")).toHaveText(""); + } + async function seek(ordinal, count) { + await page.locator("#replay-timeline").fill(String(ordinal)); + await page.locator("#replay-timeline").dispatchEvent("input"); + await expect(page.locator("#replay-position")).toContainText( + `${ordinal + 1} / ${count}`, + ); + await expect(page.locator("#replay-message")).toHaveText(""); + } + const snapshot = () => + page.evaluate(() => { + const viewer = globalThis.__testViewer; + viewer.grid.updateMatrixWorld(true); + + return { + camera: viewer.camera.position.toArray(), + target: viewer.controls.target.toArray(), + grid: viewer.grid.matrixWorld.elements.slice(), + }; + }); + function assertStationary(actual, expected) { + assert.deepEqual(actual.grid, expected.grid); + + for (const key of ["camera", "target"]) + actual[key].forEach((value, index) => { + assert.ok( + Math.abs(value - expected[key][index]) < 1e-9, + `${key}[${index}] remains stationary`, + ); + }); + } + await open("lifecycle", 5); + await expect(page.locator("#time-chip")).toHaveText("t = 0.2"); + await page.evaluate(() => { + const v = globalThis.__testViewer; + v.selectCell(0); + v.controls.enableDamping = false; + v.camera.position.multiplyScalar(2); + v.controls.update(); + }); + const initial = await snapshot(); + await expect(page.locator("#selection-title")).toHaveText("Cell 1"); + await seek(1, 5); + await expect(page.locator("#selection-title")).toHaveText("No cell selected"); + await expect(page.locator("#cell-count")).toHaveText("2"); + await page.evaluate(() => globalThis.__testViewer.selectCell(1)); + await expect(page.locator("#selection-title")).toHaveText("Cell 3"); + await seek(2, 5); + await expect(page.locator("#selection-title")).toHaveText("Cell 3"); + await expect(page.locator("#cell-count")).toHaveText("1"); + await expect(page.locator("#cell-details")).toContainText("Slot0"); + await seek(1, 5); + await expect(page.locator("#cell-details")).toContainText("Slot1"); + assertStationary(await snapshot(), initial); + await page.selectOption("#color-mode", "species"); + await expect(page.locator("#legend-title")).toHaveText("Reporter"); + // Delay an uncached frame and supersede it while its decode worker is occupied. + await page.evaluate(() => { + globalThis.__delayReplayLoad = (ordinal) => + ordinal === 4 + ? new Promise((resolve) => { + globalThis.__releaseReplay = resolve; + }) + : undefined; + }); + await page.locator("#replay-timeline").fill("4"); + await page.locator("#replay-timeline").dispatchEvent("input"); + await expect + .poll(() => page.evaluate(() => typeof globalThis.__releaseReplay)) + .toBe("function"); + await page.locator("#replay-timeline").fill("3"); + await page.locator("#replay-timeline").dispatchEvent("input"); + await page.evaluate(() => { + globalThis.__releaseReplay(); + globalThis.__delayReplayLoad = undefined; + }); + await expect(page.locator("#replay-position")).toContainText("4 / 5"); + await expect(page.locator("#time-chip")).toHaveText("t = 0.8"); + await expect(page.locator("#legend-title")).toHaveText("Reporter"); + await seek(0, 5); + await page.locator("#replay-fps").fill("20"); + await page.locator("#replay-fps").dispatchEvent("change"); + await page.locator("#replay-play").click(); + await expect(page.locator("#replay-position")).toContainText("5 / 5"); + await expect(page.locator("#replay-play")).toHaveText("Play"); + await page.locator("#replay-previous").focus(); + await page.keyboard.press("Enter"); + await expect(page.locator("#replay-position")).toContainText("4 / 5"); + assertStationary(await snapshot(), initial); + await page.screenshot({ path: `${evidence}/lifecycle-replay.png` }); + await open("grids", 4); + await page.selectOption("#color-mode", "species"); + await page.selectOption("#species-channel", "1"); + await page.selectOption("#signal-channel", "1"); + await page.locator("#signal-slice").fill("2"); + await page.locator("#signal-slice").dispatchEvent("input"); + await page.locator("#signal-visible").focus(); + await page.keyboard.press("Space"); + const gridInitial = await snapshot(); + await seek(1, 4); + await expect(page.locator("#signal-section")).toBeHidden(); + await seek(2, 4); + await expect(page.locator("#signal-slice")).toHaveValue("0"); + await seek(3, 4); + await expect(page.locator("#signal-slice")).toHaveValue("2"); + await expect(page.locator("#signal-channel")).toHaveValue("1"); + await expect(page.locator("#species-channel")).toHaveValue("1"); + await expect(page.locator("#signal-visible")).not.toBeChecked(); + await expect(page.locator("#legend-title")).toHaveText("Red"); + assertStationary(await snapshot(), gridInitial); + await page.screenshot({ path: `${evidence}/grid-replay.png` }); + await page.setViewportSize({ width: 880, height: 720 }); + + for (const id of [ + "#replay-play", + "#replay-timeline", + "#replay-fps", + "#recording-open", + ]) { + const bounds = await page.locator(id).boundingBox(); + assert.ok( + bounds && bounds.x >= 0 && bounds.x + bounds.width <= 880, + `${id} fits the supported narrow layout`, + ); + } + + await expect(page.locator("#replay-position")).toBeVisible(); + await page.screenshot({ path: `${evidence}/narrow-replay.png` }); + await page.setViewportSize({ width: 1440, height: 960 }); + // A damaged frame is reported persistently while retaining the last good frame. + const malformed = `${evidence}/malformed`; + await cp(`${fixtures}/lifecycle`, malformed, { + recursive: true, + force: true, + }); + const manifest = JSON.parse( + await readFile(`${malformed}/manifest.json`, "utf8"), + ); + const broken = Buffer.from("not a scene"); + const entry = manifest.recording.frames[2]; + entry.bytes = broken.byteLength; + entry.sha256 = createHash("sha256").update(broken).digest("hex"); + await writeFile(`${malformed}/${entry.file}`, broken); + manifest.integrity.recording = createHash("sha256") + .update(canonicalize(manifest.recording)) + .digest("hex"); + await writeFile(`${malformed}/manifest.json`, JSON.stringify(manifest)); + await page.locator("#recording-folder").setInputFiles(malformed); + await expect(page.locator("#replay-position")).toContainText("1 / 5"); + await page.locator("#replay-timeline").fill("2"); + await page.locator("#replay-timeline").dispatchEvent("input"); + await expect(page.locator("#replay-message")).toContainText( + "frame 2 (frames/00000002.scene.json)", + ); + await expect(page.locator("#replay-position")).toContainText("1 / 5"); + await seek(1, 5); + // Opening a static scene closes replay; delayed recording work cannot replace it. + await page + .locator("#scene-file") + .setInputFiles(`${fixtures}/grids/frames/00000000.scene.json`); + await expect(page.locator("#replay-transport")).toBeHidden(); + await expect(page.locator("#color-mode")).toHaveValue("cell-type"); + await expect(page.locator("#species-channel")).toHaveValue("0"); + assert.deepEqual(errors, []); + console.log( + JSON.stringify({ + status: "passed", + coverage: + "native exported topology, reverse steps, stable ID selection, time, rapid seeks, playback fps/end, metadata, missing/changing grids, camera/grid/preferences, malformed frame attribution/recovery, keyboard, narrow layout, new dataset reset", + }), + ); +} finally { + await browser.close(); +} diff --git a/viewer/browser/scalar-ranges.mjs b/viewer/browser/scalar-ranges.mjs new file mode 100644 index 0000000..93d47e6 --- /dev/null +++ b/viewer/browser/scalar-ranges.mjs @@ -0,0 +1,323 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir } from "node:fs/promises"; +import canonicalize from "canonicalize"; +const { chromium, expect } = await import( + process.env.MICROSIMULATOR_PLAYWRIGHT_MODULE ?? "@playwright/test" +); +const url = process.env.VIEWER_URL ?? "http://127.0.0.1:4315"; +const evidence = + process.env.EVIDENCE_DIR ?? "/tmp/microsimulator-scalar-ranges"; +await mkdir(evidence, { recursive: true }); +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } }); +const errors = []; +page.on("pageerror", (error) => errors.push(error.message)); +await page.route("**/src/colony-viewer.ts*", async (route) => { + const response = await route.fetch(); + const source = await response.text(); + const marker = "this.onSelection = onSelection;"; + assert.equal(source.split(marker).length, 2); + await route.fulfill({ + response, + body: source.replace(marker, `${marker}\nglobalThis.__testViewer = this;`), + }); +}); +let socket; +await page.routeWebSocket("**/api/v1/session?*", (connection) => { + socket = connection; +}); +const boundary = { kind: "no_flux", values: [] }; +const grid = { + signal_count: 2, + shape: [2, 1, 2], + origin: [-2, -2, -1], + spacing: [4, 1, 1], + boundaries: { + x_lower: boundary, + x_upper: boundary, + y_lower: boundary, + y_upper: boundary, + z_lower: boundary, + z_upper: boundary, + }, + levels: [2, 2, 4, 40, 8, 8, 6, 60], +}; +const first = { + id: "1", + parent_id: null, + slot: 0, + position: [-1, 0, 0.6], + direction: [0, 1, 0], + length: 2, + radius: 0.5, + growth_rate: 0.1, + cell_type: 0, + fixed: false, + species: [2, 8], +}; +const second = { + ...first, + id: "2", + slot: 1, + position: [1, 0, 0.6], + species: [4, 6], +}; +const base = { + backend: { + kind: "cpu", + name: "CPU fixture", + device: "host", + device_index: 0, + native: true, + }, + time: 0, + species_count: 2, + cells: [first, second], + constraints: { boxes: [], cylinders: [], planes: [], spheres: [] }, + signal_grid: grid, +}; +function scene(frame) { + return { + format: "microsimulator-scene", + version: 2, + producer: { name: "microsimulator", version: "test" }, + integrity: { + algorithm: "sha256", + frame: createHash("sha256").update(canonicalize(frame)).digest("hex"), + }, + frame, + }; +} +let revision = 0; +async function send(frame) { + socket.send( + JSON.stringify({ + type: "frame", + revision: revision++, + completed_steps: revision, + playing: false, + checkpoint_enabled: false, + scene: scene(frame), + }), + ); + await expect(page.locator("#time-chip")).toHaveText(`t = ${frame.time}`); +} +const control = (kind, field) => + page.getByRole(field === "range mode" ? "combobox" : "textbox", { + name: `${kind} ${field}`, + exact: true, + }); +async function fixed(kind, minimum, maximum) { + await control(kind, "range mode").focus(); + await page.keyboard.press("f"); + await page.keyboard.press("Tab"); + await expect(control(kind, "range mode")).toHaveValue("fixed"); + await control(kind, "minimum").fill(String(minimum)); + await control(kind, "maximum").fill(String(maximum)); + await control(kind, "maximum").press("Enter"); +} +async function colors() { + return page.evaluate(() => ({ + cells: Array.from( + globalThis.__testViewer.cellMeshes[0]?.instanceColor?.array ?? [], + ), + signal: Array.from(globalThis.__testViewer.signalTexture?.image.data ?? []), + })); +} +async function expectedColor(intensity) { + return page.evaluate(async (intensity) => { + const { rgbBytes, viridis } = await import("/src/color.ts"); + const { Color, SRGBColorSpace } = + await import("/node_modules/.vite/deps/three.js"); + const rgb = viridis(intensity); + const linear = new Color().setRGB(...rgb, SRGBColorSpace); + + return { + signal: [...rgbBytes(rgb), 205], + cell: [linear.r, linear.g, linear.b].map(Math.fround), + }; + }, intensity); +} +try { + await page.goto(`${url}/?token=scalar-range-test`); + await expect.poll(() => socket !== undefined).toBe(true); + await send(base); + await page.locator("#color-mode").selectOption("species"); + await fixed("Species", 0, 10); + await fixed("Signal", 0, 10); + await expect(page.locator("#legend-mode")).toHaveText("Fixed"); + await expect(page.locator("#signal-legend-mode")).toHaveText("Fixed"); + const initial = await colors(); + const at02 = await expectedColor(0.2); + assert.deepEqual(initial.cells.slice(0, 3), at02.cell); + assert.deepEqual(initial.signal.slice(0, 4), at02.signal); + await page.screenshot({ path: `${evidence}/fixed-ranges.png` }); + + await send({ + ...base, + time: 1, + cells: [first, { ...second, species: [40, 6] }], + signal_grid: { ...grid, levels: [2, 2, 40, 40, 8, 8, 6, 60] }, + }); + const changed = await colors(); + assert.deepEqual(changed.cells.slice(0, 3), initial.cells.slice(0, 3)); + assert.deepEqual(changed.signal.slice(0, 4), initial.signal.slice(0, 4)); + await page.locator("#signal-slice").focus(); + await page.keyboard.press("End"); + assert.deepEqual( + (await colors()).signal.slice(0, 4), + initial.signal.slice(0, 4), + ); + await expect(page.locator("#legend-max")).toHaveText("10"); + await expect(page.locator("#signal-legend-max")).toHaveText("10"); + + const clipped = { + ...base, + time: 2, + cells: [ + { ...first, species: [-5, 8] }, + { ...second, species: [50, 6] }, + ], + signal_grid: { ...grid, levels: [-5, -5, 50, 50, 8, 8, 6, 60] }, + }; + await send(clipped); + const endpoints = await colors(); + const low = await expectedColor(0); + const high = await expectedColor(1); + assert.deepEqual(endpoints.cells, [...low.cell, ...high.cell]); + assert.deepEqual(endpoints.signal, [...low.signal, ...high.signal]); + await page.evaluate(() => globalThis.__testViewer.selectCell(0)); + await expect(page.locator("#species-values code").first()).toHaveText("-5"); + assert.deepEqual( + await page.evaluate(() => + globalThis.__testViewer.cells.map((cell) => cell.species[0]), + ), + [-5, 50], + ); + + for (const kind of ["Species", "Signal"]) { + await control(kind, "minimum").fill("20"); + await control(kind, "maximum").press("Enter"); + await expect( + page + .getByRole("form", { name: `${kind} concentration range` }) + .getByRole("status"), + ).toContainText("Minimum must be less than maximum"); + assert.deepEqual(await colors(), endpoints); + await control(kind, "minimum").fill("Infinity"); + await control(kind, "maximum").press("Enter"); + await expect( + page + .getByRole("form", { name: `${kind} concentration range` }) + .getByRole("status"), + ).toContainText("finite number"); + assert.deepEqual(await colors(), endpoints); + await control(kind, "minimum").fill(""); + await control(kind, "maximum").press("Enter"); + await expect( + page + .getByRole("form", { name: `${kind} concentration range` }) + .getByRole("status"), + ).toContainText("finite number"); + await fixed(kind, 0, 10); + } + + await page.locator("#species-channel").selectOption("1"); + await expect(control("Species", "range mode")).toHaveValue("automatic"); + await fixed("Species", -100, 100); + await page.locator("#species-channel").selectOption("0"); + await expect(control("Species", "minimum")).toHaveValue("0"); + await expect(control("Species", "maximum")).toHaveValue("10"); + await page.locator("#signal-channel").selectOption("1"); + await expect(control("Signal", "range mode")).toHaveValue("automatic"); + await fixed("Signal", -2, 2); + await page.locator("#signal-channel").selectOption("0"); + await expect(control("Signal", "maximum")).toHaveValue("10"); + await send({ + ...base, + time: 3, + species_count: 0, + cells: [], + signal_grid: null, + }); + await send(base); // Same live model reset / seek back to time zero. + await expect(control("Species", "range mode")).toHaveValue("fixed"); + await expect(control("Species", "maximum")).toHaveValue("10"); + await expect(control("Signal", "maximum")).toHaveValue("10"); + assert.deepEqual((await colors()).cells, initial.cells); + + for (const kind of ["Species", "Signal"]) { + await control(kind, "range mode").focus(); + await page.keyboard.press("a"); + await page.keyboard.press("Tab"); + } + + await send({ + ...base, + time: 4, + cells: [ + { ...first, species: [7, 7] }, + { ...second, species: [7, 7] }, + ], + signal_grid: { ...grid, levels: Array(8).fill(7) }, + }); + await expect(page.locator("#legend-mode")).toHaveText("Automatic · constant"); + await expect(page.locator("#signal-legend-mode")).toHaveText( + "Automatic · constant", + ); + const midpoint = await expectedColor(0.5); + assert.deepEqual((await colors()).cells, [ + ...midpoint.cell, + ...midpoint.cell, + ]); + assert.deepEqual((await colors()).signal, [ + ...midpoint.signal, + ...midpoint.signal, + ]); + await send({ ...base, time: 5, cells: [], signal_grid: null }); + await expect(page.locator("#legend-mode")).toHaveText( + "Automatic · no values", + ); + await expect(page.locator("#legend-min")).toHaveText("—"); + await expect(page.locator("#legend-ramp")).toBeHidden(); + await send(base); + await control("Species", "range mode").selectOption("fixed"); + await expect(control("Species", "maximum")).toHaveValue("10"); + await control("Signal", "range mode").selectOption("fixed"); + await expect(control("Signal", "maximum")).toHaveValue("10"); + + await page.goto(url); + const open = async (value) => { + await page.locator("#scene-file").setInputFiles({ + name: "scene.json", + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify(scene(value))), + }); + await expect(page.locator("#time-chip")).toHaveText(`t = ${value.time}`); + }; + await open(base); + await page.locator("#color-mode").selectOption("species"); + await fixed("Species", 0, 10); + await fixed("Signal", 0, 10); + await open({ ...base, time: 99 }); + await page.locator("#color-mode").selectOption("species"); + await expect(control("Species", "range mode")).toHaveValue("automatic"); + await expect(control("Signal", "range mode")).toHaveValue("automatic"); + assert.deepEqual(errors, []); + console.log( + JSON.stringify( + { + result: "passed", + browser: browser.version(), + evidence, + coverage: + "cell instance RGB, signal texture RGBA, fixed scales across frames/slices, endpoints and inspector, invalid bounds, keyboard controls, independent channels, missing data, reset/seek, constant/empty, new dataset", + }, + null, + 2, + ), + ); +} finally { + await browser.close(); +} diff --git a/viewer/index.html b/viewer/index.html index 0b1a8a3..8ad632c 100644 --- a/viewer/index.html +++ b/viewer/index.html @@ -43,6 +43,20 @@ Open scene + + @@ -58,6 +72,7 @@

Cells

@@ -66,8 +81,15 @@

Cells

Species channel + +