mfai-viewer 0.1.0__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.
Files changed (42) hide show
  1. mfai_viewer-0.1.0/.gitignore +126 -0
  2. mfai_viewer-0.1.0/PKG-INFO +51 -0
  3. mfai_viewer-0.1.0/README.md +28 -0
  4. mfai_viewer-0.1.0/pyproject.toml +41 -0
  5. mfai_viewer-0.1.0/src/mfai_viewer/__init__.py +24 -0
  6. mfai_viewer-0.1.0/src/mfai_viewer/__main__.py +5 -0
  7. mfai_viewer-0.1.0/src/mfai_viewer/cli.py +190 -0
  8. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/__init__.py +0 -0
  9. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/__init__.py +0 -0
  10. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/cln/__init__.py +0 -0
  11. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/cln/types.py +12 -0
  12. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/cln/types_mesh.py +169 -0
  13. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/cln/types_params.py +120 -0
  14. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/cln/types_source.py +119 -0
  15. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/cln/types_targets.py +132 -0
  16. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/exceptions.py +693 -0
  17. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/models/__init__.py +3 -0
  18. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/models/bc.py +960 -0
  19. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/models/config_base.py +48 -0
  20. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/models/config_package.py +567 -0
  21. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/package_types/__init__.py +280 -0
  22. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/package_types/base.py +75 -0
  23. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/package_types/import_capabilities/index.json +47 -0
  24. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/package_types/import_capabilities/mf2005.json +325 -0
  25. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/package_types/import_capabilities/mf6.json +2187 -0
  26. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/package_types/import_capabilities/mfusg.json +1276 -0
  27. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/package_types/import_capabilities.py +281 -0
  28. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/package_types/mf6_bc.py +234 -0
  29. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/package_types/mf6_core.py +260 -0
  30. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/package_types/mf6_hfb.py +34 -0
  31. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/package_types/mfusg_bc.py +24 -0
  32. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/package_types/mfusg_core.py +198 -0
  33. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/package_types/write_forms.py +92 -0
  34. mfai_viewer-0.1.0/src/mfai_viewer/gwlink/backend/core/viewer_snapshot.py +1012 -0
  35. mfai_viewer-0.1.0/src/mfai_viewer/sources.json +215 -0
  36. mfai_viewer-0.1.0/src/mfai_viewer/upload.py +124 -0
  37. mfai_viewer-0.1.0/src/mfai_viewer/writer/__init__.py +22 -0
  38. mfai_viewer-0.1.0/src/mfai_viewer/writer/fgb.py +164 -0
  39. mfai_viewer-0.1.0/src/mfai_viewer/writer/flopy_routes.py +1902 -0
  40. mfai_viewer-0.1.0/src/mfai_viewer/writer/isosurface.py +610 -0
  41. mfai_viewer-0.1.0/src/mfai_viewer/writer/payloads3d.py +363 -0
  42. mfai_viewer-0.1.0/src/mfai_viewer/writer/snapshot.py +116 -0
