diff --git a/.clang-format b/.clang-format
new file mode 100644
index 0000000000000000000000000000000000000000..2aaf0e39557ece48add0ffeb74722b3f42ec8026
--- /dev/null
+++ b/.clang-format
@@ -0,0 +1,148 @@
+Language:        Cpp
+AccessModifierOffset: -2
+AlignAfterOpenBracket: Align
+AlignConsecutiveAssignments: true
+AlignConsecutiveDeclarations: false
+AlignEscapedNewlines: DontAlign
+AlignOperands:   true
+AlignTrailingComments: true
+AllowAllParametersOfDeclarationOnNextLine: false
+AllowShortBlocksOnASingleLine: true
+AllowShortCaseLabelsOnASingleLine: false
+AllowShortFunctionsOnASingleLine: All
+AllowShortIfStatementsOnASingleLine: true
+AllowShortLoopsOnASingleLine: false
+AlwaysBreakAfterReturnType: None
+AlwaysBreakBeforeMultilineStrings: false
+AlwaysBreakTemplateDeclarations: true
+BinPackArguments: true
+BinPackParameters: true
+BraceWrapping:   
+  AfterClass:      true
+  AfterControlStatement: true
+  AfterEnum:       false
+  AfterFunction:   true
+  AfterNamespace:  true
+  AfterObjCDeclaration: false
+  AfterStruct:     true
+  AfterUnion:      true
+  AfterExternBlock: true
+  BeforeCatch:     false
+  BeforeElse:      true
+  IndentBraces:    false
+  SplitEmptyFunction: false
+  SplitEmptyRecord: false
+  SplitEmptyNamespace: false
+BreakBeforeBinaryOperators: None
+BreakBeforeBraces: Custom
+BreakBeforeInheritanceComma: false
+BreakBeforeTernaryOperators: false
+BreakConstructorInitializers: BeforeColon
+BreakStringLiterals: true
+ColumnLimit:     120
+CommentPragmas:  '^ IWYU pragma:'
+CompactNamespaces: false
+ConstructorInitializerAllOnOneLineOrOnePerLine: false
+ConstructorInitializerIndentWidth: 3
+ContinuationIndentWidth: 3
+Cpp11BracedListStyle: false
+DerivePointerAlignment: false
+DisableFormat:   false
+ExperimentalAutoDetectBinPacking: false
+FixNamespaceComments: true
+ForEachMacros:   [ foreach, Q_FOREACH, BOOST_FOREACH ]
+IncludeBlocks: Regroup
+IncludeCategories: 
+  - Regex:           '^"blockforest/'
+    Priority:        1
+  - Regex:           '^"boundary/'
+    Priority:        2
+  - Regex:           '^"communication/'
+    Priority:        3
+  - Regex:           '^"core/'
+    Priority:        4
+  - Regex:           '^"domain_decomposition/'
+    Priority:        5
+  - Regex:           '^"executiontree/'
+    Priority:        6
+  - Regex:           '^"fft/'
+    Priority:        7
+  - Regex:           '^"field/'
+    Priority:        8
+  - Regex:           '^"gather/'
+    Priority:        9
+  - Regex:           '^"geometry/'
+    Priority:        10
+  - Regex:           '^"gpu/'
+    Priority:        11
+  - Regex:           '^"gpu/'
+    Priority:        12
+  - Regex:           '^"lbm/'
+    Priority:        13
+  - Regex:           '^"lbm_mesapd_coupling/'
+    Priority:        14
+  - Regex:           '^"mesh/'
+    Priority:        15
+  - Regex:           '^"mesa_pd/'
+    Priority:        16
+  - Regex:           '^"pde/'
+    Priority:        17
+  - Regex:           '^"pe/'
+    Priority:        18
+  - Regex:           '^"pe_coupling/'
+    Priority:        19
+  - Regex:           '^"postprocessing/'
+    Priority:        20
+  - Regex:           '^"python_coupling/'
+    Priority:        21
+  - Regex:           '^"simd/'
+    Priority:        22
+  - Regex:           '^"sqlite/'
+    Priority:        23
+  - Regex:           '^"stencil/'
+    Priority:        24
+  - Regex:           '^"timeloop/'
+    Priority:        25
+  - Regex:           '^"vtk/'
+    Priority:        26
+  - Regex:           '^<boost/'
+    Priority:        27
+  - Regex:           '^<'
+    Priority:        28
+IndentCaseLabels: false
+IndentPPDirectives: AfterHash
+IndentWidth: 3
+IndentWrappedFunctionNames: true
+KeepEmptyLinesAtTheStartOfBlocks: false
+MacroBlockBegin: ''
+MacroBlockEnd:   ''
+MaxEmptyLinesToKeep: 1
+NamespaceIndentation: None
+ObjCBlockIndentWidth: 2
+ObjCSpaceAfterProperty: false
+ObjCSpaceBeforeProtocolList: true
+PenaltyBreakAssignment: 2
+PenaltyBreakBeforeFirstCallParameter: 19
+PenaltyBreakComment: 300
+PenaltyBreakFirstLessLess: 120
+PenaltyBreakString: 1000
+PenaltyExcessCharacter: 1000000
+PenaltyReturnTypeOnItsOwnLine: 60
+PointerAlignment: Left
+ReflowComments:  true
+SortIncludes:    true
+SortUsingDeclarations: true
+SpaceAfterCStyleCast: true
+SpaceAfterTemplateKeyword: false
+SpaceBeforeAssignmentOperators: true
+SpaceBeforeParens: ControlStatements
+SpaceInEmptyParentheses: false
+SpacesBeforeTrailingComments: 1
+SpacesInAngles:  true
+SpacesInContainerLiterals: true
+SpacesInCStyleCastParentheses: false
+SpacesInParentheses: false
+SpacesInSquareBrackets: false
+Standard:        Cpp11
+TabWidth:        3
+UseTab:          Never
\ No newline at end of file
diff --git a/.flake8 b/.flake8
index 6998ab8fd6845016bf85a380d1e10f4493d93f61..b63109475dcbf82c5b2b8be1ecfb2ca8e7f85cf2 100644
--- a/.flake8
+++ b/.flake8
@@ -1,3 +1,3 @@
 [flake8]
 max-line-length=120
-exclude = src/sfg_walberla/_version.py
+exclude = src/walberla/codegen/_version.py
diff --git a/.gitattributes b/.gitattributes
index 745cc9c275f9207dac6f84baf0c6789847116ddc..7897bc3713e5e0ea605a357ee67dd54a56cc5a69 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1 @@
-src/sfg_walberla/_version.py export-subst
+src/walberla/codegen/_version.py export-subst
diff --git a/.gitignore b/.gitignore
index c970c0ef7370d24590b25d9e42386114300323f3..d26a9498db70b5db7792c6c6116afc8fcd61dffc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@
 
 #   dev environment
 **/.venv
+dev-codegen-requirements.txt
 
 #   build artifacts
 dist
@@ -25,4 +26,4 @@ coverage.xml
 CMakeUserPresets.json
 
 # scratch
-scratch
\ No newline at end of file
+scratch
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index d501e83f223767a186a1fac6c5beccf5e144a4d0..bdaaf9e2f1aab3115e158c8ba5395997f32d4458 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,41 +1,63 @@
 stages:
+ - "Code Quality"
  - Test
  - Documentation
  - Deploy
 
+# -------------------- Code Quality ---------------------------------------------------------------------
 
-testsuite-clang18:
-  image: i10git.cs.fau.de:5005/walberla/buildenvs/clang-18:latest
+.python-qa:
+  stage: "Code Quality"
+  image: i10git.cs.fau.de:5005/pycodegen/pycodegen/nox:alpine
+  needs: []
   tags:
     - docker
+
+lint:
+  extends: .python-qa
+  script:
+    - nox -s lint
+
+typecheck:
+  extends: .python-qa
+  script:
+    - nox -s typecheck
+
+# -------------------- C++ Test Suite ---------------------------------------------------------------------
+
+
+.testsuite:
   stage: "Test"
   needs: []
-  variables:
-    CXX: clang++
-    CC: clang
   before_script:
     - cd tests
-    - cmake -G Ninja -S . -B build
-    - cd build
+    - cmake --preset ${cmakePresetName}
+    - cd build/${cmakePresetName}
     - cmake --build . --target SfgTests
+    - cmake --build . --target UserManualExamples
   script:
     - ctest
 
-build-examples:
-  image: i10git.cs.fau.de:5005/walberla/buildenvs/clang-18:latest
+.clang-19:
+  image: i10git.cs.fau.de:5005/walberla/buildenvs/clang-19:latest
+
+testsuite-clang19-cpu:
+  extends: [.testsuite, .clang-19]
   tags:
     - docker
-  stage: "Test"
-  needs: []
+    - AVX2
   variables:
-    CXX: clang++
-    CC: clang
-  script:
-    - cd user_manual/examples
-    - cmake -G Ninja -S . -B build
-    - cd build
-    - cmake --build . --target Examples
+    cmakePresetName: testsuite-cpu
+
+testsuite-clang19-cuda:
+  extends: [.testsuite, .clang-19]
+  tags:
+    - docker
+    - cuda11
+  variables:
+    cmakePresetName: testsuite-cuda
 
+# -------------------- User Manual ---------------------------------------------------------------------
 
 build-user-manual:
   image: i10git.cs.fau.de:5005/pycodegen/pycodegen/nox:alpine
@@ -52,6 +74,7 @@ build-user-manual:
 pages:
   image: alpine:latest
   stage: "Deploy"
+  needs: [build-user-manual]
   script:
     - mv user_manual/_sphinx_build/html public  # folder has to be named "public" for gitlab to publish it
   artifacts:
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 2824fc2a4526d2041149446cca02146cf3b6000b..72788381e46622204d379f5e53ff3e54414b0fe2 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -3,27 +3,11 @@ project ( sfg-walberla )
 
 set(sfg_walberla_SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR})
 
-list (APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake )
-
-include( PrepareSFG )
+set(CMAKE_CXX_STANDARD 20)
+set(CMAKE_CXX_STANDARD_REQUIRED)
 
-add_library( sfg_walberla INTERFACE )
-
-target_sources( sfg_walberla
-    INTERFACE
-    include/sfg/SparseIteration.hpp
-    include/sfg/GenericHbbBoundary.hpp
-    include/sfg/IrregularFreeSlip.hpp
-)
+list (APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake )
 
-target_include_directories( sfg_walberla INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/include )
+include( WalberlaCodegen )
 
-target_link_libraries(
-    sfg_walberla
-    INTERFACE
-    walberla::core
-    walberla::stencil
-    walberla::domain_decomposition
-    walberla::blockforest
-    walberla::field
-)
+add_subdirectory( lib )
diff --git a/cmake/CodegenConfig.template.py b/cmake/CodegenConfig.template.py
index 07f29998fd188b7f4f95a2bd5409cb02f77f6c8d..61533d216b90d574bfd904def544425d8f5d2fa4 100644
--- a/cmake/CodegenConfig.template.py
+++ b/cmake/CodegenConfig.template.py
@@ -1,5 +1,5 @@
 from pystencilssfg import SfgConfig
-from sfg_walberla import WalberlaBuildConfig
+from walberla.codegen import WalberlaBuildConfig
 
 
 def configure_sfg(cfg: SfgConfig):
@@ -8,7 +8,7 @@ def configure_sfg(cfg: SfgConfig):
 
 
 def project_info() -> WalberlaBuildConfig:
