cande-wrapper 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: cande-wrapper
3
+ Version: 0.0.1
4
+ Summary: Placeholder for the CANDE FEA culvert analysis engine
5
+ Requires-Python: >=3.10
@@ -0,0 +1,130 @@
1
+ # cande-wrapper
2
+
3
+ Python wrapper for the [CANDE](https://www.fhwa.dot.gov/engineering/hydraulics/software/culvertanalysis.cfm) (Culvert ANalysis and DEsign) finite element engine.
4
+
5
+ The CANDE-2025 Fortran source is compiled via [f2py](https://numpy.org/doc/stable/f2py/) into a native Python extension module, allowing direct calls from Python without subprocess overhead or DLL wrappers.
6
+
7
+ ## Prerequisites
8
+
9
+ | Dependency | Version | Purpose |
10
+ |---|---|---|
11
+ | Python | >= 3.10 | Runtime |
12
+ | gfortran | any recent | Compiles the CANDE Fortran engine |
13
+ | NumPy | >= 1.26 | f2py extension building and runtime |
14
+ | Meson | >= 1.1 | Build system |
15
+ | Ninja | any recent | Build backend for Meson |
16
+
17
+ ### Installing gfortran on Windows
18
+
19
+ The easiest way is via MSYS2:
20
+
21
+ ```bash
22
+ # In MSYS2 terminal
23
+ pacman -S mingw-w64-x86_64-gcc-fortran
24
+
25
+ # Add to PATH (PowerShell)
26
+ $env:PATH += ";C:\msys64\mingw64\bin"
27
+ ```
28
+
29
+ Or install via conda:
30
+
31
+ ```bash
32
+ conda install -c conda-forge gfortran
33
+ ```
34
+
35
+ ### Installing build tools
36
+
37
+ ```bash
38
+ pip install meson-python meson ninja numpy
39
+ ```
40
+
41
+ ## Installation
42
+
43
+ From the project root:
44
+
45
+ ```bash
46
+ pip install -e ".[dev]"
47
+ ```
48
+
49
+ This compiles the Fortran engine via f2py and installs the Python package in editable mode. The `[dev]` extra includes pytest and jupyter for testing and notebooks.
50
+
51
+ If the build fails, see [Troubleshooting](#troubleshooting) below.
52
+
53
+ ## Quick Start
54
+
55
+ ```python
56
+ from cande_wrapper import CandeEngine
57
+
58
+ engine = CandeEngine(work_dir="path/to/my/models")
59
+ result = engine.run("EX1") # reads EX1.cid, writes EX1.out
60
+ print(result.output_text)
61
+ ```
62
+
63
+ ## CANDE Input Files
64
+
65
+ CANDE reads `.cid` input files (CANDE Input Data). These are fixed-format text files with sections:
66
+
67
+ - **A-1**: Master control line (analysis/design mode, solution level, title)
68
+ - **A-2**: Pipe type and element counts
69
+ - **B-\***: Pipe material properties
70
+ - **C-\***: Mesh, node, and element definitions
71
+ - **D-\***: Soil properties and load steps
72
+ - **STOP**: End-of-problem marker
73
+
74
+ Multiple problems can be run back-to-back in a single `.cid` file, each terminated by `STOP`.
75
+
76
+ An example input file is included at `tests/example_data/MGK-IO.cid`.
77
+
78
+ ## Output Files
79
+
80
+ After running `engine.run("prefix")`, these files are created in the working directory:
81
+
82
+ | File | Description |
83
+ |---|---|
84
+ | `prefix.out` | Main analysis output report |
85
+ | `prefix.log` | Engine log messages |
86
+ | `prefix.ctc` | Table of contents for the output |
87
+ | `prefix_MeshGeom.xml` | Mesh geometry (for visualization) |
88
+ | `prefix_MeshResults.xml` | Mesh results (for visualization) |
89
+ | `prefix_BeamResults.xml` | Beam element results |
90
+ | `prefix_PLOT1.dat` | Plot data file 1 |
91
+ | `prefix_PLOT2.dat` | Plot data file 2 |
92
+
93
+ The `CandeResult` object returned by `engine.run()` gives convenient access to the main output files:
94
+
95
+ ```python
96
+ result = engine.run("EX1")
97
+ print(result.output_file) # Path to EX1.out
98
+ print(result.log_file) # Path to EX1.log
99
+ print(result.output_text) # Full contents of EX1.out
100
+ ```
101
+
102
+ ## Testing
103
+
104
+ ```bash
105
+ pytest -v # unit tests (no build required)
106
+ pytest -m integration -v # integration tests (requires build)
107
+ pytest -m "" -v # all tests
108
+ ```
109
+
110
+ ## Troubleshooting
111
+
112
+ ### `gfortran: command not found`
113
+
114
+ gfortran is not on your PATH. Verify with `gfortran --version`. On Windows, ensure the MSYS2/MinGW or conda bin directory is in your PATH.
115
+
116
+ ### Linker errors about missing symbols
117
+
118
+ The `meson.build` file lists Engine source files explicitly. If you see unresolved symbols like `plasti_` or `prhero_`, the corresponding `.f` file needs to be added to the `engine_sources` list in `meson.build`.
119
+
120
+ ### `ImportError: CANDE Fortran extension not found`
121
+
122
+ The package was imported but the compiled extension (`_cande.pyd` / `_cande.so`) was not found. Rebuild with:
123
+
124
+ ```bash
125
+ pip install -e . --no-build-isolation
126
+ ```
127
+
128
+ ### Large memory usage
129
+
130
+ The CANDE engine statically allocates ~320 MB for the stiffness matrix (`REAL*8 A(20000,2000)` in `system.fi`). This is normal for FEA solvers and is mapped as virtual memory.
@@ -0,0 +1,9 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cande-wrapper"
7
+ version = "0.0.1"
8
+ description = "Placeholder for the CANDE FEA culvert analysis engine"
9
+ requires-python = ">=3.10"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """cande-wrapper: Python wrapper for the CANDE FEA culvert analysis engine."""
2
+ print('Hello from cande-wrapper!')
3
+ # from cande_wrapper.engine import CandeEngine, CandeResult
4
+ #
5
+ # __all__ = ["CandeEngine", "CandeResult"]
@@ -0,0 +1,165 @@
1
+ """Python interface to the CANDE Fortran engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+
10
+ def _find_gfortran_dirs() -> list[str]:
11
+ """Find directories containing gfortran runtime DLLs on Windows."""
12
+ import shutil
13
+
14
+ dirs = []
15
+ # Check if gfortran is on PATH and find its bin directory
16
+ gfortran = shutil.which("gfortran")
17
+ if gfortran:
18
+ dirs.append(str(Path(gfortran).parent))
19
+ # Common locations
20
+ for candidate in [
21
+ r"C:\Strawberry\c\bin",
22
+ r"C:\msys64\mingw64\bin",
23
+ r"C:\mingw64\bin",
24
+ ]:
25
+ if Path(candidate).is_dir():
26
+ dirs.append(candidate)
27
+ # Conda environment
28
+ conda_prefix = os.environ.get("CONDA_PREFIX")
29
+ if conda_prefix:
30
+ lib_dir = Path(conda_prefix) / "Library" / "bin"
31
+ if lib_dir.is_dir():
32
+ dirs.append(str(lib_dir))
33
+ return dirs
34
+
35
+
36
+ class CandeEngine:
37
+ """Wrapper around the CANDE FEA engine compiled via f2py.
38
+
39
+ Usage::
40
+
41
+ engine = CandeEngine(work_dir="path/to/working/directory")
42
+ result = engine.run("my_problem")
43
+ # Reads my_problem.cid, writes my_problem.out in work_dir
44
+
45
+ Parameters
46
+ ----------
47
+ work_dir : str or Path, optional
48
+ Working directory where .cid input files live and output files
49
+ will be written. Defaults to current directory.
50
+ """
51
+
52
+ def __init__(self, work_dir: Optional[str | Path] = None):
53
+ self.work_dir = Path(work_dir) if work_dir else Path.cwd()
54
+
55
+ def run(self, prefix: str) -> "CandeResult":
56
+ """Run a CANDE analysis.
57
+
58
+ Parameters
59
+ ----------
60
+ prefix : str
61
+ File prefix (without extension). The engine will read
62
+ ``{prefix}.cid`` and produce ``{prefix}.out`` and other
63
+ output files in the working directory.
64
+
65
+ Returns
66
+ -------
67
+ CandeResult
68
+ Object with paths to all output files and the return code.
69
+
70
+ Raises
71
+ ------
72
+ FileNotFoundError
73
+ If the input .cid file does not exist.
74
+ RuntimeError
75
+ If the Fortran engine returns a nonzero error code.
76
+ """
77
+ cid_file = self.work_dir / f"{prefix}.cid"
78
+ if not cid_file.exists():
79
+ raise FileNotFoundError(f"Input file not found: {cid_file}")
80
+
81
+ # Import the f2py-compiled extension
82
+ try:
83
+ from cande_wrapper._cande import run_cande
84
+ except ImportError:
85
+ # On Windows, gfortran runtime DLLs may not be on PATH.
86
+ # Try adding common gfortran bin directories.
87
+ if os.name == "nt":
88
+ for gfortran_dir in _find_gfortran_dirs():
89
+ os.add_dll_directory(gfortran_dir)
90
+ try:
91
+ from cande_wrapper._cande import run_cande
92
+ except ImportError as e:
93
+ raise ImportError(
94
+ "CANDE Fortran extension DLL load failed. "
95
+ "Ensure gfortran runtime DLLs (libgfortran-5.dll) "
96
+ "are on PATH or install via conda."
97
+ ) from e
98
+ else:
99
+ raise ImportError(
100
+ "CANDE Fortran extension not found. "
101
+ "Build it with: pip install -e . "
102
+ "(requires gfortran and numpy)"
103
+ )
104
+
105
+ # CANDE reads/writes files relative to CWD, so we chdir
106
+ original_dir = os.getcwd()
107
+ try:
108
+ os.chdir(self.work_dir)
109
+ ierror = run_cande(prefix)
110
+ finally:
111
+ os.chdir(original_dir)
112
+
113
+ if ierror != 0:
114
+ raise RuntimeError(
115
+ f"CANDE engine returned error code {ierror}. "
116
+ f"Check {prefix}.out and {prefix}.log for details."
117
+ )
118
+
119
+ return CandeResult(self.work_dir, prefix)
120
+
121
+ def check_input(self, prefix: str) -> bool:
122
+ """Validate that a .cid input file exists and is readable.
123
+
124
+ Parameters
125
+ ----------
126
+ prefix : str
127
+ File prefix (without extension).
128
+
129
+ Returns
130
+ -------
131
+ bool
132
+ True if the input file exists.
133
+ """
134
+ return (self.work_dir / f"{prefix}.cid").exists()
135
+
136
+
137
+ class CandeResult:
138
+ """Container for CANDE analysis output file paths.
139
+
140
+ Attributes
141
+ ----------
142
+ prefix : str
143
+ The file prefix used for this run.
144
+ output_file : Path
145
+ Path to the main .out output file.
146
+ log_file : Path
147
+ Path to the .log file.
148
+ toc_file : Path
149
+ Path to the .ctc table-of-contents file.
150
+ """
151
+
152
+ def __init__(self, work_dir: Path, prefix: str):
153
+ self.prefix = prefix
154
+ self.work_dir = work_dir
155
+ self.output_file = work_dir / f"{prefix}.out"
156
+ self.log_file = work_dir / f"{prefix}.log"
157
+ self.toc_file = work_dir / f"{prefix}.ctc"
158
+
159
+ @property
160
+ def output_text(self) -> str:
161
+ """Read and return the full output file contents."""
162
+ return self.output_file.read_text()
163
+
164
+ def __repr__(self) -> str:
165
+ return f"CandeResult(prefix={self.prefix!r}, output={self.output_file})"
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: cande-wrapper
3
+ Version: 0.0.1
4
+ Summary: Placeholder for the CANDE FEA culvert analysis engine
5
+ Requires-Python: >=3.10
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/cande_wrapper/__init__.py
4
+ src/cande_wrapper/engine.py
5
+ src/cande_wrapper.egg-info/PKG-INFO
6
+ src/cande_wrapper.egg-info/SOURCES.txt
7
+ src/cande_wrapper.egg-info/dependency_links.txt
8
+ src/cande_wrapper.egg-info/top_level.txt
9
+ tests/test_engine.py
10
+ tests/test_integration.py
11
+ tests/test_result.py
@@ -0,0 +1,2 @@
1
+ cande_wrapper
2
+ fortran
@@ -0,0 +1,92 @@
1
+ """Unit tests for CandeEngine."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from cande_wrapper.engine import CandeEngine
9
+
10
+
11
+ class TestCandeEngineInit:
12
+ def test_default_workdir(self):
13
+ engine = CandeEngine()
14
+ assert engine.work_dir == Path.cwd()
15
+
16
+ def test_custom_workdir_string(self, tmp_path):
17
+ engine = CandeEngine(work_dir=str(tmp_path))
18
+ assert engine.work_dir == tmp_path
19
+
20
+ def test_custom_workdir_path(self, tmp_path):
21
+ engine = CandeEngine(work_dir=tmp_path)
22
+ assert engine.work_dir == tmp_path
23
+
24
+
25
+ class TestCheckInput:
26
+ def test_exists(self, tmp_workdir):
27
+ engine = CandeEngine(work_dir=tmp_workdir)
28
+ assert engine.check_input("MGK-IO") is True
29
+
30
+ def test_missing(self, tmp_path):
31
+ engine = CandeEngine(work_dir=tmp_path)
32
+ assert engine.check_input("nonexistent") is False
33
+
34
+
35
+ class TestRun:
36
+ def test_missing_cid_raises(self, tmp_path):
37
+ engine = CandeEngine(work_dir=tmp_path)
38
+ with pytest.raises(FileNotFoundError, match="Input file not found"):
39
+ engine.run("nonexistent")
40
+
41
+ def test_missing_extension_raises(self, tmp_workdir, monkeypatch):
42
+ """Simulates missing _cande extension by blocking the import."""
43
+ import sys
44
+
45
+ # Remove cached module and make it unimportable
46
+ monkeypatch.setitem(sys.modules, "cande_wrapper._cande", None)
47
+
48
+ engine = CandeEngine(work_dir=tmp_workdir)
49
+ with pytest.raises(ImportError):
50
+ engine.run("MGK-IO")
51
+
52
+ def test_success_returns_result(self, tmp_workdir, mock_fortran):
53
+ # Create the .out file that CandeResult expects
54
+ (tmp_workdir / "MGK-IO.out").write_text("NORMAL EXIT FROM CANDE")
55
+ (tmp_workdir / "MGK-IO.log").write_text("")
56
+ (tmp_workdir / "MGK-IO.ctc").write_text("")
57
+
58
+ engine = CandeEngine(work_dir=tmp_workdir)
59
+ result = engine.run("MGK-IO")
60
+
61
+ mock_fortran.run_cande.assert_called_once_with("MGK-IO")
62
+ assert result.prefix == "MGK-IO"
63
+ assert result.output_file == tmp_workdir / "MGK-IO.out"
64
+
65
+ def test_engine_error_raises(self, tmp_workdir, mock_fortran):
66
+ mock_fortran.run_cande.return_value = 1
67
+
68
+ engine = CandeEngine(work_dir=tmp_workdir)
69
+ with pytest.raises(RuntimeError, match="error code 1"):
70
+ engine.run("MGK-IO")
71
+
72
+ def test_restores_cwd_on_success(self, tmp_workdir, mock_fortran):
73
+ original = os.getcwd()
74
+ engine = CandeEngine(work_dir=tmp_workdir)
75
+ engine.run("MGK-IO")
76
+ assert os.getcwd() == original
77
+
78
+ def test_restores_cwd_on_error(self, tmp_workdir, mock_fortran):
79
+ mock_fortran.run_cande.return_value = 1
80
+ original = os.getcwd()
81
+ engine = CandeEngine(work_dir=tmp_workdir)
82
+ with pytest.raises(RuntimeError):
83
+ engine.run("MGK-IO")
84
+ assert os.getcwd() == original
85
+
86
+ def test_restores_cwd_on_exception(self, tmp_workdir, mock_fortran):
87
+ mock_fortran.run_cande.side_effect = Exception("boom")
88
+ original = os.getcwd()
89
+ engine = CandeEngine(work_dir=tmp_workdir)
90
+ with pytest.raises(Exception, match="boom"):
91
+ engine.run("MGK-IO")
92
+ assert os.getcwd() == original
@@ -0,0 +1,39 @@
1
+ """Integration tests that require the compiled Fortran extension.
2
+
3
+ Run with: pytest -m integration
4
+ These are skipped by default in normal test runs.
5
+ """
6
+
7
+ import shutil
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ from cande_wrapper.engine import CandeEngine
13
+
14
+ EXAMPLE_DATA = Path(__file__).parent / "example_data"
15
+
16
+
17
+ @pytest.mark.integration
18
+ class TestIntegration:
19
+ def test_run_example_problem(self, tmp_path):
20
+ """Run the MGK-IO example and verify CANDE completes normally."""
21
+ shutil.copy2(EXAMPLE_DATA / "MGK-IO.cid", tmp_path / "MGK-IO.cid")
22
+
23
+ engine = CandeEngine(work_dir=tmp_path)
24
+ result = engine.run("MGK-IO")
25
+
26
+ assert result.output_file.exists()
27
+ output = result.output_text
28
+ assert "NORMAL EXIT FROM CANDE" in output
29
+
30
+ def test_output_files_created(self, tmp_path):
31
+ """Verify all expected output files are created."""
32
+ shutil.copy2(EXAMPLE_DATA / "MGK-IO.cid", tmp_path / "MGK-IO.cid")
33
+
34
+ engine = CandeEngine(work_dir=tmp_path)
35
+ result = engine.run("MGK-IO")
36
+
37
+ assert result.output_file.exists()
38
+ assert result.log_file.exists()
39
+ assert result.toc_file.exists()
@@ -0,0 +1,36 @@
1
+ """Unit tests for CandeResult."""
2
+
3
+ from pathlib import Path
4
+
5
+ from cande_wrapper.engine import CandeResult
6
+
7
+
8
+ class TestCandeResult:
9
+ def test_paths(self, tmp_path):
10
+ result = CandeResult(tmp_path, "EX1")
11
+ assert result.output_file == tmp_path / "EX1.out"
12
+ assert result.log_file == tmp_path / "EX1.log"
13
+ assert result.toc_file == tmp_path / "EX1.ctc"
14
+
15
+ def test_prefix(self, tmp_path):
16
+ result = CandeResult(tmp_path, "test_problem")
17
+ assert result.prefix == "test_problem"
18
+
19
+ def test_repr(self, tmp_path):
20
+ result = CandeResult(tmp_path, "EX1")
21
+ r = repr(result)
22
+ assert "EX1" in r
23
+ assert "CandeResult" in r
24
+
25
+ def test_output_text(self, tmp_path):
26
+ (tmp_path / "EX1.out").write_text("line 1\nline 2\n")
27
+ result = CandeResult(tmp_path, "EX1")
28
+ assert result.output_text == "line 1\nline 2\n"
29
+
30
+ def test_output_text_missing_file(self, tmp_path):
31
+ result = CandeResult(tmp_path, "EX1")
32
+ try:
33
+ _ = result.output_text
34
+ assert False, "Should have raised FileNotFoundError"
35
+ except FileNotFoundError:
36
+ pass