@@ -0,0 +1,126 @@
1
+ # Dependencies
2
+ node_modules/
3
+ .pnpm-debug.log*
4
+ .yarn/*
5
+ !.yarn/releases
6
+ !.yarn/plugins
7
+ .yarnrc.yml
8
+
9
+ # Environment files - NEVER commit these!
10
+ .env
11
+ .env.local
12
+ .env.*.local
13
+ *.env
14
+ **/.env
15
+ **/.env.local
16
+
17
+ # SSH keys - NEVER commit these!
18
+ *.pem
19
+ *.key
20
+ id_rsa*
21
+ id_ed25519*
22
+ *.ppk
23
+
24
+ # API tokens and secrets
25
+ *secret*
26
+ *token*
27
+ *apikey*
28
+ .secrets/
29
+
30
+ # Build outputs
31
+ dist/
32
+ build/
33
+ .next/
34
+ .turbo/
35
+ *.tsbuildinfo
36
+
37
+ # Testing
38
+ .playwright-mcp/
39
+ playwright-report/
40
+ test-results/
41
+ coverage/
42
+
43
+ # Archive and reference directories
44
+ .archive/
45
+ .references/
46
+ .docs/
47
+
48
+ # Local development
49
+ .vscode/
50
+ .idea/
51
+ *.swp
52
+ *.swo
53
+ *~
54
+
55
+ # Logs
56
+ *.log
57
+ logs/
58
+
59
+ # OS files
60
+ .DS_Store
61
+ Thumbs.db
62
+
63
+ # Cloudflare Wrangler
64
+ .wrangler/
65
+ .dev.vars
66
+
67
+ # Compiled MCP tools
68
+ .tools-compiled/
69
+ stdio/dist/
70
+ stdio/stdio/
71
+
72
+ # Data directories
73
+ data/
74
+ examples/
75
+ flopy-tutorials-*/
76
+ scripts/modflow6_examples_data/
77
+ scripts/plots/
78
+ scripts/temp/
79
+ scripts/*.tif
80
+ scripts/*.json
81
+
82
+ # Temporary files
83
+ *.png
84
+ !mfai-chat/public/**/*.png
85
+ !assets/**/*.png
86
+ deploy_to_vm.py
87
+ deploy_to_vm.sh
88
+ update_vm_packages.sh
89
+ fix_*.py
90
+
91
+ # Auto-generated
92
+ next-env.d.ts
93
+
94
+ # Python
95
+ __pycache__/
96
+ *.py[cod]
97
+ *$py.class
98
+ *.so
99
+ .Python
100
+ env/
101
+ venv/
102
+ .venv/
103
+
104
+ # Ignore tutorial outputs and runtime files
105
+ examples/**/output/
106
+ examples/**/plots/
107
+ examples/**/*.png
108
+ examples/**/*.jpg
109
+ examples/**/*.csv
110
+ examples/**/simulation/
111
+ examples/**/__pycache__/
112
+ examples/**/*.pyc
113
+
114
+ # Ignore test/workspace directories
115
+ workspace/
116
+ test_tutorials/
117
+ tutorial_test_env/
118
+ fixed_tutorials/
119
+ fixed_scripts/
120
+
121
+ # But keep the Python files!
122
+ !examples/**/*.py
123
+
124
+ # Keep documentation
125
+ !*.md
126
+ python_executor/modflow-executor-with-tutorials.tar.gz
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.5
2
+ Name: mfai-viewer
3
+ Version: 0.1.0
4
+ Summary: Write and upload the MODFLOW AI web viewer snapshot of a MODFLOW 6 model that was run with FloPy
5
+ Project-URL: Homepage, https://www.modflow.ai
6
+ Project-URL: Source, https://github.com/modflowai/modflowai-mcp
7
+ Author-email: GWLab <admin@modflow.ai>
8
+ License-Expression: MIT
9
+ Keywords: flopy,groundwater,modflow,viewer
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Topic :: Scientific/Engineering :: Hydrology
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: flatbuffers>=23.5
16
+ Requires-Dist: flopy>=3.5
17
+ Requires-Dist: matplotlib>=3.7
18
+ Requires-Dist: numpy>=1.24
19
+ Requires-Dist: pydantic>=2.0
20
+ Requires-Dist: scipy>=1.10
21
+ Requires-Dist: shapely>=2.0
22
+ Description-Content-Type: text/markdown
23
+
24
+ # mfai-viewer
25
+
26
+ The writer behind the `open_in_viewer` tool of the MODFLOW AI MCP
27
+ (`https://mcp.modflow.ai`). It turns a MODFLOW 6 simulation that was built
28
+ with FloPy and run on this machine into the snapshot the MODFLOW AI web
29
+ viewer (`viewer.modflow.ai`) reads, and uploads that snapshot to the link
30
+ the MCP tool created.
31
+
32
+ ```sh
33
+ python -m pip install mfai-viewer
34
+ mfai-viewer snapshot --workdir <model dir> --out <snapshot dir>
35
+ mfai-viewer upload <snapshot dir> "<uploads url from open_in_viewer>"
36
+ mfai-viewer version
37
+ ```
38
+
39
+ `snapshot` refuses a model that has not been run (no head file). It writes
40
+ `manifest.json` and a small `summary.json`; the MCP tool takes the summary
41
+ and answers the one `upload` command to run. `upload` sends every file in
42
+ parallel, retries, marks the link complete and prints the URL.
43
+
44
+ Exit codes: 0 done, 2 the environment cannot run it (Python older than 3.10,
45
+ missing packages, bad arguments), 3 the model has not been run, 4 files are
46
+ still missing after the upload, 1 anything else.
47
+
48
+ The writer code (`mfai_viewer/writer`) and the GWlink slice it vendors
49
+ (`mfai_viewer/gwlink`) are copied byte for byte from the mfai-chat and gwlink
50
+ repositories; `mfai_viewer/sources.json` records the commits and the sha256
51
+ of every file. Nothing is edited here.
@@ -0,0 +1,28 @@
1
+ # mfai-viewer
2
+
3
+ The writer behind the `open_in_viewer` tool of the MODFLOW AI MCP
4
+ (`https://mcp.modflow.ai`). It turns a MODFLOW 6 simulation that was built
5
+ with FloPy and run on this machine into the snapshot the MODFLOW AI web
6
+ viewer (`viewer.modflow.ai`) reads, and uploads that snapshot to the link
7
+ the MCP tool created.
8
+
9
+ ```sh
10
+ python -m pip install mfai-viewer
11
+ mfai-viewer snapshot --workdir <model dir> --out <snapshot dir>
12
+ mfai-viewer upload <snapshot dir> "<uploads url from open_in_viewer>"
13
+ mfai-viewer version
14
+ ```
15
+
16
+ `snapshot` refuses a model that has not been run (no head file). It writes
17
+ `manifest.json` and a small `summary.json`; the MCP tool takes the summary
18
+ and answers the one `upload` command to run. `upload` sends every file in
19
+ parallel, retries, marks the link complete and prints the URL.
20
+
21
+ Exit codes: 0 done, 2 the environment cannot run it (Python older than 3.10,
22
+ missing packages, bad arguments), 3 the model has not been run, 4 files are
23
+ still missing after the upload, 1 anything else.
24
+
25
+ The writer code (`mfai_viewer/writer`) and the GWlink slice it vendors
26
+ (`mfai_viewer/gwlink`) are copied byte for byte from the mfai-chat and gwlink
27
+ repositories; `mfai_viewer/sources.json` records the commits and the sha256
28
+ of every file. Nothing is edited here.
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mfai-viewer"
7
+ version = "0.1.0"
8
+ description = "Write and upload the MODFLOW AI web viewer snapshot of a MODFLOW 6 model that was run with FloPy"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "GWLab", email = "admin@modflow.ai" }]
13
+ keywords = ["modflow", "flopy", "groundwater", "viewer"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ "Operating System :: OS Independent",
18
+ "Topic :: Scientific/Engineering :: Hydrology",
19
+ ]
20
+ dependencies = [
21
+ "numpy>=1.24",
22
+ "flopy>=3.5",
23
+ "flatbuffers>=23.5",
24
+ "pydantic>=2.0",
25
+ "scipy>=1.10",
26
+ "matplotlib>=3.7",
27
+ "shapely>=2.0",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://www.modflow.ai"
32
+ Source = "https://github.com/modflowai/modflowai-mcp"
33
+
34
+ [project.scripts]
35
+ mfai-viewer = "mfai_viewer.cli:main"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/mfai_viewer"]
39
+
40
+ [tool.hatch.build.targets.sdist]
41
+ include = ["src/mfai_viewer", "README.md", "pyproject.toml"]
@@ -0,0 +1,24 @@
1
+ """mfai-viewer: the MODFLOW AI web viewer snapshot of a MODFLOW 6 model run with FloPy.
2
+
3
+ The writer itself is :mod:`mfai_viewer.writer`, a byte for byte copy of the
4
+ browser writer in the mfai-chat repository, with the GWlink slice it vendors
5
+ under :mod:`mfai_viewer.gwlink`. ``sources.json`` next to this file records
6
+ the commits and the sha256 of every copied file. This package adds the
7
+ version, the console script (``mfai-viewer snapshot | upload | version``)
8
+ and the uploader; nothing in the writer is edited here.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from pathlib import Path
15
+
16
+ from .writer import ModelNotRunError, write_snapshot # noqa: F401
17
+
18
+ __version__ = "0.1.0"
19
+ __all__ = ["ModelNotRunError", "write_snapshot", "__version__", "sources"]
20
+
21
+
22
+ def sources() -> dict:
23
+ """The commits and file hashes the package was built from."""
24
+ return json.loads((Path(__file__).parent / "sources.json").read_text())
@@ -0,0 +1,5 @@
1
+ """``python -m mfai_viewer`` behaves like the ``mfai-viewer`` console script."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,190 @@
1
+ """The ``mfai-viewer`` command: ``snapshot``, ``upload``, ``version``.
2
+
3
+ mfai-viewer snapshot --workdir <model dir> --out <snapshot dir>
4
+ mfai-viewer upload <snapshot dir> "<uploads url from open_in_viewer>"
5
+ mfai-viewer version
6
+
7
+ ``snapshot`` writes the viewer snapshot of a MODFLOW 6 simulation that was
8
+ run (``mfsim.nam`` plus a head file) and a small ``summary.json`` the MCP
9
+ tool ``open_in_viewer`` takes as its argument. ``upload`` sends the files to
10
+ the link that tool created and prints the URL.
11
+
12
+ Exit codes: 0 done, 2 the environment cannot run it (Python version, missing
13
+ packages, bad arguments), 3 the model has not been run, 4 files are still
14
+ missing after the upload, 1 anything else.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import hashlib
20
+ import json
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ from . import __version__
25
+
26
+ PACKAGE = "mfai-viewer"
27
+ REQUIRED = ["numpy", "flopy", "flatbuffers", "pydantic", "scipy", "matplotlib", "shapely"]
28
+ MIN_PYTHON = (3, 10)
29
+ SUMMARY_FILE = "summary.json"
30
+ EXIT_ENVIRONMENT = 2
31
+ EXIT_NOT_RUN = 3
32
+ EXIT_INCOMPLETE = 4
33
+
34
+
35
+ def check_environment() -> list[str]:
36
+ missing = []
37
+ for name in REQUIRED:
38
+ try:
39
+ __import__(name)
40
+ except ImportError:
41
+ missing.append(name)
42
+ return missing
43
+
44
+
45
+ def version_info() -> dict:
46
+ from . import sources
47
+
48
+ src = sources()
49
+ return {
50
+ "name": PACKAGE,
51
+ "version": __version__,
52
+ "python": sys.version.split()[0],
53
+ "sources": src.get("sources", {}),
54
+ }
55
+
56
+
57
+ def write_summary(out: Path, report: dict, workdir: Path) -> dict:
58
+ """Stamp the writer into ``manifest.json`` and write ``summary.json``.
59
+
60
+ The manifest gains ``writer: {name, version}`` (the link service and the
61
+ web build ignore keys they do not read). The summary is what the agent
62
+ hands to ``open_in_viewer``: a few KB, not the whole manifest. The link
63
+ service creates one upload slot per ``files`` entry plus one for the
64
+ manifest itself, whose sha256 it verifies on upload.
65
+ """
66
+ manifest_path = out / "manifest.json"
67
+ manifest = json.loads(manifest_path.read_text())
68
+ manifest["writer"] = {"name": PACKAGE, "version": __version__}
69
+ manifest_path.write_text(json.dumps(manifest, indent=1))
70
+ manifest_bytes = manifest_path.read_bytes()
71
+ files = [[f["key"], int(f["bytes"])] for f in manifest["files"]]
72
+ results = manifest.get("results") or {}
73
+ summary = {
74
+ "schemaVersion": manifest["schemaVersion"],
75
+ "writer": manifest["writer"],
76
+ "model": {k: manifest["model"].get(k) for k in ("name", "type", "ncpl", "nlay", "nper")},
77
+ "cells": report["cells"],
78
+ "results": {
79
+ "included": bool(results.get("included")),
80
+ "timestepsIncluded": len(results.get("timestepsIncluded") or []),
81
+ "timestepsTotal": int(results.get("timestepsTotal") or 0),
82
+ },
83
+ "manifest": {"bytes": len(manifest_bytes), "sha256": hashlib.sha256(manifest_bytes).hexdigest()},
84
+ "files": files,
85
+ "bytes": sum(b for _, b in files),
86
+ "snapshotDir": str(out),
87
+ "workdir": str(workdir),
88
+ }
89
+ (out / SUMMARY_FILE).write_text(json.dumps(summary, separators=(",", ":")) + "\n")
90
+ return summary
91
+
92
+
93
+ def cmd_snapshot(args: argparse.Namespace) -> int:
94
+ if sys.version_info < MIN_PYTHON:
95
+ print(f"{PACKAGE} needs Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]} or newer; this is {sys.version.split()[0]}. "
96
+ f"Install it in a newer interpreter: python3.12 -m pip install {PACKAGE}", file=sys.stderr)
97
+ return EXIT_ENVIRONMENT
98
+ missing = check_environment()
99
+ if missing:
100
+ print(f"Missing Python packages: {' '.join(missing)}. Reinstall {PACKAGE} in this interpreter so pip brings them:\n"
101
+ f" {sys.executable} -m pip install --force-reinstall {PACKAGE}", file=sys.stderr)
102
+ return EXIT_ENVIRONMENT
103
+
104
+ workdir = Path(args.workdir).expanduser().resolve()
105
+ out = Path(args.out).expanduser().resolve()
106
+ if not workdir.is_dir():
107
+ print(f"no such directory: {workdir}", file=sys.stderr)
108
+ return EXIT_ENVIRONMENT
109
+ if not (workdir / "mfsim.nam").is_file():
110
+ print(f"{workdir} holds no mfsim.nam: pass the directory of the MODFLOW 6 simulation that was run.", file=sys.stderr)
111
+ return EXIT_ENVIRONMENT
112
+ if out.exists() and any(out.iterdir()):
113
+ print(f"{out} is not empty: pass a new or empty --out directory.", file=sys.stderr)
114
+ return EXIT_ENVIRONMENT
115
+
116
+ from .writer import ModelNotRunError, write_snapshot
117
+
118
+ kwargs = {"iso_surface_steps": args.iso_surfaces}
119
+ if args.max_head_files is not None:
120
+ kwargs["max_head_files"] = args.max_head_files
121
+ try:
122
+ report = write_snapshot(str(workdir), str(out), **kwargs)
123
+ except ModelNotRunError:
124
+ # The writer's own sentence names the browser's run_modflow; here the run is MODFLOW 6 on this machine.
125
+ print(f"This model has not been run yet: {workdir} holds no head file with timesteps. Run MODFLOW 6 on it first, then write the snapshot.", file=sys.stderr)
126
+ return EXIT_NOT_RUN
127
+
128
+ summary = write_summary(out, report, workdir)
129
+ print(json.dumps(summary, separators=(",", ":")))
130
+ print(f"snapshot written: {len(summary['files'])} files, {summary['bytes']} bytes, {summary['cells']} cells, "
131
+ f"{summary['results']['timestepsIncluded']}/{summary['results']['timestepsTotal']} timesteps, "
132
+ f"{report['write_seconds']:.1f} s. Next: call the MCP tool open_in_viewer with {out / SUMMARY_FILE}.", file=sys.stderr)
133
+ return 0
134
+
135
+
136
+ def cmd_upload(args: argparse.Namespace) -> int:
137
+ from .upload import UploadError, upload_snapshot
138
+
139
+ snapshot = Path(args.snapshot).expanduser().resolve()
140
+ if not (snapshot / "manifest.json").is_file():
141
+ print(f"{snapshot} holds no manifest.json: pass the --out directory of mfai-viewer snapshot.", file=sys.stderr)
142
+ return EXIT_ENVIRONMENT
143
+ try:
144
+ result = upload_snapshot(snapshot, args.uploads_url, workers=args.workers, log=lambda line: print(line, file=sys.stderr))
145
+ except UploadError as err:
146
+ print(str(err), file=sys.stderr)
147
+ return EXIT_INCOMPLETE if err.missing else 1
148
+ print(json.dumps(result, indent=1))
149
+ return 0
150
+
151
+
152
+ def cmd_version(_args: argparse.Namespace) -> int:
153
+ print(json.dumps(version_info(), indent=1))
154
+ return 0
155
+
156
+
157
+ def build_parser() -> argparse.ArgumentParser:
158
+ parser = argparse.ArgumentParser(prog=PACKAGE, description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
159
+ parser.add_argument("--version", action="version", version=f"{PACKAGE} {__version__}")
160
+ sub = parser.add_subparsers(dest="command", required=True)
161
+
162
+ snap = sub.add_parser("snapshot", help="write the viewer snapshot of a MODFLOW 6 simulation that was run")
163
+ snap.add_argument("--workdir", required=True, help="directory of the MODFLOW 6 simulation (mfsim.nam) after it ran")
164
+ snap.add_argument("--out", required=True, help="directory to write the snapshot into (created; must be empty or absent)")
165
+ snap.add_argument("--max-head-files", type=int, default=None, help="cap on heads files (layers x timesteps); default 90")
166
+ snap.add_argument("--iso-surfaces", choices=["last", "all", "none"], default="last", help="which timesteps get an iso-surface payload")
167
+ snap.set_defaults(func=cmd_snapshot)
168
+
169
+ up = sub.add_parser("upload", help="send a snapshot to the link open_in_viewer created and print the URL")
170
+ up.add_argument("snapshot", help="the snapshot directory (--out of mfai-viewer snapshot)")
171
+ up.add_argument("uploads_url", help="the uploads URL open_in_viewer answered")
172
+ up.add_argument("--workers", type=int, default=8, help="files in flight at once; default 8")
173
+ up.set_defaults(func=cmd_upload)
174
+
175
+ ver = sub.add_parser("version", help="print the package version and the source commits")
176
+ ver.set_defaults(func=cmd_version)
177
+ return parser
178
+
179
+
180
+ def main(argv: list[str] | None = None) -> int:
181
+ parser = build_parser()
182
+ args = parser.parse_args(argv)
183
+ try:
184
+ return int(args.func(args))
185
+ except KeyboardInterrupt:
186
+ return 1
187
+
188
+
189
+ if __name__ == "__main__":
190
+ sys.exit(main())
@@ -0,0 +1,12 @@
1
+ """CLN type facade."""
2
+ from .types_mesh import ClnConduitType, ClnGwConnection, ClnMesh, ClnNode
3
+ from .types_params import AutoOr, ClnConduitParams, ClnWellParams
4
+ from .types_source import ClnImportedData, ClnPackage, ClnSource, IMPORTED_CLN_SOURCE_ID
5
+ from .types_targets import ClnBCTarget, ResolvedClnRecord, ResolvedClnTarget, SelectionMode
6
+
7
+ __all__ = [
8
+ "AutoOr", "ClnWellParams", "ClnConduitParams", "ClnSource",
9
+ "ClnPackage", "ClnImportedData", "ClnNode", "ClnGwConnection",
10
+ "ClnConduitType", "ClnMesh", "ClnBCTarget", "ResolvedClnRecord",
11
+ "ResolvedClnTarget", "SelectionMode", "IMPORTED_CLN_SOURCE_ID",
12
+ ]
@@ -0,0 +1,169 @@
1
+ """CLN mesh output models."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Literal, Optional, Union
5
+ from pydantic import BaseModel, Field
6
+
7
+ ClnRawScalar = Union[int, float]
8
+
9
+ class ClnNode(BaseModel):
10
+ """A single CLN node — one entry in the ``MfUsgCln`` ``node_prop`` table.
11
+
12
+ Example: a vertical well partially penetrating layers 0-2 produces
13
+ three :class:`ClnNode` entries (one per layer it intersects),
14
+ each with ``ifdir=0``, ``fangle=π/2``, and a ``fleng`` equal to
15
+ the screen length within that layer.
16
+
17
+ Attributes
18
+ ----------
19
+ index : int
20
+ 1-based global IFNO (matches FloPy's ``MfUsgCln`` contract).
21
+ source_id : str
22
+ Which :class:`ClnSource` produced this node.
23
+ feature_index : int
24
+ 0-based index of the originating feature within the source.
25
+ layer : int
26
+ 0-based GWF layer hosting this node.
27
+ x, y : float
28
+ Horizontal coordinates in the model CRS.
29
+ z_top, z_bot : float
30
+ Top and bottom of the node's screen segment.
31
+ fleng : float
32
+ Screen length within this layer.
33
+ fangle : float
34
+ Inclination angle (rad). ``π/2`` for vertical wells.
35
+ ifdir : int
36
+ Direction flag — ``0=vertical``, ``1=horizontal``,
37
+ ``2=angled``.
38
+ iflin : int
39
+ Flow regime — ``0=laminar`` (default), ``-1=non-laminar``.
40
+ conduit_type : int
41
+ 1-based index into :attr:`ClnMesh.conduit_types`.
42
+ gwf_cell_id : int
43
+ 0-based host cell within its layer.
44
+ is_terminal, is_junction : bool
45
+ Network classification flags.
46
+ resolved_strt : float
47
+ Per-node starting head (defaults to ``z_bot`` / FELEV).
48
+ resolved_fskin : float
49
+ Per-node skin factor.
50
+ """
51
+ index: int
52
+ source_id: str
53
+ feature_index: int
54
+ layer: int
55
+ x: float
56
+ y: float
57
+ z_top: float
58
+ z_bot: float
59
+ fleng: float
60
+ fangle: float
61
+ ifdir: Literal[0, 1, 2]
62
+ iflin: Literal[-1, 0] = 0
63
+ conduit_type: int
64
+ gwf_cell_id: int
65
+ is_terminal: bool = False
66
+ is_junction: bool = False
67
+ resolved_strt: float
68
+ resolved_fskin: float
69
+
70
+
71
+ class ClnGwConnection(BaseModel):
72
+ """One CLN-GWF coupling record in the ``cln_gwc`` table.
73
+
74
+ Each :class:`ClnNode` produces one connection per GWF cell it
75
+ intersects. The Thiem equation parameters live here, not on the
76
+ node itself, so a single node can couple to multiple cells with
77
+ different ``flengw`` and ``faniso`` values.
78
+
79
+ Attributes
80
+ ----------
81
+ cln_node_index : int
82
+ 1-based, matches :attr:`ClnNode.index`.
83
+ gwf_node_index : int
84
+ 1-based GWF node id, computed as
85
+ ``layer * ncpl + cell_id + 1``.
86
+ connection_type : int
87
+ ``0=Thiem``, ``1=Thiem+skin``. Default ``0``.
88
+ fskin : float
89
+ Skin factor (only consulted when
90
+ ``connection_type=1``). Default ``0.0``.
91
+ flengw : float
92
+ Wetted length (m).
93
+ faniso : float
94
+ Anisotropy factor for the surrounding cell. Default ``1.0``.
95
+ icgwadi : int
96
+ Adaptive-flow flag. Default ``0``.
97
+ """
98
+ cln_node_index: int
99
+ gwf_node_index: int
100
+ connection_type: int = 0
101
+ fskin: float = 0.0
102
+ flengw: float
103
+ faniso: float = 1.0
104
+ icgwadi: int = 0
105
+
106
+
107
+ class ClnConduitType(BaseModel):
108
+ """Deduplicated conduit type table entry — one ``cln_circ`` row.
109
+
110
+ Identical (frad, conduitk) pairs are deduplicated so the
111
+ ``cln_circ`` table stays compact even when hundreds of nodes
112
+ share the same screen geometry.
113
+
114
+ Attributes
115
+ ----------
116
+ type_index : int
117
+ 1-based row index.
118
+ frad : float
119
+ Effective radius (m).
120
+ conduitk : float
121
+ Conduit conductivity.
122
+ """
123
+ type_index: int
124
+ frad: float
125
+ conduitk: float
126
+
127
+
128
+ class ClnMesh(BaseModel):
129
+ """Full CLN mesh ready for the FloPy adapter.
130
+
131
+ Example: a model with two well sources and one conduit source
132
+ produces a :class:`ClnMesh` whose ``len(nodes)`` is the total
133
+ node count, whose ``conduit_types`` deduplicates the screen
134
+ geometries, and whose ``nndcln`` records one entry per segment
135
+ (well or conduit).
136
+
137
+ Attributes
138
+ ----------
139
+ nodes : list[ClnNode]
140
+ Every CLN node, in IFNO order.
141
+ connections : list[ClnGwConnection]
142
+ Every CLN-GWF connection record.
143
+ conduit_types : list[ClnConduitType]
144
+ The deduplicated conduit type table.
145
+ nndcln : list[int]
146
+ Per-segment node counts, in segment order.
147
+ is_network : bool
148
+ Whether junction detection ran. Default ``False``.
149
+ clncon : list[list[int]], optional
150
+ Network connectivity table when :attr:`is_network` is ``True``.
151
+ """
152
+ nodes: list[ClnNode] = Field(default_factory=list)
153
+ connections: list[ClnGwConnection] = Field(default_factory=list)
154
+ conduit_types: list[ClnConduitType] = Field(default_factory=list)
155
+ nndcln: list[int] = Field(default_factory=list)
156
+ is_network: bool = False
157
+ clncon: Optional[list[list[int]]] = None
158
+ ncln: int = 0
159
+ nclnnds: int = 0
160
+ nja_cln: Optional[int] = None
161
+ iac_cln: Optional[list[int]] = None
162
+ ja_cln: Optional[list[int]] = None
163
+ node_prop: list[list[ClnRawScalar]] = Field(default_factory=list)
164
+ nclngwc: int = 0
165
+ cln_gwc: list[list[ClnRawScalar]] = Field(default_factory=list)
166
+ nconduityp: int = 1
167
+ cln_circ: list[list[ClnRawScalar]] = Field(default_factory=list)
168
+ ibound: list[int] = Field(default_factory=list)
169
+ strt: list[float] = Field(default_factory=list)