-    from sfg_walberla.build_config import cmake_parse_bool
+    from walberla.codegen.build_config import cmake_parse_bool
 
     return WalberlaBuildConfig(
         c_compiler_id="${CMAKE_C_COMPILER_ID}",
diff --git a/cmake/ManageCodegenVenv.py b/cmake/ManageCodegenVenv.py
new file mode 100644
index 0000000000000000000000000000000000000000..740d66cb52b9b53fc731a72231968afe45c6309d
--- /dev/null
+++ b/cmake/ManageCodegenVenv.py
@@ -0,0 +1,192 @@
+from __future__ import annotations
+
+from typing import Generator
+import sys
+import subprocess
+import json
+import shutil
+import hashlib
+from contextlib import contextmanager
+from dataclasses import dataclass, asdict, field
+from argparse import ArgumentParser
+from pathlib import Path
+
+
+@dataclass
+class VenvState:
+    venv_dir: str | None = None
+    main_requirements_file: str | None = None
+    initialized: bool = False
+    populated: bool = False
+
+    user_requirements: list[list[str]] = field(default_factory=list)
+    user_requirements_hash: str | None = None
+
+    @property
+    def venv_python(self) -> Path:
+        assert self.venv_dir is not None
+        return Path(self.venv_dir) / "bin" / "python"
+
+    @staticmethod
+    @contextmanager
+    def lock(statefile: Path) -> Generator[VenvState, None, None]:
+        statefile_bak = statefile.with_suffix(".json.bak")
+        if statefile.exists():
+            statefile.replace(statefile_bak)
+
+            with statefile_bak.open("r") as f:
+                state_dict = json.load(f)
+
+            state = VenvState(**state_dict)
+        else:
+            state = VenvState()
+
+        yield state
+
+        #   If the consumer raises an error, execution terminates here
+
+        state_dict = asdict(state)
+        with statefile_bak.open("w") as f:
+            json.dump(state_dict, f)
+        statefile_bak.replace(statefile)
+
+
+def reinitialize(state: VenvState):
+    assert state.venv_dir is not None
+
+    venv_dir = Path(state.venv_dir)
+    if venv_dir.exists():
+        shutil.rmtree(venv_dir)
+
+    base_py = Path(sys.executable)
+
+    #   Create the virtual environment
+    venv_args = [base_py, "-m", "venv", state.venv_dir]
+    subprocess.run(venv_args).check_returncode()
+
+    #   Install base requirements
+    venv_py = str(state.venv_python)
+    install_args = [venv_py, "-m", "pip", "install", "-r", state.main_requirements_file]
+    subprocess.run(install_args).check_returncode()
+
+    state.initialized = True
+
+
+def action_initialize(args):
+    statefile = Path(args.statefile)
+
+    with VenvState.lock(statefile) as state:
+        if not state.initialized or args.force:
+            p_venv_dir = Path(args.venv_dir).resolve()
+            reqs_file = Path(args.requirements_file).resolve()
+
+            state.venv_dir = str(p_venv_dir)
+            state.main_requirements_file = str(reqs_file)
+
+            reinitialize(state)
+
+        #   Reset user requirements
+        state.user_requirements = []
+        state.populated = False
+
+
+def action_require(args):
+    statefile = Path(args.statefile)
+
+    with VenvState.lock(statefile) as state:
+        if not state.initialized:
+            raise RuntimeError(
+                "venv-require action failed: virtual environment was not initialized"
+            )
+
+        if state.populated:
+            raise RuntimeError(
+                "venv-require action failed: cannot add requirements after venv-populate was run"
+            )
+
+        state.user_requirements.append(list(args.requirements))
+
+
+def action_populate(args):
+    statefile = Path(args.statefile)
+
+    with VenvState.lock(statefile) as state:
+        if not state.initialized:
+            raise RuntimeError("venv-populate action failed: virtual environment was not initialized")
+
+        if state.populated:
+            raise RuntimeError("venv-populate action failed: venv-populate action called twice")
+
+        h = hashlib.sha256()
+        for req in state.user_requirements:
+            h.update(bytes(";".join(str(r) for r in req), encoding="utf8"))
+        digest = h.hexdigest()
+
+        if digest != state.user_requirements_hash:
+            if state.user_requirements_hash is not None:
+                #   Populate was run before -> rebuild entire environment
+                print(
+                    "User requirements have changed - rebuilding virtual environment.",
+                    flush=True,
+                )
+                reinitialize(state)
+
+            pip_args = [str(state.venv_python), "-m", "pip", "install"]
+            for req in state.user_requirements:
+                install_args = pip_args.copy() + req
+                subprocess.run(install_args).check_returncode()
+
+        state.user_requirements_hash = digest
+        state.populated = True
+
+
+def fail(*args: str):
+    for arg in args:
+        print(arg, file=sys.stderr)
+    exit(-1)
+
+
+def main():
+    parser = ArgumentParser("ManageCodegenVenv")
+    parser.add_argument(
+        "-s",
+        "--statefile",
+        required=True,
+        dest="statefile",
+        help="Path to the environment statefile",
+    )
+
+    subparsers = parser.add_subparsers(required=True)
+
+    parser_initialize = subparsers.add_parser("init")
+    parser_initialize.add_argument("-f", "--force", dest="force", action="store_true")
+    parser_initialize.add_argument(
+        "venv_dir",
+        type=str,
+        help="Location of the virtual environment in the filesystem",
+    )
+    parser_initialize.add_argument(
+        "requirements_file",
+        type=str,
+        help="Location of the virtual environment in the filesystem",
+    )
+    parser_initialize.set_defaults(func=action_initialize)
+
+    parser_require = subparsers.add_parser("require")
+    parser_require.add_argument("requirements", nargs="+", type=str)
+    parser_require.set_defaults(func=action_require)
+
+    parser_populate = subparsers.add_parser("populate")
+    parser_populate.set_defaults(func=action_populate)
+
+    try:
+        args = parser.parse_args()
+        args.func(args)
+    except RuntimeError as e:
+        fail(*e.args)
+    except subprocess.CalledProcessError as e:
+        fail()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/cmake/PrepareSFG.cmake b/cmake/WalberlaCodegen.cmake
similarity index 52%
rename from cmake/PrepareSFG.cmake
rename to cmake/WalberlaCodegen.cmake
index ca0aa53570796e8f933751a04e55070ecd81cfd9..c01b3fe2cd9eece2b7af555953648f2211e0e672 100644
--- a/cmake/PrepareSFG.cmake
+++ b/cmake/WalberlaCodegen.cmake
@@ -7,37 +7,70 @@ set(
 )
 
 if( WALBERLA_CODEGEN_PRIVATE_VENV )
+    find_package( Python COMPONENTS Interpreter REQUIRED )
+
     set(WALBERLA_CODEGEN_VENV_PATH ${CMAKE_CURRENT_BINARY_DIR}/codegen-venv CACHE PATH "Location of the virtual environment used for code generation")
     set(_venv_python_exe ${WALBERLA_CODEGEN_VENV_PATH}/bin/python)
+    set(
+        _WALBERLA_CODEGEN_VENV_STATEFILE
+        ${CMAKE_CURRENT_BINARY_DIR}/walberla-venv-state.json
+        CACHE INTERNAL
+        "venv statefile - for internal use only"
+    )
+    set(
+        _WALBERLA_CODEGEN_VENV_INVOKE_MANAGER
+            ${Python_EXECUTABLE}
+            ${sfg_walberla_SOURCE_DIR}/cmake/ManageCodegenVenv.py
+            -s
+            ${_WALBERLA_CODEGEN_VENV_STATEFILE}
+        CACHE INTERNAL
+        "venv manager filepath - for internal use only"
+    )
 
-    find_package( Python COMPONENTS Interpreter REQUIRED )
+    set(
+        WALBERLA_CODEGEN_VENV_REQUIREMENTS
+        ${sfg_walberla_SOURCE_DIR}/cmake/codegen-requirements.txt
+        CACHE PATH
+        "Location of the primary requirements file for the codegen virtual environment"
+    )
+    mark_as_advanced(WALBERLA_CODEGEN_VENV_REQUIREMENTS)
 
-    if(NOT _sfg_private_venv_done)
-        message( STATUS "Setting up Python virtual environment at ${WALBERLA_CODEGEN_VENV_PATH}" )
 
-        #   Create the venv and register its interpreter with pystencils-sfg
-        if(NOT EXISTS ${WALBERLA_CODEGEN_VENV_PATH})
-            execute_process(
-                COMMAND ${Python_EXECUTABLE} -m venv ${WALBERLA_CODEGEN_VENV_PATH}
-            )
-        endif()
+    set(
+        _requirements_file
+        ${CMAKE_CURRENT_BINARY_DIR}/codegen-requirements.txt
+    )
 
-        message( STATUS "Installing required Python packages..." )
+    file(COPY_FILE ${WALBERLA_CODEGEN_VENV_REQUIREMENTS} ${_requirements_file} )
+    file(APPEND ${_requirements_file} "\n-e ${sfg_walberla_SOURCE_DIR}\n")
 
-        execute_process(
-            COMMAND ${_venv_python_exe} -m pip install -r ${sfg_walberla_SOURCE_DIR}/cmake/codegen-requirements.txt
-            OUTPUT_QUIET
-        )
+    set(_init_args ${WALBERLA_CODEGEN_VENV_PATH} ${_requirements_file})
 
-        execute_process(
-            COMMAND ${_venv_python_exe} -m pip install -e ${sfg_walberla_SOURCE_DIR}
-            OUTPUT_QUIET
-        )
+    if(NOT _walberla_codegen_venv_initialized)
+        #   Force-rebuild environment if cmake cache was deleted
+        list(APPEND _init_args --force)
+    endif()
+
+    execute_process(
+        COMMAND
+            $CACHE{_WALBERLA_CODEGEN_VENV_INVOKE_MANAGER}
+            init
+            ${_init_args}
+        RESULT_VARIABLE _lastResult
+        ERROR_VARIABLE _lastError
+    )
 
-        set( _sfg_private_venv_done TRUE CACHE BOOL "" )
-        set( _wlb_codegen_python_init ${_venv_python_exe} )
-        mark_as_advanced(_sfg_private_venv_done)
+    if( ${_lastResult} )
+        message( FATAL_ERROR "Codegen virtual environment setup failed:\n${_lastError}" )
     endif()
+
+    set(
+        _walberla_codegen_venv_initialized
+        TRUE
+        CACHE INTERNAL "venv initialized - for internal use only"
+    )
+
+    set( _wlb_codegen_python_init ${_venv_python_exe} )
 else()
     #   Use the external Python environment, but check if all packages are installed
     find_package( Python COMPONENTS Interpreter REQUIRED )
@@ -89,21 +122,42 @@ configure_file(
 
 message( STATUS "Wrote project-wide code generator configuration to ${WALBERLA_CODEGEN_CONFIG_MODULE}" )
 
-#[[
-Run `pip install` in the code generation environment with the given arguments.
-#]]
-function(walberla_codegen_venv_install)
+function(walberla_codegen_venv_require)
     if(NOT WALBERLA_CODEGEN_PRIVATE_VENV)
         return()
     endif()
 
-    if(NOT _sfg_private_venv_done)
-        message( FATAL_ERROR "The private virtual environment for code generation was not initialized yet" )
+    execute_process(
+        COMMAND
+            $CACHE{_WALBERLA_CODEGEN_VENV_INVOKE_MANAGER}
+            require
+            --
+            ${ARGV}
+        RESULT_VARIABLE _lastResult
+        ERROR_VARIABLE _lastError
+    )
+
+    if( ${_lastResult} )
+        message( FATAL_ERROR ${_lastError} )
+    endif()
+endfunction()
+
+function(walberla_codegen_venv_populate)
+    if(NOT WALBERLA_CODEGEN_PRIVATE_VENV)
+        return()
     endif()
 
     execute_process(
-        COMMAND ${WALBERLA_CODEGEN_PYTHON} -m pip install ${ARGV}
+        COMMAND
+            $CACHE{_WALBERLA_CODEGEN_VENV_INVOKE_MANAGER}
+            populate
+        RESULT_VARIABLE _lastResult
+        ERROR_VARIABLE _lastError
     )
+
+    if( ${_lastResult} )
+        message( FATAL_ERROR ${_lastError} )
+    endif()
 endfunction()
 
 #   Code Generation Functions
diff --git a/cmake/check_python_env.py b/cmake/check_python_env.py
index 7d00009b56992b3b16fb1dc07f2854e30e9d49b3..7901b56db2041cabfd3458857e06f95b3cc4aba3 100644
--- a/cmake/check_python_env.py
+++ b/cmake/check_python_env.py
@@ -20,9 +20,9 @@ except ImportError:
     error("pystencils-sfg is not installed in the current Python environment.")
 
 try:
-    import sfg_walberla
+    import walberla.codegen
 except ImportError:
-    error("sfg_walberla is not installed in the current Python environment.")
+    error("walberla-codegen is not installed in the current Python environment.")
 
 print("found required packages pystencils, pystencils-sfg, sfg-walberla", end="")
 
diff --git a/cmake/codegen-requirements.txt b/cmake/codegen-requirements.txt
index 70dd9bb4285bca1dea8f12f657581df8bb1c0379..d9af3493da7d805e3e8464173b435a77fad301cb 100644
--- a/cmake/codegen-requirements.txt
+++ b/cmake/codegen-requirements.txt
@@ -1,8 +1,9 @@
-# pystencils 2.0 Development Branch
+
+#   pystencils
 git+https://i10git.cs.fau.de/pycodegen/pystencils.git@v2.0-dev
 
-# lbmpy: feature branch for pystencils-2.0 compatibility
+#   lbmpy
 git+https://i10git.cs.fau.de/pycodegen/lbmpy.git@fhennig/pystencils2.0-compat
 
-# pystencils-sfg: (development branch with updated CMake modules and cpptypes)
-git+https://i10git.cs.fau.de/pycodegen/pystencils-sfg.git@e467692fe48479ba2867d7a2e753e6be7dea4132
+#   pystencils-sfg
+git+https://i10git.cs.fau.de/pycodegen/pystencils-sfg.git
diff --git a/cmake/test/reqs.txt b/cmake/test/reqs.txt
new file mode 100644
index 0000000000000000000000000000000000000000..9c593dc1ea78b95d9bcabbc2fc0805a0599d5b2f
--- /dev/null
+++ b/cmake/test/reqs.txt
@@ -0,0 +1,2 @@
+pystencils
+lbmpy
\ No newline at end of file
diff --git a/cmake/test/state.json b/cmake/test/state.json
new file mode 100644
index 0000000000000000000000000000000000000000..0de44eabf4c1bb7894bb087dd15d78ddb016c8ad
--- /dev/null
+++ b/cmake/test/state.json
@@ -0,0 +1 @@
+{"venv_dir": "/media/data/fhennig/research-hpc/projects/2024_pystencils_nbackend/sfg-walberla/cmake/test/venv", "main_requirements_file": "/media/data/fhennig/research-hpc/projects/2024_pystencils_nbackend/sfg-walberla/cmake/test/reqs.txt", "initialized": true, "user_requirements": ["py-cpuinfo", "pyyaml"], "user_requirements_hash": "bebb0df474f163f05d50504ee0efc07ba064e08aabeb7b2c745d8f57200be4ac"}
\ No newline at end of file
diff --git a/include/sfg/GenericHbbBoundary.hpp b/include/sfg/GenericHbbBoundary.hpp
deleted file mode 100644
index c9164f55c9a46730dfa9d5b0b55bf241ea263f54..0000000000000000000000000000000000000000
--- a/include/sfg/GenericHbbBoundary.hpp
+++ /dev/null
@@ -1,108 +0,0 @@
-#pragma once
-
-#include "blockforest/StructuredBlockForest.h"
-
-#include "core/DataTypes.h"
-#include "core/cell/Cell.h"
-
-#include "domain_decomposition/BlockDataID.h"
-
-#include "field/FlagField.h"
-
-#include "stencil/Directions.h"
-
-#include <memory>
-#include <tuple>
-
-#include "SparseIteration.hpp"
-
-namespace walberla
-{
-namespace sfg
-{
-
-struct HbbLink
-{
-  int32_t x;
-  int32_t y;
-  int32_t z;
-  int32_t dir;
-
-  /**
-   * Create a halfway bounce-back boundary link from a fluid cell `c` to a boundary cell in direction `d`.
-   */
-  HbbLink(const Cell& c, const stencil::Direction d)
-    : x{ int32_c(c.x()) }, y{ int32_c(c.y()) }, z{ int32_c(c.z()) }, dir{ int32_t(d) }
-  {}
-
-  bool operator==(const HbbLink& other) const
-  {
-    return std::tie(x, y, z, dir) == std::tie(other.x, other.y, other.z, other.dir);
-  }
-};
-
-/**
- * @brief Boundary index list for half-way bounce-back boundary conditions
- */
-template< typename Stencil_T >
-class GenericHbbBoundary
-{
-public:
-  using Stencil      = Stencil_T;
-  using IndexVectors = internal::IndexListBuffer< HbbLink >;
-
-  GenericHbbBoundary(StructuredBlockForest& blocks)
-  {
-    auto createIdxVector = [](IBlock* const, StructuredBlockStorage* const) { return new IndexVectors(); };
-    indexVectorsId_      = blocks.addStructuredBlockData< IndexVectors >(createIdxVector);
-  }
-
-  template< typename FlagField_T >
-  void fillFromFlagField(StructuredBlockForest& blocks, ConstBlockDataID flagFieldID,
-                         FlagUID boundaryFlagUID, FlagUID domainFlagUID)
-  {
-    for (auto & block: blocks)
-      fillFromFlagField< FlagField_T >(block, flagFieldID, boundaryFlagUID, domainFlagUID);
-  }
-
-  template< typename FlagField_T >
-  void fillFromFlagField(IBlock& block, ConstBlockDataID flagFieldID, FlagUID boundaryFlagUID, FlagUID domainFlagUID);
-
-protected:
-  BlockDataID indexVectorsId_;
-};
-
-template< typename Stencil_T >
-template< typename FlagField_T >
-void GenericHbbBoundary< Stencil_T >::fillFromFlagField(IBlock& block, ConstBlockDataID flagFieldID,
-                                                                FlagUID boundaryFlagUID, FlagUID domainFlagUID)
-{
-  auto* indexVectors   = block.getData< IndexVectors >(indexVectorsId_);
-  auto& indexVectorAll = indexVectors->vector();
-  auto* flagField      = block.getData< FlagField_T >(flagFieldID);
-
-  if (!(flagField->flagExists(boundaryFlagUID) && flagField->flagExists(domainFlagUID))) return;
-
-  auto boundaryFlag = flagField->getFlag(boundaryFlagUID);
-  auto domainFlag   = flagField->getFlag(domainFlagUID);
-
-  indexVectorAll.clear();
-
-  const cell_idx_t numGhostLayers = cell_idx_c(flagField->nrOfGhostLayers() - 1);
-  for (auto it = flagField->beginWithGhostLayerXYZ(numGhostLayers); it != flagField->end(); ++it)
-  {
-    const Cell fluidCell{ it.cell() };
-
-    if (!isFlagSet(it, domainFlag)) continue;
-
-    for (auto dIt = Stencil::beginNoCenter(); dIt != Stencil::end(); ++dIt)
-    {
-      const stencil::Direction dir{ *dIt };
-
-      if (isFlagSet(it.neighbor(dir), boundaryFlag)) { indexVectorAll.emplace_back(fluidCell, dir); }
-    }
-  }
-}
-
-} // namespace sfg
-} // namespace walberla
diff --git a/include/sfg/IrregularFreeSlip.hpp b/include/sfg/IrregularFreeSlip.hpp
deleted file mode 100644
index c887b8e7bd55d437b4a0d83160c47b7fe66682a7..0000000000000000000000000000000000000000
--- a/include/sfg/IrregularFreeSlip.hpp
+++ /dev/null
@@ -1,185 +0,0 @@
-#pragma once
-
-#include "core/DataTypes.h"
-#include "core/cell/Cell.h"
-#include "domain_decomposition/BlockDataID.h"
-#include "domain_decomposition/IBlock.h"
-#include "field/GhostLayerField.h"
-#include "field/FlagField.h"
-#include "sfg/SparseIteration.hpp"
-#include "stencil/Directions.h"
-#include <tuple>
-
-#include "SparseIteration.hpp"
-
-namespace walberla::sfg
-{
-    struct IrregularFreeSlipLinkInfo
-    {
-        int32_t x;
-        int32_t y;
-        int32_t z;
-        int32_t dir;
-        int32_t source_offset_x;
-        int32_t source_offset_y;
-        int32_t source_offset_z;
-        int32_t source_dir;
-        IrregularFreeSlipLinkInfo(
-            walberla::Cell fluidCell, walberla::stencil::Direction linkDir,
-            walberla::Cell sourceCell, walberla::stencil::Direction sourceDir)
-            : x{fluidCell.x()}, y{fluidCell.y()}, z{fluidCell.z()},
-              dir{int32_t(linkDir)}, source_offset_x{sourceCell.x() - fluidCell.x()},
-              source_offset_y{sourceCell.y() - fluidCell.y()}, source_offset_z{sourceCell.z() - fluidCell.z()},
-              source_dir{int32_t(sourceDir)} {}
-
-        bool operator==(const IrregularFreeSlipLinkInfo &other) const
-        {
-            return std::tie(x, y, z, dir, source_offset_x, source_offset_y, source_offset_z, source_dir) ==
-                   std::tie(other.x, other.y, other.z, other.dir, other.source_offset_x,
-                            other.source_offset_y, other.source_offset_z, other.source_dir);
-        }
-    };
-
-    namespace detail
-    {
-        template <typename Stencil, typename FlagField_T>
-        class FreeSlipLinksFromFlagField
-        {
-        public:
-            using IndexList = SparseIndexList<IrregularFreeSlipLinkInfo>;
-            using flag_t = typename FlagField_T::flag_t;
-
-            FreeSlipLinksFromFlagField(
-                StructuredBlockForest &sbfs,
-                BlockDataID flagFieldID,
-                field::FlagUID boundaryFlagUID,
-                field::FlagUID fluidFlagUID) : sbfs_{sbfs}, flagFieldID_(flagFieldID), boundaryFlagUID_(boundaryFlagUID), fluidFlagUID_(fluidFlagUID) {}
-
-            IndexList collectLinks()
-            {
-                IndexList indexList{sbfs_};
-
-                for (auto &block : sbfs_)
-                {
-                    FlagField_T *flagField = block.getData<FlagField_T>(flagFieldID_);
-                    flag_t boundaryFlag{flagField->getFlag(boundaryFlagUID_)};
-                    flag_t fluidFlag{flagField->getFlag(fluidFlagUID_)};
-
-                    auto &idxVector = indexList.get(block);
-
-                    for (auto it = flagField->beginXYZ(); it != flagField->end(); ++it)
-                    {
-                        Cell c{it.cell()};
-                        if (!isFlagSet(it, fluidFlag))
-                        {
-                            continue;
-                        }
-
-                        for (auto dIt = Stencil::beginNoCenter(); dIt != Stencil::end(); ++dIt)
-                        {
-                            stencil::Direction dir{*dIt};
-                            Cell neighbor{c + dir};
-                            if (flagField->isFlagSet(neighbor, boundaryFlag))
-                            {
-                                idxVector.emplace_back(createLink(flagField, c, dir));
-                            }
-                        }
-                    }
-                }
-
-                return indexList;
-            }
-
-        private:
-            IrregularFreeSlipLinkInfo createLink(FlagField_T *flagField, const Cell &fluidCell, const stencil::Direction dir)
-            {
-                const flag_t fluidFlag{flagField->getFlag(fluidFlagUID_)};
-                const Cell wallCell{fluidCell + dir};
-
-                // inverse direction of 'dir' as lattice vector
-
-                const cell_idx_t ix = stencil::cx[stencil::inverseDir[dir]];
-                const cell_idx_t iy = stencil::cy[stencil::inverseDir[dir]];
-                const cell_idx_t iz = stencil::cz[stencil::inverseDir[dir]];
-
-                stencil::Direction sourceDir = stencil::inverseDir[dir]; // compute reflected (mirrored) of inverse direction of 'dir'
-
-                cell_idx_t wnx = 0; // compute "normal" vector of free slip wall
-                cell_idx_t wny = 0;
-                cell_idx_t wnz = 0;
-
-                if (flagField->isFlagSet(wallCell.x() + ix, wallCell.y(), wallCell.z(), fluidFlag))
-                {
-                    wnx = ix;
-                    sourceDir = stencil::mirrorX[sourceDir];
-                }
-                if (flagField->isFlagSet(wallCell.x(), wallCell.y() + iy, wallCell.z(), fluidFlag))
-                {
-                    wny = iy;
-                    sourceDir = stencil::mirrorY[sourceDir];
-                }
-                if (flagField->isFlagSet(wallCell.x(), wallCell.y(), wallCell.z() + iz, fluidFlag))
-                {
-                    wnz = iz;
-                    sourceDir = stencil::mirrorZ[sourceDir];
-                }
-
-                // concave corner (neighbors are non-fluid)
-                if (wnx == 0 && wny == 0 && wnz == 0)
-                {
-                    wnx = ix;
-                    wny = iy;
-                    wnz = iz;
-                    sourceDir = dir;
-                }
-
-                const Cell sourceCell{
-                    wallCell.x() + wnx,
-                    wallCell.y() + wny,
-                    wallCell.z() + wnz};
-
-                return {
-                    fluidCell,
-                    dir,
-                    sourceCell,
-                    sourceDir};
-            }
-
-            StructuredBlockForest &sbfs_;
-            const BlockDataID flagFieldID_;
-            const field::FlagUID boundaryFlagUID_;
-            const field::FlagUID fluidFlagUID_;
-        };
-    }
-
-    template <typename Impl>
-    class IrregularFreeSlipFactory
-    {
-    public:
-        IrregularFreeSlipFactory(const shared_ptr<StructuredBlockForest> blocks, BlockDataID pdfFieldID)
-            : blocks_{blocks}, pdfFieldID_{pdfFieldID} {}
-
-        template <typename FlagField_T>
-        auto fromFlagField(BlockDataID flagFieldID, field::FlagUID boundaryFlagUID, field::FlagUID fluidFlagUID)
-        {
-            detail::FreeSlipLinksFromFlagField<typename Impl::Stencil, FlagField_T> linksFromFlagField{*blocks_, flagFieldID, boundaryFlagUID, fluidFlagUID};
-            auto indexVector = linksFromFlagField.collectLinks();
-            return impl().irregularFromIndexVector(indexVector);
-        }
-
-    protected:
-        shared_ptr<StructuredBlockForest> blocks_;
-        BlockDataID pdfFieldID_;
-
-    private:
-        Impl &impl()
-        {
-            return *static_cast<Impl *>(this);
-        }
-
-        const Impl &impl() const
-        {
-            return *static_cast<const Impl *>(this);
-        }
-    };
-}
diff --git a/include/sfg/SparseIteration.hpp b/include/sfg/SparseIteration.hpp
deleted file mode 100644
index 9862e7662b63cceb337217392aa92cb55cf06739..0000000000000000000000000000000000000000
--- a/include/sfg/SparseIteration.hpp
+++ /dev/null
@@ -1,80 +0,0 @@
-#pragma once
-
-#include <vector>
-#include "core/DataTypes.h"
-#include "core/cell/Cell.h"
-
-#include "domain_decomposition/BlockDataID.h"
-#include "blockforest/StructuredBlockForest.h"
-
-namespace walberla::sfg
-{
-  namespace internal
-  {
-    /**
-     * @brief Index vectors for sparse sweeps
-     */
-    template <typename IndexStruct>
-    class IndexListBuffer
-    {
-    public:
-      using CpuVector = std::vector<IndexStruct>;
-
-      IndexListBuffer() = default;
-      bool operator==(const IndexListBuffer &other) const { return other.vec_ == vec_; }
-
-      CpuVector &vector() { return vec_; }
-
-      IndexStruct *pointerCpu() { return vec_.data(); }
-
-    private:
-      CpuVector vec_;
-    };
-  }
-
-  struct CellIdx
-  {
-    int64_t x;
-    int64_t y;
-    int64_t z;
-
-    CellIdx(const Cell &c)
-        : x{int64_c(c.x())}, y{int64_c(c.y())}, z{int64_c(c.z())}
-    {
-    }
-
-    bool operator==(const CellIdx &other) const
-    {
-      return std::tie(x, y, z) == std::tie(other.x, other.y, other.z);
-    }
-  };
-
-  template <typename IndexStruct = CellIdx>
-  class SparseIndexList
-  {
-  public:
-    using Buffer_T = internal::IndexListBuffer<IndexStruct>;
-
-    explicit SparseIndexList(StructuredBlockForest &sbfs) : bufferId_{createBuffers(sbfs)} {}
-
-    std::vector<IndexStruct> &get(IBlock &block)
-    {
-      Buffer_T *buf = block.template getData<Buffer_T>(bufferId_);
-      return buf->vector();
-    }
-
-    const BlockDataID & bufferId() const {
-      return bufferId_;
-    }
-
-  private:
-    static BlockDataID createBuffers(StructuredBlockForest &sbfs)
-    {
-      auto createIdxVector = [](IBlock *const, StructuredBlockStorage *const)
-      { return new Buffer_T(); };
-      return sbfs.addStructuredBlockData<Buffer_T>(createIdxVector);
-    }
-
-    BlockDataID bufferId_;
-  };
-}
diff --git a/lib/CMakeLists.txt b/lib/CMakeLists.txt
new file mode 100644
index 0000000000000000000000000000000000000000..4a50799e8f99a005de5969996af1ffc6170449a0
--- /dev/null
+++ b/lib/CMakeLists.txt
@@ -0,0 +1,22 @@
+add_library( walberla_experimental INTERFACE )
+add_library( walberla::experimental ALIAS walberla_experimental )
+
+target_sources( walberla_experimental
+    INTERFACE
+    walberla/experimental/memory/UnifiedMemoryAllocator.hpp
+    walberla/experimental/sweep/SparseIndexList.hpp
+    walberla/experimental/lbm/GenericHbbBoundary.hpp
+    walberla/experimental/lbm/IrregularFreeSlip.hpp
+)
+
+target_link_libraries(
+    walberla_experimental
+    INTERFACE
+    walberla_core
+    walberla_stencil
+    walberla_blockforest
+    walberla_field
+    walberla_gpu
+)
+
+target_include_directories( walberla_experimental INTERFACE ${CMAKE_CURRENT_SOURCE_DIR} )
diff --git a/lib/walberla/experimental/Memory.hpp b/lib/walberla/experimental/Memory.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..877249e1d7a323e36654cbb94ec0614f30892f48
--- /dev/null
+++ b/lib/walberla/experimental/Memory.hpp
@@ -0,0 +1,4 @@
+#pragma once
+
+#include "./memory/MemoryTags.hpp"
+#include "./memory/UnifiedMemoryAllocator.hpp"
diff --git a/lib/walberla/experimental/Sweep.hpp b/lib/walberla/experimental/Sweep.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..84aeeb3fe5e07b9b72e1581b29fe4897c4c3a9d9
--- /dev/null
+++ b/lib/walberla/experimental/Sweep.hpp
@@ -0,0 +1,5 @@
+#pragma once
+
+#include "./sweep/DomainSlices.hpp"
+#include "./sweep/SparseIndexList.hpp"
+#include "./sweep/Sweeper.hpp"
diff --git a/lib/walberla/experimental/lbm/GenericHbbBoundary.hpp b/lib/walberla/experimental/lbm/GenericHbbBoundary.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..54a536fa5a6dd9f7f9d1c6f9f7fb153625d80157
--- /dev/null
+++ b/lib/walberla/experimental/lbm/GenericHbbBoundary.hpp
@@ -0,0 +1,107 @@
+#pragma once
+
+#include "blockforest/StructuredBlockForest.h"
+
+#include "core/DataTypes.h"
+#include "core/cell/Cell.h"
+
+#include "domain_decomposition/BlockDataID.h"
+
+#include "field/FlagField.h"
+
+#include "stencil/Directions.h"
+
+#include <memory>
+#include <tuple>
+
+#include "walberla/experimental/sweep/SparseIndexList.hpp"
+
+namespace walberla::experimental::lbm
+{
+
+struct HbbLink
+{
+   int32_t x;
+   int32_t y;
+   int32_t z;
+   int32_t dir;
+
+   /**
+    * Create a halfway bounce-back boundary link from a fluid cell `c` to a boundary cell in direction `d`.
+    */
+   HbbLink(const Cell& c, const stencil::Direction d)
+      : x{ int32_c(c.x()) }, y{ int32_c(c.y()) }, z{ int32_c(c.z()) }, dir{ int32_t(d) }
+   {}
+
+   bool operator==(const HbbLink& other) const
+   {
+      return std::tie(x, y, z, dir) == std::tie(other.x, other.y, other.z, other.dir);
+   }
+};
+
+template< typename Impl >
+struct GenericHbbFactoryImplTraits
+{
+   using Stencil_T         = typename Impl::Stencil;
+   using IdxStruct_T       = HbbLink;
+   using IndexListMemTag_T = typename Impl::memtag_t;
+   using IndexList_T       = sweep::SparseIndexList< IdxStruct_T, IndexListMemTag_T >;
+};
+
+template< typename Impl >
+class GenericHbbFactory
+{
+   using ImplTraits = GenericHbbFactoryImplTraits< Impl >;
+
+ public:
+   GenericHbbFactory(const shared_ptr< StructuredBlockForest > blocks, BlockDataID pdfFieldID)
+      : blocks_{ blocks }, pdfFieldID_{ pdfFieldID }
+   {}
+
+   template< typename FlagField_T >
+   auto fromFlagField(BlockDataID flagFieldID, field::FlagUID boundaryFlagUID, field::FlagUID fluidFlagUID)
+   {
+      using flag_t = typename FlagField_T::flag_t;
+      using IndexList_T = typename ImplTraits::IndexList_T;
+      using Stencil = typename ImplTraits::Stencil_T;
+
+      IndexList_T indexList{ *blocks_ };
+
+      for (auto& block : *blocks_)
+      {
+         FlagField_T* flagField = block.getData< FlagField_T >(flagFieldID);
+         flag_t boundaryFlag{ flagField->getFlag(boundaryFlagUID) };
+         flag_t fluidFlag{ flagField->getFlag(fluidFlagUID) };
+
+         if (!(flagField->flagExists(boundaryFlagUID) && flagField->flagExists(fluidFlagUID))) continue;
+
+         auto& idxVector = indexList.getVector(block);
+
+         for (auto it = flagField->beginXYZ(); it != flagField->end(); ++it)
+         {
+            Cell fluidCell{ it.cell() };
+            if (!isFlagSet(it, fluidFlag)) { continue; }
+
+            for (auto dIt = Stencil::beginNoCenter(); dIt != Stencil::end(); ++dIt)
+            {
+               stencil::Direction dir{ *dIt };
+               Cell neighbor{ fluidCell + dir };
+               if (flagField->isFlagSet(neighbor, boundaryFlag)) { idxVector.emplace_back(fluidCell, dir); }
+            }
+         }
+      }
+
+      return impl().irregularFromIndexVector(indexList);
+   }
+
+ protected:
+   shared_ptr< StructuredBlockForest > blocks_;
+   BlockDataID pdfFieldID_;
+
+ private:
+   Impl& impl() { return *static_cast< Impl* >(this); }
+
+   const Impl& impl() const { return *static_cast< const Impl* >(this); }
+};
+
+} // namespace walberla::experimental::lbm
diff --git a/lib/walberla/experimental/lbm/IrregularFreeSlip.hpp b/lib/walberla/experimental/lbm/IrregularFreeSlip.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..0b9097fc53a64a3d3578122908b14b140c8f3cff
--- /dev/null
+++ b/lib/walberla/experimental/lbm/IrregularFreeSlip.hpp
@@ -0,0 +1,187 @@
+#pragma once
+
+#include "core/DataTypes.h"
+#include "core/cell/Cell.h"
+
+#include "domain_decomposition/BlockDataID.h"
+#include "domain_decomposition/IBlock.h"
+
+#include "field/FlagField.h"
+#include "field/GhostLayerField.h"
+
+#include "stencil/Directions.h"
+
+#include <memory>
+#include <tuple>
+#include <type_traits>
+
+#include "walberla/experimental/Memory.hpp"
+#include "walberla/experimental/sweep/SparseIndexList.hpp"
+
+namespace walberla::experimental::lbm
+{
+struct IrregularFreeSlipLinkInfo
+{
+   int32_t x;
+   int32_t y;
+   int32_t z;
+   int32_t dir;
+   int32_t source_offset_x;
+   int32_t source_offset_y;
+   int32_t source_offset_z;
+   int32_t source_dir;
+   IrregularFreeSlipLinkInfo(walberla::Cell fluidCell, walberla::stencil::Direction linkDir, walberla::Cell sourceCell,
+                             walberla::stencil::Direction sourceDir)
+      : x{ fluidCell.x() }, y{ fluidCell.y() }, z{ fluidCell.z() }, dir{ int32_t(linkDir) },
+        source_offset_x{ sourceCell.x() - fluidCell.x() }, source_offset_y{ sourceCell.y() - fluidCell.y() },
+        source_offset_z{ sourceCell.z() - fluidCell.z() }, source_dir{ int32_t(sourceDir) }
+   {}
+
+   bool operator==(const IrregularFreeSlipLinkInfo& other) const
+   {
+      return std::tie(x, y, z, dir, source_offset_x, source_offset_y, source_offset_z, source_dir) ==
+             std::tie(other.x, other.y, other.z, other.dir, other.source_offset_x, other.source_offset_y,
+                      other.source_offset_z, other.source_dir);
+   }
+};
+
+namespace detail
+{
+template< typename Stencil, typename FlagField_T, typename IndexList_T >
+class FreeSlipLinksFromFlagField
+{
+ public:
+   using flag_t    = typename FlagField_T::flag_t;
+
+   FreeSlipLinksFromFlagField(StructuredBlockForest& sbfs, BlockDataID flagFieldID, field::FlagUID boundaryFlagUID,
+                              field::FlagUID fluidFlagUID)
+      : sbfs_{ sbfs }, flagFieldID_(flagFieldID), boundaryFlagUID_(boundaryFlagUID), fluidFlagUID_(fluidFlagUID)
+   {}
+
+   IndexList_T collectLinks()
+   {
+      IndexList_T indexList{ sbfs_ };
+
+      for (auto& block : sbfs_)
+      {
+         FlagField_T* flagField = block.getData< FlagField_T >(flagFieldID_);
+         flag_t boundaryFlag{ flagField->getFlag(boundaryFlagUID_) };
+         flag_t fluidFlag{ flagField->getFlag(fluidFlagUID_) };
+
+         auto& idxVector = indexList.getVector(block);
+
+         for (auto it = flagField->beginXYZ(); it != flagField->end(); ++it)
+         {
+            Cell c{ it.cell() };
+            if (!isFlagSet(it, fluidFlag)) { continue; }
+
+            for (auto dIt = Stencil::beginNoCenter(); dIt != Stencil::end(); ++dIt)
+            {
+               stencil::Direction dir{ *dIt };
+               Cell neighbor{ c + dir };
+               if (flagField->isFlagSet(neighbor, boundaryFlag))
+               {
+                  idxVector.emplace_back(createLink(flagField, c, dir));
+               }
+            }
+         }
+      }
+
+      return indexList;
+   }
+
+ private:
+   IrregularFreeSlipLinkInfo createLink(FlagField_T* flagField, const Cell& fluidCell, const stencil::Direction dir)
+   {
+      const flag_t fluidFlag{ flagField->getFlag(fluidFlagUID_) };
+      const Cell wallCell{ fluidCell + dir };
+
+      // inverse direction of 'dir' as lattice vector
+
+      const cell_idx_t ix = stencil::cx[stencil::inverseDir[dir]];
+      const cell_idx_t iy = stencil::cy[stencil::inverseDir[dir]];
+      const cell_idx_t iz = stencil::cz[stencil::inverseDir[dir]];
+
+      stencil::Direction sourceDir =
+         stencil::inverseDir[dir]; // compute reflected (mirrored) of inverse direction of 'dir'
+
+      cell_idx_t wnx = 0; // compute "normal" vector of free slip wall
+      cell_idx_t wny = 0;
+      cell_idx_t wnz = 0;
+
+      if (flagField->isFlagSet(wallCell.x() + ix, wallCell.y(), wallCell.z(), fluidFlag))
+      {
+         wnx       = ix;
+         sourceDir = stencil::mirrorX[sourceDir];
+      }
+      if (flagField->isFlagSet(wallCell.x(), wallCell.y() + iy, wallCell.z(), fluidFlag))
+      {
+         wny       = iy;
+         sourceDir = stencil::mirrorY[sourceDir];
+      }
+      if (flagField->isFlagSet(wallCell.x(), wallCell.y(), wallCell.z() + iz, fluidFlag))
+      {
+         wnz       = iz;
+         sourceDir = stencil::mirrorZ[sourceDir];
+      }
+
+      // concave corner (neighbors are non-fluid)
+      if (wnx == 0 && wny == 0 && wnz == 0)
+      {
+         wnx       = ix;
+         wny       = iy;
+         wnz       = iz;
+         sourceDir = dir;
+      }
+
+      const Cell sourceCell{ wallCell.x() + wnx, wallCell.y() + wny, wallCell.z() + wnz };
+
+      return { fluidCell, dir, sourceCell, sourceDir };
+   }
+
+   StructuredBlockForest& sbfs_;
+   const BlockDataID flagFieldID_;
+   const field::FlagUID boundaryFlagUID_;
+   const field::FlagUID fluidFlagUID_;
+};
+} // namespace detail
+
+template< typename Impl >
+struct IrregularFreeSlipFactoryImplTraits
+{
+   using Stencil_T   = typename Impl::Stencil;
+   using IdxStruct_T = IrregularFreeSlipLinkInfo;
+   using IndexListMemTag_T = typename Impl::memtag_t;
+   using IndexList_T = sweep::SparseIndexList< IrregularFreeSlipLinkInfo, IndexListMemTag_T >;
+};
+
+template< typename Impl >
+class IrregularFreeSlipFactory
+{
+   using ImplTraits = IrregularFreeSlipFactoryImplTraits< Impl >;
+
+ public:
+   IrregularFreeSlipFactory(const shared_ptr< StructuredBlockForest > blocks, BlockDataID pdfFieldID)
+      : blocks_{ blocks }, pdfFieldID_{ pdfFieldID }
+   {}
+
+   template< typename FlagField_T >
+   auto fromFlagField(BlockDataID flagFieldID, field::FlagUID boundaryFlagUID, field::FlagUID fluidFlagUID)
+   {
+      detail::FreeSlipLinksFromFlagField< typename ImplTraits::Stencil_T, FlagField_T, typename ImplTraits::IndexList_T > linksFromFlagField{
+         *blocks_, flagFieldID, boundaryFlagUID, fluidFlagUID
+      };
+      auto indexVector = linksFromFlagField.collectLinks();
+      return impl().irregularFromIndexVector(indexVector);
+   }
+
+ protected:
+   shared_ptr< StructuredBlockForest > blocks_;
+   BlockDataID pdfFieldID_;
+
+ private:
+   Impl& impl() { return *static_cast< Impl* >(this); }
+
+   const Impl& impl() const { return *static_cast< const Impl* >(this); }
+};
+} // namespace walberla::experimental::lbm
diff --git a/lib/walberla/experimental/memory/MemoryTags.hpp b/lib/walberla/experimental/memory/MemoryTags.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..902d7399f385e6b8dab559cfaa3731cda0ea7a16
--- /dev/null
+++ b/lib/walberla/experimental/memory/MemoryTags.hpp
@@ -0,0 +1,93 @@
+#pragma once
+
+#include "waLBerlaDefinitions.h"
+
+#include <concepts>
+#include <memory>
+
+#if defined(WALBERLA_BUILD_WITH_CUDA) || defined(WALBERLA_BUILD_WITH_HIP)
+
+#include "./UnifiedMemoryAllocator.hpp"
+
+#endif
+
+
+namespace walberla::experimental::memory
+{
+
+namespace memtag
+{
+
+/**
+ * @brief Common base class for all memory tags.
+ */
+struct _mem_tag
+{};
+
+/**
+ * @brief Regular host memory tag.
+ */
+struct host : public _mem_tag
+{};
+inline host host_v;
+
+/**
+ * @brief Memory tag indicating managed GPU memory with unified memory semantics.
+ */
+struct unified : public _mem_tag
+{};
+inline unified unified_v;
+
+} // namespace memtag
+
+template< typename T >
+concept MemTag = std::derived_from< T, memtag::_mem_tag >;
+
+namespace detail
+{
+template< MemTag memtag_t >
+struct SelectStandardAllocator;
+
+template<>
+struct SelectStandardAllocator< memtag::host >
+{
+   template< typename T >
+   using allocator = std::allocator< T >;
+};
+
+#if defined(WALBERLA_BUILD_WITH_CUDA) || defined(WALBERLA_BUILD_WITH_HIP)
+
+/* If CUDA or HIP are active, use the managed memory allocator for unified memory */
+
+template<>
+struct SelectStandardAllocator< memtag::unified >
+{
+   template< typename T >
+   using allocator = UnifiedMemoryAllocator< T >;
+};
+
+#else
+
+/* If no GPU support is active, use std::allocator */
+
+template<>
+struct SelectStandardAllocator< memtag::unified >
+{
+   template< typename T >
+   using allocator = std::allocator< T >;
+};
+
+#endif
+
+} // namespace detail
+
+/**
+ * @brief Standard allocator implementation for a given memory tag and value type.
+ *
+ * This typedef alias references a type implementing the Allocator named requirement for type T
+ * which implements the memory semantics required by the given memory tag.
+ */
+template< MemTag memtag_t, typename T >
+using StandardAllocator_T = detail::SelectStandardAllocator< memtag_t >::template allocator< T >;
+
+} // namespace walberla::experimental::memory
diff --git a/lib/walberla/experimental/memory/UnifiedMemoryAllocator.hpp b/lib/walberla/experimental/memory/UnifiedMemoryAllocator.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..e08b16c0830d62b2f9f2809ece2554fc78bc5ca9
--- /dev/null
+++ b/lib/walberla/experimental/memory/UnifiedMemoryAllocator.hpp
@@ -0,0 +1,50 @@
+#pragma once
+
+#include "waLBerlaDefinitions.h"
+
+#if defined(WALBERLA_BUILD_WITH_CUDA) || defined(WALBERLA_BUILD_WITH_HIP)
+
+#include "gpu/GPUWrapper.h"
+#include "gpu/ErrorChecking.h"
+
+namespace walberla::experimental::memory
+{
+
+/**
+ * @brief Allocator for managed GPU memory.
+ * 
+ * This allocator implementes the Allocator named requirement and
+ * manages allocation and disposal of memory blocks with unified
+ * memory semantics, using the CUDA or HIP runtime (depending on which is active).
+ */
+template< typename T >
+class UnifiedMemoryAllocator
+{
+ public:
+   using value_type = T;
+   using pointer_type = T *;
+
+
+   pointer_type allocate(size_t n) {
+      pointer_type ptr;
+      size_t bytes { n * sizeof(value_type) };
+      WALBERLA_GPU_CHECK(
+         gpuMallocManaged(&ptr, bytes)
+      );
+      return ptr;
+   }
+
+   void deallocate(pointer_type ptr, size_t n) {
+      WALBERLA_GPU_CHECK(
+         gpuFree(ptr)
+      );
+   }
+
+   bool operator== (const UnifiedMemoryAllocator< value_type > &) {
+      return true;
+   }
+};
+
+} // namespace walberla::experimental::memory
+
+#endif
diff --git a/lib/walberla/experimental/sweep/DomainSlices.hpp b/lib/walberla/experimental/sweep/DomainSlices.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..0492cb53bdc2baddae1f8b4f8468eea4b95bfc02
--- /dev/null
+++ b/lib/walberla/experimental/sweep/DomainSlices.hpp
@@ -0,0 +1,81 @@
+#pragma once
+
+#include "blockforest/StructuredBlockForest.h"
+
+#include "core/cell/CellInterval.h"
+
+#include "domain_decomposition/IBlock.h"
+
+#include "stencil/Directions.h"
+
+namespace walberla::experimental::sweep
+{
+
+template< typename T >
+concept CellIntervalSweep = requires(T obj, IBlock* block, const CellInterval& ci) {
+   { obj.runOnCellInterval(block, ci) } -> std::same_as< void >;
+};
+
+namespace detail
+{
+template< stencil::Direction BorderDir >
+struct BorderSweepSlice
+{
+   shared_ptr< StructuredBlockForest > blocks;
+   cell_idx_t offset;
+
+   CellInterval ci();
+   bool isBlockAtBorder(IBlock& b);
+};
+
+template<>
+CellInterval BorderSweepSlice< stencil::Direction::T >::ci()
+{
+   return { { 0, 0, cell_idx_c(blocks->getNumberOfZCellsPerBlock()) - (offset + 1) },
+            { cell_idx_c(blocks->getNumberOfXCellsPerBlock()) - 1, //
+              cell_idx_c(blocks->getNumberOfYCellsPerBlock()) - 1, //
+              cell_idx_c(blocks->getNumberOfZCellsPerBlock()) - (offset + 1) } };
+};
+
+template<>
+bool BorderSweepSlice< stencil::Direction::B >::isBlockAtBorder(IBlock& b)
+{
+   return blocks->atDomainZMinBorder(b);
+}
+
+template<>
+CellInterval BorderSweepSlice< stencil::Direction::B >::ci()
+{
+   return { { 0, 0, offset },
+            { cell_idx_c(blocks->getNumberOfXCellsPerBlock()) - 1, //
+              cell_idx_c(blocks->getNumberOfYCellsPerBlock()) - 1, //
+              offset } };
+};
+
+template<>
+bool BorderSweepSlice< stencil::Direction::T >::isBlockAtBorder(IBlock& b)
+{
+   return blocks->atDomainZMaxBorder(b);
+}
+} // namespace detail
+
+template< stencil::Direction BorderDir, CellIntervalSweep Sweep >
+class BorderSweep
+{
+ private:
+   Sweep sweep_;
+   detail::BorderSweepSlice< BorderDir > slice_;
+   CellInterval ci_;
+
+ public:
+   BorderSweep(const shared_ptr< StructuredBlockForest >& blocks, const Sweep& sweep, cell_idx_t offset = 0)
+      : sweep_{ sweep }, slice_{ blocks, offset }, ci_{ slice_.ci() }
+   {}
+
+   void operator()(IBlock* block)
+   {
+      if (slice_.isBlockAtBorder(*block)) { sweep_.runOnCellInterval(block, ci_); }
+   }
+};
+
+} // namespace walberla::experimental::sweep
diff --git a/lib/walberla/experimental/sweep/SparseIndexList.hpp b/lib/walberla/experimental/sweep/SparseIndexList.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..c94436eae9286fa92c2585c71431a01d68b6d04f
--- /dev/null
+++ b/lib/walberla/experimental/sweep/SparseIndexList.hpp
@@ -0,0 +1,89 @@
+#pragma once
+
+#include "blockforest/StructuredBlockForest.h"
+
+#include "core/DataTypes.h"
+#include "core/cell/Cell.h"
+
+#include "domain_decomposition/BlockDataID.h"
+
+#include <type_traits>
+#include <vector>
+#include <span>
+
+#include "../memory/MemoryTags.hpp"
+
+namespace walberla::experimental::sweep
+{
+namespace internal
+{
+/**
+ * @brief Index vectors for sparse sweeps
+ */
+template< typename IndexStruct, typename Allocator_T >
+class IndexListBuffer
+{
+ public:
+   using Vector_T = std::vector< IndexStruct, Allocator_T >;
+
+   IndexListBuffer() = default;
+   bool operator==(const IndexListBuffer& other) const { return other.vec_ == vec_; }
+
+   Vector_T& vector() { return vec_; }
+
+   IndexStruct* data() { return vec_.data(); }
+   const IndexStruct* data() const { return vec_.data(); }
+   size_t size() const { return vec_.size(); }
+
+ private:
+   Vector_T vec_;
+};
+} // namespace internal
+
+struct CellIdx
+{
+   int64_t x;
+   int64_t y;
+   int64_t z;
+
+   CellIdx(const Cell& c) : x{ int64_c(c.x()) }, y{ int64_c(c.y()) }, z{ int64_c(c.z()) } {}
+
+   bool operator==(const CellIdx& other) const { return std::tie(x, y, z) == std::tie(other.x, other.y, other.z); }
+};
+
+template< typename IndexStruct = CellIdx, memory::MemTag memtag_t = memory::memtag::host >
+class SparseIndexList
+{
+ public:
+   using Buffer_T = internal::IndexListBuffer< IndexStruct, memory::StandardAllocator_T< memtag_t, IndexStruct > >;
+
+   explicit SparseIndexList(StructuredBlockForest& sbfs) : bufferId_{ createBuffers(sbfs) } {}
+
+   Buffer_T::Vector_T& getVector(IBlock& block)
+   {
+      Buffer_T* buf = block.template getData< Buffer_T >(bufferId_);
+      return buf->vector();
+   }
+
+   std::span< IndexStruct > view(IBlock& block){
+    Buffer_T& buf = *block.template getData< Buffer_T >(bufferId_);
+    return { buf.data(), buf.size() };
+   }
+
+   std::span< const IndexStruct > view(IBlock& block) const {
+    const Buffer_T& buf = *block.template getData< Buffer_T >(bufferId_);
+    return { buf.data(), buf.size() };
+   }
+
+   const BlockDataID& bufferId() const { return bufferId_; }
+
+ private:
+   static BlockDataID createBuffers(StructuredBlockForest& sbfs)
+   {
+      auto createIdxVector = [](IBlock* const, StructuredBlockStorage* const) { return new Buffer_T(); };
+      return sbfs.addStructuredBlockData< Buffer_T >(createIdxVector);
+   }
+
+   BlockDataID bufferId_;
+};
+} // namespace walberla::experimental::sweep
diff --git a/lib/walberla/experimental/sweep/Sweeper.hpp b/lib/walberla/experimental/sweep/Sweeper.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..051c53107e5ad3e308647241929afa4316926cad
--- /dev/null
+++ b/lib/walberla/experimental/sweep/Sweeper.hpp
@@ -0,0 +1,53 @@
+#pragma once
+
+#include "blockforest/StructuredBlockForest.h"
+
+#include <memory>
+
+#include "core/cell/all.h"
+
+namespace walberla::experimental::sweep
+{
+
+class SerialSweeper
+{
+ private:
+   std::shared_ptr< StructuredBlockForest > blocks_;
+
+ public:
+   SerialSweeper(const std::shared_ptr< StructuredBlockForest >& blocks) : blocks_{ blocks } {}
+
+   void sweep(std::function< void(IBlock&) > func)
+   {
+      for (auto& block : *blocks_)
+         func(block);
+   }
+
+   void sweep(std::function< void(IBlock*) > func)
+   {
+      for (auto& block : *blocks_)
+         func(&block);
+   }
+
+   void forAllBlocks(std::function< void(IBlock&) > func) { sweep(func); }
+
+   void forAllBlocks(std::function< void(IBlock*) > func) { sweep(func); }
+
+   void forAllCells(std::function< void(Cell) > func)
+   {
+      for (uint_t z = 0; z < blocks_->getNumberOfZCellsPerBlock(); ++z)
+         for (uint_t y = 0; y < blocks_->getNumberOfYCellsPerBlock(); ++y)
+            for (uint_t x = 0; x < blocks_->getNumberOfXCellsPerBlock(); ++x)
+               func({ x, y, z });
+   }
+
+   void forAllCells(const CellInterval& ci, std::function< void(Cell) > func)
+   {
+      for (uint_t z = ci.zMin(); z <= ci.zMax(); ++z)
+         for (uint_t y = ci.yMin(); y <= ci.yMax(); ++y)
+            for (uint_t x = ci.xMin(); x <= ci.xMax(); ++x)
+               func({ x, y, z });
+   }
+};
+
+} // namespace walberla::experimental::sweep
diff --git a/mypy.ini b/mypy.ini
index ca7990ba101438a4b0bebc14824640a2f0c29479..6f30e1453a12560935b3019bce3bb7fd1678540e 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -3,3 +3,6 @@ python_version=3.10
 
 [mypy-pystencils.*]
 ignore_missing_imports=true
+
+[mypy-lbmpy.*]
+ignore_missing_imports=true
diff --git a/noxfile.py b/noxfile.py
index 0d4bf13187c7c6a37309469d5945c12abf8a4e5e..df62eaa8721363507e42a7cc3e0d62aebc5fab14 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -1,5 +1,28 @@
 import nox
 
+nox.options.sessions = ["lint", "typecheck"]
+
+
+def editable_install(session: nox.Session):
+    session.install("-r", "cmake/codegen-requirements.txt")
+    session.install("-e", ".")
+
+
+@nox.session(python="3.10", tags=["qa", "code-quality"])
+def lint(session: nox.Session):
+    """Lint code using flake8"""
+
+    session.install("flake8")
+    session.run("flake8", "src/walberla/codegen")
+
+
+@nox.session(python="3.10", tags=["qa", "code-quality"])
+def typecheck(session: nox.Session):
+    """Run MyPy for static type checking"""
+    editable_install(session)
+    session.install("mypy")
+    session.run("mypy", "src/walberla/codegen")
+
 
 @nox.session
 def user_manual(session: nox.Session):
diff --git a/pyproject.toml b/pyproject.toml
index 10b9a0f28a9825243990a85f357f480221e846ce..ecf2f4ddf06eedecff97707f13a269bc1096a580 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,5 +1,5 @@
 [project]
-name = "sfg_walberla"
+name = "walberla-codegen"
 description = "Code Generation Utilities for walBerla"
 authors = [
     {name = "Frederik Hennig", email = "frederik.hennig@fau.de"},
@@ -20,10 +20,14 @@ requires = [
 ]
 build-backend = "setuptools.build_meta"
 
+[tool.setuptools.packages.find]
+where = ["src/"]
+include = ["walberla.codegen", "walberla.codegen.*"]
+
 [tool.versioneer]
 VCS = "git"
 style = "pep440"
-versionfile_source = "src/sfg_walberla/_version.py"
-versionfile_build = "sfg_walberla/_version.py"
+versionfile_source = "src/walberla/codegen/_version.py"
+versionfile_build = "walberla/codegen/_version.py"
 tag_prefix = "v"
-parentdir_prefix = "sfg_walberla-"
+parentdir_prefix = "walberla-codegen-"
diff --git a/src/sfg_walberla/boundaries/__init__.py b/src/sfg_walberla/boundaries/__init__.py
deleted file mode 100644
index baeed122b07f2f2e9f07e36b6d63c5392688134f..0000000000000000000000000000000000000000
--- a/src/sfg_walberla/boundaries/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from .hbb import SimpleHbbBoundary
-from .freeslip import FreeSlip
-
-__all__ = ["SimpleHbbBoundary", "FreeSlip"]
diff --git a/src/sfg_walberla/boundaries/freeslip.py b/src/sfg_walberla/boundaries/freeslip.py
deleted file mode 100644
index 863d49eb13c812721d96b5541d14654f7247888e..0000000000000000000000000000000000000000
--- a/src/sfg_walberla/boundaries/freeslip.py
+++ /dev/null
@@ -1,129 +0,0 @@
-from pystencils import Field, Assignment, CreateKernelConfig
-from pystencils.types import PsStructType
-
-from lbmpy.methods import AbstractLbMethod
-from lbmpy.boundaries.boundaryconditions import LbBoundary
-
-from pystencilssfg import SfgComposer
-from pystencilssfg.composer.custom import CustomGenerator
-
-from .hbb import BoundaryIndexType
-from .boundary_utils import WalberlaLbmBoundary
-from ..sweep import Sweep
-from ..api import SparseIndexList
-
-__all__ = ["FreeSlip"]
-
-
-class _IrregularSentinel:
-    def __repr__(self) -> str:
-        return "IRREGULAR"
-
-
-class FreeSlip(CustomGenerator):
-    """Free-Slip boundary condition"""
-
-    IRREGULAR = _IrregularSentinel()
-
-    def __init__(
-        self,
-        name: str,
-        lb_method: AbstractLbMethod,
-        pdf_field: Field,
-        wall_normal: tuple[int, int, int] | _IrregularSentinel,
-    ):
-        self._name = name
-        self._method = lb_method
-        self._pdf_field = pdf_field
-        self._wall_normal = wall_normal
-
-    def generate(self, sfg: SfgComposer) -> None:
-        if self._wall_normal == self.IRREGULAR:
-            self._generate_irregular(sfg)
-        else:
-            self._generate_regular(sfg)
-
-    def _generate_irregular(self, sfg: SfgComposer):
-        sfg.include("sfg/IrregularFreeSlip.hpp")
-
-        #   Get waLBerla build config
-        bc_obj = WalberlaIrregularFreeSlip()
-
-        #   Get assignments for bc
-        bc_asm = bc_obj.get_assignments(self._method, self._pdf_field)
-
-        #   Build generator config
-        bc_cfg = CreateKernelConfig()
-        bc_cfg.index_dtype = BoundaryIndexType
-        index_field = bc_obj.get_index_field()
-        bc_cfg.index_field = index_field
-
-        #   Prepare sweep
-        bc_sweep = Sweep(self._name, bc_asm, bc_cfg)
-
-        #   Emit code
-        sfg.generate(bc_sweep)
-
-        #   Build factory
-        factory_name = f"{self._name}Factory"
-        factory_crtp_base = f"walberla::sfg::IrregularFreeSlipFactory< {factory_name} >"
-        index_vector = SparseIndexList(
-            WalberlaIrregularFreeSlip.idx_struct_type, ref=True
-        ).var("indexVector")
-
-        sweep_type = bc_sweep.generated_class()
-        sweep_ctor_args = {
-            f"{self._pdf_field.name}Id": "this->pdfFieldID_",
-            f"{index_field.name}Id": index_vector.bufferId(),
-        }
-
-        stencil_name = self._method.stencil.name
-        sfg.include(f"stencil/{stencil_name}.h")
-
-        sfg.klass(factory_name, bases=[f"public {factory_crtp_base}"])(
-            sfg.public(
-                f"using Base = {factory_crtp_base};",
-                f"friend class {factory_crtp_base};",
-                f"using Stencil = walberla::stencil::{stencil_name};",
-                f"using Sweep = {sweep_type.get_dtype().c_string()};",
-                "using Base::Base;",
-            ),
-            sfg.private(
-                sfg.method(
-                    "irregularFromIndexVector",
-                    returns=sweep_type.get_dtype(),
-                    inline=True,
-                )(sfg.expr("return {};", sweep_type.ctor(**sweep_ctor_args))),
-            ),
-        )
-
-    def _generate_regular(self, sfg: SfgComposer):
-        raise NotImplementedError("Regular-geometry free slip is not implemented yet")
-
-
-class WalberlaIrregularFreeSlip(LbBoundary, WalberlaLbmBoundary):
-    idx_struct_type = PsStructType(
-        (
-            ("x", BoundaryIndexType),
-            ("y", BoundaryIndexType),
-            ("z", BoundaryIndexType),
-            ("dir", BoundaryIndexType),
-            ("source_offset_x", BoundaryIndexType),
-            ("source_offset_y", BoundaryIndexType),
-            ("source_offset_z", BoundaryIndexType),
-            ("source_dir", BoundaryIndexType),
-        ),
-        "walberla::sfg::IrregularFreeSlipLinkInfo",
-    )
-
-    def __call__(
-        self, f_out, f_in, dir_symbol, inv_dir, lb_method, index_field, force_vector
-    ):
-        source_cell = (
-            index_field("source_offset_x"),
-            index_field("source_offset_y"),
-            index_field("source_offset_z"),
-        )
-        source_dir = index_field("source_dir")
-
-        return Assignment(f_in(inv_dir[dir_symbol]), f_out[source_cell](source_dir))
diff --git a/src/sfg_walberla/boundaries/hbb.py b/src/sfg_walberla/boundaries/hbb.py
deleted file mode 100644
index d041f1b19a67df5da2107d4563c5b3b560b2d6f5..0000000000000000000000000000000000000000
--- a/src/sfg_walberla/boundaries/hbb.py
+++ /dev/null
@@ -1,205 +0,0 @@
-"""Code generation for half-way bounce-back LBM boundary conditions in waLBerla"""
-
-from pystencils import Field, FieldType, TypedSymbol, Target, AssignmentCollection, DynamicType
-
-from pystencils.types import PsStructType, create_type
-
-from pystencilssfg import SfgComposer
-from pystencilssfg.composer.class_composer import SfgClassComposer
-from pystencilssfg.composer.custom import CustomGenerator
-from pystencilssfg.lang import AugExpr
-
-from lbmpy import LBStencil
-from lbmpy.methods import AbstractLbMethod
-from lbmpy.boundaries.boundaryconditions import LbBoundary
-from lbmpy.advanced_streaming.indexing import Timestep
-
-from ..sweep import (
-    FieldInfo,
-    SweepClassProperties,
-    BlockforestParameters,
-    combine_vectors,
-)
-from ..api import GhostLayerFieldPtr, BlockDataID, IBlockPtr, StructuredBlockForest, IndexListBufferPtr
-from .boundary_utils import HbbLinkType, BoundaryIndexType
-
-
-class HbbBoundaryProperties(SweepClassProperties):
-    def _constructor(self, sfg: SfgComposer) -> SfgClassComposer.ConstructorBuilder:
-        blocks = StructuredBlockForest.shared_ptr_ref()
-        return sfg.constructor(blocks).init("GenericHbbBoundary")(
-            AugExpr.format("*{}", blocks)
-        )
-
-
-class SimpleHbbBoundary(CustomGenerator):
-    def __init__(self, bc: LbBoundary, lb_method: AbstractLbMethod, pdf_field: Field):
-        if bc.additional_data:
-            raise ValueError(
-                "The SimpleHbbBoundary generator only supports boundary conditions without "
-                "additional per-link data."
-            )
-
-        self._bc = bc
-        self._name = bc.name
-        self._lb_method = lb_method
-        self._pdf_field = pdf_field
-        self._index_field = self._get_index_field()
-        self._stencil = lb_method.stencil
-
-    def generate(self, sfg: SfgComposer) -> None:
-        sfg.include("sfg/GenericHbbBoundary.hpp")
-        sfg.include(f"stencil/{self._stencil.name}.h")
-
-        knamespace = sfg.kernel_namespace(f"{self._name}_kernels")
-        bc_kernel = _create_lb_boundary_kernel(
-            self._pdf_field, self._get_index_field(), self._lb_method, self._bc
-        )
-
-        khandle = knamespace.add(bc_kernel, self._name)
-
-        domain_fields: list[FieldInfo] = sorted(
-            (
-                FieldInfo(
-                    f, GhostLayerFieldPtr.create(f), BlockDataID().var(f"{f.name}Id")
-                )
-                for f in khandle.fields
-                if f.field_type == FieldType.GENERIC
-            ),
-            key=lambda f: f.field.name,
-        )
-
-        props = HbbBoundaryProperties()
-        block = IBlockPtr().var("block")
-
-        parameters = khandle.scalar_parameters
-
-        blockforest_params = BlockforestParameters(props, block, None)
-        parameters = blockforest_params.filter_params(parameters)
-
-        vector_groups = combine_vectors(parameters)
-
-        for fi in domain_fields:
-            props.add_property(fi.data_id, setter=False, getter=True)
-
-        for s in sorted(parameters, key=lambda p: p.name):
-            props.add_property(s, setter=True, getter=True)
-
-        idx_vector = IndexListBufferPtr(HbbLinkType).var("indexVector")
-        idx_vector_id = BlockDataID().bind("this->indexVectorsId_")
-
-        base_class = f"walberla::sfg::GenericHbbBoundary< walberla::stencil::{self._stencil.name} >"
-
-        sfg.klass(self._name, bases=[f"public {base_class}"])(
-            *props.render(sfg),
-            sfg.public(
-                sfg.method("operator()")(
-                    #   Get block data IDs for fields
-                    *(
-                        sfg.init(fi.data_id)(props.get(fi.data_id))
-                        for fi in domain_fields
-                    ),
-                    #   Get fields from block
-                    *(
-                        sfg.init(fi.wlb_field_ptr)(
-                            block.getData(fi.wlb_field_ptr.field_type, fi.data_id)
-                        )
-                        for fi in domain_fields
-                    ),
-                    #   Map GhostLayerFields to pystencils fields
-                    *(sfg.map_field(fi.field, fi.wlb_field_ptr) for fi in domain_fields),
-                    #   Get index vector
-                    sfg.init(idx_vector)(
-                        block.getData(idx_vector.field_type, idx_vector_id)
-                    ),
-                    #   Map index vector to pystencils field
-                    sfg.map_field(self._index_field, idx_vector),
-                    #   Extract parameters
-                    *(sfg.init(param)(props.get(param)) for param in parameters),
-                    #   Extract vector components
-                    *(
-                        sfg.map_vector(comps, vec)
-                        for vec, comps in vector_groups.items()
-                    ),
-                    #   Extract geometry information
-                    *(blockforest_params.render_extractions(sfg)),
-                    #   Call kernel
-                    sfg.call(khandle),
-                )
-            ),
-        )
-
-    def _get_index_field(self) -> Field:
-        return Field(
-            "indexField",
-            FieldType.INDEXED,
-            HbbLinkType,
-            (0,),
-            (TypedSymbol("index_vector_length", BoundaryIndexType), 1),
-            (1, 1),
-        )
-
-
-def _create_lb_boundary_kernel(
-    pdf_field,
-    index_field,
-    lb_method,
-    boundary_functor,
-    prev_timestep=Timestep.BOTH,
-    streaming_pattern="pull",
-    target=Target.CPU,
-    **kernel_creation_args,
-):
-    """Almost-copy of `lbmpy.boundaryhandling.create_lattice_boltzmann_boundary_kernel`,
-    extended with waLBerla-specific function expansion."""
-
-    from lbmpy.advanced_streaming.indexing import BetweenTimestepsIndexing
-
-    indexing = BetweenTimestepsIndexing(
-        pdf_field, lb_method.stencil, prev_timestep, streaming_pattern, "int32", "int32"
-    )
-
-    f_out, f_in = indexing.proxy_fields
-    dir_symbol = indexing.dir_symbol
-    inv_dir = indexing.inverse_dir_symbol
-
-    boundary_assignments = boundary_functor(
-        f_out, f_in, dir_symbol, inv_dir, lb_method, index_field, None  # TODO: Fix force vector
-    )
-    boundary_assignments = indexing.substitute_proxies(boundary_assignments)
-
-    from pystencils.types.quick import SInt
-    from pystencils import CreateKernelConfig, Assignment, create_kernel
-    from pystencils.simp import add_subexpressions_for_field_reads
-
-    config = CreateKernelConfig(
-        index_field=index_field,
-        target=target,
-        index_dtype=SInt(32),
-        default_dtype=pdf_field.dtype,
-        skip_independence_check=True,
-        **kernel_creation_args,
-    )
-
-    default_data_type = config.default_dtype
-    if pdf_field.dtype != default_data_type:
-        boundary_assignments = add_subexpressions_for_field_reads(
-            boundary_assignments, data_type=default_data_type
-        )
-
-    elements: list[Assignment] = []
-
-    index_arrs_node = indexing.create_code_node()
-    elements += index_arrs_node.get_array_declarations()
-
-    for node in boundary_functor.get_additional_code_nodes(lb_method)[::-1]:
-        elements += node.get_array_declarations()
-
-    elements += [Assignment(dir_symbol, index_field[0]("dir"))]
-    elements += boundary_assignments.all_assignments
-
-    asms = AssignmentCollection(elements)
-    asms = BlockforestParameters.process(asms)
-
-    kernel = create_kernel(asms, config=config)
-    return kernel
diff --git a/src/sfg_walberla/build_config.py b/src/sfg_walberla/build_config.py
deleted file mode 100644
index c47539d357be23d5c922ebd728e45061282a8eaf..0000000000000000000000000000000000000000
--- a/src/sfg_walberla/build_config.py
+++ /dev/null
@@ -1,74 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-
-from pystencils import CreateKernelConfig
-from pystencils.types.quick import Fp
-from pystencils.jit import no_jit
-
-from pystencilssfg import SfgContext
-from pystencilssfg.composer import SfgIComposer
-
-
-def cmake_parse_bool(var: str):
-    var = var.upper()
-    if var in ("ON", "1", "TRUE"):
-        return True
-    elif var in ("OFF", "0", "FALSE"):
-        return False
-    else:
-        raise ValueError(f"Could not parse cmake value `{var}` as boolean.")
-
-
-@dataclass
-class WalberlaBuildConfig:
-    """Represents a waLBerla build system configuration"""
-
-    c_compiler_id: str
-    """Value of `CMAKE_C_COMPILER_ID`."""
-
-    cxx_compiler_id: str
-    """Value of `CMAKE_CXX_COMPILER_ID`."""
-
-    use_double_precision: bool
-    """Value of `WALBERLA_DOUBLE_ACCURACY`"""
-
-    optimize_for_localhost: bool
-    """Value of `WALBERLA_OPTIMIZE_FOR_LOCALHOST`."""
-
-    mpi_enabled: bool
-    """Value of `WALBERLA_BUILD_WITH_MPI`."""
-
-    openmp_enabled: bool
-    """Value of `WALBERLA_BUILD_WITH_OPENMP`."""
-
-    cuda_enabled: bool
-    """Value of `WALBERLA_BUILD_WITH_CUDA`."""
-
-    hip_enabled: bool
-    """Value of `WALBERLA_BUILD_WITH_HIP`."""
-
-    likwid_enabled: bool
-    """Value of `WALBERLA_BUILD_WITH_LIKWID_MARKERS`"""
-
-    @staticmethod
-    def from_sfg(sfg: SfgContext | SfgIComposer) -> WalberlaBuildConfig:
-        if isinstance(sfg, SfgIComposer):
-            ctx = sfg.context
-        else:
-            ctx = sfg
-
-        if isinstance(ctx.project_info, WalberlaBuildConfig):
-            return ctx.project_info
-        else:
-            raise ValueError(
-                "The given SfgContext does not encapsulate a waLBerla build config object."
-            )
-
-    def get_pystencils_config(self) -> CreateKernelConfig:
-        dtype = Fp(64) if self.use_double_precision else Fp(32)
-
-        return CreateKernelConfig(
-            default_dtype=dtype,
-            jit=no_jit,
-        )
diff --git a/src/sfg_walberla/postprocessing.py b/src/sfg_walberla/postprocessing.py
deleted file mode 100644
index d4a911f1318cca77802af0d321a0e01d89b8c8ab..0000000000000000000000000000000000000000
--- a/src/sfg_walberla/postprocessing.py
+++ /dev/null
@@ -1,58 +0,0 @@
-from typing import Iterable
-import sympy as sp
-
-from pystencils.types import deconstify
-from pystencilssfg.ir.postprocessing import SfgDeferredNode, PostProcessingContext
-from pystencilssfg.ir import (
-    SfgCallTreeNode,
-    SfgStatements,
-    SfgVisibility,
-    SfgVisibilityBlock,
-    SfgMemberVariable,
-    SfgSequence,
-    SfgConstructor,
-)
-from pystencilssfg.lang import AugExpr, SfgVar
-from pystencilssfg.exceptions import SfgException
-
-
-class CaptureToClass(SfgDeferredNode):
-    def __init__(
-        self, ignore: Iterable[str | SfgVar | AugExpr | sp.Symbol] = ()
-    ) -> None:
-        self._ignore = set()
-        for ig in ignore:
-            if isinstance(ig, AugExpr):
-                self._ignore |= ig.depends
-            elif isinstance(ig, sp.Symbol):
-                self._ignore.add(ig.name)
-            else:
-                self._ignore.add(ig)
-
-    def expand(self, ppc: PostProcessingContext) -> SfgCallTreeNode:
-        cls = ppc.enclosing_class
-        if cls is None:
-            raise SfgException("Cannot capture variables to class in a free function")
-
-        statements: list[SfgStatements] = []
-        captured: list[SfgVar] = []
-        members_block = SfgVisibilityBlock(SfgVisibility.PRIVATE)
-
-        for var in ppc.live_variables:
-            if not (var in self._ignore or var.name in self._ignore):
-                member = SfgMemberVariable(f"{var}_", deconstify(var.dtype))
-                members_block.append_member(member)
-                captured.append(var)
-                statements.append(
-                    SfgStatements(f"{var.dtype.c_string()} {var.name} {{ {member} }};", [var], [])
-                )
-
-        cls.append_visibility_block(members_block)
-
-        constructor_block = SfgVisibilityBlock(SfgVisibility.PUBLIC)
-        constructor_block.append_member(
-            SfgConstructor(captured, [f"{var}_ {{ {var} }}" for var in captured])
-        )
-        cls.append_visibility_block(constructor_block)
-
-        return SfgSequence(statements)
diff --git a/src/sfg_walberla/symbolic.py b/src/sfg_walberla/symbolic.py
deleted file mode 100644
index 9e85ddd28a8ee180bed74c8a4ceb9dc3b43ee651..0000000000000000000000000000000000000000
--- a/src/sfg_walberla/symbolic.py
+++ /dev/null
@@ -1,136 +0,0 @@
-import sympy as sp
-
-import inspect
-
-from pystencils import TypedSymbol, DynamicType, tcast
-from pystencils.defaults import DEFAULTS
-
-
-def expandable(name: str):
-    def wrap(expansion_func):
-        nargs = len(inspect.signature(expansion_func).parameters)
-        return sp.Function(
-            name, nargs=nargs, expansion_func=staticmethod(expansion_func)
-        )
-
-    return wrap
-
-
-class DomainCoordinates:
-    aabb_min = tuple(
-        TypedSymbol(f"blockforest_aabb_min_{i}", DynamicType.NUMERIC_TYPE)
-        for i in range(3)
-    )
-
-    aabb_max = tuple(
-        TypedSymbol(f"blockforest_aabb_max_{i}", DynamicType.NUMERIC_TYPE)
-        for i in range(3)
-    )
-
-    @expandable("domain.x_min")
-    def x_min():
-        return DomainCoordinates.aabb_min[0]
-
-    @expandable("domain.y_min")
-    def y_min():
-        return DomainCoordinates.aabb_min[1]
-
-    @expandable("domain.z_min")
-    def z_min():
-        return DomainCoordinates.aabb_min[2]
-
-    @expandable("domain.x_max")
-    def x_max():
-        return DomainCoordinates.aabb_max[0]
-
-    @expandable("domain.y_max")
-    def y_max():
-        return DomainCoordinates.aabb_max[1]
-
-    @expandable("domain.z_max")
-    def z_max():
-        return DomainCoordinates.aabb_max[2]
-
-
-domain = DomainCoordinates
-
-
-class BlockCoordinates:
-    aabb_min = tuple(
-        TypedSymbol(f"block_aabb_min_{i}", DynamicType.NUMERIC_TYPE) for i in range(3)
-    )
-    aabb_max = tuple(
-        TypedSymbol(f"block_aabb_max_{i}", DynamicType.NUMERIC_TYPE) for i in range(3)
-    )
-    ci_min = tuple(
-        TypedSymbol(f"cell_interval_min_{i}", DynamicType.INDEX_TYPE) for i in range(3)
-    )
-    ci_max = tuple(
-        TypedSymbol(f"cell_interval_max_{i}", DynamicType.INDEX_TYPE) for i in range(3)
-    )
-
-
-block = BlockCoordinates
-
-
-class CellCoordinates:
-    cell_extents = tuple(
-        TypedSymbol(f"cell_extents_{i}", DynamicType.NUMERIC_TYPE) for i in range(3)
-    )
-
-    @expandable("cell.x")
-    def x():
-        return BlockCoordinates.aabb_min[0] + CellCoordinates.cell_extents[
-            0
-        ] * tcast.as_numeric(
-            block.ci_min[0] + sp.Symbol(DEFAULTS.spatial_counter_names[0])
-        )
-
-    @expandable("cell.y")
-    def y():
-        return BlockCoordinates.aabb_min[1] + CellCoordinates.cell_extents[
-            1
-        ] * tcast.as_numeric(
-            block.ci_min[1] + sp.Symbol(DEFAULTS.spatial_counter_names[1])
-        )
-
-    @expandable("cell.z")
-    def z():
-        return BlockCoordinates.aabb_min[2] + CellCoordinates.cell_extents[
-            2
-        ] * tcast.as_numeric(
-            block.ci_min[2] + sp.Symbol(DEFAULTS.spatial_counter_names[2])
-        )
-
-    @expandable("cell.local_x")
-    def local_x():
-        return CellCoordinates.cell_extents[0] * tcast.as_numeric(
-            block.ci_min[0] + DEFAULTS.spatial_counters[0]
-        )
-
-    @expandable("cell.local_y")
-    def local_y():
-        return CellCoordinates.cell_extents[1] * tcast.as_numeric(
-            block.ci_min[1] + DEFAULTS.spatial_counters[1]
-        )
-
-    @expandable("cell.local_z")
-    def local_z():
-        return CellCoordinates.cell_extents[2] * tcast.as_numeric(
-            block.ci_min[2] + DEFAULTS.spatial_counters[2]
-        )
-
-    @expandable("cell.dx")
-    def dx():
-        return CellCoordinates.cell_extents[0]
-
-    @expandable("cell.dy")
-    def dy():
-        return CellCoordinates.cell_extents[1]
-
-    @expandable("cell.dz")
-    def dz():
-        return CellCoordinates.cell_extents[2]
-
-
-cell = CellCoordinates
diff --git a/src/sfg_walberla/__init__.py b/src/walberla/codegen/__init__.py
similarity index 73%
rename from src/sfg_walberla/__init__.py
rename to src/walberla/codegen/__init__.py
index 50097ef8b19500c16e44a5e25622d4fbaf6889d9..8f08ca8d480ccea626cf575109d0f81a35f65366 100644
--- a/src/sfg_walberla/__init__.py
+++ b/src/walberla/codegen/__init__.py
@@ -1,7 +1,6 @@
 from .api import real_t, Vector3, GhostLayerFieldPtr, glfield, IBlockPtr
-from .postprocessing import CaptureToClass
 from .sweep import Sweep
-from .build_config import WalberlaBuildConfig
+from .build_config import WalberlaBuildConfig, get_build_config
 
 __all__ = [
     "real_t",
@@ -9,9 +8,9 @@ __all__ = [
     "GhostLayerFieldPtr",
     "glfield",
     "IBlockPtr",
-    "CaptureToClass",
     "Sweep",
     "WalberlaBuildConfig",
+    "get_build_config",
 ]
 
 from . import _version
diff --git a/src/sfg_walberla/_version.py b/src/walberla/codegen/_version.py
similarity index 99%
rename from src/sfg_walberla/_version.py
rename to src/walberla/codegen/_version.py
index b1d29248b519116f7f6ab4aac65ea978f4d2f874..c6263f517ea306095d7fa3c7975ec53f4a19ec39 100644
--- a/src/sfg_walberla/_version.py
+++ b/src/walberla/codegen/_version.py
@@ -52,8 +52,8 @@ def get_config() -> VersioneerConfig:
     cfg.VCS = "git"
     cfg.style = "pep440"
     cfg.tag_prefix = "v"
-    cfg.parentdir_prefix = "sfg_walberla-"
-    cfg.versionfile_source = "src/sfg_walberla/_version.py"
+    cfg.parentdir_prefix = "walberla-codegen-"
+    cfg.versionfile_source = "src/walberla/codegen/_version.py"
     cfg.verbose = False
     return cfg
 
diff --git a/src/sfg_walberla/api.py b/src/walberla/codegen/api.py
similarity index 69%
rename from src/sfg_walberla/api.py
rename to src/walberla/codegen/api.py
index 207c433230b72df39c1927664e7878c010cb896f..766c6fc48653f74d6398c9c71235f82f2f2620ae 100644
--- a/src/sfg_walberla/api.py
+++ b/src/walberla/codegen/api.py
@@ -2,7 +2,7 @@ from __future__ import annotations
 
 from typing import cast
 
-from pystencils import Field
+from pystencils import Field, DynamicType, FieldType, Target
 from pystencils.types import (
     UserTypeSpec,
     create_type,
@@ -12,10 +12,9 @@ from pystencils.types import (
     PsStructType,
 )
 from pystencilssfg.lang import (
-    IFieldExtraction,
     AugExpr,
-    SrcField,
-    SrcVector,
+    SupportsFieldExtraction,
+    SupportsVectorExtraction,
     Ref,
     ExprLike,
     cpptype,
@@ -38,7 +37,7 @@ class _PlainCppClass(AugExpr):
         super().__init__(dtype)
 
 
-class Vector2(SrcVector):
+class Vector2(AugExpr, SupportsVectorExtraction):
     _template = cpptype("walberla::Vector2< {element_type} >", "core/math/Vector2.h")
 
     def __init__(
@@ -48,14 +47,14 @@ class Vector2(SrcVector):
         dtype = self._template(element_type=element_type, const=const, ref=ref)
         super().__init__(dtype)
 
-    def extract_component(self, coordinate: int) -> AugExpr:
+    def _extract_component(self, coordinate: int) -> AugExpr:
         if coordinate > 1:
             raise ValueError(f"Cannot extract component {coordinate} from Vector2")
 
         return AugExpr(self._element_type).bind("{}[{}]", self, coordinate)
 
 
-class Vector3(SrcVector):
+class Vector3(AugExpr, SupportsVectorExtraction):
     _template = cpptype("walberla::Vector3< {element_type} >", "core/math/Vector3.h")
 
     def __init__(
@@ -65,7 +64,7 @@ class Vector3(SrcVector):
         dtype = self._template(element_type=element_type, const=const, ref=ref)
         super().__init__(dtype)
 
-    def extract_component(self, coordinate: int) -> AugExpr:
+    def _extract_component(self, coordinate: int) -> AugExpr:
         if coordinate > 2:
             raise ValueError(f"Cannot extract component {coordinate} from Vector3")
 
@@ -97,10 +96,19 @@ class Cell(_PlainCppClass):
     def z(self) -> AugExpr:
         return AugExpr.format("{}.z()", self)
 
+    def __getitem__(self, coord: int | ExprLike) -> AugExpr:
+        return AugExpr.format("{}[{}]", self, coord)
+
 
 class CellInterval(_PlainCppClass):
     _type = cpptype("walberla::CellInterval", "core/cell/CellInterval.h")
 
+    def min(self) -> Cell:
+        return Cell(ref=True).bind("{}.min()", self)
+
+    def max(self) -> Cell:
+        return Cell(ref=True).bind("{}.max()", self)
+
 
 class Direction(_PlainCppClass):
     _type = cpptype("walberla::stencil::Direction", "stencil/Directions.h")
@@ -129,8 +137,8 @@ class IBlockPtr(_PlainCppClass):
     def getAABB(self) -> AABB:
         return AABB().bind("{}->getAABB()", self)
 
-    def deref(self) -> AugExpr:
-        return AugExpr.format("*{}", self)
+    def deref(self) -> IBlock:
+        return IBlock(ref=True).bind("*{}", self)
 
 
 class SharedPtr(CppClass):
@@ -180,17 +188,23 @@ class StructuredBlockForest(AugExpr):
         x = ["X", "Y", "Z"][coord]
         return AugExpr(uint_t).bind("{}.getNumberOf{}Cells({})", self, x, block)
 
+    def getDomainCellBB(self, level: ExprLike) -> CellInterval:
+        return CellInterval(ref=True).bind("{}.getDomainCellBB({})", self, level)
+
+    def getBlockCellBB(self, block: IBlock) -> CellInterval:
+        return CellInterval().bind("{}.getBlockCellBB({})", self, block)
+
     def getLevel(self, block: AugExpr) -> AugExpr:
         return AugExpr(uint_t).bind("{}.getLevel({})", self, block)
 
 
-class GenericWalberlaField(SrcField):
+class GenericWalberlaField(AugExpr, SupportsFieldExtraction):
     """Common base class for GhostLayerField and GpuField defining their shared interface."""
 
     def __init__(
         self,
         element_type: PsType,
-        field_type: PsCustomType,
+        field_type: PsType,
         ptr: bool = False,
         ref: bool = False,
     ):
@@ -200,6 +214,7 @@ class GenericWalberlaField(SrcField):
         self._element_type = element_type
         self._field_type = field_type
 
+        obj_type: PsType
         if ptr:
             obj_type = PsPointerType(self._field_type)
         elif ref:
@@ -209,6 +224,8 @@ class GenericWalberlaField(SrcField):
 
         super().__init__(obj_type)
 
+        self._extraction = GhostLayerFieldExtraction(self, None)
+
     @property
     def _a(self) -> AugExpr:
         """Member access"""
@@ -218,13 +235,19 @@ class GenericWalberlaField(SrcField):
             return AugExpr.format("{}.", self)
 
     @property
-    def field_type(self) -> PsCustomType:
+    def field_type(self) -> PsType:
         return self._field_type
 
-    def get_extraction(self) -> IFieldExtraction:
-        return GhostLayerFieldExtraction(self, None)
+    def _extract_ptr(self) -> AugExpr:
+        return self._extraction._extract_ptr()
+
+    def _extract_size(self, coordinate: int) -> AugExpr | None:
+        return self._extraction._extract_size(coordinate)
 
-    def with_cell_interval(self, ci: CellInterval | None):
+    def _extract_stride(self, coordinate: int) -> AugExpr | None:
+        return self._extraction._extract_stride(coordinate)
+
+    def with_cell_interval(self, ci: CellInterval | None) -> GhostLayerFieldExtraction:
         return GhostLayerFieldExtraction(self, ci)
 
     def cloneUninitialized(self) -> AugExpr:
@@ -248,6 +271,12 @@ class GhostLayerFieldPtr(GenericWalberlaField):
             )
 
         element_type = field.dtype
+
+        if isinstance(element_type, DynamicType):
+            raise ValueError(
+                f"Cannot create GhostLayerField from dynamically typed field {field}"
+            )
+
         fsize = field.index_shape[0] if field.index_shape else 1
 
         return GhostLayerFieldPtr(element_type, fsize).var(field.name)
@@ -265,8 +294,8 @@ class GhostLayerFieldPtr(GenericWalberlaField):
 
 class GpuFieldPtr(GenericWalberlaField):
     _template = cpptype(
-        "walberla::gpu::GpuField< {element_type} >",
-        "gpu/GpuField.h",
+        "walberla::gpu::GPUField< {element_type} >",
+        "gpu/GPUField.h",
     )
 
     @staticmethod
@@ -277,6 +306,12 @@ class GpuFieldPtr(GenericWalberlaField):
             )
 
         element_type = field.dtype
+
+        if isinstance(element_type, DynamicType):
+            raise ValueError(
+                f"Cannot create GpuField from dynamically typed field {field}"
+            )
+
         fsize = field.index_shape[0] if field.index_shape else 1
 
         return GpuFieldPtr(element_type, fsize).var(field.name)
@@ -292,7 +327,7 @@ class GpuFieldPtr(GenericWalberlaField):
         super().__init__(element_type, field_type, ptr=True)
 
 
-class GhostLayerFieldExtraction(IFieldExtraction):
+class GhostLayerFieldExtraction(SupportsFieldExtraction):
     def __init__(
         self,
         field_ptr: AugExpr,
@@ -301,7 +336,7 @@ class GhostLayerFieldExtraction(IFieldExtraction):
         self._field_ptr = field_ptr
         self._ci = cell_interval
 
-    def ptr(self) -> AugExpr:
+    def _extract_ptr(self) -> AugExpr:
         data_at: AugExpr | str
         if self._ci is not None:
             ci = self._ci
@@ -311,7 +346,7 @@ class GhostLayerFieldExtraction(IFieldExtraction):
 
         return AugExpr.format("{}->dataAt({})", self._field_ptr, data_at)
 
-    def size(self, coordinate: int) -> AugExpr | None:
+    def _extract_size(self, coordinate: int) -> AugExpr | None:
         size_call = ["xSize()", "ySize()", "zSize()", "fSize()"][coordinate]
 
         if self._ci is not None and coordinate < 3:
@@ -319,7 +354,7 @@ class GhostLayerFieldExtraction(IFieldExtraction):
         else:
             return AugExpr.format("{}->{}", self._field_ptr, size_call)
 
-    def stride(self, coordinate: int) -> AugExpr | None:
+    def _extract_stride(self, coordinate: int) -> AugExpr | None:
         stride_call = ("xStride()", "yStride()", "zStride()", "fStride()")[coordinate]
         return AugExpr.format("{}->{}", self._field_ptr, stride_call)
 
@@ -330,32 +365,75 @@ def glfield(field: Field, ci: str | AugExpr | None = None):
     if ci is not None and not isinstance(ci, AugExpr):
         ci = AugExpr(PsCustomType("walberla::CellInterval", const=True)).var(ci)
 
+    assert not isinstance(ci, str)
+
     return GhostLayerFieldExtraction(field_ptr, ci)
 
 
+class MemTags:
+    _header = "walberla/experimental/Memory.hpp"
+
+    host = cpptype("walberla::experimental::memory::memtag::host", _header)()
+    unified = cpptype("walberla::experimental::memory::memtag::unified", _header)()
+
+
 class SparseIndexList(AugExpr):
     _template = cpptype(
-        "walberla::sfg::SparseIndexList< {IndexStruct} >", "sfg/SparseIteration.hpp"
+        "walberla::experimental::sweep::SparseIndexList< {IndexStruct}, {memtag_t} >",
+        "walberla/experimental/sweep/SparseIndexList.hpp",
     )
 
     def __init__(
-        self, idx_struct: PsStructType, const: bool = False, ref: bool = False
+        self,
+        idx_struct: PsStructType,
+        memtag_t: PsType = MemTags.host,
+        const: bool = False,
+        ref: bool = False,
     ):
         self._idx_struct = idx_struct
-        dtype = self._template(IndexStruct=idx_struct, const=const, ref=ref)
+        dtype = self._template(
+            IndexStruct=idx_struct, memtag_t=memtag_t, const=const, ref=ref
+        )
         super().__init__(dtype)
 
-    def get(self, block: IBlock) -> std.vector:
-        return std.vector(self._idx_struct, ref=True).bind("{}.get({})", self, block)
+    def view_type(self) -> std.span:
+        return std.span(self._idx_struct)
+
+    def view(self, block: IBlock) -> std.span:
+        return std.span(self._idx_struct).bind("{}.view({})", self, block)
 
     def bufferId(self) -> BlockDataID:
         return BlockDataID().bind("{}.bufferId()", self)
 
+    @staticmethod
+    def from_field(
+        field: Field,
+        target: Target | None = None,
+        const: bool = False,
+        ref: bool = False,
+    ):
+        if (
+            not isinstance(field.dtype, PsStructType)
+            or field.field_type != FieldType.INDEXED
+        ):
+            raise ValueError(
+                "Can only create instances of SparseIndexList from index fields holding structures."
+            )
+
+        if target is not None and target.is_gpu():
+            memtag = MemTags.unified
+        else:
+            memtag = MemTags.host
+
+        return SparseIndexList(field.dtype, memtag_t=memtag, const=const, ref=ref).var(
+            field.name
+        )
 
-class IndexListBufferPtr(SrcField):
+
+class IndexListBufferPtr(AugExpr, SupportsFieldExtraction):
     _template = cpptype(
-        "walberla::sfg::internal::IndexListBuffer< {IndexStruct} >",
-        "sfg/SparseIteration.hpp",
+        "walberla::experimental::sweep::internal::IndexListBuffer< {IndexStruct} >",
+        "walberla/experimental/sweep/SparseIndexList.hpp",
     )
 
     def __init__(self, idx_struct: PsStructType):
@@ -374,26 +452,22 @@ class IndexListBufferPtr(SrcField):
     def vector(self) -> AugExpr:
         return AugExpr().format("{}->vector()", self)
 
-    def get_extraction(self) -> IFieldExtraction:
-        class IdxVectorExtraction(IFieldExtraction):
-            def ptr(_) -> AugExpr:
-                return self.pointerCpu()
-
-            def size(_, coordinate: int) -> AugExpr | None:
-                if coordinate == 0:
-                    return AugExpr.format("{}.size()", self.vector())
-                elif coordinate == 1:
-                    return AugExpr.format("1")
-                else:
-                    raise ValueError()
+    def _extract_ptr(self) -> AugExpr:
+        return self.pointerCpu()
 
-            def stride(_, coordinate: int) -> AugExpr | None:
-                if coordinate <= 1:
-                    return AugExpr.format("1")
-                else:
-                    raise ValueError()
+    def _extract_size(self, coordinate: int) -> AugExpr | None:
+        if coordinate == 0:
+            return AugExpr.format("{}.size()", self.vector())
+        elif coordinate == 1:
+            return AugExpr.format("1")
+        else:
+            raise ValueError()
 
-        return IdxVectorExtraction()
+    def _extract_stride(self, coordinate: int) -> AugExpr | None:
+        if coordinate <= 1:
+            return AugExpr.format("1")
+        else:
+            raise ValueError()
 
 
 CellIdx = PsStructType(
@@ -402,5 +476,5 @@ CellIdx = PsStructType(
         ("y", create_type("int64_t")),
         ("z", create_type("int64_t")),
     ),
-    "walberla::sfg::CellIdx",
+    "walberla::experimental::sweep::CellIdx",
 )
diff --git a/src/walberla/codegen/boundaries/__init__.py b/src/walberla/codegen/boundaries/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..4364cb21aab5d7e712f8773c5f3f8363e2fe8b1c
--- /dev/null
+++ b/src/walberla/codegen/boundaries/__init__.py
@@ -0,0 +1,3 @@
+from .linkwise import NoSlip, FreeSlip, GenericHBB
+
+__all__ = ["NoSlip", "FreeSlip", "GenericHBB"]
diff --git a/src/sfg_walberla/boundaries/boundary_utils.py b/src/walberla/codegen/boundaries/boundary_utils.py
similarity index 81%
rename from src/sfg_walberla/boundaries/boundary_utils.py
rename to src/walberla/codegen/boundaries/boundary_utils.py
index 6618ddf1a50ceb2e5f00d90cb832130316c7287e..a151259b25133e389e4452eec8cfe70e3f149db7 100644
--- a/src/sfg_walberla/boundaries/boundary_utils.py
+++ b/src/walberla/codegen/boundaries/boundary_utils.py
@@ -1,15 +1,13 @@
 from functools import cache
+from abc import abstractmethod
 
 from pystencils import Field, FieldType, TypedSymbol, Assignment
 from pystencils.types import PsStructType, create_type
 
-from lbmpy import LBStencil
 from lbmpy.methods import AbstractLbMethod
 from lbmpy.boundaries.boundaryconditions import LbBoundary
 from lbmpy.advanced_streaming import Timestep
 
-from pystencilssfg import SfgComposer
-from pystencilssfg.composer.custom import CustomGenerator
 
 BoundaryIndexType = create_type("int32")
 
@@ -20,7 +18,7 @@ HbbLinkType = PsStructType(
         ("z", BoundaryIndexType),
         ("dir", BoundaryIndexType),
     ],
-    "walberla::sfg::HbbLink",
+    "walberla::experimental::lbm::HbbLink",
 )
 
 
@@ -39,6 +37,9 @@ class WalberlaLbmBoundary:
             (1, 1),
         )
 
+    @abstractmethod
+    def boundary_obj(self) -> LbBoundary: ...
+
     def get_assignments(
         self,
         lb_method: AbstractLbMethod,
@@ -46,8 +47,6 @@ class WalberlaLbmBoundary:
         prev_timestep: Timestep = Timestep.BOTH,
         streaming_pattern="pull",
     ) -> list[Assignment]:
-        assert isinstance(self, LbBoundary)
-
         index_field = self.get_index_field()
 
         from lbmpy.advanced_streaming.indexing import BetweenTimestepsIndexing
@@ -65,7 +64,7 @@ class WalberlaLbmBoundary:
         dir_symbol = indexing.dir_symbol
         inv_dir = indexing.inverse_dir_symbol
 
-        boundary_assignments = self(
+        boundary_assignments = self.boundary_obj()(
             f_out,
             f_in,
             dir_symbol,
@@ -81,10 +80,20 @@ class WalberlaLbmBoundary:
         index_arrs_node = indexing.create_code_node()
         elements += index_arrs_node.get_array_declarations()
 
-        for node in self.get_additional_code_nodes(lb_method)[::-1]:
+        for node in self.boundary_obj().get_additional_code_nodes(lb_method)[::-1]:
             elements += node.get_array_declarations()
 
         elements += [Assignment(dir_symbol, index_field[0]("dir"))]
         elements += boundary_assignments.all_assignments
 
         return elements
+
+
+class GenericHbbWrapper(WalberlaLbmBoundary):
+    idx_struct_type = HbbLinkType
+
+    def __init__(self, hbb: LbBoundary):
+        self._hbb = hbb
+
+    def boundary_obj(self) -> LbBoundary:
+        return self._hbb
diff --git a/src/walberla/codegen/boundaries/linkwise.py b/src/walberla/codegen/boundaries/linkwise.py
new file mode 100644
index 0000000000000000000000000000000000000000..6d81dc10d34f49ea7ef199d253cfbbf55dce4307
--- /dev/null
+++ b/src/walberla/codegen/boundaries/linkwise.py
@@ -0,0 +1,333 @@
+from pystencils import (
+    Field,
+    Assignment,
+    AssignmentCollection,
+)
+from pystencils.types import PsStructType
+
+from lbmpy.methods import AbstractLbMethod
+from lbmpy.boundaries.boundaryconditions import LbBoundary
+
+from pystencilssfg import SfgComposer
+from pystencilssfg.composer.custom import CustomGenerator
+
+from .boundary_utils import BoundaryIndexType, GenericHbbWrapper
+from .boundary_utils import WalberlaLbmBoundary
+from ..sweep import Sweep
+from ..api import SparseIndexList, MemTags
+from ..build_config import get_build_config
+
+__all__ = ["FreeSlip"]
+
+
+class _IrregularSentinel:
+    def __repr__(self) -> str:
+        return "IRREGULAR"
+
+
+class GenericLinkwiseBoundary(CustomGenerator):
+
+    IRREGULAR = _IrregularSentinel()
+
+
+class NoSlip(GenericLinkwiseBoundary):
+    """Zero-velocity half-way bounce back boundary condition.
+
+    Args:
+        name: Name of the generated sweep class
+        lb_method: lbmpy Method description
+        pdf_field: Symbolic LBM PDF field
+        wall_orientation: Vector :math:`\\vec{n} \\in \\{ -1, 0, 1 \\}^d` pointing from the fluid to the wall,
+            or `NoSlip.IRREGULAR` to indicate a non-grid-aligned boundary
+    """
+
+    def __init__(
+        self,
+        name: str,
+        lb_method: AbstractLbMethod,
+        pdf_field: Field,
+        wall_orientation: tuple[int, int, int] | _IrregularSentinel,
+    ):
+        self._name = name
+        self._lb_method = lb_method
+        self._pdf_field = pdf_field
+        self._wall_normal = wall_orientation
+
+    def generate(self, sfg: SfgComposer) -> None:
+        if self._wall_normal == self.IRREGULAR:
+            self._generate_irregular(sfg)
+        else:
+            self._generate_regular(sfg)
+
+    def _generate_irregular(self, sfg: SfgComposer):
+        raise NotImplementedError()
+
+    def _generate_regular(self, sfg: SfgComposer):
+        bc_asm = self._grid_aligned_assignments()
+        bc_sweep = Sweep(self._name, bc_asm)
+        sfg.generate(bc_sweep)
+
+    def _grid_aligned_assignments(self):
+        stencil = self._lb_method.stencil
+
+        assert isinstance(self._wall_normal, tuple)
+        wall_normal = self._wall_normal
+        x_b = (0,) * stencil.D
+
+        f = self._pdf_field
+
+        def wall_aligned(vec):
+            """Test if v1 is aligned with v2, i.e. v1 has the same nonzero entries as v2"""
+            for x_v, x_n in zip(vec, wall_normal):
+                if x_n != 0 and x_v != x_n:
+                    return False
+            return True
+
+        asms = []
+
+        for i_out, x_w in enumerate(stencil):
+            if wall_aligned(x_w):
+                i_inverse = stencil.inverse_index(x_w)
+                asms.append(Assignment(f[x_w](i_inverse), f[x_b](i_out)))
+
+        return AssignmentCollection(asms)
+
+
+class GenericHBB(GenericLinkwiseBoundary):
+    def __init__(
+        self,
+        bc: LbBoundary,
+        lb_method: AbstractLbMethod,
+        pdf_field: Field,
+    ):
+        if bc.additional_data:
+            raise ValueError(
+                "The SimpleHbbBoundary generator only supports boundary conditions without "
+                "additional per-link data."
+            )
+
+        self._bc = GenericHbbWrapper(bc)
+        self._name = bc.name
+        self._lb_method = lb_method
+        self._pdf_field = pdf_field
+        self._stencil = lb_method.stencil
+
+    def generate(self, sfg: SfgComposer) -> None:
+        return self._generate_irregular(sfg)
+
+    def _generate_irregular(self, sfg: SfgComposer):
+        sfg.include("walberla/experimental/lbm/GenericHbbBoundary.hpp")
+
+        #   Get assignments for bc
+        bc_obj = self._bc
+        bc_asm = bc_obj.get_assignments(self._lb_method, self._pdf_field)
+
+        #   Build generator config
+        bc_cfg = get_build_config(sfg).get_pystencils_config()
+        bc_cfg.index_dtype = BoundaryIndexType
+        index_field = bc_obj.get_index_field()
+        bc_cfg.index_field = index_field
+
+        #   Prepare sweep
+        bc_sweep = Sweep(self._name, bc_asm, bc_cfg)
+
+        #   Emit code
+        sfg.generate(bc_sweep)
+
+        #   Build factory
+        factory_name = f"{self._name}Factory"
+        factory_crtp_base = (
+            f"walberla::experimental::lbm::GenericHbbFactory< {factory_name} >"
+        )
+        memtag_t = MemTags.unified if bc_cfg.get_target().is_gpu() else MemTags.host
+        index_vector = SparseIndexList(
+            GenericHbbWrapper.idx_struct_type, memtag_t=memtag_t, ref=True
+        ).var("indexVector")
+
+        sweep_type = bc_sweep.generated_class()
+        sweep_ctor_args = {
+            f"{self._pdf_field.name}Id": "this->pdfFieldID_",
+            f"{index_field.name}": index_vector,
+        }
+
+        stencil_name = self._lb_method.stencil.name
+        sfg.include(f"stencil/{stencil_name}.h")
+
+        sfg.klass(factory_name, bases=[f"public {factory_crtp_base}"])(
+            sfg.public(
+                f"using Base = {factory_crtp_base};",
+                f"friend class {factory_crtp_base};",
+                f"using Stencil = walberla::stencil::{stencil_name};",
+                f"using Sweep = {sweep_type.get_dtype().c_string()};",
+                f"using memtag_t = {memtag_t.c_string()};" "using Base::Base;",
+            ),
+            sfg.private(
+                sfg.method(
+                    "irregularFromIndexVector",
+                )
+                .returns(sweep_type.get_dtype())
+                .inline()(sfg.expr("return {};", sweep_type.ctor(**sweep_ctor_args))),
+            ),
+        )
+
+    def _generate_regular(self, sfg: SfgComposer):
+        assert False
+
+
+class FreeSlip(GenericLinkwiseBoundary):
+    """Specular-reflection half-way bounce back boundary condition.
+
+    This boundary handler implements specular reflection of PDFs at a wall located
+    at the lattice links' half-way point.
+    It can be used to mode both free slip and symmetry boundaries.
+
+    Args:
+        name: Name of the generated sweep class
+        lb_method: lbmpy Method description
+        pdf_field: Symbolic LBM PDF field
+        wall_orientation: Vector :math:`\\vec{n} \\in \\{ -1, 0, 1 \\}^d` pointing from the wall into the fluid,
+            or `FreeSlip.IRREGULAR` to indicate a non-grid-aligned boundary
+    """
+
+    def __init__(
+        self,
+        name: str,
+        lb_method: AbstractLbMethod,
+        pdf_field: Field,
+        wall_orientation: tuple[int, int, int] | _IrregularSentinel,
+    ):
+        self._name = name
+        self._lb_method = lb_method
+        self._pdf_field = pdf_field
+        self._wall_normal = wall_orientation
+
+    def generate(self, sfg: SfgComposer) -> None:
+        if self._wall_normal == self.IRREGULAR:
+            self._generate_irregular(sfg)
+        else:
+            self._generate_regular(sfg)
+
+    def _generate_irregular(self, sfg: SfgComposer):
+        sfg.include("walberla/experimental/lbm/IrregularFreeSlip.hpp")
+
+        #   Get assignments for bc
+        bc_obj = WalberlaIrregularFreeSlip()
+        bc_asm = bc_obj.get_assignments(self._lb_method, self._pdf_field)
+
+        #   Build generator config
+        bc_cfg = get_build_config(sfg).get_pystencils_config()
+        bc_cfg.index_dtype = BoundaryIndexType
+        index_field = bc_obj.get_index_field()
+        bc_cfg.index_field = index_field
+
+        #   Prepare sweep
+        bc_sweep = Sweep(self._name, bc_asm, bc_cfg)
+
+        #   Emit code
+        sfg.generate(bc_sweep)
+
+        #   Build factory
+        factory_name = f"{self._name}Factory"
+        factory_crtp_base = (
+            f"walberla::experimental::lbm::IrregularFreeSlipFactory< {factory_name} >"
+        )
+        memtag_t = MemTags.unified if bc_cfg.get_target().is_gpu() else MemTags.host
+        index_vector = SparseIndexList(
+            WalberlaIrregularFreeSlip.idx_struct_type, memtag_t=memtag_t, ref=True
+        ).var("indexVector")
+
+        sweep_type = bc_sweep.generated_class()
+        sweep_ctor_args = {
+            f"{self._pdf_field.name}Id": "this->pdfFieldID_",
+            f"{index_field.name}": index_vector,
+        }
+
+        stencil_name = self._lb_method.stencil.name
+        sfg.include(f"stencil/{stencil_name}.h")
+
+        sfg.klass(factory_name, bases=[f"public {factory_crtp_base}"])(
+            sfg.public(
+                f"using Base = {factory_crtp_base};",
+                f"friend class {factory_crtp_base};",
+                f"using Stencil = walberla::stencil::{stencil_name};",
+                f"using Sweep = {sweep_type.get_dtype().c_string()};",
+                f"using memtag_t = {memtag_t.c_string()};" "using Base::Base;",
+            ),
+            sfg.private(
+                sfg.method(
+                    "irregularFromIndexVector",
+                )
+                .returns(sweep_type.get_dtype())
+                .inline()(sfg.expr("return {};", sweep_type.ctor(**sweep_ctor_args))),
+            ),
+        )
+
+    def _generate_regular(self, sfg: SfgComposer):
+        bc_asm = self._grid_aligned_assignments()
+        bc_sweep = Sweep(self._name, bc_asm)
+        sfg.generate(bc_sweep)
+
+    def _grid_aligned_assignments(self):
+        stencil = self._lb_method.stencil
+
+        assert isinstance(self._wall_normal, tuple)
+        wall_normal = self._wall_normal
+        inverse_wall_normal = tuple(-c for c in wall_normal)
+
+        f = self._pdf_field
+
+        def is_missing(vec):
+            for x_v, x_n in zip(vec, inverse_wall_normal):
+                if x_n != 0 and x_v != x_n:
+                    return False
+            return True
+
+        def wall_mirror(vec):
+            return tuple(
+                (-x_v if x_n != 0 else x_v) for x_v, x_n in zip(vec, wall_normal)
+            )
+
+        asms = []
+
+        for i_missing, c_missing in enumerate(stencil):
+            #   Iterate all inbound populations to the fluid cell crossing the wall
+            if is_missing(c_missing):
+                x_w = tuple(-c for c in c_missing)
+                c_refl = wall_mirror(c_missing)
+                i_refl = stencil.index(c_refl)
+                x_orig = tuple(w - n for w, n in zip(x_w, wall_normal))
+
+                asms.append(Assignment(f[x_w](i_missing), f[x_orig](i_refl)))
+
+        return AssignmentCollection(asms)
+
+
+class WalberlaIrregularFreeSlip(LbBoundary, WalberlaLbmBoundary):
+    idx_struct_type = PsStructType(
+        (
+            ("x", BoundaryIndexType),
+            ("y", BoundaryIndexType),
+            ("z", BoundaryIndexType),
+            ("dir", BoundaryIndexType),
+            ("source_offset_x", BoundaryIndexType),
+            ("source_offset_y", BoundaryIndexType),
+            ("source_offset_z", BoundaryIndexType),
+            ("source_dir", BoundaryIndexType),
+        ),
+        "walberla::experimental::lbm::IrregularFreeSlipLinkInfo",
+    )
+
+    def boundary_obj(self) -> LbBoundary:
+        return self
+
+    def __call__(
+        self, f_out, f_in, dir_symbol, inv_dir, lb_method, index_field, force_vector
+    ):
+        source_cell = (
+            index_field("source_offset_x"),
+            index_field("source_offset_y"),
+            index_field("source_offset_z"),
+        )
+        source_dir = index_field("source_dir")
+
+        return Assignment(f_in(inv_dir[dir_symbol]), f_out[source_cell](source_dir))
diff --git a/src/walberla/codegen/build_config.py b/src/walberla/codegen/build_config.py
new file mode 100644
index 0000000000000000000000000000000000000000..434512f8d413ce99c0881ac5e67125b8b3b20b7a
--- /dev/null
+++ b/src/walberla/codegen/build_config.py
@@ -0,0 +1,137 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+from pystencils import CreateKernelConfig, Target
+from pystencils.types.quick import Fp
+from pystencils.jit import no_jit
+
+from pystencilssfg import SfgContext
+from pystencilssfg.composer import SfgIComposer
+
+
+def cmake_parse_bool(var: str):
+    var = var.upper()
+    if var in ("ON", "1", "TRUE"):
+        return True
+    elif var in ("OFF", "0", "FALSE"):
+        return False
+    else:
+        raise ValueError(f"Could not parse cmake value `{var}` as boolean.")
+
+
+@dataclass
+class CodegenOverrides:
+    target: Target | None = None
+    """Override code generation target"""
+
+
+@dataclass
+class WalberlaBuildConfig:
+    """Represents a waLBerla build system configuration"""
+
+    c_compiler_id: str
+    """Value of `CMAKE_C_COMPILER_ID`."""
+
+    cxx_compiler_id: str
+    """Value of `CMAKE_CXX_COMPILER_ID`."""
+
+    use_double_precision: bool
+    """Value of `WALBERLA_DOUBLE_ACCURACY`"""
+
+    optimize_for_localhost: bool
+    """Value of `WALBERLA_OPTIMIZE_FOR_LOCALHOST`."""
+
+    mpi_enabled: bool
+    """Value of `WALBERLA_BUILD_WITH_MPI`."""
+
+    openmp_enabled: bool
+    """Value of `WALBERLA_BUILD_WITH_OPENMP`."""
+
+    cuda_enabled: bool
+    """Value of `WALBERLA_BUILD_WITH_CUDA`."""
+
+    hip_enabled: bool
+    """Value of `WALBERLA_BUILD_WITH_HIP`."""
+
+    likwid_enabled: bool
+    """Value of `WALBERLA_BUILD_WITH_LIKWID_MARKERS`"""
+
+    override: CodegenOverrides = field(default_factory=CodegenOverrides)
+    """Override code generator options that would otherwise be inferred from the build system."""
+
+    @staticmethod
+    def from_sfg(sfg: SfgContext | SfgIComposer) -> WalberlaBuildConfig:
+        if isinstance(sfg, SfgIComposer):
+            ctx = sfg.context
+        else:
+            ctx = sfg
+
+        if isinstance(ctx.project_info, WalberlaBuildConfig):
+            return ctx.project_info
+        elif DEBUG_MOCK_CMAKE.BUILD_CONFIG is not None:
+            return DEBUG_MOCK_CMAKE.BUILD_CONFIG
+        else:
+            raise ValueError(
+                "The given SfgContext does not encapsulate a waLBerla build config object."
+            )
+
+    def get_pystencils_config(self) -> CreateKernelConfig:
+        cfg = CreateKernelConfig()
+        cfg.default_dtype = Fp(64) if self.use_double_precision else Fp(32)
+        cfg.jit = no_jit
+
+        if self.override.target is not None:
+            cfg.target = self.override.target
+        elif self.cuda_enabled:
+            cfg.target = Target.CUDA
+        elif self.hip_enabled:
+            cfg.target = Target.HIP
+        else:
+            #  CPU target
+            if self.optimize_for_localhost:
+                cfg.target = Target.CurrentCPU
+            else:
+                cfg.target = Target.GenericCPU
+
+        if self.openmp_enabled:
+            cfg.cpu.openmp.enable = True
+
+        return cfg
+
+
+def get_build_config(sfg: SfgContext | SfgIComposer):
+    """Get the waLBerla build config object for the current generator script."""
+    return WalberlaBuildConfig.from_sfg(sfg)
+
+
+class DEBUG_MOCK_CMAKE:
+    BUILD_CONFIG: WalberlaBuildConfig | None = None
+
+    @staticmethod
+    def use_cpu_default():
+        DEBUG_MOCK_CMAKE.BUILD_CONFIG = WalberlaBuildConfig(
+            c_compiler_id="GNU",
+            cxx_compiler_id="GNU",
+            use_double_precision=True,
+            optimize_for_localhost=False,
+            mpi_enabled=False,
+            openmp_enabled=False,
+            hip_enabled=False,
+            cuda_enabled=False,
+            likwid_enabled=False,
+        )
+
+    @staticmethod
+    def use_hip_default():
+        DEBUG_MOCK_CMAKE.BUILD_CONFIG = WalberlaBuildConfig(
+            c_compiler_id="hipcc",
+            cxx_compiler_id="hipcc",
+            use_double_precision=True,
+            optimize_for_localhost=False,
+            mpi_enabled=False,
+            openmp_enabled=False,
+            hip_enabled=True,
+            cuda_enabled=False,
+            likwid_enabled=False,
+        )
diff --git a/src/sfg_walberla/reflection.py b/src/walberla/codegen/reflection.py
similarity index 69%
rename from src/sfg_walberla/reflection.py
rename to src/walberla/codegen/reflection.py
index 13e422d94cf2329d339a23077a51a13553928652..1b8d2021d8381bf8d5cf8a8430f8e2d33d177779 100644
--- a/src/sfg_walberla/reflection.py
+++ b/src/walberla/codegen/reflection.py
@@ -5,14 +5,9 @@ from pystencilssfg.ir import SfgClass
 
 class GeneratedClassWrapperBase(CppClass):
     _class: SfgClass
-    _namespace: str | None
 
     def __init_subclass__(cls) -> None:
-        typename = (
-            f"{cls._namespace}::{cls._class.class_name}"
-            if cls._namespace is not None
-            else cls._class.class_name
-        )
+        typename = cls._class.fqname
         cls.template = cpptype(typename)
 
     def ctor(self, **kwargs) -> AugExpr:
@@ -22,7 +17,7 @@ class GeneratedClassWrapperBase(CppClass):
                 break
         else:
             raise Exception(
-                f"No constructor of class {self._class.class_name} matches the argument names {kwargs.keys()}"
+                f"No constructor of class {self._class.fqname} matches the argument names {kwargs.keys()}"
             )
 
         ctor_args = [kwargs[name] for name in ctor_argnames]
diff --git a/src/sfg_walberla/sweep.py b/src/walberla/codegen/sweep.py
similarity index 58%
rename from src/sfg_walberla/sweep.py
rename to src/walberla/codegen/sweep.py
index a79d133f4381deea94d06e96ff57c33531e73e26..5537633b6e3a142793575bfa29127a75f00aaf83 100644
--- a/src/sfg_walberla/sweep.py
+++ b/src/walberla/codegen/sweep.py
@@ -1,14 +1,16 @@
-from typing import Sequence, Iterable
-from dataclasses import dataclass, replace
+from __future__ import annotations
+
+from typing import Sequence, Iterable, cast, Callable
+from dataclasses import dataclass
 from itertools import chain
 from collections import defaultdict
+from abc import ABC, abstractmethod
 import warnings
 
 import sympy as sp
 
 from pystencilssfg.composer.custom import CustomGenerator
-from pystencilssfg.composer.class_composer import SfgClassComposer
-from pystencilssfg.ir import SfgMethod
+from pystencilssfg.composer.class_composer import SfgClassComposer, SfgMethodSequencer
 
 from pystencils import (
     Assignment,
@@ -28,10 +30,12 @@ from pystencilssfg.lang import (
     SfgVar,
     AugExpr,
     Ref,
-    SrcVector,
-    strip_ptr_ref,
+    SupportsFieldExtraction,
+    SupportsVectorExtraction,
 )
-from pystencilssfg.lang.types import CppTypeFactory, cpptype
+from pystencilssfg.lang.cpp import std
+from pystencilssfg.ir import SfgKernelHandle, SfgCallTreeNode
+from .build_config import WalberlaBuildConfig
 from .reflection import GeneratedClassWrapperBase
 from .api import (
     StructuredBlockForest,
@@ -39,16 +43,19 @@ from .api import (
     GhostLayerFieldPtr,
     GpuFieldPtr,
     IndexListBufferPtr,
+    SparseIndexList,
     IBlockPtr,
     BlockDataID,
     Vector2,
     Vector3,
     CellInterval,
     CellIdx,
+    SharedPtr,
 )
 
 
 class SweepClassProperties:
+
     @dataclass(frozen=True)
     class Property:
         name: str
@@ -69,32 +76,29 @@ class SweepClassProperties:
         def member_var(self) -> SfgVar:
             return SfgVar(self.name + "_", self.dtype)
 
-        def make_methods(self, sfg: SfgComposer) -> Sequence[SfgMethod]:
+        def make_methods(self, sfg: SfgComposer) -> Sequence[SfgMethodSequencer]:
             methods = []
 
             if self.getter:
                 methods.append(
-                    sfg.method(
-                        f"{self.name}",
-                        returns=Ref(constify(self.dtype)),
-                        const=True,
-                        inline=True,
-                    )(f"return {self.name}_;")
+                    sfg.method(f"{self.name}")
+                    .returns(Ref(constify(self.dtype)))
+                    .const()
+                    .inline()(f"return {self.name}_;")
                 )
 
             if self.setter:
                 methods.append(
-                    sfg.method(
-                        f"{self.name}",
-                        returns=Ref(self.dtype),
-                        const=False,
-                        inline=True,
-                    )(f"return {self.name}_;")
+                    sfg.method(f"{self.name}")
+                    .returns(Ref(self.dtype))
+                    .inline()(f"return {self.name}_;")
                 )
 
             return methods
 
-    def __init__(self):
+    blockforest_shared_ptr = StructuredBlockForest.shared_ptr()
+
+    def __init__(self) -> None:
         self._properties: dict[str, SweepClassProperties.Property] = dict()
 
     @property
@@ -119,6 +123,15 @@ class SweepClassProperties:
             prop.name, dtype, getter, setter, initializer
         )
 
+    def add_blockforest_shared_ptr(self) -> SharedPtr:
+        self.add_property(
+            self.blockforest_shared_ptr,
+            getter=False,
+            setter=False,
+            initializer=(StructuredBlockForest.shared_ptr_ref(),),
+        )
+        return self.blockforest_shared_ptr
+
     def get(self, prop: VarLike) -> AugExpr:
         prop = asvar(prop)
 
@@ -138,10 +151,7 @@ class SweepClassProperties:
             else:
                 for var in chain.from_iterable(e.depends for e in p.initializer):
                     if var not in ctor.parameters:
-                        if (
-                            deconstify(strip_ptr_ref(var.dtype))
-                            == StructuredBlockForest.typename()
-                        ):
+                        if var.name == asvar(self.blockforest_shared_ptr).name:
                             ctor.add_param(var, 0)
                         else:
                             ctor.add_param(var)
@@ -166,12 +176,12 @@ class BlockforestParameters:
         self,
         property_cache: SweepClassProperties,
         block: IBlockPtr,
-        cell_interval: AugExpr | None = None,
     ):
         self._property_cache = property_cache
         self._block = block
-        self._ci = cell_interval
-        self._extractions: dict[SfgVar, AugExpr] = dict()
+        self._extractions: dict[SfgVar, Callable[[CellInterval | None], AugExpr]] = (
+            dict()
+        )
 
         self._blockforest: StructuredBlockForest | None = None
 
@@ -186,7 +196,7 @@ class BlockforestParameters:
         subs: dict[AppliedUndef, sp.Symbol] = dict()
         for appl in expandable_appls:
             expansion: sp.Expr = appl.expansion_func(*appl.args)  # type: ignore
-            symb = next(asms.subexpression_symbol_generator)
+            symb = next(asms.subexpression_symbol_generator)  # type: ignore
             asms.subexpressions.insert(0, Assignment(symb, expansion))
             subs[appl] = symb
 
@@ -194,13 +204,7 @@ class BlockforestParameters:
 
     def blockforest(self) -> StructuredBlockForest:
         if self._blockforest is None:
-            blocks = StructuredBlockForest.shared_ptr()
-            self._property_cache.add_property(
-                blocks,
-                getter=False,
-                setter=False,
-                initializer=(StructuredBlockForest.shared_ptr_ref(),),
-            )
+            blocks = self._property_cache.add_blockforest_shared_ptr()
             self._blockforest = StructuredBlockForest().bind(
                 "(*{})", self._property_cache.get(blocks)
             )
@@ -209,80 +213,124 @@ class BlockforestParameters:
             return self._blockforest
 
     def filter_params(self, params: set[SfgVar]) -> set[SfgVar]:
-        from .symbolic import domain, block, cell
+        from .symbolic import domain, domain_cell_bb, block, block_cell_bb, cell
 
         params_filtered = set()
 
-        for param in params:
-            for coord in range(3):
-                if param.name == domain.aabb_min[coord].name:
-                    self._extractions[param] = (
-                        self.blockforest().getDomain().min()[coord]
-                    )
-                    break
-                elif param.name == domain.aabb_max[coord].name:
-                    self._extractions[param] = (
-                        self.blockforest().getDomain().max()[coord]
-                    )
-                    break
-                elif param.name == block.aabb_min[coord].name:
-                    self._extractions[param] = self._block.getAABB().min()[coord]
-                    break
-                elif param.name == block.aabb_max[coord].name:
-                    self._extractions[param] = self._block.getAABB().max()[coord]
-                    break
-                elif param.name == block.ci_min[coord].name:
-                    if self._ci is None:
-                        self._extractions[param] = AugExpr.format("0")
-                    else:
-                        raise NotImplementedError()
-                    break
-                elif param.name == block.ci_max[coord].name:
-                    if self._ci is None:
-                        self._extractions[param] = AugExpr.format(
-                            "{} - 1",
-                            self.blockforest().cellsPerBlock(coord, self._block),
-                        )
-                    else:
-                        raise NotImplementedError()
-                    break
-                elif param.name == cell.cell_extents[coord].name:
-                    self._extractions[param] = self.blockforest().cell_extents(
+        def get_mapping(param: SfgVar, coord: int):
+            #   We're populating the mappings with lambdas here,
+            #   to make sure that `self.blockforest()` is only called
+            #   iff it is actually reqired.
+            #   Otherwise blockforest will appear in the parameter list
+            #   of sweeps which don't actually need it.
+            return {
+                domain.aabb_min[coord].name: lambda _: (
+                    self.blockforest().getDomain().min()[coord]
+                ),
+                domain.aabb_max[coord].name: lambda _: (
+                    self.blockforest().getDomain().max()[coord]
+                ),
+                domain_cell_bb.cell_bb_min[coord].name: lambda _: (
+                    self.blockforest()
+                    .getDomainCellBB(self.blockforest().getLevel(self._block.deref()))
+                    .min()[coord]
+                ),
+                domain_cell_bb.cell_bb_max[coord].name: lambda _: (
+                    self.blockforest()
+                    .getDomainCellBB(self.blockforest().getLevel(self._block.deref()))
+                    .max()[coord]
+                ),
+                block.aabb_min[coord].name: lambda _: (
+                    self._block.getAABB().min()[coord]
+                ),
+                block.aabb_max[coord].name: lambda _: (
+                    self._block.getAABB().max()[coord]
+                ),
+                block.ci_min[coord].name: lambda ci: (
+                    AugExpr.format("0") if ci is None else ci.min()[coord]
+                ),
+                block.ci_max[coord].name: lambda ci: (
+                    AugExpr.format("0") if ci is None else ci.max()[coord]
+                ),
+                block_cell_bb.cell_bb_min[coord].name: lambda _: (
+                    self.blockforest().getBlockCellBB(self._block.deref()).min()[coord]
+                ),
+                block_cell_bb.cell_bb_max[coord].name: lambda _: (
+                    self.blockforest().getBlockCellBB(self._block.deref()).max()[coord]
+                ),
+                cell.cell_extents[coord].name: lambda _: (
+                    self.blockforest().cell_extents(
                         coord, self.blockforest().getLevel(self._block.deref())
                     )
+                ),
+            }.get(param.name, None)
+
+        for param in params:
+            for coord in range(3):
+                if (extraction := get_mapping(param, coord)) is not None:
+                    self._extractions[param] = extraction
                     break
             else:
                 params_filtered.add(param)
 
         return params_filtered
 
-    def render_extractions(self, sfg: SfgComposer):
-        return (sfg.init(p)(expr) for p, expr in self._extractions.items())
+    def render_extractions(self, sfg: SfgComposer, ci: CellInterval | None):
+        return (sfg.init(p)(expr(ci)) for p, expr in self._extractions.items())
 
 
 @dataclass
-class FieldInfo:
+class FieldInfo(ABC):
     field: Field
-    wlb_field_ptr: GenericWalberlaField | IndexListBufferPtr
+
+    @property
+    @abstractmethod
+    def entity(self) -> AugExpr: ...
+
+    @property
+    @abstractmethod
+    def view(self) -> AugExpr: ...
+
+    @abstractmethod
+    def create_view(self, block: IBlockPtr) -> AugExpr: ...
+
+    def view_extraction(self) -> SupportsFieldExtraction:
+        assert isinstance(self.view, SupportsFieldExtraction)
+        return self.view
+
+
+@dataclass
+class GlFieldInfo(FieldInfo):
+    glfield: GenericWalberlaField
     data_id: BlockDataID
 
-    def as_glfield(self) -> GenericWalberlaField:
-        if not isinstance(self.wlb_field_ptr, GenericWalberlaField):
-            raise AttributeError(
-                "This FieldInfo does not encapsulate a ghost layer field"
-            )
-        return self.wlb_field_ptr
+    @property
+    def entity(self) -> AugExpr:
+        return self.data_id
+
+    @property
+    def view(self) -> AugExpr:
+        return self.glfield
 
-    def is_glfield(self) -> bool:
-        return isinstance(self.wlb_field_ptr, GenericWalberlaField)
+    def create_view(self, block: IBlockPtr) -> AugExpr:
+        return block.getData(self.glfield.field_type, self.data_id)
 
-    def as_index_list(self) -> IndexListBufferPtr:
-        if not isinstance(self.wlb_field_ptr, IndexListBufferPtr):
-            raise AttributeError("This FieldInfo does not encapsulate an index list")
-        return self.wlb_field_ptr
 
-    def is_index_list(self) -> bool:
-        return isinstance(self.wlb_field_ptr, IndexListBufferPtr)
+@dataclass
+class IndexListInfo(FieldInfo):
+    idx_list: SparseIndexList
+    idx_vector_view: std.span
+
+    @property
+    def entity(self) -> AugExpr:
+        return self.idx_list
+
+    @property
+    def view(self) -> AugExpr:
+        return self.idx_vector_view
+
+    def create_view(self, block: IBlockPtr) -> AugExpr:
+        return self.idx_list.view(block.deref())
 
 
 class ShadowFieldCache:
@@ -292,21 +340,21 @@ class ShadowFieldCache:
         - Name of shadow field
     """
 
-    def __init__(self):
-        self._originals: dict[str, FieldInfo] = dict()
+    def __init__(self) -> None:
+        self._originals: dict[str, GlFieldInfo] = dict()
 
-    def add_shadow(self, original: FieldInfo):
+    def add_shadow(self, original: GlFieldInfo):
         self._originals[original.field.name] = original
 
     def get_shadow(self, original_name: str) -> AugExpr:
         original = self._originals[original_name]
         return AugExpr.format(
-            "this->{}({})", self._getter(original.field.name), original.wlb_field_ptr
+            "this->{}({})", self._getter(original.field.name), original.glfield
         )
 
-    def perform_swap(self, original_name: str, shadow_field: FieldInfo) -> AugExpr:
+    def perform_swap(self, original_name: str, shadow_field: GlFieldInfo) -> AugExpr:
         original = self._originals[original_name]
-        return original.as_glfield().swapDataPointers(shadow_field.wlb_field_ptr)
+        return original.glfield.swapDataPointers(shadow_field.glfield)
 
     def render(self, sfg: SfgComposer):
         if not self._originals:
@@ -319,22 +367,20 @@ class ShadowFieldCache:
 
         for orig_name, orig in self._originals.items():
             unique_ptr_type = PsCustomType(
-                f"std::unique_ptr< {orig.as_glfield().field_type} >"
+                f"std::unique_ptr< {orig.glfield.field_type} >"
             )
-            cache_ptr_name = self._cache_ptr(orig.as_glfield())
+            cache_ptr_name = self._cache_ptr(orig.glfield)
             cache_ptrs.append(sfg.var(cache_ptr_name, unique_ptr_type))
 
             getters.append(
-                sfg.method(
-                    self._getter(orig_name),
-                    returns=orig.wlb_field_ptr.get_dtype(),
-                    inline=True,
-                )(
+                sfg.method(self._getter(orig_name))
+                .returns(orig.glfield.get_dtype())
+                .inline()(
                     sfg.branch(f"{cache_ptr_name} == nullptr")(
                         AugExpr.format(
                             "{}.reset({});",
                             cache_ptr_name,
-                            orig.as_glfield().cloneUninitialized(),
+                            orig.glfield.cloneUninitialized(),
                         )
                     ),
                     f"return {cache_ptr_name}.get();",
@@ -350,7 +396,9 @@ class ShadowFieldCache:
         return f"shadow_{str(orig)}_"
 
 
-def combine_vectors(scalars: set[SfgVar]) -> dict[SrcVector, tuple[SfgVar, ...]]:
+def combine_vectors(
+    scalars: set[SfgVar],
+) -> dict[SupportsVectorExtraction, tuple[SfgVar, ...]]:
     """Attempt to combine vector component symbols into vectors.
 
     This function modifies the `scalars` parameter in-place by removing grouped scalar
@@ -367,7 +415,7 @@ def combine_vectors(scalars: set[SfgVar]) -> dict[SrcVector, tuple[SfgVar, ...]]
         except ValueError:
             pass
 
-    all_vectors: dict[SrcVector, tuple[SfgVar, ...]] = dict()
+    all_vectors: dict[SupportsVectorExtraction, tuple[SfgVar, ...]] = dict()
 
     for name, entries in potential_vectors.items():
         entries = sorted(entries, key=lambda t: t[0])
@@ -375,6 +423,7 @@ def combine_vectors(scalars: set[SfgVar]) -> dict[SrcVector, tuple[SfgVar, ...]]
         components = tuple(e[1] for e in entries)
         scalar_types = set(e[1].dtype for e in entries)
 
+        vec_type: type[AugExpr]
         if set(coords) == {0, 1}:
             vec_type = Vector2
         elif set(coords) == {0, 1, 2}:
@@ -424,37 +473,37 @@ class Sweep(CustomGenerator):
         config: CreateKernelConfig | None = None,
     ):
         if config is not None:
-            config = config.copy()
+            cfg = config.copy()
 
-            if config.get_option("ghost_layers") is not None:
+            if cfg.get_option("ghost_layers") is not None:
                 raise ValueError(
                     "Specifying `ghost_layers` in your codegen config is invalid when generating a waLBerla sweep."
                 )
 
             if (
-                config.get_option("iteration_slice") is None
-                and config.get_option("index_field") is None
+                cfg.get_option("iteration_slice") is None
+                and cfg.get_option("index_field") is None
             ):
-                config.ghost_layers = 0
+                cfg.ghost_layers = 0
         else:
-            config = CreateKernelConfig(ghost_layers=0)
+            cfg = CreateKernelConfig(ghost_layers=0)
+
+        manual_grid: bool = cfg.gpu.get_option("manual_launch_grid")
+        if manual_grid:
+            raise ValueError(
+                "Setting `gpu.manual_launch_grid = True` is invalid for waLBerla sweeps."
+            )
 
         self._name = name
+        self._gen_config = cfg
 
         if isinstance(assignments, AssignmentCollection):
             self._assignments = assignments
         else:
             self._assignments = AssignmentCollection(assignments)  # type: ignore
-        self._gen_config = config
 
-        if self._gen_config.get_target() == Target.CUDA:
-            self._glfield_type = GpuFieldPtr
-        elif self._gen_config.get_target().is_cpu():
-            self._glfield_type = GhostLayerFieldPtr
-        else:
-            raise ValueError(
-                f"Cannot generate sweep for target {self._gen_config.target}"
-            )
+        #   Set only later once the full codegen config is known
+        self._glfield_type: type[GpuFieldPtr] | type[GhostLayerFieldPtr]
 
         #   Map from shadow field to shadowed field
         self._shadow_fields: dict[Field, Field] = dict()
@@ -501,6 +550,16 @@ class Sweep(CustomGenerator):
 
     #   CODE GENERATION
 
+    def _set_field_interface(self, target: Target):
+        if target.is_gpu():
+            self._glfield_type = GpuFieldPtr
+        elif target.is_cpu():
+            self._glfield_type = GhostLayerFieldPtr
+        else:
+            raise ValueError(
+                f"Cannot generate sweep for target {self._gen_config.target}"
+            )
+
     def _walberla_field(self, f: Field) -> GenericWalberlaField | IndexListBufferPtr:
         match f.field_type:
             case FieldType.GENERIC | FieldType.CUSTOM:
@@ -513,27 +572,72 @@ class Sweep(CustomGenerator):
                     f"Unable to map field {f} of type {f.field_type} to a waLBerla field."
                 )
 
+    def _make_field_info(self, f: Field, target: Target) -> FieldInfo:
+        match f.field_type:
+            case FieldType.GENERIC | FieldType.CUSTOM:
+                glfield = self._glfield_type.create(f)
+                data_id = BlockDataID().var(f"{f.name}Id")
+                return GlFieldInfo(f, glfield, data_id)
+            case FieldType.INDEXED:
+                assert isinstance(f.dtype, PsStructType)
+                idx_list = SparseIndexList.from_field(f, target=target)
+                view = idx_list.view_type().var(f"{f.name}_view")
+                return IndexListInfo(f, idx_list, view)
+            case _:
+                raise ValueError(f"Unexpected field type: {f.field_type} at field  {f}")
+
+    def _render_invocation(
+        self, sfg: SfgComposer, target: Target, khandle: SfgKernelHandle
+    ) -> tuple[SfgCallTreeNode, set[SfgVar]]:
+        """Render and return the kernel invocation plus a set of additional parameters required
+        at the call site."""
+
+        if target.is_gpu():
+            # from pystencils.codegen.config import GpuIndexingScheme
+
+            #   TODO: Want default values for properties first,
+            #   to define default stream and block size values
+            # indexing_scheme = self._gen_config.gpu.get_option("indexing_scheme")
+            # if indexing_scheme == GpuIndexingScheme.Linear3D:
+            #     block_size = sfg.gpu_api.dim3(const=True).var("gpuBlockSize")
+            #     return (sfg.gpu_invoke(khandle, block_size=block_size), {block_size})
+            # else:
+            return (sfg.gpu_invoke(khandle), set())
+
+        else:
+            return (sfg.call(khandle), set())
+
     def generate(self, sfg: SfgComposer) -> None:
+        build_config = WalberlaBuildConfig.from_sfg(sfg)
+        gen_config = build_config.get_pystencils_config()
+        gen_config.override(self._gen_config)
+
+        target = gen_config.get_target()
+        self._set_field_interface(target)
+
         knamespace = sfg.kernel_namespace(f"{self._name}_kernels")
 
         assignments = BlockforestParameters.process(self._assignments)
-        #   TODO: Get default config from waLBerla build system and override its entries
-        #   from the user-provided config
-        khandle = knamespace.create(assignments, self._name, self._gen_config)
+
+        khandle = knamespace.create(assignments, self._name, gen_config)
+        ker_invocation, ker_call_site_params = self._render_invocation(
+            sfg, target, khandle
+        )
 
         all_fields: dict[str, FieldInfo] = {
-            f.name: FieldInfo(
-                f, self._walberla_field(f), BlockDataID().var(f"{f.name}Id")
-            )
-            for f in khandle.fields
+            f.name: self._make_field_info(f, target) for f in khandle.fields
         }
 
-        swaps: dict[str, FieldInfo] = dict()
+        swaps: dict[str, GlFieldInfo] = dict()
         shadows_cache = ShadowFieldCache()
 
         for shadow, orig in self._shadow_fields.items():
-            swaps[orig.name] = all_fields[shadow.name]
-            shadows_cache.add_shadow(all_fields[orig.name])
+            shadow_fi = all_fields[shadow.name]
+            assert isinstance(shadow_fi, GlFieldInfo)
+            original_fi = all_fields[orig.name]
+            assert isinstance(original_fi, GlFieldInfo)
+            swaps[orig.name] = shadow_fi
+            shadows_cache.add_shadow(original_fi)
 
         block = IBlockPtr().var("block")
         block_fields = sorted(
@@ -543,15 +647,15 @@ class Sweep(CustomGenerator):
 
         props = SweepClassProperties()
 
-        parameters = khandle.scalar_parameters
+        parameters = khandle.scalar_parameters | ker_call_site_params
 
-        blockforest_params = BlockforestParameters(props, block, None)
+        blockforest_params = BlockforestParameters(props, block)
         parameters = blockforest_params.filter_params(parameters)
 
         vector_groups = combine_vectors(parameters)
 
         for fi in block_fields:
-            props.add_property(fi.data_id, setter=False, getter=True)
+            props.add_property(fi.entity, setter=False, getter=True)
 
         for s in sorted(parameters, key=lambda p: p.name):
             props.add_property(s, setter=True, getter=True)
@@ -559,32 +663,25 @@ class Sweep(CustomGenerator):
         def render_runmethod(ci: CellInterval | None = None):
             return [
                 #   Get IDs from class
-                *(sfg.init(fi.data_id)(props.get(fi.data_id)) for fi in block_fields),
-                #   Extract regular fields from block
-                *(
-                    sfg.init(fi.wlb_field_ptr)(
-                        block.getData(fi.wlb_field_ptr.field_type, fi.data_id)
-                    )
-                    for fi in block_fields
-                ),
+                *(sfg.init(fi.entity)(props.get(fi.entity)) for fi in block_fields),
+                #   Extract field views
+                *(sfg.init(fi.view)(fi.create_view(block)) for fi in block_fields),
                 #   Get shadow fields from cache
                 *(
-                    sfg.init(shadow_info.wlb_field_ptr)(
-                        shadows_cache.get_shadow(orig_name)
-                    )
+                    sfg.init(shadow_info.glfield)(shadows_cache.get_shadow(orig_name))
                     for orig_name, shadow_info in swaps.items()
                 ),
                 #   Map GhostLayerFields to pystencils fields
                 *(
-                    sfg.map_field(fi.field, fi.as_glfield().with_cell_interval(ci))
+                    sfg.map_field(fi.field, fi.glfield.with_cell_interval(ci))
                     for fi in all_fields.values()
-                    if fi.is_glfield()
+                    if isinstance(fi, GlFieldInfo)
                 ),
-                #   Map index lists to pystencils fields
+                #   Extract indexing information from field view for all remaining fields
                 *(
-                    sfg.map_field(fi.field, fi.as_index_list())
+                    sfg.map_field(fi.field, fi.view_extraction())
                     for fi in all_fields.values()
-                    if fi.is_index_list()
+                    if not isinstance(fi, GlFieldInfo)
                 ),
                 #   Get parameters from class
                 *(sfg.init(param)(props.get(param)) for param in parameters),
@@ -594,8 +691,9 @@ class Sweep(CustomGenerator):
                     for vector, components in vector_groups.items()
                 ),
                 #   Extract geometry information
-                *(blockforest_params.render_extractions(sfg)),
-                sfg.call(khandle),
+                *(blockforest_params.render_extractions(sfg, ci)),
+                #   Invoke the kernel
+                ker_invocation,
                 #   Perform field swaps
                 *(
                     shadows_cache.perform_swap(orig_name, shadow_info)
@@ -618,12 +716,14 @@ class Sweep(CustomGenerator):
             *shadows_cache.render(sfg),
         )
 
-        gen_class = sfg.context.get_class(self._name)
+        gen_class = sfg._cursor.get_entity(self._name)
         assert gen_class is not None
+        from pystencilssfg.ir.entities import SfgClass
+
+        assert isinstance(gen_class, SfgClass)
 
         class GenClassWrapper(GeneratedClassWrapperBase):
-            _class = gen_class
-            _namespace = sfg.context.fully_qualified_namespace
+            _class = cast(SfgClass, gen_class)
 
         self._generated_class = GenClassWrapper
 
diff --git a/src/walberla/codegen/symbolic.py b/src/walberla/codegen/symbolic.py
new file mode 100644
index 0000000000000000000000000000000000000000..7d591beaaa8ff198c66f40f0509c96511b09c1e8
--- /dev/null
+++ b/src/walberla/codegen/symbolic.py
@@ -0,0 +1,335 @@
+import sympy as sp
+
+import inspect
+
+from pystencils import TypedSymbol, DynamicType, tcast
+from pystencils.defaults import DEFAULTS
+
+
+def expandable(name: str):
+    def wrap(expansion_func):
+        nargs = len(inspect.signature(expansion_func).parameters)
+        return sp.Function(
+            name, nargs=nargs, expansion_func=staticmethod(expansion_func)
+        )
+
+    return wrap
+
+
+class DomainCoordinates:
+    aabb_min = tuple(
+        TypedSymbol(f"blockforest_aabb_min_{i}", DynamicType.NUMERIC_TYPE)
+        for i in range(3)
+    )
+
+    aabb_max = tuple(
+        TypedSymbol(f"blockforest_aabb_max_{i}", DynamicType.NUMERIC_TYPE)
+        for i in range(3)
+    )
+
+    @expandable("domain.x_min")
+    @staticmethod
+    def x_min():
+        """The minimum X coordinate of the domain axis-aligned bounding box"""
+        return DomainCoordinates.aabb_min[0]
+
+    @expandable("domain.y_min")
+    @staticmethod
+    def y_min():
+        """The minimum Y coordinate of the domain axis-aligned bounding box"""
+        return DomainCoordinates.aabb_min[1]
+
+    @expandable("domain.z_min")
+    @staticmethod
+    def z_min():
+        """The minimum Z coordinate of the domain axis-aligned bounding box"""
+        return DomainCoordinates.aabb_min[2]
+
+    @expandable("domain.x_max")
+    @staticmethod
+    def x_max():
+        """The maximum X coordinate of the domain axis-aligned bounding box"""
+        return DomainCoordinates.aabb_max[0]
+
+    @expandable("domain.y_max")
+    @staticmethod
+    def y_max():
+        """The maximum Y coordinate of the domain axis-aligned bounding box"""
+        return DomainCoordinates.aabb_max[1]
+
+    @expandable("domain.z_max")
+    @staticmethod
+    def z_max():
+        """The maximum Z coordinate of the domain axis-aligned bounding box"""
+        return DomainCoordinates.aabb_max[2]
+
+
+domain = DomainCoordinates
+
+
+class DomainCellBoundingBox:
+    """Represents the domain cell bounding box on the current block's refinement level.
+
+    See ``StructuredBlockStorage::getDomainCellBB``.
+    """
+
+    cell_bb_min = tuple(
+        TypedSymbol(f"domain_cell_b_min_{i}", DynamicType.INDEX_TYPE) for i in range(3)
+    )
+
+    cell_bb_max = tuple(
+        TypedSymbol(f"domain_cell_b_max_{i}", DynamicType.INDEX_TYPE) for i in range(3)
+    )
+
+    @expandable("domain_cell_bb.x_min")
+    @staticmethod
+    def x_min():
+        """The X coordinate of the domain's minimum cell index"""
+        return DomainCellBoundingBox.cell_bb_min[0]
+
+    @expandable("domain_cell_bb.y_min")
+    @staticmethod
+    def y_min():
+        """The Y coordinate of the domain's minimum cell index"""
+        return DomainCellBoundingBox.cell_bb_min[1]
+
+    @expandable("domain_cell_bb.z_min")
+    @staticmethod
+    def z_min():
+        """The Z coordinate of the domain's minimum cell index"""
+        return DomainCellBoundingBox.cell_bb_min[2]
+
+    @expandable("domain_cell_bb.x_max")
+    @staticmethod
+    def x_max():
+        """The X coordinate of the domain's maximum cell index"""
+        return DomainCellBoundingBox.cell_bb_max[0]
+
+    @expandable("domain_cell_bb.y_max")
+    @staticmethod
+    def y_max():
+        """The Y coordinate of the domain's maximum cell index"""
+        return DomainCellBoundingBox.cell_bb_max[1]
+
+    @expandable("domain_cell_bb.z_max")
+    @staticmethod
+    def z_max():
+        """The Z coordinate of the domain's maximum cell index"""
+        return DomainCellBoundingBox.cell_bb_max[2]
+
+
+domain_cell_bb = DomainCellBoundingBox
+
+
+class BlockCoordinates:
+    aabb_min = tuple(
+        TypedSymbol(f"block_aabb_min_{i}", DynamicType.NUMERIC_TYPE) for i in range(3)
+    )
+    aabb_max = tuple(
+        TypedSymbol(f"block_aabb_max_{i}", DynamicType.NUMERIC_TYPE) for i in range(3)
+    )
+    ci_min = tuple(
+        TypedSymbol(f"cell_interval_min_{i}", DynamicType.INDEX_TYPE) for i in range(3)
+    )
+    ci_max = tuple(
+        TypedSymbol(f"cell_interval_max_{i}", DynamicType.INDEX_TYPE) for i in range(3)
+    )
+
+
+block = BlockCoordinates
+
+
+class BlockCellBoundingBox:
+    """Represents the cell bounding box of the current block.
+
+    See ``StructuredBlockStorage::getBlockCellBB``.
+    """
+
+    cell_bb_min = tuple(
+        TypedSymbol(f"block_cell_bb_min_{i}", DynamicType.INDEX_TYPE) for i in range(3)
+    )
+
+    cell_bb_max = tuple(
+        TypedSymbol(f"block_cell_bb_max_{i}", DynamicType.INDEX_TYPE) for i in range(3)
+    )
+
+    @expandable("block_cell_bb.x_min")
+    @staticmethod
+    def x_min():
+        """The X coordinate of the current block's minimum cell index"""
+        return BlockCellBoundingBox.cell_bb_min[0]
+
+    @expandable("block_cell_bb.y_min")
+    @staticmethod
+    def y_min():
+        """The Y coordinate of the current block's minimum cell index"""
+        return BlockCellBoundingBox.cell_bb_min[1]
+
+    @expandable("block_cell_bb.z_min")
+    @staticmethod
+    def z_min():
+        """The Z coordinate of the current block's minimum cell index"""
+        return BlockCellBoundingBox.cell_bb_min[2]
+
+    @expandable("block_cell_bb.x_max")
+    @staticmethod
+    def x_max():
+        """The X coordinate of the current block's maximum cell index"""
+        return BlockCellBoundingBox.cell_bb_max[0]
+
+    @expandable("block_cell_bb.y_max")
+    @staticmethod
+    def y_max():
+        """The Y coordinate of the current block's maximum cell index"""
+        return BlockCellBoundingBox.cell_bb_max[1]
+
+    @expandable("block_cell_bb.z_max")
+    @staticmethod
+    def z_max():
+        """The Z coordinate of the current block's maximum cell index"""
+        return BlockCellBoundingBox.cell_bb_max[2]
+
+
+block_cell_bb = BlockCellBoundingBox
+
+
+class CellCoordinates:
+    cell_extents = tuple(
+        TypedSymbol(f"cell_extents_{i}", DynamicType.NUMERIC_TYPE) for i in range(3)
+    )
+
+    @expandable("cell.x")
+    @staticmethod
+    def x():
+        """X coordinate of the current cell's center in the global coordinate system"""
+        return BlockCoordinates.aabb_min[0] + CellCoordinates.cell_extents[0] * (
+            tcast.as_numeric(
+                block.ci_min[0] + sp.Symbol(DEFAULTS.spatial_counter_names[0])
+            )
+            + sp.Rational(1, 2)
+        )
+
+    @expandable("cell.y")
+    @staticmethod
+    def y():
+        """Y coordinate of the current cell's center in the global coordinate system"""
+        return BlockCoordinates.aabb_min[1] + CellCoordinates.cell_extents[1] * (
+            tcast.as_numeric(
+                block.ci_min[1] + sp.Symbol(DEFAULTS.spatial_counter_names[1])
+            )
+            + sp.Rational(1, 2)
+        )
+
+    @expandable("cell.z")
+    @staticmethod
+    def z():
+        """Z coordinate of the current cell's center in the global coordinate system"""
+        return BlockCoordinates.aabb_min[2] + CellCoordinates.cell_extents[2] * (
+            tcast.as_numeric(
+                block.ci_min[2] + sp.Symbol(DEFAULTS.spatial_counter_names[2])
+            )
+            + sp.Rational(1, 2)
+        )
+
+    @expandable("cell.local_x")
+    @staticmethod
+    def local_x():
+        """X coordinate of the current cell's center in the current block's local coordinate system"""
+        return CellCoordinates.cell_extents[0] * (
+            tcast.as_numeric(block.ci_min[0] + DEFAULTS.spatial_counters[0])
+            + sp.Rational(1, 2)
+        )
+
+    @expandable("cell.local_y")
+    @staticmethod
+    def local_y():
+        """Y coordinate of the current cell's center in the current block's local coordinate system"""
+        return CellCoordinates.cell_extents[1] * (
+            tcast.as_numeric(block.ci_min[1] + DEFAULTS.spatial_counters[1])
+            + sp.Rational(1, 2)
+        )
+
+    @expandable("cell.local_z")
+    @staticmethod
+    def local_z():
+        """Z coordinate of the current cell's center in the current block's local coordinate system"""
+        return CellCoordinates.cell_extents[2] * (
+            tcast.as_numeric(block.ci_min[2] + DEFAULTS.spatial_counters[2])
+            + sp.Rational(1, 2)
+        )
+
+    @expandable("cell.dx")
+    @staticmethod
+    def dx():
+        """Size of this cell in x-direction"""
+        return CellCoordinates.cell_extents[0]
+
+    @expandable("cell.dy")
+    @staticmethod
+    def dy():
+        """Size of this cell in y-direction"""
+        return CellCoordinates.cell_extents[1]
+
+    @expandable("cell.dz")
+    @staticmethod
+    def dz():
+        """Size of this cell in z-direction"""
+        return CellCoordinates.cell_extents[2]
+
+
+cell = CellCoordinates
+
+
+class CellIndex:
+    """Represents the index of the current cell in the global and block-local cell grids."""
+
+    @expandable("cell_index.x_global")
+    @staticmethod
+    def x_global():
+        """X component of the current cell's index in the global cell grid on the current block's refinement level."""
+        return (
+            BlockCellBoundingBox.cell_bb_min[0]
+            + block.ci_min[0]
+            + DEFAULTS.spatial_counters[0]
+        )
+
+    @expandable("cell_index.y_global")
+    @staticmethod
+    def y_global():
+        """Y component of the current cell's index in the global cell grid on the current block's refinement level."""
+        return (
+            BlockCellBoundingBox.cell_bb_min[1]
+            + block.ci_min[1]
+            + DEFAULTS.spatial_counters[1]
+        )
+
+    @expandable("cell_index.z_global")
+    @staticmethod
+    def z_global():
+        """Z component of the current cell's index in the global cell grid on the current block's refinement level."""
+        return (
+            BlockCellBoundingBox.cell_bb_min[2]
+            + block.ci_min[2]
+            + DEFAULTS.spatial_counters[2]
+        )
+
+    @expandable("cell_index.x_local")
+    @staticmethod
+    def x_local():
+        """X component of the current cell's index in the current block's local cell grid."""
+        return block.ci_min[0] + DEFAULTS.spatial_counters[0]
+
+    @expandable("cell_index.y_local")
+    @staticmethod
+    def y_local():
+        """Y component of the current cell's index in the current block's local cell grid."""
+        return block.ci_min[1] + DEFAULTS.spatial_counters[1]
+
+    @expandable("cell_index.z_local")
+    @staticmethod
+    def z_local():
+        """Z component of the current cell's index in the current block's local cell grid."""
+        return block.ci_min[2] + DEFAULTS.spatial_counters[2]
+
+
+cell_index = CellIndex
diff --git a/tests/BasicLbmScenarios/CMakeLists.txt b/tests/BasicLbmScenarios/CMakeLists.txt
new file mode 100644
index 0000000000000000000000000000000000000000..58dfde5a5c2de2ce2901c0efd94b4eefcf11104e
--- /dev/null
+++ b/tests/BasicLbmScenarios/CMakeLists.txt
@@ -0,0 +1,44 @@
+
+set( TestIDs 
+    FullyPeriodic
+    MirroredHalfChannel
+    FreeSlipPipe
+)
+
+add_executable( TestBasicLbmScenariosCPU TestBasicLbmScenarios.cpp )
+walberla_generate_sources( TestBasicLbmScenariosCPU SCRIPTS LbmAlgorithms.py SCRIPT_ARGS --target=cpu )
+target_link_libraries( TestBasicLbmScenariosCPU PRIVATE walberla::core walberla::blockforest walberla::field walberla::geometry walberla::experimental )
+add_dependencies( SfgTests TestBasicLbmScenariosCPU )
+
+foreach( TestID ${TestIDs} )
+    add_test( NAME "BasicLbmScenarios - CPU - ${TestID}" COMMAND TestBasicLbmScenariosCPU ${TestID} )
+endforeach()
+
+
+if( $CACHE{WALBERLA_BUILD_WITH_CUDA} )
+    set( _codegen_suffixes hpp cu )
+
+    add_executable( TestBasicLbmScenariosCUDA TestBasicLbmScenarios.cpp )
+    walberla_generate_sources( TestBasicLbmScenariosCUDA SCRIPTS LbmAlgorithms.py SCRIPT_ARGS --target=cuda FILE_EXTENSIONS ${_codegen_suffixes} )
+    target_link_libraries( TestBasicLbmScenariosCUDA PRIVATE walberla::core walberla::blockforest walberla::field walberla::gpu walberla::geometry walberla::experimental CUDA::cudart )
+
+    add_dependencies( SfgTests TestBasicLbmScenariosCUDA )
+
+    foreach( TestID ${TestIDs} )
+        add_test( NAME "BasicLbmScenarios - CUDA - ${TestID}" COMMAND TestBasicLbmScenariosCUDA ${TestID} )
+    endforeach()
+endif()
+
+if( $CACHE{WALBERLA_BUILD_WITH_HIP} )
+    set( _codegen_suffixes hpp hip )
+
+    add_executable( TestBasicLbmScenariosHIP TestBasicLbmScenarios.cpp )
+    walberla_generate_sources( TestBasicLbmScenariosHIP SCRIPTS LbmAlgorithms.py SCRIPT_ARGS --target=hip FILE_EXTENSIONS ${_codegen_suffixes} )
+    target_link_libraries( TestBasicLbmScenariosHIP PRIVATE walberla::core walberla::blockforest walberla::field walberla::gpu walberla::geometry walberla::experimental )
+
+    add_dependencies( SfgTests TestBasicLbmScenariosHIP )
+
+    foreach( TestID ${TestIDs} )
+        add_test( NAME "BasicLbmScenarios - HIP - ${TestID}" COMMAND TestBasicLbmScenariosHIP ${TestID} )
+    endforeach()
+endif()
diff --git a/tests/BasicLbmScenarios/LbmAlgorithms.py b/tests/BasicLbmScenarios/LbmAlgorithms.py
new file mode 100644
index 0000000000000000000000000000000000000000..eefe063997ded8cbdb183b1244336479f8514e8e
--- /dev/null
+++ b/tests/BasicLbmScenarios/LbmAlgorithms.py
@@ -0,0 +1,119 @@
+import argparse
+
+import sympy as sp
+from pystencilssfg import SourceFileGenerator
+
+from pystencils import fields, Field, Target
+from lbmpy import (
+    LBStencil,
+    Stencil,
+    LBMConfig,
+    LBMOptimisation,
+    create_lb_update_rule,
+    ForceModel,
+)
+from lbmpy.macroscopic_value_kernels import macroscopic_values_setter
+
+from walberla.codegen import Sweep, get_build_config
+from walberla.codegen.boundaries import NoSlip, FreeSlip
+
+from walberla.codegen.build_config import DEBUG_MOCK_CMAKE
+
+DEBUG_MOCK_CMAKE.use_hip_default()
+
+with SourceFileGenerator(keep_unknown_argv=True) as sfg:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("-t", "--target", dest="target", default="cpu", type=str)
+
+    args = parser.parse_args(sfg.context.argv)
+
+    build_config = get_build_config(sfg)
+
+    match args.target:
+        case "cpu":
+            build_config.override.target = Target.CurrentCPU
+            sfg.code("#define LBM_SCENARIOS_CPU_BUILD true")
+        case "hip":
+            build_config.override.target = Target.HIP
+            sfg.code("#define LBM_SCENARIOS_GPU_BUILD true")
+        case "cuda":
+            build_config.override.target = Target.CUDA
+            sfg.code("#define LBM_SCENARIOS_GPU_BUILD true")
+        case _:
+            raise ValueError(f"Unexpected target id: {args.target}")
+
+    sfg.namespace("BasicLbmScenarios::gen")
+
+    stencil = LBStencil(Stencil.D3Q19)
+    d, q = stencil.D, stencil.Q
+    f: Field
+    f_tmp: Field
+    f, f_tmp, rho, u = fields(
+        f"f({q}), f_tmp({q}), rho, u({d}): double[{d}D]", layout="fzyx"
+    )  # type: ignore
+    force = sp.symbols(f"F_:{d}")
+
+    stencil_name = stencil.name
+    sfg.include(f"stencil/{stencil_name}.h")
+    sfg.code(f"using LbStencil = walberla::stencil::{stencil_name};")
+
+    lbm_config = LBMConfig(
+        stencil=stencil,
+        output={"density": rho, "velocity": u},
+        force=force,
+        force_model=ForceModel.HE,
+    )
+    lbm_opt = LBMOptimisation(
+        symbolic_field=f,
+        symbolic_temporary_field=f_tmp,
+    )
+
+    lb_update = create_lb_update_rule(lbm_config=lbm_config, lbm_optimisation=lbm_opt)
+    assert lb_update is not None
+
+    with sfg.namespace("bulk"):
+        lb_update_sweep = Sweep("LbStreamCollide", lb_update)
+        lb_update_sweep.swap_fields(f, f_tmp)
+        sfg.generate(lb_update_sweep)
+
+        lb_init = macroscopic_values_setter(
+            lb_update.method,
+            density=rho.center,
+            velocity=u.center_vector,
+            pdfs=f,
+            set_pre_collision_pdfs=True
+        )
+        lb_init_sweep = Sweep("LbInitFromFields", lb_init)
+        sfg.generate(lb_init_sweep)
+
+        lb_init_constant = macroscopic_values_setter(
+            lb_update.method,
+            density=sp.Symbol("density"),
+            velocity=sp.symbols(f"velocity_:{d}"),
+            pdfs=f,
+            set_pre_collision_pdfs=True
+        )
+        lb_init_constant_sweep = Sweep("LbInitConstant", lb_init_constant)
+        sfg.generate(lb_init_constant_sweep)
+
+    with sfg.namespace("bc_grid_aligned"):
+        sfg.generate(NoSlip("NoSlipTop", lb_update.method, f, wall_orientation=(0, 0, 1)))
+
+        sfg.generate(NoSlip("NoSlipBottom", lb_update.method, f, wall_orientation=(0, 0, -1)))
+
+        sfg.generate(
+            FreeSlip("FreeSlipTop", lb_update.method, f, wall_orientation=(0, 0, 1))
+        )
+
+        sfg.generate(
+            FreeSlip("FreeSlipBottom", lb_update.method, f, wall_orientation=(0, 0, -1))
+        )
+
+    with sfg.namespace("bc_sparse"):
+        irreg_freeslip = FreeSlip(
+            "FreeSlipIrregular",
+            lb_update.method,
+            f,
+            wall_orientation=FreeSlip.IRREGULAR,
+        )
+        sfg.generate(irreg_freeslip)
diff --git a/tests/BasicLbmScenarios/SimDomain.hpp b/tests/BasicLbmScenarios/SimDomain.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..4417699a8d2eec630f684ce46755fee0fecd45dd
--- /dev/null
+++ b/tests/BasicLbmScenarios/SimDomain.hpp
@@ -0,0 +1,274 @@
+#pragma once
+
+#include "blockforest/all.h"
+
+#include "core/all.h"
+
+#include "field/all.h"
+#include "field/communication/StencilRestrictedPackInfo.h"
+
+#include "walberla/experimental/Sweep.hpp"
+
+#include "stencil/Directions.h"
+
+#include <memory>
+
+#include "gen/LbmAlgorithms.hpp"
+
+#if defined(LBM_SCENARIOS_GPU_BUILD)
+#   include "gpu/AddGPUFieldToStorage.h"
+#   include "gpu/FieldCopy.h"
+#   include "gpu/GPUField.h"
+#   include "gpu/GPUWrapper.h"
+#   include "gpu/HostFieldAllocator.h"
+#   include "gpu/communication/MemcpyPackInfo.h"
+#include "gpu/communication/GPUPackInfo.h"
+#   include "gpu/communication/UniformGPUScheme.h"
+#endif
+
+namespace BasicLbmScenarios
+{
+
+using namespace walberla;
+using namespace walberla::experimental;
+
+using PdfField_T    = field::GhostLayerField< real_t, gen::LbStencil::Q >;
+using ScalarField_T = field::GhostLayerField< real_t, 1 >;
+using VectorField_T = field::GhostLayerField< real_t, gen::LbStencil::D >;
+using FlagField_T   = FlagField< uint8_t >;
+
+using CpuCommScheme   = blockforest::communication::UniformBufferedScheme< gen::LbStencil >;
+using CpuPdfsPackInfo = field::communication::StencilRestrictedPackInfo< PdfField_T, gen::LbStencil >;
+
+#if defined(LBM_SCENARIOS_GPU_BUILD)
+using CommonGpuField = gpu::GPUField< PdfField_T::value_type >;
+
+using GpuCommScheme   = gpu::communication::UniformGPUScheme< gen::LbStencil >;
+// using GpuPdfsPackInfo = gpu::communication::MemcpyPackInfo< CommonGpuField >;
+using GpuPdfsPackInfo = gpu::communication::GPUPackInfo< CommonGpuField >;
+#endif
+
+struct SimDomain
+{
+   const std::shared_ptr< StructuredBlockForest > blocks;
+
+   struct
+   {
+      const BlockDataID pdfsId;
+      const BlockDataID rhoId;
+      const BlockDataID uId;
+      const BlockDataID flagFieldId;
+   } cpuFields;
+
+   CpuCommScheme commCpu;
+
+#if defined(LBM_SCENARIOS_GPU_BUILD)
+   struct
+   {
+      const BlockDataID pdfsId;
+      const BlockDataID rhoId;
+      const BlockDataID uId;
+   } gpuFields;
+
+   // GpuCommScheme commGpu;
+
+   void initFromFields(const Vector3< real_t > force)
+   {
+      gen::bulk::LbInitFromFields initialize{ gpuFields.pdfsId, gpuFields.rhoId, gpuFields.uId, force };
+
+      for (auto& b : *blocks)
+      {
+         initialize(&b);
+      }
+
+      wait();
+   }
+
+   void initConstant(const real_t rho, const Vector3< real_t > u, const Vector3< real_t > force)
+   {
+      gen::bulk::LbInitConstant initialize{ gpuFields.pdfsId, force, rho, u };
+
+      for (auto& b : *blocks)
+      {
+         initialize(&b);
+      }
+
+      wait();
+   }
+
+   gen::bulk::LbStreamCollide streamCollideSweep(const real_t omega, const Vector3< real_t > force)
+   {
+      return { gpuFields.pdfsId, gpuFields.rhoId, gpuFields.uId, force, omega };
+   }
+
+   auto
+   freeSlipBottom() {
+      return sweep::BorderSweep< stencil::Direction::B, gen::bc_grid_aligned::FreeSlipBottom > { blocks, gen::bc_grid_aligned::FreeSlipBottom { gpuFields.pdfsId } };
+   }
+
+   auto
+   noSlipTop() {
+      return sweep::BorderSweep< stencil::Direction::T, gen::bc_grid_aligned::NoSlipTop > { blocks, gen::bc_grid_aligned::NoSlipTop { gpuFields.pdfsId } };
+   }
+
+   auto
+   irregularFreeSlipFactory() {
+      return gen::bc_sparse::FreeSlipIrregularFactory(blocks, gpuFields.pdfsId);
+   }
+
+   void wait() { WALBERLA_GPU_CHECK(gpuDeviceSynchronize()); }
+
+   void syncGhostLayers()
+   {
+      // WALBERLA_GPU_CHECK(gpuPeekAtLastError());
+      commCpu();
+   }
+
+   void fields2host()
+   {
+      wait();
+      gpu::fieldCpy< PdfField_T, CommonGpuField >(blocks, cpuFields.pdfsId, gpuFields.pdfsId);
+      gpu::fieldCpy< ScalarField_T, CommonGpuField >(blocks, cpuFields.rhoId, gpuFields.rhoId);
+      gpu::fieldCpy< VectorField_T, CommonGpuField >(blocks, cpuFields.uId, gpuFields.uId);
+   }
+
+   void fields2device()
+   {
+      wait();
+      gpu::fieldCpy< CommonGpuField, PdfField_T >(blocks, gpuFields.pdfsId, cpuFields.pdfsId);
+      gpu::fieldCpy< CommonGpuField, ScalarField_T >(blocks, gpuFields.rhoId, cpuFields.rhoId);
+      gpu::fieldCpy< CommonGpuField, VectorField_T >(blocks, gpuFields.uId, cpuFields.uId);
+   }
+
+#else
+
+   void initFromFields(const Vector3< real_t > force)
+   {
+      gen::bulk::LbInitFromFields initialize{ cpuFields.pdfsId, cpuFields.rhoId, cpuFields.uId, force };
+
+      for (auto& b : *blocks)
+      {
+         initialize(&b);
+      }
+   }
+
+   void initConstant(const real_t rho, const Vector3< real_t > u, const Vector3< real_t > force)
+   {
+      gen::bulk::LbInitConstant initialize{ cpuFields.pdfsId, force, rho, u };
+
+      for (auto& b : *blocks)
+      {
+         initialize(&b);
+      }
+   }
+
+   gen::bulk::LbStreamCollide streamCollideSweep(const real_t omega, const Vector3< real_t > force)
+   {
+      return { cpuFields.pdfsId, cpuFields.rhoId, cpuFields.uId, force, omega };
+   }
+
+   auto
+   freeSlipTop() {
+      return sweep::BorderSweep< stencil::Direction::T, gen::bc_grid_aligned::FreeSlipTop > { blocks, gen::bc_grid_aligned::FreeSlipTop { cpuFields.pdfsId } };
+   }
+
+   auto
+   freeSlipBottom() {
+      return sweep::BorderSweep< stencil::Direction::B, gen::bc_grid_aligned::FreeSlipBottom > { blocks, gen::bc_grid_aligned::FreeSlipBottom { cpuFields.pdfsId } };
+   }
+
+   auto
+   noSlipTop() {
+      return sweep::BorderSweep< stencil::Direction::T, gen::bc_grid_aligned::NoSlipTop > { blocks, gen::bc_grid_aligned::NoSlipTop { cpuFields.pdfsId } };
+   }
+
+   auto irregularFreeSlipFactory() {
+      return gen::bc_sparse::FreeSlipIrregularFactory(blocks, cpuFields.pdfsId);
+   }
+
+   void syncGhostLayers() { commCpu(); }
+
+   void wait() { /* NOP */ }
+
+   void fields2host() { /* NOP */ }
+   void fields2device() { /* NOP */ }
+
+#endif
+
+   void forAllBlocks(std::function< void(IBlock&) > func)
+   {
+      for (auto& block : *blocks)
+         func(block);
+   }
+
+   void forAllCells(std::function< void(Cell) > func)
+   {
+      for (uint_t z = 0; z < blocks->getNumberOfZCellsPerBlock(); ++z)
+         for (uint_t y = 0; y < blocks->getNumberOfYCellsPerBlock(); ++y)
+            for (uint_t x = 0; x < blocks->getNumberOfXCellsPerBlock(); ++x)
+               func({ x, y, z });
+   }
+};
+
+struct SimDomainBuilder
+{
+   std::array< uint_t, 3 > blocks;
+   std::array< uint_t, 3 > cellsPerBlock;
+   std::array< bool, 3 > periodic;
+
+   SimDomain build()
+   {
+      auto sbfs =
+         blockforest::createUniformBlockGrid(blocks[0], blocks[1], blocks[2], cellsPerBlock[0], cellsPerBlock[1],
+                                             cellsPerBlock[2], 1.0, true, periodic[0], periodic[1], periodic[2]);
+
+#if defined(LBM_SCENARIOS_GPU_BUILD)
+      auto hostAlloc = make_shared< gpu::HostFieldAllocator< PdfField_T::value_type > >();
+#else
+      auto hostAlloc = make_shared< field::StdFieldAlloc< PdfField_T::value_type > >();
+#endif
+
+      const BlockDataID pdfsId      = field::addToStorage< PdfField_T >(sbfs, "f", real_c(0.0), field::fzyx, 1, hostAlloc);
+      const BlockDataID rhoId       = field::addToStorage< ScalarField_T >(sbfs, "rho", real_c(0.0), field::fzyx, 1, hostAlloc);
+      const BlockDataID uId         = field::addToStorage< VectorField_T >(sbfs, "u", real_c(0.0), field::fzyx, 1, hostAlloc);
+      const BlockDataID flagFieldId = field::addFlagFieldToStorage< FlagField_T >(sbfs, "flagField");
+
+      CpuCommScheme commCpu{ sbfs };
+      auto pdfsPackInfo = std::make_shared< CpuPdfsPackInfo >(pdfsId);
+      commCpu.addPackInfo(pdfsPackInfo);
+
+#if defined(LBM_SCENARIOS_GPU_BUILD)
+      const BlockDataID pdfsIdGpu = gpu::addGPUFieldToStorage< PdfField_T >(sbfs, pdfsId, "f_gpu");
+      const BlockDataID rhoIdGpu  = gpu::addGPUFieldToStorage< ScalarField_T >(sbfs, rhoId, "rho_gpu");
+      const BlockDataID uIdGpu    = gpu::addGPUFieldToStorage< VectorField_T >(sbfs, uId, "u_gpu");
+
+      // GpuCommScheme commGpu{ sbfs };
+      // auto gpuPdfsPackInfo = std::make_shared< GpuPdfsPackInfo >(pdfsIdGpu);
+      // commGpu.addPackInfo(gpuPdfsPackInfo);
+      auto gpuPdfsPackInfo = std::make_shared< GpuPdfsPackInfo >(pdfsIdGpu);
+      commCpu.addPackInfo(gpuPdfsPackInfo);
+#endif
+
+      return
+      {
+         .blocks = sbfs, //
+         .cpuFields = { //
+            .pdfsId = pdfsId,
+            .rhoId = rhoId,
+            .uId = uId,
+            .flagFieldId = flagFieldId
+         }, //
+         .commCpu = commCpu, //
+#if defined(LBM_SCENARIOS_GPU_BUILD)
+         .gpuFields = { //
+            .pdfsId = pdfsIdGpu,
+            .rhoId = rhoIdGpu,
+            .uId = uIdGpu
+         },
+         // .commGpu = commGpu
+#endif
+      };
+   }
+};
+
+} // namespace BasicLbmScenarios
diff --git a/tests/BasicLbmScenarios/TestBasicLbmScenarios.cpp b/tests/BasicLbmScenarios/TestBasicLbmScenarios.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..974e5f28b914698aef91aa6c73604447616be0c9
--- /dev/null
+++ b/tests/BasicLbmScenarios/TestBasicLbmScenarios.cpp
@@ -0,0 +1,273 @@
+
+#include "blockforest/all.h"
+
+#include "core/all.h"
+
+#include "geometry/all.h"
+
+#include "vtk/all.h"
+
+#include <array>
+#include <iostream>
+#include <map>
+
+#include "SimDomain.hpp"
+
+namespace BasicLbmScenarios
+{
+using namespace walberla;
+
+using TestFunction = std::function< void(mpi::Environment&) >;
+
+/**
+ * Fully periodic force-driven flow.
+ * The velocity in each cell should be steadily increasing.
+ */
+void fullyPeriodic(mpi::Environment& env)
+{
+   SimDomain dom{ SimDomainBuilder{
+      .blocks = { 1, 1, 1 }, .cellsPerBlock = { 32, 32, 32 }, .periodic = { true, true, true } }
+                     .build() };
+
+   const Vector3< real_t > force{ 0.005, 0., 0. };
+
+   dom.initConstant(1.0, Vector3< real_t >{ 0.0 }, force);
+
+   auto streamCollide = dom.streamCollideSweep(1.0, force);
+
+   for (uint_t t = 0; t < 10; ++t)
+   {
+      dom.forAllBlocks([&](IBlock& b) { streamCollide(&b); });
+      dom.syncGhostLayers();
+
+      dom.fields2host();
+
+      dom.forAllBlocks([&](auto& block) {
+         const VectorField_T& velField = *block.template getData< VectorField_T >(dom.cpuFields.uId);
+
+         dom.forAllCells([&](Cell c) {
+            real_t expected{ real_c(t) * force[0] };
+            real_t actual{ velField.get(c, 0) };
+            WALBERLA_CHECK_FLOAT_EQUAL(expected, actual);
+         });
+      });
+   }
+}
+
+/**
+ * Periodic channel flow with a no-slip boundary at the top
+ * and a symmetry plane at the bottom implemented using the free-slip boundary.
+ * Flow is governed by the Hagen-Poiseuille-law,
+ * with the maximum at the bottom.
+ */
+void mirroredHalfChannel(mpi::Environment& env)
+{
+   size_t zCells{ 64 };
+
+   SimDomain dom{ SimDomainBuilder{
+      .blocks = { 1, 1, 1 }, .cellsPerBlock = { 4, 4, zCells }, .periodic = { true, true, false } }
+                     .build() };
+
+   /* Hagen-Poiseuille-law in lattice units */
+   const real_t u_max{ 0.025 };
+   const real_t reynolds{ 10.0 };
+   const real_t L_z{ real_c(2 * zCells) };
+   const real_t radius{ L_z / 2.0 };
+   const real_t r_squared{ radius * radius };
+   const real_t lattice_viscosity{ L_z * u_max / reynolds };
+   const real_t omega{ 2. / (6. * lattice_viscosity + 1.) };
+   const real_t acceleration{ (u_max * 2.0 * lattice_viscosity) / r_squared };
+
+   const Vector3< real_t > force{ acceleration, 0., 0. };
+
+   auto velocityProfile = [&](real_t z) -> real_t {
+      return acceleration / (2.0 * lattice_viscosity) * (r_squared - z * z);
+   };
+
+   dom.forAllBlocks([&](IBlock& block) {
+      ScalarField_T& densityField  = *block.getData< ScalarField_T >(dom.cpuFields.rhoId);
+      VectorField_T& velocityField = *block.getData< VectorField_T >(dom.cpuFields.uId);
+
+      dom.forAllCells([&](Cell c) {
+         Cell globalCell{ c };
+         dom.blocks->transformBlockLocalToGlobalCell(globalCell, block);
+         Vector3< real_t > cellCenter{ dom.blocks->getCellCenter(globalCell) };
+
+         densityField.get(c)     = 1.0;
+         velocityField.get(c, 0) = velocityProfile(cellCenter[2]);
+         velocityField.get(c, 1) = velocityField.get(c, 2) = 0.;
+      });
+   });
+
+   dom.fields2device();
+   dom.initFromFields(force);
+
+   auto streamCollide  = dom.streamCollideSweep(omega, force);
+   auto noSlipTop      = dom.noSlipTop();
+   auto freeSlipBottom = dom.freeSlipBottom();
+   auto velOutput      = field::createVTKOutput< VectorField_T >(dom.cpuFields.uId, *dom.blocks, "vel");
+
+   for (uint_t t = 0; t < 50; ++t)
+   {
+      dom.forAllBlocks([&](IBlock& b) { streamCollide(&b); });
+      dom.syncGhostLayers();
+
+      dom.fields2host();
+
+      dom.forAllBlocks([&](auto& block) {
+         const VectorField_T& velField = *block.template getData< VectorField_T >(dom.cpuFields.uId);
+
+         dom.forAllCells([&](Cell c) {
+            Cell globalCell{ c };
+            dom.blocks->transformBlockLocalToGlobalCell(globalCell, block);
+            Vector3< real_t > cellCenter{ dom.blocks->getCellCenter(globalCell) };
+
+            real_t expected{ velocityProfile(cellCenter[2]) };
+            real_t actual{ velField.get(c, 0) };
+            WALBERLA_CHECK_FLOAT_EQUAL_EPSILON(expected, actual, 1e-5);
+         });
+      });
+
+      velOutput();
+
+      dom.forAllBlocks([&](IBlock& b) {
+         noSlipTop(&b);
+         freeSlipBottom(&b);
+      });
+   }
+}
+
+/**
+ * Pipe flow with circular cross-section and free-slip walls.
+ * The pipe flow is initialized with a constant velocity in x-direction.
+ * As free-slip walls do not impose any friction, velocity should remain constant in time.
+ */
+void freeSlipPipe(mpi::Environment& env)
+{
+   SimDomain dom{ SimDomainBuilder{
+      .blocks = { 1, 1, 1 }, .cellsPerBlock = { 4, 32, 32 }, .periodic = { true, false, false } }
+                     .build() };
+
+   const FlagUID fluidFlagUid{ "Fluid" };
+   const FlagUID freeSlipFlagUID{ "FreeSlip" };
+
+   const CellInterval allCells{ { 0, 0, 0 },
+                                { dom.blocks->getNumberOfXCellsPerBlock() - 1,
+                                  dom.blocks->getNumberOfYCellsPerBlock() - 1,
+                                  dom.blocks->getNumberOfZCellsPerBlock() - 1 } };
+   const CellInterval allCellsWithGl{ { -1, 0, 0 },
+                                      { dom.blocks->getNumberOfXCellsPerBlock(),
+                                        dom.blocks->getNumberOfYCellsPerBlock() - 1,
+                                        dom.blocks->getNumberOfZCellsPerBlock() - 1 } };
+
+   const real_t pipeRadius{ 14.0 };
+   const Vector3< real_t > pipeAnchor{ 0.0, 16.0, 16.0 };
+   const real_t maxVelocity{ 0.02 };
+   const Vector3< real_t > force{ 0., 0., 0. };
+
+   dom.forAllBlocks([&](IBlock& block) {
+      FlagField_T& flagField = *block.getData< FlagField_T >(dom.cpuFields.flagFieldId);
+      const uint8_t freeSlipFlag{ flagField.getOrRegisterFlag(freeSlipFlagUID) };
+
+      ScalarField_T& densField = *block.getData< ScalarField_T >(dom.cpuFields.rhoId);
+      VectorField_T& velField  = *block.getData< VectorField_T >(dom.cpuFields.uId);
+
+      for (Cell c : allCellsWithGl)
+      {
+         Cell globalCell{ c };
+         dom.blocks->transformBlockLocalToGlobalCell(globalCell, block);
+         Vector3< real_t > cellCenter{ dom.blocks->getCellCenter(globalCell) };
+         cellCenter[0] = 0.0;
+
+         Vector3< real_t > initVelocity;
+         real_t radialDistance = (cellCenter - pipeAnchor).length();
+         if (radialDistance > pipeRadius)
+         {
+            flagField.addFlag(c, freeSlipFlag);
+            initVelocity = Vector3< real_t >{ NAN };
+         }
+         else { initVelocity = Vector3< real_t >{ maxVelocity, 0., 0. }; }
+
+         densField.get(c, 0) = 1.0;
+         velField.get(c, 0)  = initVelocity[0];
+         velField.get(c, 1)  = initVelocity[1];
+         velField.get(c, 2)  = initVelocity[2];
+      }
+   });
+
+   geometry::setNonBoundaryCellsToDomain< FlagField_T >(*dom.blocks, dom.cpuFields.flagFieldId, fluidFlagUid);
+
+   auto flagsOutput = field::createVTKOutput< FlagField_T >(dom.cpuFields.flagFieldId, *dom.blocks, "flags");
+   flagsOutput();
+
+   dom.fields2device();
+   dom.initFromFields(force);
+
+   gen::bc_sparse::FreeSlipIrregular freeSlip{ dom.irregularFreeSlipFactory().fromFlagField< FlagField_T >(
+      dom.cpuFields.flagFieldId, freeSlipFlagUID, fluidFlagUid) };
+
+   auto streamCollide = dom.streamCollideSweep(1.0, force);
+
+   auto velOutput = field::createVTKOutput< VectorField_T >(dom.cpuFields.uId, *dom.blocks, "vel");
+
+   for (uint_t i = 0; i < 10; ++i)
+   {
+      dom.forAllBlocks([&](IBlock& block) { streamCollide(&block); });
+      dom.syncGhostLayers();
+
+      dom.fields2host();
+
+      dom.forAllBlocks([&](IBlock& block) {
+         VectorField_T& velField = *block.getData< VectorField_T >(dom.cpuFields.uId);
+         FlagField_T& flagField  = *block.getData< FlagField_T >(dom.cpuFields.flagFieldId);
+         uint8_t fluidFlag       = flagField.getFlag(fluidFlagUid);
+
+         dom.forAllCells([&](Cell c) {
+            if (flagField.isFlagSet(c, fluidFlag))
+            {
+               WALBERLA_CHECK_FLOAT_EQUAL(velField.get(c, 0), maxVelocity);
+               WALBERLA_CHECK_FLOAT_EQUAL(velField.get(c, 1), 0.);
+               WALBERLA_CHECK_FLOAT_EQUAL(velField.get(c, 2), 0.);
+            }
+         });
+      });
+
+      velOutput();
+
+      for (auto& block : *dom.blocks)
+      {
+         freeSlip(&block);
+      }
+   }
+
+   velOutput();
+}
+
+std::map< std::string, TestFunction > TESTS{
+   { "FullyPeriodic", fullyPeriodic },
+   { "MirroredHalfChannel", mirroredHalfChannel },
+   { "FreeSlipPipe", freeSlipPipe },
+};
+
+} // namespace BasicLbmScenarios
+
+int main(int argc, char** argv)
+{
+   walberla::mpi::Environment env{ argc, argv };
+
+   if (argc < 1)
+   {
+      std::cerr << "No Test ID was specified." << std::endl;
+      return -1;
+   }
+
+   std::string testId{ argv[1] };
+
+   if (auto entry = BasicLbmScenarios::TESTS.find(testId); entry != BasicLbmScenarios::TESTS.end())
+   {
+      std::get< BasicLbmScenarios::TestFunction >(*entry)(env);
+      return EXIT_SUCCESS;
+   }
+
+   WALBERLA_ABORT("Invalid test ID: " << testId);
+}
diff --git a/tests/BlockforestGeometry.py b/tests/BlockforestGeometry.py
deleted file mode 100644
index 09445e9e8be29f59556150ff6f9b0754ff040d21..0000000000000000000000000000000000000000
--- a/tests/BlockforestGeometry.py
+++ /dev/null
@@ -1,20 +0,0 @@
-from pystencils.session import *
-from pystencilssfg import SourceFileGenerator, SfgConfiguration
-from sfg_walberla import Sweep
-from sfg_walberla.symbolic import cell
-
-cfg = SfgConfiguration(
-    output_directory="out"
-)
-
-with SourceFileGenerator(cfg) as sfg:
-    f = ps.fields("f(3): [3D]")
-
-    asms = [
-        ps.Assignment(f.center(0), cell.x()),
-        ps.Assignment(f.center(1), cell.y()),
-        ps.Assignment(f.center(2), cell.z())
-    ]
-
-    sweep = Sweep("coordinates", asms)
-    sfg.generate(sweep)
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 72acde9c5dc7ca88b51038b3798c79810a812883..e27ee17385997b895b7db5f8f45f8928f0c5b0fb 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -1,11 +1,22 @@
 cmake_minimum_required( VERSION 3.24 )
-project( sfg-walberla-testsuite )
+project( sfg-walberla-testsuite LANGUAGES CXX )
+
+if( $CACHE{WALBERLA_BUILD_WITH_CUDA} )
+    enable_language( CUDA )
+endif()
+
+if( $CACHE{WALBERLA_BUILD_WITH_HIP} )
+    enable_language( HIP )
+endif()
 
 set(WALBERLA_BUILD_TESTS OFF CACHE BOOL "")
 set(WALBERLA_BUILD_BENCHMARKS OFF CACHE BOOL "")
 set(WALBERLA_BUILD_SHOWCASES OFF CACHE BOOL "")
 set(WALBERLA_BUILD_TUTORIALS OFF CACHE BOOL "")
 
+set(CMAKE_CXX_STANDARD 20)
+set(CMAKE_CXX_STANDARD_REQUIRED)
+
 include(FetchContent)
 
 FetchContent_Declare(
@@ -16,11 +27,31 @@ FetchContent_Declare(
 message( STATUS "Fetching waLBerla sources (this might take a while)..." )
 FetchContent_MakeAvailable(walberla)
 
+# Workarounds for CUDA and HIP library dependencies
+if( $CACHE{WALBERLA_BUILD_WITH_HIP})
+    find_package(hip REQUIRED)
+
+    target_link_libraries( walberla_core PUBLIC hip::host )
+    target_link_libraries( walberla_gpu PUBLIC hip::host )
+endif()
+
+if( $CACHE{WALBERLA_BUILD_WITH_CUDA})
+    find_package( CUDAToolkit REQUIRED )
+
+    target_link_libraries( walberla_core PUBLIC CUDA::cudart )
+    target_link_libraries( walberla_gpu PUBLIC CUDA::cudart )
+endif()
+
 add_subdirectory(${CMAKE_SOURCE_DIR}/.. ${CMAKE_BINARY_DIR}/sfg-walberla)
 
+walberla_codegen_venv_require( py-cpuinfo )
+walberla_codegen_venv_populate()
+
 #   Test Directories
 include(CTest)
 
 add_custom_target( SfgTests )
 
-add_subdirectory( FreeSlip )
+add_subdirectory( CodegenFeatures )
+add_subdirectory( BasicLbmScenarios )
+add_subdirectory( ${CMAKE_SOURCE_DIR}/../user_manual/examples ${CMAKE_CURRENT_BINARY_DIR}/user-manual-examples )
diff --git a/tests/CMakePresets.json b/tests/CMakePresets.json
index 48449c2d856200adfa38b7019aa2bcff44144615..6528f715d09c3d814ca1812c5883c22e1c506210 100644
--- a/tests/CMakePresets.json
+++ b/tests/CMakePresets.json
@@ -7,24 +7,34 @@
     },
     "configurePresets": [
         {
-            "name": "testsuite-dbg",
+            "name": "testsuite-cpu",
             "binaryDir": "${sourceDir}/build/${presetName}",
             "generator": "Ninja",
             "cacheVariables": {
-                "CMAKE_BUILD_TYPE": "Debug",
+                "CMAKE_BUILD_TYPE": "Release",
                 "WALBERLA_BUILD_TESTS": false
             }
-        }
-    ],
-   "testPresets": [
-    {
-        "name": "SFG Testsuite",
-        "configurePreset": "testsuite-dbg",
-        "filter": {
-            "include": {
-                "name": "TestSparseSweeps"
+        },
+        {
+            "name": "testsuite-cuda",
+            "binaryDir": "${sourceDir}/build/${presetName}",
+            "generator": "Ninja",
+            "cacheVariables": {
+                "CMAKE_BUILD_TYPE": "Release",
+                "WALBERLA_BUILD_TESTS": false,
+                "WALBERLA_BUILD_WITH_CUDA": true,
+                "CMAKE_CUDA_ARCHITECTURES": "native"
+            }
+        },
+        {
+            "name": "testsuite-hip",
+            "binaryDir": "${sourceDir}/build/${presetName}",
+            "generator": "Ninja",
+            "cacheVariables": {
+                "CMAKE_BUILD_TYPE": "Release",
+                "WALBERLA_BUILD_TESTS": false,
+                "WALBERLA_BUILD_WITH_HIP": true
             }
         }
-    }
-   ]
+    ]
 }
diff --git a/tests/CodegenFeatures/CMakeLists.txt b/tests/CodegenFeatures/CMakeLists.txt
new file mode 100644
index 0000000000000000000000000000000000000000..a559c85b0627e587702914df41406b86528be1b0
--- /dev/null
+++ b/tests/CodegenFeatures/CMakeLists.txt
@@ -0,0 +1,8 @@
+
+add_executable( TestBlockforestGeometry TestBlockforestGeometry.cpp )
+walberla_generate_sources( TestBlockforestGeometry SCRIPTS GeometryKernels.py )
+target_link_libraries( TestBlockforestGeometry PRIVATE walberla::core walberla::blockforest walberla::field walberla::experimental )
+
+add_dependencies( SfgTests TestBlockforestGeometry )
+
+add_test( NAME TestBlockforestGeometry COMMAND TestBlockforestGeometry )
diff --git a/tests/CodegenFeatures/GeometryKernels.py b/tests/CodegenFeatures/GeometryKernels.py
new file mode 100644
index 0000000000000000000000000000000000000000..3bd67068fc0039c3b4e465cdb7d2aea60601c271
--- /dev/null
+++ b/tests/CodegenFeatures/GeometryKernels.py
@@ -0,0 +1,57 @@
+import pystencils as ps
+from pystencilssfg import SourceFileGenerator
+from walberla.codegen import Sweep, get_build_config
+from walberla.codegen.build_config import DEBUG_MOCK_CMAKE
+from walberla.codegen.symbolic import cell, cell_index
+
+DEBUG_MOCK_CMAKE.use_cpu_default()
+
+with SourceFileGenerator() as sfg:
+    get_build_config(sfg).override.target = ps.Target.GenericCPU
+
+    out_real = ps.fields("out(3): double[3D]")
+    out_int = ps.fields("out(3): int64_t[3D]")
+
+    #   Global Cell Centers
+
+    asms = [
+        ps.Assignment(out_real.center(0), cell.x()),
+        ps.Assignment(out_real.center(1), cell.y()),
+        ps.Assignment(out_real.center(2), cell.z())
+    ]
+
+    sweep = Sweep("CellCentersGlobal", asms)
+    sfg.generate(sweep)
+
+    #   Local Cell Centers
+
+    asms = [
+        ps.Assignment(out_real.center(0), cell.local_x()),
+        ps.Assignment(out_real.center(1), cell.local_y()),
+        ps.Assignment(out_real.center(2), cell.local_z())
+    ]
+
+    sweep = Sweep("CellCentersLocal", asms)
+    sfg.generate(sweep)
+
+    #   Local Cell Indices
+
+    asms = [
+        ps.Assignment(out_int.center(0), cell_index.x_local()),
+        ps.Assignment(out_int.center(1), cell_index.y_local()),
+        ps.Assignment(out_int.center(2), cell_index.z_local())
+    ]
+
+    sweep = Sweep("CellIndicesLocal", asms)
+    sfg.generate(sweep)
+
+    #   Global Cell Indices
+
+    asms = [
+        ps.Assignment(out_int.center(0), cell_index.x_global()),
+        ps.Assignment(out_int.center(1), cell_index.y_global()),
+        ps.Assignment(out_int.center(2), cell_index.z_global())
+    ]
+
+    sweep = Sweep("CellIndicesGlobal", asms)
+    sfg.generate(sweep)
diff --git a/tests/CodegenFeatures/TestBlockforestGeometry.cpp b/tests/CodegenFeatures/TestBlockforestGeometry.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..31464974638dd0baa496c68492d3273c064ac6f1
--- /dev/null
+++ b/tests/CodegenFeatures/TestBlockforestGeometry.cpp
@@ -0,0 +1,160 @@
+#include "blockforest/all.h"
+
+#include "core/all.h"
+
+#include "field/all.h"
+
+#include "gen/GeometryKernels.hpp"
+#include "walberla/experimental/Sweep.hpp"
+
+using namespace walberla;
+namespace wex = walberla::experimental;
+
+using Int64Field = field::GhostLayerField< int64_t, 3 >;
+using RealField  = field::GhostLayerField< real_t, 3 >;
+
+int main(int argc, char** argv)
+{
+   Environment env{ argc, argv };
+
+   auto blocks = blockforest::createUniformBlockGrid(2, 2, 2, 4, 4, 4, 1.2);
+
+   auto intFieldId  = field::addToStorage< Int64Field >(blocks, "intField");
+   auto realFieldId = field::addToStorage< RealField >(blocks, "realField");
+
+   wex::sweep::SerialSweeper sweeper{ blocks };
+
+   {
+      CellCentersGlobal cellCenters{ blocks, realFieldId };
+      sweeper.sweep(cellCenters);
+
+      sweeper.sweep([&](IBlock& block) {
+         RealField& outp = *block.getData< RealField >(realFieldId);
+         sweeper.forAllCells([&](Cell c) {
+            Vector3< real_t > desired = blocks->getBlockLocalCellCenter(block, c);
+
+            WALBERLA_CHECK_FLOAT_EQUAL(outp.get(c, 0), desired[0]);
+            WALBERLA_CHECK_FLOAT_EQUAL(outp.get(c, 1), desired[1]);
+            WALBERLA_CHECK_FLOAT_EQUAL(outp.get(c, 2), desired[2]);
+         });
+      });
+   }
+
+   {
+      CellCentersLocal cellCenters{ blocks, realFieldId };
+      sweeper.sweep(cellCenters);
+
+      sweeper.sweep([&](IBlock& block) {
+         RealField& outp = *block.getData< RealField >(realFieldId);
+         AABB blockAbb = block.getAABB();
+         sweeper.forAllCells([&](Cell c) {
+            Vector3< real_t > desired = blocks->getBlockLocalCellCenter(block, c) - blockAbb.min();
+
+            WALBERLA_CHECK_FLOAT_EQUAL(outp.get(c, 0), desired[0]);
+            WALBERLA_CHECK_FLOAT_EQUAL(outp.get(c, 1), desired[1]);
+            WALBERLA_CHECK_FLOAT_EQUAL(outp.get(c, 2), desired[2]);
+         });
+      });
+   }
+
+   {
+    CellIndicesLocal localIndices{ intFieldId };
+      sweeper.sweep(localIndices);
+
+      sweeper.sweep([&](IBlock& block) {
+         Int64Field& outp = *block.getData< Int64Field >(intFieldId);
+         sweeper.forAllCells([&](Cell c) {
+            WALBERLA_CHECK_EQUAL(outp.get(c, 0), c[0]);
+            WALBERLA_CHECK_EQUAL(outp.get(c, 1), c[1]);
+            WALBERLA_CHECK_EQUAL(outp.get(c, 2), c[2]);
+         });
+      });
+   }
+
+   {
+    CellIndicesGlobal glocalIndices{ blocks, intFieldId };
+      sweeper.sweep(glocalIndices);
+
+      sweeper.sweep([&](IBlock& block) {
+         Int64Field& outp = *block.getData< Int64Field >(intFieldId);
+         sweeper.forAllCells([&](Cell c) {
+            Cell globalCell{ c };
+            blocks->transformBlockLocalToGlobalCell(globalCell, block);
+
+            WALBERLA_CHECK_EQUAL(outp.get(c, 0), globalCell[0]);
+            WALBERLA_CHECK_EQUAL(outp.get(c, 1), globalCell[1]);
+            WALBERLA_CHECK_EQUAL(outp.get(c, 2), globalCell[2]);
+         });
+      });
+   }
+
+   /* The same with cell intervals */
+
+   CellInterval ci { Cell {1, 2, 1}, Cell (3, 3, 2) };
+
+   {
+      CellCentersGlobal cellCenters{ blocks, realFieldId };
+      sweeper.sweep([&](IBlock * b){ cellCenters.runOnCellInterval(b, ci); });
+
+      sweeper.sweep([&](IBlock& block) {
+         RealField& outp = *block.getData< RealField >(realFieldId);
+         sweeper.forAllCells(ci, [&](Cell c) {
+            Vector3< real_t > desired = blocks->getBlockLocalCellCenter(block, c);
+
+            WALBERLA_CHECK_FLOAT_EQUAL(outp.get(c, 0), desired[0]);
+            WALBERLA_CHECK_FLOAT_EQUAL(outp.get(c, 1), desired[1]);
+            WALBERLA_CHECK_FLOAT_EQUAL(outp.get(c, 2), desired[2]);
+         });
+      });
+   }
+
+   {
+      CellCentersLocal cellCenters{ blocks, realFieldId };
+      sweeper.sweep([&](IBlock * b){ cellCenters.runOnCellInterval(b, ci); });
+
+      sweeper.sweep([&](IBlock& block) {
+         RealField& outp = *block.getData< RealField >(realFieldId);
+         AABB blockAbb = block.getAABB();
+         sweeper.forAllCells(ci, [&](Cell c) {
+            Vector3< real_t > desired = blocks->getBlockLocalCellCenter(block, c) - blockAbb.min();
+
+            WALBERLA_CHECK_FLOAT_EQUAL(outp.get(c, 0), desired[0]);
+            WALBERLA_CHECK_FLOAT_EQUAL(outp.get(c, 1), desired[1]);
+            WALBERLA_CHECK_FLOAT_EQUAL(outp.get(c, 2), desired[2]);
+         });
+      });
+   }
+
+   {
+    CellIndicesLocal localIndices{ intFieldId };
+      sweeper.sweep([&](IBlock * b){ localIndices.runOnCellInterval(b, ci); });
+
+      sweeper.sweep([&](IBlock& block) {
+         Int64Field& outp = *block.getData< Int64Field >(intFieldId);
+         sweeper.forAllCells(ci, [&](Cell c) {
+            WALBERLA_CHECK_EQUAL(outp.get(c, 0), c[0]);
+            WALBERLA_CHECK_EQUAL(outp.get(c, 1), c[1]);
+            WALBERLA_CHECK_EQUAL(outp.get(c, 2), c[2]);
+         });
+      });
+   }
+
+   {
+    CellIndicesGlobal glocalIndices{ blocks, intFieldId };
+    sweeper.sweep([&](IBlock * b){ glocalIndices.runOnCellInterval(b, ci); });
+
+      sweeper.sweep([&](IBlock& block) {
+         Int64Field& outp = *block.getData< Int64Field >(intFieldId);
+         sweeper.forAllCells(ci, [&](Cell c) {
+            Cell globalCell{ c };
+            blocks->transformBlockLocalToGlobalCell(globalCell, block);
+
+            WALBERLA_CHECK_EQUAL(outp.get(c, 0), globalCell[0]);
+            WALBERLA_CHECK_EQUAL(outp.get(c, 1), globalCell[1]);
+            WALBERLA_CHECK_EQUAL(outp.get(c, 2), globalCell[2]);
+         });
+      });
+   }
+
+   return EXIT_SUCCESS;
+}
\ No newline at end of file
diff --git a/tests/FreeSlip/CMakeLists.txt b/tests/FreeSlip/CMakeLists.txt
deleted file mode 100644
index e47cae2e1bef502e62a8b64dd2dea7f9fa396151..0000000000000000000000000000000000000000
--- a/tests/FreeSlip/CMakeLists.txt
+++ /dev/null
@@ -1,6 +0,0 @@
-add_executable( TestIrregularFreeSlip TestIrregularFreeSlip.cpp )
-walberla_generate_sources( TestIrregularFreeSlip SCRIPTS IrregularFreeSlip.py )
-target_link_libraries( TestIrregularFreeSlip core blockforest field geometry sfg_walberla )
-add_test( NAME TestIrregularFreeSlip COMMAND TestIrregularFreeSlip )
-
-add_dependencies( SfgTests TestIrregularFreeSlip )
diff --git a/tests/FreeSlip/IrregularFreeSlip.py b/tests/FreeSlip/IrregularFreeSlip.py
deleted file mode 100644
index 3fa69cf2042e054618db15ec1779945b9198214a..0000000000000000000000000000000000000000
--- a/tests/FreeSlip/IrregularFreeSlip.py
+++ /dev/null
@@ -1,54 +0,0 @@
-import sympy as sp
-from pystencilssfg import SourceFileGenerator
-
-from pystencils import fields, Field
-from lbmpy import LBStencil, Stencil, LBMConfig, LBMOptimisation, create_lb_update_rule
-from lbmpy.macroscopic_value_kernels import macroscopic_values_setter
-
-
-from sfg_walberla import Sweep
-from sfg_walberla.boundaries import FreeSlip
-
-with SourceFileGenerator() as sfg:
-    sfg.namespace("TestIrregularFreeSlip::gen")
-
-    stencil = LBStencil(Stencil.D3Q19)
-    d, q = stencil.D, stencil.Q
-    f: Field
-    f_tmp: Field
-    f, f_tmp, rho, u = fields(
-        f"f({q}), f_tmp({q}), rho, u({d}): double[{d}D]", layout="fzyx"
-    )  # type: ignore
-
-    stencil_name = stencil.name
-    sfg.include(f"stencil/{stencil_name}.h")
-    sfg.code(f"using LbStencil = walberla::stencil::{stencil_name};")
-
-    lbm_config = LBMConfig(
-        stencil=stencil,
-        output={"density": rho, "velocity": u},
-    )
-    lbm_opt = LBMOptimisation(
-        symbolic_field=f,
-        symbolic_temporary_field=f_tmp,
-    )
-
-    lb_update = create_lb_update_rule(lbm_config=lbm_config, lbm_optimisation=lbm_opt)
-    assert lb_update is not None
-
-    lb_update_sweep = Sweep("LbStreamCollide", lb_update)
-    lb_update_sweep.swap_fields(f, f_tmp)
-    sfg.generate(lb_update_sweep)
-
-    lb_init = macroscopic_values_setter(
-        lb_update.method,
-        density=sp.Symbol("density"),
-        velocity=u.center_vector,
-        pdfs=f,
-    )
-    lb_init_sweep = Sweep("LbInit", lb_init)
-    sfg.generate(lb_init_sweep)
-
-    irreg_freeslip = FreeSlip("FreeSlipIrregular", lb_update.method, f, wall_normal=FreeSlip.IRREGULAR)
-    sfg.generate(irreg_freeslip)
-
diff --git a/tests/FreeSlip/TestIrregularFreeSlip.cpp b/tests/FreeSlip/TestIrregularFreeSlip.cpp
deleted file mode 100644
index c08df34308cc4e8c64d02717cfe1c091c0ecb5a4..0000000000000000000000000000000000000000
--- a/tests/FreeSlip/TestIrregularFreeSlip.cpp
+++ /dev/null
@@ -1,147 +0,0 @@
-
-#include "core/all.h"
-#include "blockforest/all.h"
-
-#include "field/all.h"
-#include "field/communication/PackInfo.h"
-#include "field/communication/StencilRestrictedPackInfo.h"
-
-#include "geometry/all.h"
-
-#include "vtk/all.h"
-
-#include <array>
-
-#include "gen/IrregularFreeSlip.hpp"
-
-namespace TestIrregularFreeSlip
-{
-    using namespace walberla;
-
-    using PdfField_T = field::GhostLayerField<real_t, gen::LbStencil::Q>;
-    using ScalarField_T = field::GhostLayerField<real_t, 1>;
-    using VectorField_T = field::GhostLayerField<real_t, gen::LbStencil::D>;
-    using FlagField_T = FlagField<uint8_t>;
-    using CommScheme = blockforest::communication::UniformBufferedScheme<gen::LbStencil>;
-    using PdfsPackInfo = field::communication::StencilRestrictedPackInfo<PdfField_T, gen::LbStencil>;
-
-    /**
-     * Pipe flow with circular cross-section and free-slip walls.
-     * The pipe flow is initialized with a constant velocity in x-direction.
-     * As free-slip walls do not impose any friction, velocity should remain constant in time.
-     */
-    void freeSlipPipe(Environment &env)
-    {
-        std::array<uint_t, 3> blocks{1, 1, 1};
-        std::array<uint_t, 3> cpb{4, 32, 32};
-        std::array<bool, 3> periodic{true, false, false};
-
-        auto sbfs = blockforest::createUniformBlockGrid(
-            blocks[0], blocks[1], blocks[2],
-            cpb[0], cpb[1], cpb[2],
-            1.0,
-            true,
-            periodic[0], periodic[1], periodic[2]
-        );
-
-        BlockDataID pdfsId = field::addToStorage<PdfField_T>(sbfs, "f", real_c(0.0));
-        BlockDataID rhoId = field::addToStorage<ScalarField_T>(sbfs, "rho", real_c(0.0));
-        BlockDataID uId = field::addToStorage<VectorField_T>(sbfs, "u", real_c(0.0));
-
-        const BlockDataID flagFieldId = field::addFlagFieldToStorage<FlagField_T>(sbfs, "flagField");
-        const FlagUID fluidFlagUid{"Fluid"};
-        const FlagUID freeSlipFlagUID{"FreeSlip"};
-
-        const CellInterval allCells{{0, 0, 0}, {sbfs->getNumberOfXCellsPerBlock() - 1, sbfs->getNumberOfYCellsPerBlock() - 1, sbfs->getNumberOfZCellsPerBlock() - 1}};
-        const CellInterval allCellsWithGl{{-1, 0, 0}, {sbfs->getNumberOfXCellsPerBlock(), sbfs->getNumberOfYCellsPerBlock() - 1, sbfs->getNumberOfZCellsPerBlock() - 1}};
-
-        const real_t pipeRadius{14.0};
-        const Vector3<real_t> pipeAnchor{0.0, 16.0, 16.0};
-        const real_t maxVelocity{0.02};
-
-        for (auto &block : *sbfs)
-        {
-            FlagField_T &flagField = *block.getData<FlagField_T>(flagFieldId);
-            const uint8_t freeSlipFlag{flagField.getOrRegisterFlag(freeSlipFlagUID)};
-
-            VectorField_T &velField = *block.getData<VectorField_T>(uId);
-
-            for (Cell c : allCellsWithGl)
-            {
-                Cell globalCell{c};
-                sbfs->transformBlockLocalToGlobalCell(globalCell, block);
-                Vector3<real_t> cellCenter{sbfs->getCellCenter(globalCell)};
-                cellCenter[0] = 0.0;
-
-                Vector3<real_t> initVelocity;
-                real_t radialDistance = (cellCenter - pipeAnchor).length();
-                if (radialDistance > pipeRadius)
-                {
-                    flagField.addFlag(c, freeSlipFlag);
-                    initVelocity = Vector3<real_t>{NAN};
-                }
-                else
-                {
-                    initVelocity = Vector3<real_t>{maxVelocity, 0., 0.};
-                }
-
-                velField.get(c, 0) = initVelocity[0];
-                velField.get(c, 1) = initVelocity[1];
-                velField.get(c, 2) = initVelocity[2];
-            }
-        }
-
-        geometry::setNonBoundaryCellsToDomain<FlagField_T>(*sbfs, flagFieldId, fluidFlagUid);
-
-        auto flagsOutput = field::createVTKOutput<FlagField_T>(flagFieldId, *sbfs, "flags");
-        flagsOutput();
-
-        gen::LbInit initialize { pdfsId, uId, 1.0 };
-
-        for(auto& b : *sbfs){
-            initialize(&b);
-        }
-
-        gen::FreeSlipIrregular freeSlip{
-            gen::FreeSlipIrregularFactory(sbfs, pdfsId).fromFlagField<FlagField_T>(flagFieldId, freeSlipFlagUID, fluidFlagUid)
-        };
-
-        gen::LbStreamCollide streamCollide { pdfsId, rhoId, uId, 1.0 };
-
-        CommScheme comm{sbfs};
-        auto pdfsPackInfo = std::make_shared<PdfsPackInfo>(pdfsId);
-        comm.addPackInfo(pdfsPackInfo);
-
-        auto velOutput = field::createVTKOutput<VectorField_T>(uId, *sbfs, "vel");
-
-        for(uint_t i = 0; i < 10; ++i){
-            comm();
-            for (auto &block : *sbfs)
-            {
-                velOutput();
-                freeSlip(&block);
-                streamCollide(&block);
-
-                VectorField_T & velField = *block.getData< VectorField_T >(uId);
-                FlagField_T & flagField = *block.getData< FlagField_T >(flagFieldId);
-                uint8_t fluidFlag = flagField.getFlag(fluidFlagUid);
-                
-                for(Cell c : allCells){
-                    if( flagField.isFlagSet(c, fluidFlag) ){
-                        WALBERLA_CHECK_FLOAT_EQUAL( velField.get(c, 0), maxVelocity );
-                        WALBERLA_CHECK_FLOAT_EQUAL( velField.get(c, 1), 0. );
-                        WALBERLA_CHECK_FLOAT_EQUAL( velField.get(c, 2), 0. );
-                    }
-                }
-            }
-        }
-
-        velOutput();
-    }
-}
-
-int main(int argc, char **argv)
-{
-    walberla::Environment env{argc, argv};
-    TestIrregularFreeSlip::freeSlipPipe(env);
-}
diff --git a/tests/Jacobi.py b/tests/Jacobi.py
index 0a07ca80fb133b5b6bd38a6bf4d0486d33f33bce..c7f1fbe569d8c788bc3bb71ccf868082bac78572 100644
--- a/tests/Jacobi.py
+++ b/tests/Jacobi.py
@@ -3,7 +3,7 @@ import sympy as sp
 from pystencils import fields, kernel
 
 from pystencilssfg import SourceFileGenerator, SfgConfiguration
-from sfg_walberla import Sweep
+from walberla.codegen import Sweep
 
 cfg = SfgConfiguration(output_directory="out")
 with SourceFileGenerator(cfg) as sfg:
diff --git a/user_manual/CMakeSetup/CMakeSetup.md b/user_manual/CMakeSetup/CMakeSetup.md
index d4ce0d39f2169d40ef26152849f08aeb96a7db70..ef5f1fc349f5a6e3fbddc2608050b9eeba498fdc 100644
--- a/user_manual/CMakeSetup/CMakeSetup.md
+++ b/user_manual/CMakeSetup/CMakeSetup.md
@@ -43,7 +43,7 @@ Now, you can run `cmake` to configure your build system:
 
 ```bash
 mkdir build
-cmake -S . -B build -DWALBERLA_CODEGEN_PRIVATE_VENV=ON
+cmake -S . -B build
 ```
 
 If, near the end of the long configuration log, you see two messages like this:
@@ -55,19 +55,3 @@ If, near the end of the long configuration log, you see two messages like this:
 
 Then your build system setup was successful!
 Now you can get down to business and populate your project with simulation applications.
-
-(adding_examples)=
-## Adding Examples from this Manual
-
-As you explore the examples in this book,
-you can try out each by downloading, extracting, and including them into your project.
-For example, to include the [force-driven poiseulle channel example](#ForceDrivenChannel),
-extract its code into the `ForceDrivenChannel` subdirectory and add
-
-```CMake
-add_subdirectory( ForceDrivenChannel )
-```
-
-to your root `CMakeLists.txt`.
-
-[FetchContent]: https://cmake.org/cmake/help/latest/module/FetchContent.html
diff --git a/user_manual/Makefile b/user_manual/Makefile
index a25a0fb5753254bcfe5f61266a6b7655c85f6ce0..779833bd3867df27a0d3a6c143fa51166ebd0e4e 100644
--- a/user_manual/Makefile
+++ b/user_manual/Makefile
@@ -9,7 +9,8 @@ SOURCEDIR     = .
 BUILDDIR      = _sphinx_build
 
 ZIPPED_EXAMPLES := zipped-examples
-EXAMPLES_DIR := examples
+ZIPPED_EXAMPLES_ABS := $(shell pwd)/$(ZIPPED_EXAMPLES)
+EXAMPLES_DIR := $(shell pwd)/examples
 
 include examples.mk
 
@@ -32,9 +33,9 @@ clean:
 	@rm -rf $(ZIPPED_EXAMPLES)
 	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
 
-ZipExamples: $(foreach example, $(EXAMPLES), $(ZIPPED_EXAMPLES)/$(example).zip)
+ZipExamples: $(foreach example, $(EXAMPLES), $(ZIPPED_EXAMPLES_ABS)/$(example).zip)
 
-$(ZIPPED_EXAMPLES)/%.zip: $(EXAMPLES_DIR)/%/*
+$(ZIPPED_EXAMPLES_ABS)/%.zip: $(EXAMPLES_DIR)/%/*
 	@$(dir_guard)
 	@echo Zipping $(<D)
-	@zip -r $@ $(<D)
+	@cd $(EXAMPLES_DIR); zip -r $@ $(notdir $(<D))
diff --git a/user_manual/examples.mk b/user_manual/examples.mk
index 4ab97ae0510e8b4cebe7a5b73de8313a6a88447b..cdefaef69bbb6f843d952d303d74e12a8b646e45 100644
--- a/user_manual/examples.mk
+++ b/user_manual/examples.mk
@@ -1 +1 @@
-EXAMPLES = GeneratorScriptBasics SparseSpiral ForceDrivenChannel
+EXAMPLES = GeneratorScriptBasics ForceDrivenChannel SparseSpiral
diff --git a/user_manual/examples/CMakeLists.txt b/user_manual/examples/CMakeLists.txt
index 499f1f083bf4419e3f4c8d72c7ccb55123500ad4..79972aaf20c471ad55fa0fb7c1fa3ab0a4a8b4cc 100644
--- a/user_manual/examples/CMakeLists.txt
+++ b/user_manual/examples/CMakeLists.txt
@@ -1,26 +1,11 @@
-cmake_minimum_required( VERSION 3.24 )
-project( walberla-codegen-examples )
-
-include(FetchContent)
-
-FetchContent_Declare(
-    walberla
-    GIT_REPOSITORY https://i10git.cs.fau.de/walberla/walberla.git
-)
-
-message( STATUS "Fetching waLBerla sources (this might take a while)..." )
-FetchContent_MakeAvailable(walberla)
-
-add_subdirectory(${CMAKE_SOURCE_DIR}/../.. ${CMAKE_BINARY_DIR}/sfg-walberla)
-
 add_subdirectory( GeneratorScriptBasics )
 add_subdirectory( ForceDrivenChannel )
 add_subdirectory( SparseSpiral )
 add_subdirectory( FreeSlipBubble )
 
-add_custom_target( Examples )
+add_custom_target( UserManualExamples )
 add_dependencies( 
-    Examples
+    UserManualExamples
     Ex_GeneratorScriptBasics
     Ex_ForceDrivenChannel
     SparseSpiral
diff --git a/user_manual/examples/ForceDrivenChannel/CMakeLists.txt b/user_manual/examples/ForceDrivenChannel/CMakeLists.txt
index 3e21d3e157882b198cb7099828e15beade497ac6..3d7391c1ed7399d7436f070218e210c93bf9c7b3 100644
--- a/user_manual/examples/ForceDrivenChannel/CMakeLists.txt
+++ b/user_manual/examples/ForceDrivenChannel/CMakeLists.txt
@@ -10,5 +10,5 @@ walberla_generate_sources( Ex_ForceDrivenChannel
 target_link_libraries(
     Ex_ForceDrivenChannel
     PRIVATE 
-    core stencil blockforest geometry vtk sfg_walberla 
+    walberla::core walberla::stencil walberla::blockforest walberla::geometry walberla::vtk walberla::experimental
 )
diff --git a/user_manual/examples/ForceDrivenChannel/ForceDrivenChannel.cpp b/user_manual/examples/ForceDrivenChannel/ForceDrivenChannel.cpp
index 2871b8d363703cba9fb93bddb69469b3e5ae103a..f87b21e243f489a63df9f5d2b20c25e472dcd14f 100644
--- a/user_manual/examples/ForceDrivenChannel/ForceDrivenChannel.cpp
+++ b/user_manual/examples/ForceDrivenChannel/ForceDrivenChannel.cpp
@@ -86,12 +86,13 @@ namespace Ex_ForceDrivenChannel
         auto flagFieldOutput = field::createVTKOutput<FlagField_T>(flagFieldId, *blocks, "flagField", 1, 0);
         flagFieldOutput();
 
-        auto noSlip = make_unique<gen::NoSlip>(blocks, pdfsId);
-        noSlip->fillFromFlagField<FlagField_T>(*blocks, flagFieldId, noSlipFlagUid, fluidFlagUid);
+        gen::NoSlip noSlip {
+            gen::NoSlipFactory(blocks, pdfsId).fromFlagField< FlagField_T >(flagFieldId, noSlipFlagUid, fluidFlagUid)
+        };
 
         auto boundarySweep = [&](IBlock *block)
         {
-            (*noSlip)(block);
+            noSlip(block);
         };
 
         //  Timeloop
diff --git a/user_manual/examples/ForceDrivenChannel/LbmAlgorithms.py b/user_manual/examples/ForceDrivenChannel/LbmAlgorithms.py
index c468a8da68a1f2152afb2e5773e881b1d34cbe2c..2a1292f53a9b1536df69d9820e59deaefaaa107c 100644
--- a/user_manual/examples/ForceDrivenChannel/LbmAlgorithms.py
+++ b/user_manual/examples/ForceDrivenChannel/LbmAlgorithms.py
@@ -2,8 +2,8 @@ import sympy as sp
 import pystencils as ps
 
 from pystencilssfg import SourceFileGenerator
-from sfg_walberla import Sweep
-from sfg_walberla.boundaries import SimpleHbbBoundary
+from walberla.codegen import Sweep, get_build_config
+from walberla.codegen.boundaries import GenericHBB
 
 from lbmpy import (
     LBStencil,
@@ -21,7 +21,8 @@ from lbmpy.macroscopic_value_kernels import macroscopic_values_setter
 stencil = LBStencil(Stencil.D3Q19)
 dim = stencil.D
 f, f_tmp, rho, u = ps.fields(
-    f"f({stencil.Q}), f_tmp({stencil.Q}), rho(1), u({dim}): double[{dim}D]", layout="fzyx"
+    f"f({stencil.Q}), f_tmp({stencil.Q}), rho(1), u({dim}): double[{dim}D]",
+    layout="fzyx",
 )
 omega = sp.Symbol("omega")
 force = sp.symbols(f"F_:{dim}")
@@ -46,20 +47,19 @@ with SourceFileGenerator() as sfg:
 
     lbm_opt = LBMOptimisation(symbolic_field=f, symbolic_temporary_field=f_tmp)
 
-    gen_config = ps.CreateKernelConfig()
-    gen_config.target = ps.Target.CPU
-    gen_config.cpu.openmp.enable = True
+    wlb_config = get_build_config(sfg)
+    wlb_config.override.target = ps.Target.GenericCPU
 
     lb_update = create_lb_update_rule(lbm_config=lbm_config, lbm_optimisation=lbm_opt)
-    lb_update_sweep = Sweep("LbStreamCollide", lb_update, gen_config)
+    lb_update_sweep = Sweep("LbStreamCollide", lb_update)
     lb_update_sweep.swap_fields(f, f_tmp)
     sfg.generate(lb_update_sweep)
 
     lb_init = macroscopic_values_setter(
         lb_update.method, density=rho, velocity=u, pdfs=f, set_pre_collision_pdfs=True
     )
-    lb_init_sweep = Sweep("LbInit", lb_init, gen_config)
+    lb_init_sweep = Sweep("LbInit", lb_init)
     sfg.generate(lb_init_sweep)
 
     #   No-Slip Wall
-    sfg.generate(SimpleHbbBoundary(NoSlip(), lb_method, f))
+    sfg.generate(GenericHBB(NoSlip(), lb_method, f))
diff --git a/user_manual/examples/FreeSlipBubble/CMakeLists.txt b/user_manual/examples/FreeSlipBubble/CMakeLists.txt
index cd3e7af346973a31ce122c55531821489e74ce14..63299c46ce7987d32ccc91f61b928abc5f2b8383 100644
--- a/user_manual/examples/FreeSlipBubble/CMakeLists.txt
+++ b/user_manual/examples/FreeSlipBubble/CMakeLists.txt
@@ -3,4 +3,4 @@ walberla_link_files_to_builddir( *.prm )
 add_executable( FreeSlipBubble FreeSlipBubble.cpp )
 
 walberla_generate_sources( FreeSlipBubble SCRIPTS LbmAlgorithms.py )
-target_link_libraries( FreeSlipBubble core blockforest field geometry stencil sfg_walberla )
+target_link_libraries( FreeSlipBubble walberla::core walberla::blockforest walberla::field walberla::geometry walberla::stencil walberla::experimental )
diff --git a/user_manual/examples/FreeSlipBubble/LbmAlgorithms.py b/user_manual/examples/FreeSlipBubble/LbmAlgorithms.py
index a2975f2617a73d9b129dc7bd55b1a308e9a37b2a..150144f14a785e74ceb8bf359515e87fd6afc59b 100644
--- a/user_manual/examples/FreeSlipBubble/LbmAlgorithms.py
+++ b/user_manual/examples/FreeSlipBubble/LbmAlgorithms.py
@@ -1,12 +1,12 @@
 import sympy as sp
 from pystencilssfg import SourceFileGenerator
 
-from pystencils import fields, Field
+from pystencils import fields, Field, Target, CreateKernelConfig
 from lbmpy import LBStencil, Stencil, LBMConfig, LBMOptimisation, create_lb_update_rule
 from lbmpy.macroscopic_value_kernels import macroscopic_values_setter
 
 
-from sfg_walberla import Sweep
+from walberla.codegen import Sweep, get_build_config
 
 with SourceFileGenerator() as sfg:
     sfg.namespace("FreeSlipBubble::gen")
@@ -28,6 +28,9 @@ with SourceFileGenerator() as sfg:
         symbolic_temporary_field=pdfs_tmp,
     )
 
+    wlb_config = get_build_config(sfg)
+    wlb_config.override.target = Target.GenericCPU
+
     lb_update = create_lb_update_rule(lbm_config=lbm_config, lbm_optimisation=lbm_opt)
     lb_method = lb_update.method
     assert lb_update is not None
@@ -46,13 +49,13 @@ with SourceFileGenerator() as sfg:
     sfg.generate(lb_init_sweep)
 
     #   begin irregular-freeslip
-    from sfg_walberla.boundaries import FreeSlip
+    from walberla.codegen.boundaries import FreeSlip
 
     freeslip = FreeSlip(
         "FreeSlip",
         lb_method,
         pdfs,
-        wall_normal=FreeSlip.IRREGULAR,
+        wall_orientation=FreeSlip.IRREGULAR,
     )
 
     sfg.generate(freeslip)
diff --git a/user_manual/examples/SparseSpiral/CMakeLists.txt b/user_manual/examples/SparseSpiral/CMakeLists.txt
index 6ec9191dc263b3eee1015fa00433cbc1987a71a1..92426be82f61205916b892b7e04775542af61584 100644
--- a/user_manual/examples/SparseSpiral/CMakeLists.txt
+++ b/user_manual/examples/SparseSpiral/CMakeLists.txt
@@ -1,5 +1,8 @@
 
 add_executable( SparseSpiral SparseSpiral.cpp )
 walberla_generate_sources( SparseSpiral SCRIPTS SpiralSweep.py )
-target_link_libraries( SparseSpiral PRIVATE core blockforest field vtk sfg_walberla )
+target_link_libraries( SparseSpiral PRIVATE walberla::core walberla::blockforest walberla::field walberla::vtk walberla::experimental )
 
+add_dependencies( SfgTests SparseSpiral )
+
+add_test( NAME SparseSpiral COMMAND SparseSpiral )
diff --git a/user_manual/examples/SparseSpiral/SparseSpiral.cpp b/user_manual/examples/SparseSpiral/SparseSpiral.cpp
index 2234ba1e74e7d8b51f241c1cd6be3edad86f0863..fe96364af00fd05478164004b1ec3a22d136c909 100644
--- a/user_manual/examples/SparseSpiral/SparseSpiral.cpp
+++ b/user_manual/examples/SparseSpiral/SparseSpiral.cpp
@@ -3,7 +3,7 @@
 #include "field/GhostLayerField.h"
 #include "field/AddToStorage.h"
 #include "field/vtk/all.h"
-#include "sfg/SparseIteration.hpp"
+#include "walberla/experimental/sweep/SparseIndexList.hpp"
 
 #include <array>
 
@@ -12,6 +12,7 @@
 namespace SparseSpiral
 {
     using namespace walberla;
+    using namespace walberla::experimental;
 
     using std::array;
     using std::vector;
@@ -43,7 +44,7 @@ namespace SparseSpiral
             cpb[0], cpb[1], cpb[2]);
 
         BlockDataID fID = field::addToStorage<PointField>(blocks, "f", real_c(0.0), field::fzyx);
-        sfg::SparseIndexList indexList{*blocks};
+        sweep::SparseIndexList indexList{*blocks};
 
         auto isOnSpiral = [&](Cell globalCell) -> bool
         {
@@ -56,7 +57,7 @@ namespace SparseSpiral
 
         for (auto &b : *blocks)
         {
-            vector<sfg::CellIdx> & idxVec{indexList.get(b)};
+            auto & idxVec{indexList.getVector(b)};
             for (Cell c : allCells)
             {
                 Cell globalCell{c};
@@ -68,7 +69,7 @@ namespace SparseSpiral
             }
         }
 
-        gen::SpiralSweep sweep{blocks, fID, indexList.bufferId()};
+        gen::SpiralSweep sweep{blocks, fID, indexList};
 
         for (auto &b : *blocks)
         {
diff --git a/user_manual/examples/SparseSpiral/SpiralSweep.py b/user_manual/examples/SparseSpiral/SpiralSweep.py
index e573c812b9499d58abdc5a28557bdef0a394c2a1..4fd59bd19a737b1b01e7473bc9343fa8e8022b16 100644
--- a/user_manual/examples/SparseSpiral/SpiralSweep.py
+++ b/user_manual/examples/SparseSpiral/SpiralSweep.py
@@ -1,7 +1,7 @@
 from pystencilssfg import SourceFileGenerator
-from sfg_walberla import Sweep
-from sfg_walberla.symbolic import cell
-from pystencils import fields, Assignment
+from walberla.codegen import Sweep
+from walberla.codegen.symbolic import cell
+from pystencils import fields, Assignment, CreateKernelConfig, Target
 
 with SourceFileGenerator() as sfg:
     sfg.namespace("SparseSpiral::gen")
@@ -13,8 +13,9 @@ with SourceFileGenerator() as sfg:
         Assignment(f.center(2), cell.z()),
     ]
 
-    sweep = Sweep("SpiralSweep", asms)
+    cfg = CreateKernelConfig(target=Target.GenericCPU)
+
+    sweep = Sweep("SpiralSweep", asms, cfg)
     sweep.sparse = True
 
     sfg.generate(sweep)
-
diff --git a/user_manual/guides/SparseSweeps.md b/user_manual/guides/SparseSweeps.md
index 822d92fbc3894a07e09def2e2797b95d0f349e46..f0de79f777d87a5692f3136186158e45aee4e7b7 100644
--- a/user_manual/guides/SparseSweeps.md
+++ b/user_manual/guides/SparseSweeps.md
@@ -25,13 +25,13 @@ in a field:
 In your C++ code, you must set up an index list which lists all cells the sweep should operate on.
 Due to waLBerla's block-structured nature, the index list will actually be a collection of cell vectors,
 with one vector per block.
-The SFG library provides `sfg::SparseIndexList` for this, which is defined in `sfg/SparseIteration.hpp`.
+WaLBerla provides the `SparseIndexList` class for this.
 The following code sample creates and populates an index list, using a predicate here called `isOnSpiral`.
 The index list's internal block data ID must then be passed to the sweep's constructor.
 
-:::{literalinclude} ../examples/SparseSpiral/SparseSpiral.cpp
+```{literalinclude} ../examples/SparseSpiral/SparseSpiral.cpp
 :caption: SparseSpiral.cpp
 :lines: 46,56-72
 :dedent: 8
 :language: C++
-:::
+```
diff --git a/user_manual/index.md b/user_manual/index.md
index 29564ac46045ef4171c75bd23f1ee63d1e19985c..21a0e1197e747eac3b09c7412b1188eac5dacef6 100644
--- a/user_manual/index.md
+++ b/user_manual/index.md
@@ -38,7 +38,8 @@ examples/ForceDrivenChannel/ForceDrivenChannel
 :caption: Reference
 :maxdepth: 1
 
-Python Environment <reference/PythonEnvironment>
+reference/PystencilsLanguageExtensions
+reference/PythonEnvironment
 :::
 
 
diff --git a/user_manual/reference/PystencilsLanguageExtensions.md b/user_manual/reference/PystencilsLanguageExtensions.md
new file mode 100644
index 0000000000000000000000000000000000000000..df1d8630e514107b4bc2d3880e6f957192cca712
--- /dev/null
+++ b/user_manual/reference/PystencilsLanguageExtensions.md
@@ -0,0 +1,78 @@
+# Extensions to the pystencils Language
+
+The waLBerla code generator provides several extensions to the symbolic
+language of pystencils, representing waLBerla-specific features and modelling aspects.
+These are available through the `walberla.codegen.symbolic` module.
+
+## Blockforest, Block, and Cell Geometry
+
+WaLBerla's blockforest comprises a hierarchy of geometry-defining objects:
+The global simulation domain, which has one or more refinement levels,
+each home to a set of blocks, which in turn define a grid of cells.
+Many of these geometric properties are modelled as symbolic functions
+and therefore accessible to kernels.
+As Sweep kernels are always defined with respect to a single archetypical cell,
+these symbolic functions always relate to that cell and its parent block.
+
+The following tables lists the available symbolic functions,
+the geometric concept they represent,
+as well as equivalent methods of the waLBerla C++ API.
+All Python APIs are listed relative to `walberla.codegen.symbolic`.
+
+### Domain Geometry
+
+:::{list-table}
+:header-rows: 1
+:widths: auto
+
+*   - Concept
+    - Symbolic Functions
+    - C++ API
+*   - Domain Axis-Aligned Bounding Box
+    - `domain.[xyz]_min()`, `domain.[xyz]_max()`
+    - `StructuredBlockForest::getDomain()`
+*   - Domain Cell Bounding Box (Current Refinement Level)
+    - `domain_cell_bb.[xyz]_min()`, `domain_cell_bb.[xyz]_max()`
+    - `StructuredBlockStorage::getDomainCellBB()`
+:::
+
+### Block Geometry
+
+:::{list-table}
+:header-rows: 1
+:widths: auto
+
+*   - Concept
+    - Symbolic Functions
+    - C++ API
+*   - Block Cell Bounding Box
+    - `block_cell_bb.[x|y|z]_min()`, `block_cell_bb.[x|y|z]_max()`
+    - `StructuredBlockForest::getBlockCellBB()`
+:::
+
+
+### Cell Geometry
+
+:::{list-table}
+:header-rows: 1
+:widths: auto
+
+*   - Concept
+    - Symbolic Functions
+    - C++ API
+*   - Cell Center Coordinates in Global Coordinate System
+    - `cell.[x|y|z]()`
+    - `StructuredBlockForest::getBlockLocalCellCenter()`
+*   - Cell Center Coordinates in Block-Local Coordinate System
+    - `cell.local_[x|y|z]()`
+    - *No Equivalent*
+*   - Cell Spacing
+    - `cell.[dx|dy|dz]()`
+    - `StructuredBlockForest::[dx|dy|dz]()`
+*   - Cell Index in the Global Cell Grid
+    - `cell_index.[x|y|z]_global()`
+    - `StructuredBlockForest::transformBlockLocalToGlobalCell()`
+*   - Cell Index in the Block-Local Cell Grid
+    - `cell_index.[x|y|z]_local()`
+    - `StructuredBlockForest::transformGlobalToBlockLocalCell()`
+:::
diff --git a/user_manual/reference/PythonEnvironment.md b/user_manual/reference/PythonEnvironment.md
index df1530814c6606a9998f221ee30dd2d125c55728..6249039d929851587849991474eeb6260df4d5ed 100644
--- a/user_manual/reference/PythonEnvironment.md
+++ b/user_manual/reference/PythonEnvironment.md
@@ -1,38 +1,45 @@
-# Managing the Code Generator's Python Environment
+# Python Environment for Code Generation
 
-On this page, you can find information on managing, customizing, and extending the Python environment
-used by the waLBerla code generation system.
+The waLBerla build system will set up a [virtual Python environment][venv] inside its
+build tree, and use its Python interpreter to run code generation scripts.
+On this page, you can find reference information on how this Python environment can be customized.
 
-## Using the Private Virtual Environment
+## Setting the Base Interpreter
 
-By default, `sfg-walberla` creates a new Python virtual environment within the CMake build tree,
-and there installs all packages required for code generation.
-This can be disabled by setting the `WALBERLA_CODEGEN_PRIVATE_VENV` CMake cache variable to `FALSE`.
+WaLBerla uses [FindPython][FindPython] to locate the base Python interpreter
+which will be used to create the virtual environment.
+Refer to its documentation for ways to affect the discovery process.
 
-### Install Additional Packages
+## Adding Additional Packages
 
-For projects that require external dependencies, *sfg-walberla* exposes the CMake function
-`walberla_codegen_venv_install`, which can be used to install additional packages into the
-code generator virtual environment;
-for instance, the following invocation installs `pyyaml`:
+To install additional packages into the code generation environment,
+first register them in your CMake file using the `walberla_codegen_venv_require` function.
+This function can be invoked multiple times to add multiple requirements.
+The arguments to `walberla_codegen_venv_require` will be directly forwarded to `pip install`,
+so you can include any options `pip install` understands to affect the installation.
 
-```CMake
-walberla_codegen_venv_install( pyyaml )
-```
+Calls to `walberla_codegen_venv_require` will only collect the set of requirements.
+To perform the installation, `walberla_codegen_venv_populate` must be called after all
+requirements are declared.
+
+:::{card} Example
 
-The arguments passed to `walberla_codegen_venv_install` are forwarded directly to `pip install`.
-You can therefore use any parameters that `pip` can interpret, for instance `-e` to perform an
-editable install, or `-r <requirements-file>` to install packages from a requirements file.
+```CMake
+#   First, list requirements
+walberla_codegen_venv_require( pycowsay )  # Require a single package
+walberla_codegen_venv_require( -r my-requirements.txt )  # Specify a requirements file
 
-## Using an External Virtual Environment
+#   Then, populate the virtual environment
+walberla_codegen_venv_populate()
+```
 
-If `WALBERLA_CODEGEN_PRIVATE_VENV` is set to `FALSE`, sfg-walberla will use the Python interpreter
-found in the CMake environment for running the code generators.
-You can customize your Python interpreter by setting the `Python_EXECUTABLE` or `Python_ROOT_DIR` hints.
+:::
 
-:::{seealso}
-[FindPython CMake Module](https://cmake.org/cmake/help/latest/module/FindPython.html)
+:::{error}
+It is an error for your CMake system to call
+`walberla_codegen_venv_require` after `walberla_codegen_venv_populate`,
+or to call `walberla_codegen_venv_populate` more than once.
 :::
 
-Sfg-walberla will check if the required packages are installed into the given external Python environment,
-and raise an error if any are missing.
+[venv]: https://docs.python.org/3/library/venv.html
+[FindPython]: https://cmake.org/cmake/help/latest/module/FindPython.html