Spaces:
Sleeping
Sleeping
Commit ·
7c3bfa9
0
Parent(s):
Mountain Waves — deploy to Hugging Face Space
Browse filesRust + Python/Dash port of Hart (1995) 2-D mountain-wave model.
Full development history at github.com/swnesbitt/mountainwaves-bannonhart.
- .gitignore +33 -0
- Cargo.lock +323 -0
- Cargo.toml +27 -0
- Dockerfile +82 -0
- README.md +297 -0
- pyproject.toml +52 -0
- python/mountain_waves/__init__.py +26 -0
- python/mountain_waves/app.py +1996 -0
- python/mountain_waves/assets/climas_icon.png +0 -0
- python/mountain_waves/assets/climas_icon_64.png +0 -0
- python/mountain_waves/assets/favicon.png +0 -0
- python/mountain_waves/hrrr.py +369 -0
- python/mountain_waves/profile.py +90 -0
- python/mountain_waves/reference.py +441 -0
- python/mountain_waves/solver.py +122 -0
- run.py +53 -0
- src/lib.rs +559 -0
- stream.m +66 -0
- tlwmenu.m +800 -0
- tlwplot.m +114 -0
- uv.lock +0 -0
- validate.py +175 -0
.gitignore
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Rust build artifacts
|
| 2 |
+
/target/
|
| 3 |
+
|
| 4 |
+
# Python virtual envs
|
| 5 |
+
.venv/
|
| 6 |
+
venv/
|
| 7 |
+
env/
|
| 8 |
+
|
| 9 |
+
# Python bytecode / caches
|
| 10 |
+
__pycache__/
|
| 11 |
+
*.py[co]
|
| 12 |
+
*.egg-info/
|
| 13 |
+
.pytest_cache/
|
| 14 |
+
|
| 15 |
+
# Compiled Rust extensions (platform-specific — we rebuild in CI / Docker)
|
| 16 |
+
python/mountain_waves/_core*.so
|
| 17 |
+
python/mountain_waves/_core*.pyd
|
| 18 |
+
python/mountain_waves/_core*.dylib
|
| 19 |
+
|
| 20 |
+
# Editors
|
| 21 |
+
.idea/
|
| 22 |
+
.vscode/
|
| 23 |
+
*.swp
|
| 24 |
+
.DS_Store
|
| 25 |
+
bluesky_post.md
|
| 26 |
+
linkedin_post.md
|
| 27 |
+
target/
|
| 28 |
+
*.so
|
| 29 |
+
*.dylib
|
| 30 |
+
*.rlib
|
| 31 |
+
*.rmeta
|
| 32 |
+
__pycache__/
|
| 33 |
+
*.pyc
|
Cargo.lock
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is automatically @generated by Cargo.
|
| 2 |
+
# It is not intended for manual editing.
|
| 3 |
+
version = 4
|
| 4 |
+
|
| 5 |
+
[[package]]
|
| 6 |
+
name = "autocfg"
|
| 7 |
+
version = "1.5.0"
|
| 8 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 9 |
+
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
| 10 |
+
|
| 11 |
+
[[package]]
|
| 12 |
+
name = "cfg-if"
|
| 13 |
+
version = "1.0.4"
|
| 14 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 15 |
+
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
| 16 |
+
|
| 17 |
+
[[package]]
|
| 18 |
+
name = "crossbeam-deque"
|
| 19 |
+
version = "0.8.6"
|
| 20 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 21 |
+
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
|
| 22 |
+
dependencies = [
|
| 23 |
+
"crossbeam-epoch",
|
| 24 |
+
"crossbeam-utils",
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
[[package]]
|
| 28 |
+
name = "crossbeam-epoch"
|
| 29 |
+
version = "0.9.18"
|
| 30 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 31 |
+
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
| 32 |
+
dependencies = [
|
| 33 |
+
"crossbeam-utils",
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
[[package]]
|
| 37 |
+
name = "crossbeam-utils"
|
| 38 |
+
version = "0.8.21"
|
| 39 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 40 |
+
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
| 41 |
+
|
| 42 |
+
[[package]]
|
| 43 |
+
name = "either"
|
| 44 |
+
version = "1.15.0"
|
| 45 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 46 |
+
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
| 47 |
+
|
| 48 |
+
[[package]]
|
| 49 |
+
name = "heck"
|
| 50 |
+
version = "0.5.0"
|
| 51 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 52 |
+
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
| 53 |
+
|
| 54 |
+
[[package]]
|
| 55 |
+
name = "indoc"
|
| 56 |
+
version = "2.0.7"
|
| 57 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 58 |
+
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
| 59 |
+
dependencies = [
|
| 60 |
+
"rustversion",
|
| 61 |
+
]
|
| 62 |
+
|
| 63 |
+
[[package]]
|
| 64 |
+
name = "libc"
|
| 65 |
+
version = "0.2.185"
|
| 66 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 67 |
+
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
| 68 |
+
|
| 69 |
+
[[package]]
|
| 70 |
+
name = "matrixmultiply"
|
| 71 |
+
version = "0.3.10"
|
| 72 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 73 |
+
checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
|
| 74 |
+
dependencies = [
|
| 75 |
+
"autocfg",
|
| 76 |
+
"rawpointer",
|
| 77 |
+
]
|
| 78 |
+
|
| 79 |
+
[[package]]
|
| 80 |
+
name = "memoffset"
|
| 81 |
+
version = "0.9.1"
|
| 82 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 83 |
+
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
| 84 |
+
dependencies = [
|
| 85 |
+
"autocfg",
|
| 86 |
+
]
|
| 87 |
+
|
| 88 |
+
[[package]]
|
| 89 |
+
name = "mountain_waves_core"
|
| 90 |
+
version = "0.1.0"
|
| 91 |
+
dependencies = [
|
| 92 |
+
"ndarray",
|
| 93 |
+
"num-complex",
|
| 94 |
+
"numpy",
|
| 95 |
+
"pyo3",
|
| 96 |
+
"rayon",
|
| 97 |
+
]
|
| 98 |
+
|
| 99 |
+
[[package]]
|
| 100 |
+
name = "ndarray"
|
| 101 |
+
version = "0.16.1"
|
| 102 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 103 |
+
checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841"
|
| 104 |
+
dependencies = [
|
| 105 |
+
"matrixmultiply",
|
| 106 |
+
"num-complex",
|
| 107 |
+
"num-integer",
|
| 108 |
+
"num-traits",
|
| 109 |
+
"portable-atomic",
|
| 110 |
+
"portable-atomic-util",
|
| 111 |
+
"rawpointer",
|
| 112 |
+
]
|
| 113 |
+
|
| 114 |
+
[[package]]
|
| 115 |
+
name = "num-complex"
|
| 116 |
+
version = "0.4.6"
|
| 117 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 118 |
+
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
| 119 |
+
dependencies = [
|
| 120 |
+
"num-traits",
|
| 121 |
+
]
|
| 122 |
+
|
| 123 |
+
[[package]]
|
| 124 |
+
name = "num-integer"
|
| 125 |
+
version = "0.1.46"
|
| 126 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 127 |
+
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
| 128 |
+
dependencies = [
|
| 129 |
+
"num-traits",
|
| 130 |
+
]
|
| 131 |
+
|
| 132 |
+
[[package]]
|
| 133 |
+
name = "num-traits"
|
| 134 |
+
version = "0.2.19"
|
| 135 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 136 |
+
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
| 137 |
+
dependencies = [
|
| 138 |
+
"autocfg",
|
| 139 |
+
]
|
| 140 |
+
|
| 141 |
+
[[package]]
|
| 142 |
+
name = "numpy"
|
| 143 |
+
version = "0.22.1"
|
| 144 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 145 |
+
checksum = "edb929bc0da91a4d85ed6c0a84deaa53d411abfb387fc271124f91bf6b89f14e"
|
| 146 |
+
dependencies = [
|
| 147 |
+
"libc",
|
| 148 |
+
"ndarray",
|
| 149 |
+
"num-complex",
|
| 150 |
+
"num-integer",
|
| 151 |
+
"num-traits",
|
| 152 |
+
"pyo3",
|
| 153 |
+
"rustc-hash",
|
| 154 |
+
]
|
| 155 |
+
|
| 156 |
+
[[package]]
|
| 157 |
+
name = "once_cell"
|
| 158 |
+
version = "1.21.4"
|
| 159 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 160 |
+
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
| 161 |
+
|
| 162 |
+
[[package]]
|
| 163 |
+
name = "portable-atomic"
|
| 164 |
+
version = "1.13.1"
|
| 165 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 166 |
+
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
| 167 |
+
|
| 168 |
+
[[package]]
|
| 169 |
+
name = "portable-atomic-util"
|
| 170 |
+
version = "0.2.7"
|
| 171 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 172 |
+
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
|
| 173 |
+
dependencies = [
|
| 174 |
+
"portable-atomic",
|
| 175 |
+
]
|
| 176 |
+
|
| 177 |
+
[[package]]
|
| 178 |
+
name = "proc-macro2"
|
| 179 |
+
version = "1.0.106"
|
| 180 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 181 |
+
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
| 182 |
+
dependencies = [
|
| 183 |
+
"unicode-ident",
|
| 184 |
+
]
|
| 185 |
+
|
| 186 |
+
[[package]]
|
| 187 |
+
name = "pyo3"
|
| 188 |
+
version = "0.22.6"
|
| 189 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 190 |
+
checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884"
|
| 191 |
+
dependencies = [
|
| 192 |
+
"cfg-if",
|
| 193 |
+
"indoc",
|
| 194 |
+
"libc",
|
| 195 |
+
"memoffset",
|
| 196 |
+
"once_cell",
|
| 197 |
+
"portable-atomic",
|
| 198 |
+
"pyo3-build-config",
|
| 199 |
+
"pyo3-ffi",
|
| 200 |
+
"pyo3-macros",
|
| 201 |
+
"unindent",
|
| 202 |
+
]
|
| 203 |
+
|
| 204 |
+
[[package]]
|
| 205 |
+
name = "pyo3-build-config"
|
| 206 |
+
version = "0.22.6"
|
| 207 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 208 |
+
checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38"
|
| 209 |
+
dependencies = [
|
| 210 |
+
"once_cell",
|
| 211 |
+
"target-lexicon",
|
| 212 |
+
]
|
| 213 |
+
|
| 214 |
+
[[package]]
|
| 215 |
+
name = "pyo3-ffi"
|
| 216 |
+
version = "0.22.6"
|
| 217 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 218 |
+
checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636"
|
| 219 |
+
dependencies = [
|
| 220 |
+
"libc",
|
| 221 |
+
"pyo3-build-config",
|
| 222 |
+
]
|
| 223 |
+
|
| 224 |
+
[[package]]
|
| 225 |
+
name = "pyo3-macros"
|
| 226 |
+
version = "0.22.6"
|
| 227 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 228 |
+
checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453"
|
| 229 |
+
dependencies = [
|
| 230 |
+
"proc-macro2",
|
| 231 |
+
"pyo3-macros-backend",
|
| 232 |
+
"quote",
|
| 233 |
+
"syn",
|
| 234 |
+
]
|
| 235 |
+
|
| 236 |
+
[[package]]
|
| 237 |
+
name = "pyo3-macros-backend"
|
| 238 |
+
version = "0.22.6"
|
| 239 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 240 |
+
checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe"
|
| 241 |
+
dependencies = [
|
| 242 |
+
"heck",
|
| 243 |
+
"proc-macro2",
|
| 244 |
+
"pyo3-build-config",
|
| 245 |
+
"quote",
|
| 246 |
+
"syn",
|
| 247 |
+
]
|
| 248 |
+
|
| 249 |
+
[[package]]
|
| 250 |
+
name = "quote"
|
| 251 |
+
version = "1.0.45"
|
| 252 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 253 |
+
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
| 254 |
+
dependencies = [
|
| 255 |
+
"proc-macro2",
|
| 256 |
+
]
|
| 257 |
+
|
| 258 |
+
[[package]]
|
| 259 |
+
name = "rawpointer"
|
| 260 |
+
version = "0.2.1"
|
| 261 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 262 |
+
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
|
| 263 |
+
|
| 264 |
+
[[package]]
|
| 265 |
+
name = "rayon"
|
| 266 |
+
version = "1.12.0"
|
| 267 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 268 |
+
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
| 269 |
+
dependencies = [
|
| 270 |
+
"either",
|
| 271 |
+
"rayon-core",
|
| 272 |
+
]
|
| 273 |
+
|
| 274 |
+
[[package]]
|
| 275 |
+
name = "rayon-core"
|
| 276 |
+
version = "1.13.0"
|
| 277 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 278 |
+
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
| 279 |
+
dependencies = [
|
| 280 |
+
"crossbeam-deque",
|
| 281 |
+
"crossbeam-utils",
|
| 282 |
+
]
|
| 283 |
+
|
| 284 |
+
[[package]]
|
| 285 |
+
name = "rustc-hash"
|
| 286 |
+
version = "1.1.0"
|
| 287 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 288 |
+
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
| 289 |
+
|
| 290 |
+
[[package]]
|
| 291 |
+
name = "rustversion"
|
| 292 |
+
version = "1.0.22"
|
| 293 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 294 |
+
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
| 295 |
+
|
| 296 |
+
[[package]]
|
| 297 |
+
name = "syn"
|
| 298 |
+
version = "2.0.117"
|
| 299 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 300 |
+
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
| 301 |
+
dependencies = [
|
| 302 |
+
"proc-macro2",
|
| 303 |
+
"quote",
|
| 304 |
+
"unicode-ident",
|
| 305 |
+
]
|
| 306 |
+
|
| 307 |
+
[[package]]
|
| 308 |
+
name = "target-lexicon"
|
| 309 |
+
version = "0.12.16"
|
| 310 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 311 |
+
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
| 312 |
+
|
| 313 |
+
[[package]]
|
| 314 |
+
name = "unicode-ident"
|
| 315 |
+
version = "1.0.24"
|
| 316 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 317 |
+
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
| 318 |
+
|
| 319 |
+
[[package]]
|
| 320 |
+
name = "unindent"
|
| 321 |
+
version = "0.2.4"
|
| 322 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 323 |
+
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
|
Cargo.toml
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[package]
|
| 2 |
+
name = "mountain_waves_core"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
edition = "2021"
|
| 5 |
+
description = "Rust core for the Mountain Waves interactive visualization tool. Port of Robert E. Hart's 1995 MATLAB mountain-wave model (https://moe.met.fsu.edu/~rhart/mtnwave.html)."
|
| 6 |
+
authors = [
|
| 7 |
+
"Robert E. Hart <rhart@fsu.edu> (original MATLAB model, 1995)",
|
| 8 |
+
"Mountain Waves port (Rust + Dash)",
|
| 9 |
+
]
|
| 10 |
+
license = "MIT"
|
| 11 |
+
|
| 12 |
+
[lib]
|
| 13 |
+
name = "_core"
|
| 14 |
+
# cdylib is required for a PyO3 Python extension module.
|
| 15 |
+
crate-type = ["cdylib"]
|
| 16 |
+
|
| 17 |
+
[dependencies]
|
| 18 |
+
pyo3 = { version = "0.22", features = ["extension-module"] }
|
| 19 |
+
numpy = "0.22"
|
| 20 |
+
ndarray = "0.16"
|
| 21 |
+
num-complex = "0.4"
|
| 22 |
+
rayon = "1.10"
|
| 23 |
+
|
| 24 |
+
[profile.release]
|
| 25 |
+
opt-level = 3
|
| 26 |
+
lto = "thin"
|
| 27 |
+
codegen-units = 1
|
Dockerfile
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1.6
|
| 2 |
+
#
|
| 3 |
+
# Hugging Face Space Docker build for Mountain Waves.
|
| 4 |
+
#
|
| 5 |
+
# Two-stage build: the first stage has the Rust toolchain and produces a
|
| 6 |
+
# manylinux-ish wheel of the compiled extension; the second stage is a slim
|
| 7 |
+
# Python runtime that just installs the wheel + deps. This keeps the final
|
| 8 |
+
# image under ~400 MB without needing cargo at runtime.
|
| 9 |
+
#
|
| 10 |
+
# HF Spaces routes external traffic to whatever port the container listens
|
| 11 |
+
# on via `app_port` in README.md — we use 7860 (the HF default).
|
| 12 |
+
|
| 13 |
+
# ---------- build stage: compile the Rust extension into a wheel ----------
|
| 14 |
+
# rayon 1.12 needs rustc ≥ 1.80; pin a modern stable image so the MSRV of the
|
| 15 |
+
# transitive crate graph (rayon, pyo3, numpy) stays satisfied.
|
| 16 |
+
FROM rust:1.82-slim AS build
|
| 17 |
+
|
| 18 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 19 |
+
python3 python3-pip python3-venv python3-dev \
|
| 20 |
+
build-essential pkg-config \
|
| 21 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 22 |
+
|
| 23 |
+
RUN python3 -m pip install --break-system-packages "maturin==1.7.*"
|
| 24 |
+
|
| 25 |
+
WORKDIR /src
|
| 26 |
+
COPY Cargo.toml Cargo.lock pyproject.toml README.md ./
|
| 27 |
+
COPY src ./src
|
| 28 |
+
COPY python ./python
|
| 29 |
+
|
| 30 |
+
# Build a release wheel. -i python3 pins against the build image's 3.11
|
| 31 |
+
# interpreter; the runtime stage uses the same major.minor so the abi3
|
| 32 |
+
# wheel loads cleanly.
|
| 33 |
+
RUN maturin build --release --out /wheels -i python3
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ---------- runtime stage: Python 3.11 slim + app + wheel ----------
|
| 37 |
+
FROM python:3.11-slim
|
| 38 |
+
|
| 39 |
+
# libgomp1 is the OpenMP runtime rayon links against; eccodes pulls its own
|
| 40 |
+
# shared libs in via the eccodeslib wheel, so nothing else is needed.
|
| 41 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 42 |
+
libgomp1 \
|
| 43 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 44 |
+
|
| 45 |
+
WORKDIR /app
|
| 46 |
+
|
| 47 |
+
# Install the compiled Rust wheel + all runtime deps. Deps are listed
|
| 48 |
+
# explicitly here instead of using `pip install .` so we don't drag maturin
|
| 49 |
+
# and the build system into the runtime image.
|
| 50 |
+
COPY --from=build /wheels /wheels
|
| 51 |
+
RUN pip install --no-cache-dir /wheels/*.whl \
|
| 52 |
+
&& pip install --no-cache-dir \
|
| 53 |
+
"numpy>=1.23" \
|
| 54 |
+
"scipy>=1.10" \
|
| 55 |
+
"dash>=2.16" \
|
| 56 |
+
"plotly>=5.20" \
|
| 57 |
+
"boto3>=1.28" \
|
| 58 |
+
"xarray>=2023.1" \
|
| 59 |
+
"cfgrib>=0.9.10" \
|
| 60 |
+
"eccodes>=2.37" \
|
| 61 |
+
"eccodeslib>=2.46"
|
| 62 |
+
|
| 63 |
+
# HF Spaces runs the container as uid 1000 with /tmp as the only writable
|
| 64 |
+
# directory by default. Cache dirs that libraries write to (plotly, boto3,
|
| 65 |
+
# xdg) need to live somewhere the process can actually create files.
|
| 66 |
+
ENV HOME=/tmp \
|
| 67 |
+
XDG_CACHE_HOME=/tmp/.cache \
|
| 68 |
+
MPLCONFIGDIR=/tmp/.mplconfig
|
| 69 |
+
|
| 70 |
+
# Note: we intentionally do NOT copy python/ into the runtime image. The wheel
|
| 71 |
+
# built in the previous stage contains both the pure-Python source and the
|
| 72 |
+
# compiled _core*.so, installed into site-packages. If we also copied the
|
| 73 |
+
# source tree here, run.py's sys.path.insert(0, "./python") would shadow the
|
| 74 |
+
# wheel's copy with a _core-less source tree, and `from . import _core` would
|
| 75 |
+
# fail with "partially initialized module" (HF deploy 2026-04-18).
|
| 76 |
+
COPY run.py ./run.py
|
| 77 |
+
# README.md is rendered inside the app's "Theory & About" modal at runtime.
|
| 78 |
+
# Without this copy the modal falls back to a short placeholder.
|
| 79 |
+
COPY README.md ./README.md
|
| 80 |
+
|
| 81 |
+
EXPOSE 7860
|
| 82 |
+
CMD ["python", "run.py", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Mountain Waves
|
| 3 |
+
emoji: 🏔️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
short_description: Interactive 2-D mountain-wave visualizer (Rust + Dash)
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# Mountain Waves
|
| 14 |
+
|
| 15 |
+
An interactive **2-D linear mountain-wave visualizer** with a Rust compute
|
| 16 |
+
core and a Python/Dash front end. Live web demo:
|
| 17 |
+
<https://huggingface.co/spaces/snesbitt/mountain-waves>.
|
| 18 |
+
|
| 19 |
+
## Attribution
|
| 20 |
+
|
| 21 |
+
This project is a port and extension of the interactive MATLAB mountain-wave
|
| 22 |
+
model originally written by **Dr. Robert E. (Bob) Hart** (currently at Florida
|
| 23 |
+
State University) as a Fall 1995 seminar project for Meteo 574 at Penn State
|
| 24 |
+
University under Dr. Peter Bannon. The numerical method, user-interface
|
| 25 |
+
design, and example cases all originate from Hart's work.
|
| 26 |
+
|
| 27 |
+
> Hart, R. E., 1995: *Interactive Model for 2-D Mountain Wave Visualization.*
|
| 28 |
+
> Penn State Meteo 574 seminar project.
|
| 29 |
+
> Documentation and MATLAB source: <https://moe.met.fsu.edu/~rhart/mtnwave.html>
|
| 30 |
+
> Contact: `rhart@fsu.edu`
|
| 31 |
+
|
| 32 |
+
Bob Hart last updated the MATLAB package in March 2018 for newer MATLAB
|
| 33 |
+
versions. This port, by **Steve Nesbitt** (CliMAS, University of Illinois
|
| 34 |
+
Urbana–Champaign), preserves the physics and the MATLAB user-experience
|
| 35 |
+
conventions while replacing MATLAB-specific glue with a Rust compute core
|
| 36 |
+
and a browser-based Dash UI. Any bugs in the port are the port's fault,
|
| 37 |
+
not the original model's.
|
| 38 |
+
|
| 39 |
+
## Theory
|
| 40 |
+
|
| 41 |
+
### Problem setup
|
| 42 |
+
|
| 43 |
+
Steady, 2-D, linearized, Boussinesq flow over a smooth obstacle. A mean-state
|
| 44 |
+
horizontal wind `U(z)` and potential temperature `θ̄(z)` are prescribed; the
|
| 45 |
+
solver returns the stationary perturbation fields `(w', u', p', θ')` forced
|
| 46 |
+
by a mountain shape `h(x)`.
|
| 47 |
+
|
| 48 |
+
### The Taylor–Goldstein equation
|
| 49 |
+
|
| 50 |
+
Linearizing the Boussinesq momentum, continuity, and thermodynamic equations
|
| 51 |
+
about the mean state and taking a single horizontal Fourier mode
|
| 52 |
+
`w'(x, z) = Re{ŵ(k, z) · exp(ikx)}` reduces the full PDE system to a single
|
| 53 |
+
ODE in `z` per horizontal wavenumber `k`:
|
| 54 |
+
|
| 55 |
+
```
|
| 56 |
+
∂²ŵ/∂z² + [ l²(z) − k² ] ŵ = 0
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
This is the Taylor–Goldstein equation. The coefficient `l²(z)` is the
|
| 60 |
+
**Scorer parameter**:
|
| 61 |
+
|
| 62 |
+
```
|
| 63 |
+
l²(z) = N²(z) / U²(z) − (1/U(z)) · d²U/dz²
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
with `N² = (g/θ̄) · dθ̄/dz` the Brunt–Väisälä frequency squared. `l²` can
|
| 67 |
+
go negative — unstable stratification (`N² < 0`) or a locally positive
|
| 68 |
+
shear curvature (`d²U/dz² > 0`) can flip its sign, in which case every
|
| 69 |
+
wavenumber is evanescent at that height.
|
| 70 |
+
|
| 71 |
+
### Propagating vs. evanescent, and the Scorer condition
|
| 72 |
+
|
| 73 |
+
Within a layer of approximately uniform `l²`:
|
| 74 |
+
|
| 75 |
+
* `l² > k²` — `ŵ` is oscillatory; the wave propagates vertically.
|
| 76 |
+
* `l² < k²` — `ŵ` is evanescent; the physical branch decays with height.
|
| 77 |
+
|
| 78 |
+
A configuration with `l²_lower > l²_upper` admits a band of intermediate
|
| 79 |
+
wavenumbers that propagate in the lower layer but are evanescent aloft.
|
| 80 |
+
These partially reflect at the transition, are trapped between the surface
|
| 81 |
+
and the aloft "lid," and interfere downstream to produce the classic
|
| 82 |
+
resonant **lee-wave train** (Scorer 1949).
|
| 83 |
+
|
| 84 |
+
### Two-layer analytic solver
|
| 85 |
+
|
| 86 |
+
Each layer has uniform `L`, so `ŵ` is a linear combination of upward and
|
| 87 |
+
downward modes per layer. Three conditions close the system:
|
| 88 |
+
|
| 89 |
+
1. **Surface:** `ŵ(0, k) = ikU(0) ĥ(k)` — the linearized kinematic
|
| 90 |
+
boundary condition `w' = U · ∂h/∂x` for a stationary obstacle of shape
|
| 91 |
+
`h(x)` with Fourier transform `ĥ(k)`.
|
| 92 |
+
2. **Interface (`z = H`):** continuity of `ŵ` and `∂ŵ/∂z`.
|
| 93 |
+
3. **Top:** Sommerfeld radiation — above the interface, only the
|
| 94 |
+
upward-energy-propagating branch is retained.
|
| 95 |
+
|
| 96 |
+
This gives closed-form reflection and transmission coefficients that the
|
| 97 |
+
Rust core evaluates analytically for every `k` in parallel. It is a direct
|
| 98 |
+
port of Hart's `tlwplot.m`.
|
| 99 |
+
|
| 100 |
+
### Multi-layer propagator-matrix solver
|
| 101 |
+
|
| 102 |
+
For arbitrary `U(z), θ̄(z)` the atmosphere is discretized into a fine stack
|
| 103 |
+
of sub-layers, each with its own locally-uniform `l²_j`. Inside each
|
| 104 |
+
sub-layer the exact analytic solution is still available (oscillatory if
|
| 105 |
+
`l²_j > k²`, evanescent if `l²_j < k²`). A 2×2 transfer matrix propagates
|
| 106 |
+
`(ŵ, ∂ŵ/∂z)` between sub-layer interfaces; sweeping bottom-to-top yields the
|
| 107 |
+
full vertical structure per `k`. The top boundary is again a radiation
|
| 108 |
+
condition, here expressed in a `(σ, ŵ)` basis that stays well-conditioned
|
| 109 |
+
when `l² < k²` aloft (pure decay).
|
| 110 |
+
|
| 111 |
+
### Synthesis: inverse Fourier transform
|
| 112 |
+
|
| 113 |
+
After solving per-`k`, the physical fields are reconstructed by trapezoidal
|
| 114 |
+
inverse Fourier transform over the wavenumber grid:
|
| 115 |
+
|
| 116 |
+
```
|
| 117 |
+
w'(x, z) = ∫ ŵ(k, z) · ĥ(k) · exp(ikx) dk + c.c.
|
| 118 |
+
```
|
| 119 |
+
|
| 120 |
+
The horizontal wind perturbation `u'` is recovered from linearized
|
| 121 |
+
continuity `ik û + ∂ŵ/∂z = 0` directly in spectral space:
|
| 122 |
+
|
| 123 |
+
```
|
| 124 |
+
u'(x, z) = ∫ [ −(i/k) · ∂ŵ/∂z ] · ĥ(k) · exp(ikx) dk + c.c.
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
evaluated on the same wavenumber grid as `w'`. Both fields are displayed in
|
| 128 |
+
the UI as colored contour maps side-by-side.
|
| 129 |
+
|
| 130 |
+
### Linearized streamlines
|
| 131 |
+
|
| 132 |
+
For small perturbations, a streamline originating at upstream height `z₀`
|
| 133 |
+
is vertically displaced by
|
| 134 |
+
|
| 135 |
+
```
|
| 136 |
+
δz(x, z₀) = (1 / U(z₀)) · ∫₋∞ˣ w'(x', z₀) dx'
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
The solver integrates this along `x` for a set of evenly spaced release
|
| 140 |
+
heights to produce the overlay. When `U` varies with height, the
|
| 141 |
+
per-streamline advection speed is `U(z₀)`, not a single surface value.
|
| 142 |
+
|
| 143 |
+
### What linear theory cannot capture
|
| 144 |
+
|
| 145 |
+
This is a small-amplitude, inviscid, non-rotating, 2-D model. **Downslope
|
| 146 |
+
windstorms, hydraulic jumps, wave breaking, and critical-level nonlinear
|
| 147 |
+
amplification are fundamentally nonlinear phenomena and are not captured
|
| 148 |
+
here.** The "Near-downslope" preset in the two-layer mode approaches that
|
| 149 |
+
regime as a forcing-parameter lookup but will systematically underpredict
|
| 150 |
+
the surface winds observed in real events.
|
| 151 |
+
|
| 152 |
+
## What's in the port
|
| 153 |
+
|
| 154 |
+
1. **Two-layer analytic solver** — direct port of Hart's `tlwplot.m`.
|
| 155 |
+
Uniform Scorer parameter in each layer, analytic reflection /
|
| 156 |
+
transmission at the interface, Fourier summation over horizontal
|
| 157 |
+
wavenumbers.
|
| 158 |
+
2. **Multi-layer profile solver** — takes arbitrary `θ̄(z)` and `u(z)` and
|
| 159 |
+
solves the Taylor–Goldstein ODE per wavenumber via the propagator-matrix
|
| 160 |
+
sweep described above.
|
| 161 |
+
3. **Interactive profile editor** — drag control points on `θ̄(z)` and
|
| 162 |
+
`u(z)` graphs to prescribe arbitrary profiles; results re-render on the
|
| 163 |
+
next *Analyze flow* click.
|
| 164 |
+
4. **u′ heatmap** — rendered alongside `w'` so you can see where the wave
|
| 165 |
+
train speeds up or slows the low-level flow.
|
| 166 |
+
5. **Latitude slider** — exposes the Coriolis parameter so the Rossby-number
|
| 167 |
+
readout reflects the user's chosen latitude (the MATLAB original
|
| 168 |
+
hard-coded the pole).
|
| 169 |
+
6. **HRRR initialization** (Profile tab) — fetch the nearest HRRR analysis
|
| 170 |
+
column from NOAA's public AWS bucket to seed `θ̄(z)` and `u(z)` from a
|
| 171 |
+
real atmosphere, then drag the gold circles to edit further.
|
| 172 |
+
|
| 173 |
+
## Layout
|
| 174 |
+
|
| 175 |
+
```
|
| 176 |
+
Mountain Waves/
|
| 177 |
+
├── Cargo.toml # Rust crate manifest
|
| 178 |
+
├── pyproject.toml # maturin build + project metadata
|
| 179 |
+
├── Dockerfile # Hugging Face Spaces build
|
| 180 |
+
├── src/lib.rs # Rust compute core (PyO3 bindings)
|
| 181 |
+
├── python/mountain_waves/
|
| 182 |
+
│ ├── __init__.py
|
| 183 |
+
│ ├── app.py # Dash app entry point
|
| 184 |
+
│ ├── reference.py # pure-Python reference solver (fallback)
|
| 185 |
+
│ ├── profile.py # θ(z)/u(z) → Scorer parameter utilities
|
| 186 |
+
│ ├── hrrr.py # HRRR column fetcher (AWS)
|
| 187 |
+
│ └── solver.py # picks Rust or Python backend
|
| 188 |
+
├── run.py # launcher: `uv run python run.py`
|
| 189 |
+
├── validate.py # compares Rust vs. reference vs. MATLAB
|
| 190 |
+
└── tlwmenu.m / tlwplot.m / stream.m # Hart's original MATLAB sources
|
| 191 |
+
```
|
| 192 |
+
|
| 193 |
+
## Quick start (uv)
|
| 194 |
+
|
| 195 |
+
[`uv`](https://docs.astral.sh/uv/) is a fast Python package + environment
|
| 196 |
+
manager. One-shot setup from the project root:
|
| 197 |
+
|
| 198 |
+
```bash
|
| 199 |
+
# 1. Install uv if you don't have it
|
| 200 |
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
| 201 |
+
# or: brew install uv (macOS)
|
| 202 |
+
# or: pipx install uv
|
| 203 |
+
|
| 204 |
+
# 2. Create a virtual environment pinned to Python 3.11
|
| 205 |
+
uv venv --python 3.11
|
| 206 |
+
source .venv/bin/activate # or `.venv\Scripts\activate` on Windows
|
| 207 |
+
|
| 208 |
+
# 3. Install Python dependencies from pyproject.toml
|
| 209 |
+
uv sync
|
| 210 |
+
|
| 211 |
+
# 4. (Optional, for the Rust backend) install the Rust toolchain
|
| 212 |
+
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
| 213 |
+
|
| 214 |
+
# 5. Build the Rust extension into the active venv
|
| 215 |
+
uv run maturin develop --release --uv
|
| 216 |
+
|
| 217 |
+
# 6. Launch the web UI
|
| 218 |
+
uv run python run.py # http://127.0.0.1:8050
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
`uv sync` reads `pyproject.toml` and installs every runtime dependency
|
| 222 |
+
(`numpy`, `scipy`, `dash`, `plotly`, plus the HRRR stack `boto3`, `xarray`,
|
| 223 |
+
`cfgrib`, `eccodes`, `eccodeslib`). `maturin develop --uv` installs the
|
| 224 |
+
compiled extension into the uv-managed environment; `--release` enables
|
| 225 |
+
optimizations (typically 20×–30× faster than the pure-Python fallback).
|
| 226 |
+
|
| 227 |
+
Dev extras (`pytest` etc.):
|
| 228 |
+
|
| 229 |
+
```bash
|
| 230 |
+
uv sync --extra dev
|
| 231 |
+
```
|
| 232 |
+
|
| 233 |
+
### Pure-Python fallback (no Rust toolchain needed)
|
| 234 |
+
|
| 235 |
+
```bash
|
| 236 |
+
uv venv --python 3.11
|
| 237 |
+
source .venv/bin/activate
|
| 238 |
+
uv sync
|
| 239 |
+
uv run python run.py
|
| 240 |
+
```
|
| 241 |
+
|
| 242 |
+
The launcher detects the missing Rust extension and transparently uses
|
| 243 |
+
`python/mountain_waves/reference.py`. Every numerical result matches the
|
| 244 |
+
Rust core within floating-point tolerance; it just runs slower.
|
| 245 |
+
|
| 246 |
+
### pip alternative
|
| 247 |
+
|
| 248 |
+
```bash
|
| 249 |
+
pip install -e . # installs deps; maturin builds _core if rustc is found
|
| 250 |
+
python run.py
|
| 251 |
+
```
|
| 252 |
+
|
| 253 |
+
## Validation
|
| 254 |
+
|
| 255 |
+
```bash
|
| 256 |
+
uv run python validate.py
|
| 257 |
+
```
|
| 258 |
+
|
| 259 |
+
Runs the solver on three canonical cases (uniform atmosphere, trapped lee
|
| 260 |
+
waves, and a profile-based approximation of the trapped case) and asserts
|
| 261 |
+
reasonable amplitudes. When both the Rust and Python backends are built
|
| 262 |
+
it also cross-checks them element-wise.
|
| 263 |
+
|
| 264 |
+
## Docker / Hugging Face Space
|
| 265 |
+
|
| 266 |
+
The `Dockerfile` is a two-stage build tuned for Hugging Face Spaces
|
| 267 |
+
(SDK `docker`, port 7860). Stage 1 builds a release wheel of the Rust
|
| 268 |
+
extension on `rust:1.82-slim`; stage 2 installs that wheel onto
|
| 269 |
+
`python:3.11-slim` plus the runtime Python dependencies. The live Space
|
| 270 |
+
is at <https://huggingface.co/spaces/snesbitt/mountain-waves>.
|
| 271 |
+
|
| 272 |
+
## References
|
| 273 |
+
|
| 274 |
+
* Scorer, R. S., 1949: *Theory of waves in the lee of mountains*.
|
| 275 |
+
Q. J. R. Meteorol. Soc., **75**, 41–56.
|
| 276 |
+
* Booker, J. R., and F. P. Bretherton, 1967: *The critical layer for
|
| 277 |
+
internal gravity waves in a shear flow.* J. Fluid Mech., **27**,
|
| 278 |
+
513–539. Motivates the "Critical layer at 2 km" preset: wave
|
| 279 |
+
attenuation across `U = 0` scales as `exp(−2π √(Ri − 1/4))` for
|
| 280 |
+
`Ri > 1/4`.
|
| 281 |
+
* Durran, D. R., 1986: *Mountain Waves*, in *Mesoscale Meteorology and
|
| 282 |
+
Forecasting*, American Meteorological Society, pp. 472–492.
|
| 283 |
+
* Hart, R. E., 1995: *Interactive Model for 2-D Mountain Wave
|
| 284 |
+
Visualization.* Penn State Meteo 574 seminar project.
|
| 285 |
+
* Doyle, J. D., and D. R. Durran, 2002: *The dynamics of mountain-wave-
|
| 286 |
+
induced rotors.* J. Atmos. Sci., **59**, 186–201. Observational and
|
| 287 |
+
numerical context for the wind-reversal preset; the T-REX / Sierra
|
| 288 |
+
Rotors campaign (Grubišić et al. 2008, BAMS **89**, 1513–1533)
|
| 289 |
+
documented the atmospheric structures this preset is meant to
|
| 290 |
+
caricature.
|
| 291 |
+
|
| 292 |
+
## License
|
| 293 |
+
|
| 294 |
+
Original MATLAB model and physics © Robert E. Hart. The Rust + Python
|
| 295 |
+
port is provided for research and teaching purposes under the MIT license.
|
| 296 |
+
If you use this tool in published work, please cite Hart (1995) and the
|
| 297 |
+
[FSU documentation page](https://moe.met.fsu.edu/~rhart/mtnwave.html).
|
pyproject.toml
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["maturin>=1.7,<2.0"]
|
| 3 |
+
build-backend = "maturin"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "mountain_waves"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "Interactive 2-D mountain-wave model with Rust core and Dash front end. Port of Robert E. Hart's 1995 MATLAB mountain-wave tool."
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
requires-python = ">=3.9"
|
| 11 |
+
authors = [{ name = "Mountain Waves port (Rust + Dash)" }]
|
| 12 |
+
maintainers = [{ name = "Mountain Waves port (Rust + Dash)" }]
|
| 13 |
+
license = { text = "MIT" }
|
| 14 |
+
keywords = ["meteorology", "mountain-waves", "atmospheric-science", "taylor-goldstein", "scorer-parameter"]
|
| 15 |
+
dependencies = [
|
| 16 |
+
"numpy>=1.23",
|
| 17 |
+
"scipy>=1.10",
|
| 18 |
+
"dash>=2.16",
|
| 19 |
+
"plotly>=5.20",
|
| 20 |
+
# HRRR profile fetcher (Profile tab "Initialize from HRRR").
|
| 21 |
+
# cfgrib needs the eccodes C library. Rather than requiring a system
|
| 22 |
+
# install (brew install eccodes / apt install libeccodes-dev) we pull
|
| 23 |
+
# it in from PyPI only. Current story (verified on PyPI, 2026-04):
|
| 24 |
+
# - `cfgrib` depends on `eccodes` (Python CFFI bindings, pure-Python
|
| 25 |
+
# wheel — no binary).
|
| 26 |
+
# - `eccodes` depends on `findlibs`, which looks for `libeccodes` in
|
| 27 |
+
# an installed Python package named `eccodeslib` first.
|
| 28 |
+
# - `eccodeslib` is ECMWF's new official binary-wheel package
|
| 29 |
+
# (macOS 13+ arm64/x86_64, manylinux_2_28 x86_64/aarch64, cp310..cp314).
|
| 30 |
+
# - `ecmwflibs` is the older bundle; stale since 2024 and missing
|
| 31 |
+
# cp312+. We keep it only as a Windows fallback since `eccodeslib`
|
| 32 |
+
# has no Windows wheels.
|
| 33 |
+
# Result: `uv sync` / `pip install -e .` is enough on macOS and Linux.
|
| 34 |
+
"boto3>=1.28",
|
| 35 |
+
"xarray>=2023.1",
|
| 36 |
+
"cfgrib>=0.9.10",
|
| 37 |
+
"eccodes>=2.37",
|
| 38 |
+
"eccodeslib>=2.46 ; sys_platform != 'win32'",
|
| 39 |
+
"ecmwflibs>=0.6 ; sys_platform == 'win32'",
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
[project.urls]
|
| 43 |
+
"Original model (Hart 1995)" = "https://moe.met.fsu.edu/~rhart/mtnwave.html"
|
| 44 |
+
|
| 45 |
+
[project.optional-dependencies]
|
| 46 |
+
dev = ["maturin>=1.7", "pytest>=7"]
|
| 47 |
+
|
| 48 |
+
[tool.maturin]
|
| 49 |
+
# Mixed layout: Rust crate at project root, Python package under python/.
|
| 50 |
+
python-source = "python"
|
| 51 |
+
module-name = "mountain_waves._core"
|
| 52 |
+
features = ["pyo3/extension-module"]
|
python/mountain_waves/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Interactive 2-D mountain-wave model.
|
| 2 |
+
|
| 3 |
+
Python/Rust port of Dr. Robert E. (Bob) Hart's 1995 MATLAB mountain-wave
|
| 4 |
+
model (``tlwplot.m`` / ``tlwmenu.m`` / ``stream.m``). The numerical scheme,
|
| 5 |
+
user-interface layout, and example cases originate from Hart's Penn State
|
| 6 |
+
Meteo 574 seminar project. Documentation and MATLAB sources:
|
| 7 |
+
https://moe.met.fsu.edu/~rhart/mtnwave.html (contact: ``rhart@fsu.edu``).
|
| 8 |
+
|
| 9 |
+
Top-level package. Numerical entry points live in :mod:`mountain_waves.solver`
|
| 10 |
+
which selects the Rust extension if available and otherwise falls back to
|
| 11 |
+
the pure-Python reference implementation.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from .solver import (
|
| 15 |
+
compute_two_layer,
|
| 16 |
+
compute_from_profile,
|
| 17 |
+
streamlines,
|
| 18 |
+
backend_name,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
__all__ = [
|
| 22 |
+
"compute_two_layer",
|
| 23 |
+
"compute_from_profile",
|
| 24 |
+
"streamlines",
|
| 25 |
+
"backend_name",
|
| 26 |
+
]
|
python/mountain_waves/app.py
ADDED
|
@@ -0,0 +1,1996 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Interactive Dash front end for the Mountain Waves model.
|
| 2 |
+
|
| 3 |
+
This UI is the browser-based counterpart to the original MATLAB menu in
|
| 4 |
+
Dr. Robert E. (Bob) Hart's 1995 mountain-wave tool (``tlwmenu.m``). Hart's
|
| 5 |
+
slider layout, example cases, and readouts (Scorer condition, Rossby
|
| 6 |
+
number, dimensionless mountain height) guided the design here. Original
|
| 7 |
+
model and documentation: https://moe.met.fsu.edu/~rhart/mtnwave.html.
|
| 8 |
+
|
| 9 |
+
Two solver modes are exposed:
|
| 10 |
+
|
| 11 |
+
* **Two-layer** — sliders for the original MATLAB parameters
|
| 12 |
+
(surface wind, upper/lower Scorer, interface height, mountain geometry,
|
| 13 |
+
domain, spectrum).
|
| 14 |
+
* **Profile** — drag control points on an ``u(z)`` graph and a
|
| 15 |
+
``theta(z)`` graph to prescribe arbitrary profiles. The app converts
|
| 16 |
+
these to a Scorer parameter profile and runs the multi-layer solver.
|
| 17 |
+
|
| 18 |
+
Both modes render streamline analysis and a vertical-velocity contour
|
| 19 |
+
plot side-by-side, along with diagnostic readouts (Scorer condition,
|
| 20 |
+
Rossby number, solver backend).
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import math
|
| 26 |
+
import os
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
from typing import Tuple
|
| 29 |
+
|
| 30 |
+
import numpy as np
|
| 31 |
+
import plotly.graph_objects as go
|
| 32 |
+
from dash import Dash, Input, Output, State, ctx, dcc, html, no_update
|
| 33 |
+
|
| 34 |
+
from . import solver
|
| 35 |
+
from .profile import (
|
| 36 |
+
brunt_vaisala,
|
| 37 |
+
default_profile_heights,
|
| 38 |
+
default_theta_profile,
|
| 39 |
+
default_u_profile,
|
| 40 |
+
scorer_from_profile,
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
OMEGA = 7.292e-5
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
# README loader (for the Theory & About modal)
|
| 49 |
+
# ---------------------------------------------------------------------------
|
| 50 |
+
#
|
| 51 |
+
# The "Theory & About" link in the header opens a modal that renders the
|
| 52 |
+
# project README.md as markdown. The README lives at the project root in the
|
| 53 |
+
# dev layout, but in the Docker runtime the installed package is in
|
| 54 |
+
# site-packages while README.md is at /app/README.md (copied there by the
|
| 55 |
+
# Dockerfile). We probe both and fall back to a short placeholder.
|
| 56 |
+
|
| 57 |
+
def _load_readme_markdown() -> str:
|
| 58 |
+
"""Return README.md with the HF YAML frontmatter stripped.
|
| 59 |
+
|
| 60 |
+
Candidates are tried in order of specificity: next to the project root
|
| 61 |
+
(dev), next to the launcher at /app (Docker), and finally cwd. The HF
|
| 62 |
+
Spaces metadata block at the top (between leading ``---`` markers) is
|
| 63 |
+
not useful to end users and is stripped before returning.
|
| 64 |
+
"""
|
| 65 |
+
here = Path(__file__).resolve()
|
| 66 |
+
candidates = [
|
| 67 |
+
here.parents[2] / "README.md", # <repo>/python/mountain_waves/app.py → <repo>/README.md
|
| 68 |
+
Path("/app/README.md"), # Docker runtime
|
| 69 |
+
Path.cwd() / "README.md", # last-ditch
|
| 70 |
+
]
|
| 71 |
+
for p in candidates:
|
| 72 |
+
try:
|
| 73 |
+
if p.is_file():
|
| 74 |
+
text = p.read_text(encoding="utf-8")
|
| 75 |
+
break
|
| 76 |
+
except OSError:
|
| 77 |
+
continue
|
| 78 |
+
else:
|
| 79 |
+
return (
|
| 80 |
+
"# Mountain Waves\n\n"
|
| 81 |
+
"*README.md could not be located at runtime — see "
|
| 82 |
+
"[the project page](https://huggingface.co/spaces/snesbitt/mountain-waves).*"
|
| 83 |
+
)
|
| 84 |
+
# Strip leading YAML frontmatter (HF Spaces metadata).
|
| 85 |
+
if text.startswith("---\n"):
|
| 86 |
+
close = text.find("\n---\n", 4)
|
| 87 |
+
if close != -1:
|
| 88 |
+
text = text[close + 5:]
|
| 89 |
+
return text
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
_README_MD = _load_readme_markdown()
|
| 93 |
+
LATIT_RAD_DEFAULT = math.radians(45.0)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# ---------------------------------------------------------------------------
|
| 97 |
+
# Preset scenarios
|
| 98 |
+
# ---------------------------------------------------------------------------
|
| 99 |
+
|
| 100 |
+
# Default two-layer slider values shown when the page first loads. Also
|
| 101 |
+
# what the "Reset to defaults" button in the Actions column restores.
|
| 102 |
+
# These match the initial slider-default arguments in ``_two_layer_controls``
|
| 103 |
+
# and coincide with the "trapped" preset — Hart's Example 2 — which is the
|
| 104 |
+
# most instructive starting point.
|
| 105 |
+
TWO_LAYER_DEFAULTS = {
|
| 106 |
+
"U": 20.0,
|
| 107 |
+
"L_upper": 4.0,
|
| 108 |
+
"L_lower": 10.0,
|
| 109 |
+
"H": 3.5,
|
| 110 |
+
"mtn_h": 0.5,
|
| 111 |
+
"mtn_a": 2.5,
|
| 112 |
+
"xdom": 40.0,
|
| 113 |
+
"zdom": 10.0,
|
| 114 |
+
"mink_k": 0,
|
| 115 |
+
"maxk_k": 30,
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
PRESETS = {
|
| 120 |
+
"uniform": {
|
| 121 |
+
"label": "Uniform atmosphere (Example 1)",
|
| 122 |
+
"U": 20.0,
|
| 123 |
+
"L_upper": 4.0,
|
| 124 |
+
"L_lower": 4.0,
|
| 125 |
+
"H": 3.5,
|
| 126 |
+
"mtn_h": 0.5,
|
| 127 |
+
"mtn_a": 2.5,
|
| 128 |
+
"xdom": 40.0,
|
| 129 |
+
"zdom": 10.0,
|
| 130 |
+
"mink_k": 0,
|
| 131 |
+
"maxk_k": 30,
|
| 132 |
+
},
|
| 133 |
+
"trapped": {
|
| 134 |
+
"label": "Trapped lee waves (Example 2)",
|
| 135 |
+
"U": 20.0,
|
| 136 |
+
"L_upper": 4.0,
|
| 137 |
+
"L_lower": 10.0,
|
| 138 |
+
"H": 3.5,
|
| 139 |
+
"mtn_h": 0.5,
|
| 140 |
+
"mtn_a": 2.5,
|
| 141 |
+
"xdom": 40.0,
|
| 142 |
+
"zdom": 10.0,
|
| 143 |
+
"mink_k": 0,
|
| 144 |
+
"maxk_k": 30,
|
| 145 |
+
},
|
| 146 |
+
"downslope": {
|
| 147 |
+
# Not from Hart's MATLAB (he only documented Examples 1 & 2). This is
|
| 148 |
+
# a "strong lee wave / near-downslope" case kept inside linear theory.
|
| 149 |
+
# Nh/U = L_lower * ho = 10e-4 * 800 = 0.80, just under the
|
| 150 |
+
# wave-breaking threshold (~0.85). Scorer condition ≈ 3.0, so a
|
| 151 |
+
# robust trapped-wave packet forms downstream — the closest the
|
| 152 |
+
# linear Fourier/Scorer model can get to a real downslope windstorm
|
| 153 |
+
# without blowing up, which Hart himself flagged in the "Problems"
|
| 154 |
+
# section of his documentation.
|
| 155 |
+
"label": "Strong lee waves / near-downslope (linear-theory edge)",
|
| 156 |
+
"U": 25.0,
|
| 157 |
+
"L_upper": 3.0,
|
| 158 |
+
"L_lower": 10.0,
|
| 159 |
+
"H": 2.5,
|
| 160 |
+
"mtn_h": 0.8,
|
| 161 |
+
"mtn_a": 2.5,
|
| 162 |
+
"xdom": 40.0,
|
| 163 |
+
"zdom": 10.0,
|
| 164 |
+
"mink_k": 0,
|
| 165 |
+
"maxk_k": 30,
|
| 166 |
+
},
|
| 167 |
+
"critical_caricature": {
|
| 168 |
+
# Two-layer caricature of a critical-layer absorber. A real critical
|
| 169 |
+
# level requires U(z)=0 somewhere — impossible in two-layer mode —
|
| 170 |
+
# so this preset instead stacks a strongly evanescent upper layer
|
| 171 |
+
# (L_upper very small) on top of a moderate-Scorer lower layer, with
|
| 172 |
+
# the interface at 2 km. For a mountain with a=2500 m the dominant
|
| 173 |
+
# forcing wavenumbers are k ~ 4e-4, which satisfies l_upper^2 < k^2
|
| 174 |
+
# (evanescent aloft) and l_lower^2 > k^2 (propagating below).
|
| 175 |
+
# Most upgoing wave energy is reflected at the interface, so the
|
| 176 |
+
# field above 2 km decays rapidly — a linear-theory analog of
|
| 177 |
+
# Booker & Bretherton (1967) critical-level absorption. See the
|
| 178 |
+
# Profile tab for a physically faithful U(z)=0 setup.
|
| 179 |
+
"label": "Near critical-layer caricature (strong absorption at 2 km)",
|
| 180 |
+
"U": 20.0,
|
| 181 |
+
"L_upper": 1.0,
|
| 182 |
+
"L_lower": 10.0,
|
| 183 |
+
"H": 2.0,
|
| 184 |
+
"mtn_h": 0.5,
|
| 185 |
+
"mtn_a": 2.5,
|
| 186 |
+
"xdom": 40.0,
|
| 187 |
+
"zdom": 10.0,
|
| 188 |
+
"mink_k": 0,
|
| 189 |
+
"maxk_k": 30,
|
| 190 |
+
},
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def _rossby(u: float, a: float, latit_deg: float) -> float:
|
| 195 |
+
"""Return U / (f a) with f evaluated at the given latitude (degrees N)."""
|
| 196 |
+
lat = math.radians(max(0.0, min(90.0, latit_deg)))
|
| 197 |
+
f = 2.0 * OMEGA * math.sin(lat)
|
| 198 |
+
if f == 0.0 or a == 0.0:
|
| 199 |
+
return float("inf")
|
| 200 |
+
return u / (f * a)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def _two_layer_plots(params, latit_deg: float = 45.0) -> Tuple[go.Figure, go.Figure, go.Figure, float, float]:
|
| 204 |
+
"""Run the two-layer solver and build the streamline, w, and u' figures."""
|
| 205 |
+
U = params["U"]
|
| 206 |
+
L_upper = params["L_upper"] * 1e-4
|
| 207 |
+
L_lower = params["L_lower"] * 1e-4
|
| 208 |
+
H = params["H"] * 1000.0
|
| 209 |
+
a = params["mtn_a"] * 1000.0
|
| 210 |
+
ho = params["mtn_h"] * 1000.0
|
| 211 |
+
xdom = params["xdom"] * 1000.0
|
| 212 |
+
zdom = params["zdom"] * 1000.0
|
| 213 |
+
mink = params["mink_k"] / a
|
| 214 |
+
maxk = params["maxk_k"] / a
|
| 215 |
+
|
| 216 |
+
x, z, w, u_prime = solver.compute_two_layer(
|
| 217 |
+
L_upper, L_lower, U, H, a, ho, xdom, zdom, mink, maxk, npts=100
|
| 218 |
+
)
|
| 219 |
+
lines = solver.streamlines(x, z, U, w, num=10)
|
| 220 |
+
|
| 221 |
+
scorer_cond = 4.0 * H * H * (L_lower ** 2 - L_upper ** 2) / (math.pi ** 2)
|
| 222 |
+
rossby = _rossby(U, a, latit_deg)
|
| 223 |
+
|
| 224 |
+
return (*_make_plots(x, z, w, u_prime, lines, H), scorer_cond, rossby)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def _profile_plots(params, z_prof, u_prof, theta_prof, latit_deg: float = 45.0) -> Tuple[go.Figure, go.Figure, go.Figure, float, float]:
|
| 228 |
+
"""Run the multi-layer solver from prescribed profiles."""
|
| 229 |
+
U_surface = float(u_prof[0])
|
| 230 |
+
a = params["mtn_a"] * 1000.0
|
| 231 |
+
ho = params["mtn_h"] * 1000.0
|
| 232 |
+
xdom = params["xdom"] * 1000.0
|
| 233 |
+
zdom = params["zdom"] * 1000.0
|
| 234 |
+
mink = params["mink_k"] / a
|
| 235 |
+
maxk = params["maxk_k"] / a
|
| 236 |
+
|
| 237 |
+
x, z, w, u_prime = solver.compute_from_profile(
|
| 238 |
+
z_prof, u_prof, theta_prof, a, ho, xdom, zdom, mink, maxk, npts=100
|
| 239 |
+
)
|
| 240 |
+
# Pass the full u(z) profile (interpolated onto the render grid) so the
|
| 241 |
+
# streamline tracer uses the local mean wind at each streamline's height.
|
| 242 |
+
# Using a single U_surface over-amplifies upper streamlines whenever the
|
| 243 |
+
# profile has shear, because linear theory gives η(x, z₀) = (1/U(z₀)) ·
|
| 244 |
+
# ∫ w dx'.
|
| 245 |
+
u_at_z = np.interp(np.asarray(z, dtype=float), np.asarray(z_prof, dtype=float),
|
| 246 |
+
np.asarray(u_prof, dtype=float))
|
| 247 |
+
lines = solver.streamlines(x, z, u_at_z, w, num=10)
|
| 248 |
+
|
| 249 |
+
l2 = scorer_from_profile(z_prof, u_prof, theta_prof)
|
| 250 |
+
# Effective 2-layer diagnostic: split profile at midpoint of z_prof range.
|
| 251 |
+
mid = z_prof[len(z_prof) // 2]
|
| 252 |
+
below = l2[z_prof <= mid]
|
| 253 |
+
above = l2[z_prof > mid]
|
| 254 |
+
l_lower2 = float(np.mean(np.maximum(below, 0.0))) if below.size else 0.0
|
| 255 |
+
l_upper2 = float(np.mean(np.maximum(above, 0.0))) if above.size else 0.0
|
| 256 |
+
scorer_cond = 4.0 * mid ** 2 * (l_lower2 - l_upper2) / (math.pi ** 2)
|
| 257 |
+
|
| 258 |
+
rossby = _rossby(U_surface, a, latit_deg)
|
| 259 |
+
|
| 260 |
+
# Decorate the streamline plot with a dashed line showing the profile's
|
| 261 |
+
# dominant interface: the height of maximum |dL^2/dz|.
|
| 262 |
+
interface_z = None
|
| 263 |
+
if l2.size > 2:
|
| 264 |
+
dl = np.abs(np.diff(l2))
|
| 265 |
+
interface_z = float(z_prof[1:][np.argmax(dl)])
|
| 266 |
+
|
| 267 |
+
return (*_make_plots(x, z, w, u_prime, lines, interface_z), scorer_cond, rossby)
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def _make_plots(x, z, w, u_prime, lines, interface_z):
|
| 271 |
+
"""Build streamline, vertical-velocity, and u' figures from solver output."""
|
| 272 |
+
x_km = np.asarray(x) / 1000.0
|
| 273 |
+
z_km = np.asarray(z) / 1000.0
|
| 274 |
+
|
| 275 |
+
# Streamline figure — the first streamline traces the mountain surface;
|
| 276 |
+
# fill it to draw the mountain.
|
| 277 |
+
stream_fig = go.Figure()
|
| 278 |
+
mountain_color = "rgb(50, 196, 50)"
|
| 279 |
+
if lines:
|
| 280 |
+
xs, ys = lines[0]
|
| 281 |
+
xs_km = np.asarray(xs) / 1000.0
|
| 282 |
+
ys_km = np.asarray(ys) / 1000.0
|
| 283 |
+
stream_fig.add_trace(
|
| 284 |
+
go.Scatter(
|
| 285 |
+
x=np.concatenate([xs_km, [x_km[-1], x_km[0], x_km[0]]]),
|
| 286 |
+
y=np.concatenate([ys_km, [0.0, 0.0, ys_km[0]]]),
|
| 287 |
+
fill="toself",
|
| 288 |
+
mode="lines",
|
| 289 |
+
line=dict(width=1, color=mountain_color),
|
| 290 |
+
fillcolor=mountain_color,
|
| 291 |
+
name="Mountain",
|
| 292 |
+
hoverinfo="skip",
|
| 293 |
+
)
|
| 294 |
+
)
|
| 295 |
+
for xs, ys in lines[1:]:
|
| 296 |
+
stream_fig.add_trace(
|
| 297 |
+
go.Scatter(
|
| 298 |
+
x=np.asarray(xs) / 1000.0,
|
| 299 |
+
y=np.asarray(ys) / 1000.0,
|
| 300 |
+
mode="lines",
|
| 301 |
+
line=dict(width=1.2, color="white"),
|
| 302 |
+
hoverinfo="skip",
|
| 303 |
+
showlegend=False,
|
| 304 |
+
)
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
if interface_z is not None:
|
| 308 |
+
stream_fig.add_hline(
|
| 309 |
+
y=interface_z / 1000.0,
|
| 310 |
+
line=dict(color="magenta", dash="dash", width=1.5),
|
| 311 |
+
annotation_text="interface",
|
| 312 |
+
annotation_position="top right",
|
| 313 |
+
annotation_font_color="magenta",
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
stream_fig.update_layout(
|
| 317 |
+
title="Streamline Analysis",
|
| 318 |
+
xaxis_title="x (km)",
|
| 319 |
+
yaxis_title="height (km)",
|
| 320 |
+
template="plotly_dark",
|
| 321 |
+
yaxis=dict(range=[0, z_km[-1]]),
|
| 322 |
+
xaxis=dict(range=[x_km[0], x_km[-1]]),
|
| 323 |
+
margin=dict(l=60, r=20, t=50, b=50),
|
| 324 |
+
height=420,
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
# Vertical-velocity contour. Clip at ±10 m/s like the MATLAB caxis.
|
| 328 |
+
w_clip = np.clip(w, -10.0, 10.0)
|
| 329 |
+
w_fig = go.Figure(
|
| 330 |
+
data=go.Heatmap(
|
| 331 |
+
x=x_km,
|
| 332 |
+
y=z_km,
|
| 333 |
+
z=w_clip,
|
| 334 |
+
zmin=-10,
|
| 335 |
+
zmax=10,
|
| 336 |
+
colorscale="RdBu_r",
|
| 337 |
+
reversescale=False,
|
| 338 |
+
colorbar=dict(title="w (m/s)"),
|
| 339 |
+
zsmooth="best",
|
| 340 |
+
)
|
| 341 |
+
)
|
| 342 |
+
if interface_z is not None:
|
| 343 |
+
w_fig.add_hline(
|
| 344 |
+
y=interface_z / 1000.0,
|
| 345 |
+
line=dict(color="magenta", dash="dash", width=1.5),
|
| 346 |
+
)
|
| 347 |
+
w_fig.update_layout(
|
| 348 |
+
title="Vertical Velocity w (m/s)",
|
| 349 |
+
xaxis_title="x (km)",
|
| 350 |
+
yaxis_title="height (km)",
|
| 351 |
+
template="plotly_dark",
|
| 352 |
+
margin=dict(l=60, r=20, t=50, b=50),
|
| 353 |
+
height=420,
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
# Horizontal-wind perturbation u'. Scale symmetrically around zero using
|
| 357 |
+
# the 98th-percentile magnitude so outlier spikes near the mountain don't
|
| 358 |
+
# wash out the lee-wave pattern. Use a diverging colormap (PuOr) distinct
|
| 359 |
+
# from w's RdBu_r so the two fields stay visually separable at a glance.
|
| 360 |
+
up = np.asarray(u_prime)
|
| 361 |
+
up_absmax = float(np.nanpercentile(np.abs(up), 98.0))
|
| 362 |
+
if not math.isfinite(up_absmax) or up_absmax <= 0.0:
|
| 363 |
+
up_absmax = 1.0
|
| 364 |
+
up_clip = np.clip(up, -up_absmax, up_absmax)
|
| 365 |
+
uprime_fig = go.Figure(
|
| 366 |
+
data=go.Heatmap(
|
| 367 |
+
x=x_km,
|
| 368 |
+
y=z_km,
|
| 369 |
+
z=up_clip,
|
| 370 |
+
zmin=-up_absmax,
|
| 371 |
+
zmax=up_absmax,
|
| 372 |
+
colorscale="PuOr",
|
| 373 |
+
reversescale=False,
|
| 374 |
+
colorbar=dict(title="u' (m/s)"),
|
| 375 |
+
zsmooth="best",
|
| 376 |
+
)
|
| 377 |
+
)
|
| 378 |
+
if interface_z is not None:
|
| 379 |
+
uprime_fig.add_hline(
|
| 380 |
+
y=interface_z / 1000.0,
|
| 381 |
+
line=dict(color="magenta", dash="dash", width=1.5),
|
| 382 |
+
)
|
| 383 |
+
uprime_fig.update_layout(
|
| 384 |
+
title="Horizontal-wind perturbation u' (m/s)",
|
| 385 |
+
xaxis_title="x (km)",
|
| 386 |
+
yaxis_title="height (km)",
|
| 387 |
+
template="plotly_dark",
|
| 388 |
+
margin=dict(l=60, r=20, t=50, b=50),
|
| 389 |
+
height=420,
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
return stream_fig, w_fig, uprime_fig
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
# ---------------------------------------------------------------------------
|
| 396 |
+
# Layout helpers
|
| 397 |
+
# ---------------------------------------------------------------------------
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def _slider(id_, label, mn, mx, step, value, unit=""):
|
| 401 |
+
return html.Div(
|
| 402 |
+
className="slider-row",
|
| 403 |
+
children=[
|
| 404 |
+
html.Div(
|
| 405 |
+
[html.Span(label, className="slider-label"), html.Span(f"{value}{unit}", id=f"{id_}-val", className="slider-value")],
|
| 406 |
+
className="slider-header",
|
| 407 |
+
),
|
| 408 |
+
dcc.Slider(
|
| 409 |
+
id=id_,
|
| 410 |
+
min=mn,
|
| 411 |
+
max=mx,
|
| 412 |
+
step=step,
|
| 413 |
+
value=value,
|
| 414 |
+
marks=None,
|
| 415 |
+
tooltip={"placement": "bottom", "always_visible": False},
|
| 416 |
+
),
|
| 417 |
+
],
|
| 418 |
+
)
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
PROFILE_ZDOM_KM = 10.0
|
| 422 |
+
PROFILE_N_POINTS = 32
|
| 423 |
+
PROFILE_FIG_HEIGHT_PX = 680 # taller graph so 32 layers don't crowd
|
| 424 |
+
# Ranges used both for axis limits and drag clamping. Kept as module-level
|
| 425 |
+
# constants so the figure builder, the drag handler, and the redraw all agree.
|
| 426 |
+
# The U axis allows negative values so HRRR profiles whose mean flow points
|
| 427 |
+
# against the user-chosen "flow from" direction read as negative rather than
|
| 428 |
+
# getting silently clamped to 0. Theta floor is dropped to 250 K so cold
|
| 429 |
+
# continental winter columns stay visible.
|
| 430 |
+
U_RANGE = (-40.0, 80.0)
|
| 431 |
+
THETA_RANGE = (250.0, 400.0)
|
| 432 |
+
UNDO_HISTORY_MAX = 50
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
def _init_profile_figures():
|
| 436 |
+
# Defaults tuned to reproduce MATLAB "Example 2" (trapped lee waves):
|
| 437 |
+
# sharp low-level stability, weak stability aloft, light surface shear.
|
| 438 |
+
# 32 evenly-spaced levels give finer resolution in both the boundary
|
| 439 |
+
# layer and the free troposphere while keeping the column at 10 km.
|
| 440 |
+
# At n=32 the transfer-matrix solver converges to within ~1% of the
|
| 441 |
+
# two-layer analytic result for the trapped-wave case.
|
| 442 |
+
zs = default_profile_heights(PROFILE_ZDOM_KM, PROFILE_N_POINTS)
|
| 443 |
+
us = default_u_profile(zs, u_surface=20.0, shear=0.5)
|
| 444 |
+
thetas = default_theta_profile(zs, interface_km=3.5, lapse_lower=11.8, lapse_upper=1.9)
|
| 445 |
+
return zs, us, thetas
|
| 446 |
+
|
| 447 |
+
|
| 448 |
+
# ---------------------------------------------------------------------------
|
| 449 |
+
# App factory
|
| 450 |
+
# ---------------------------------------------------------------------------
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
def create_app() -> Dash:
|
| 454 |
+
# suppress_callback_exceptions is required because `controls-block` is
|
| 455 |
+
# swapped between two-layer and profile panels at runtime — the IDs
|
| 456 |
+
# referenced in the Analyze callback don't all exist in the initial layout.
|
| 457 |
+
# Ship static assets (CliMAS logo, favicon) from the package itself
|
| 458 |
+
# so the app works regardless of where it's launched from.
|
| 459 |
+
_pkg_dir = os.path.dirname(os.path.abspath(__file__))
|
| 460 |
+
_assets_dir = os.path.join(_pkg_dir, "assets")
|
| 461 |
+
|
| 462 |
+
app = Dash(
|
| 463 |
+
__name__,
|
| 464 |
+
title="Mountain Waves",
|
| 465 |
+
update_title=None,
|
| 466 |
+
suppress_callback_exceptions=True,
|
| 467 |
+
assets_folder=_assets_dir,
|
| 468 |
+
)
|
| 469 |
+
|
| 470 |
+
zs0, us0, thetas0 = _init_profile_figures()
|
| 471 |
+
|
| 472 |
+
app.layout = html.Div(
|
| 473 |
+
className="mw-root",
|
| 474 |
+
children=[
|
| 475 |
+
html.Header(
|
| 476 |
+
className="mw-header",
|
| 477 |
+
children=[
|
| 478 |
+
html.Img(
|
| 479 |
+
src=app.get_asset_url("climas_icon_64.png"),
|
| 480 |
+
alt="Climate, Meteorology & Atmospheric Sciences",
|
| 481 |
+
className="mw-logo",
|
| 482 |
+
),
|
| 483 |
+
html.Div(
|
| 484 |
+
className="mw-header-text",
|
| 485 |
+
children=[
|
| 486 |
+
html.H1("Interactive 2-D Linear Mountain Wave Visualizer"),
|
| 487 |
+
html.Div(
|
| 488 |
+
className="mw-subtitle",
|
| 489 |
+
children=[
|
| 490 |
+
html.Span("Rust + Python port of "),
|
| 491 |
+
html.A("Bob Hart's original MATLAB model", href="https://moe.met.fsu.edu/~rhart/mtnwave.html", target="_blank"),
|
| 492 |
+
html.Span(" by "),
|
| 493 |
+
html.A("Steve Nesbitt", href="https://publish.illinois.edu/swnesbitt/", target="_blank"),
|
| 494 |
+
html.Span(" — CliMAS UIUC"),
|
| 495 |
+
html.Span(f" · compute backend: {solver.backend_name()}"),
|
| 496 |
+
],
|
| 497 |
+
),
|
| 498 |
+
# "Theory & About" link sits on its own line directly
|
| 499 |
+
# under the subtitle. Clicking it toggles the modal
|
| 500 |
+
# defined at the bottom of the layout.
|
| 501 |
+
html.Div(
|
| 502 |
+
className="mw-readme-link-row",
|
| 503 |
+
children=[
|
| 504 |
+
html.Button(
|
| 505 |
+
"📖 Theory & About",
|
| 506 |
+
id="show-readme",
|
| 507 |
+
n_clicks=0,
|
| 508 |
+
className="mw-readme-link",
|
| 509 |
+
),
|
| 510 |
+
],
|
| 511 |
+
),
|
| 512 |
+
],
|
| 513 |
+
),
|
| 514 |
+
],
|
| 515 |
+
),
|
| 516 |
+
dcc.Tabs(
|
| 517 |
+
id="mode-tabs",
|
| 518 |
+
value="two-layer",
|
| 519 |
+
children=[
|
| 520 |
+
dcc.Tab(label="Two-layer (analytic)", value="two-layer"),
|
| 521 |
+
dcc.Tab(label="Profile (multi-layer)", value="profile"),
|
| 522 |
+
],
|
| 523 |
+
),
|
| 524 |
+
html.Div(
|
| 525 |
+
className="mw-global",
|
| 526 |
+
children=[
|
| 527 |
+
html.Div("Latitude (°N)", className="mw-global-label"),
|
| 528 |
+
dcc.Slider(
|
| 529 |
+
id="latit",
|
| 530 |
+
min=0,
|
| 531 |
+
max=90,
|
| 532 |
+
step=1,
|
| 533 |
+
value=45,
|
| 534 |
+
marks={0: "0°", 23: "23°", 45: "45°", 66: "66°", 90: "90°"},
|
| 535 |
+
tooltip={"placement": "bottom", "always_visible": False},
|
| 536 |
+
),
|
| 537 |
+
html.Span(id="latit-val", className="mw-global-value"),
|
| 538 |
+
html.Span(
|
| 539 |
+
"Coriolis f = 2Ω sin(φ) — affects the Rossby-number readout only.",
|
| 540 |
+
className="mw-global-note",
|
| 541 |
+
),
|
| 542 |
+
],
|
| 543 |
+
),
|
| 544 |
+
# Both control panels live in the DOM simultaneously so every
|
| 545 |
+
# callback Input/State ID always resolves; the inactive one is
|
| 546 |
+
# hidden via CSS (see the mode-tabs callback below).
|
| 547 |
+
html.Div(
|
| 548 |
+
id="controls-block",
|
| 549 |
+
children=[
|
| 550 |
+
html.Div(id="controls-two-layer", children=_two_layer_controls()),
|
| 551 |
+
html.Div(
|
| 552 |
+
id="controls-profile",
|
| 553 |
+
children=_profile_controls(zs0, us0, thetas0),
|
| 554 |
+
style={"display": "none"},
|
| 555 |
+
),
|
| 556 |
+
],
|
| 557 |
+
),
|
| 558 |
+
html.Div(
|
| 559 |
+
className="mw-diagnostics",
|
| 560 |
+
children=[
|
| 561 |
+
html.Div(id="scorer-readout", className="diag-card"),
|
| 562 |
+
html.Div(id="rossby-readout", className="diag-card"),
|
| 563 |
+
html.Div(id="nonlin-readout", className="diag-card"),
|
| 564 |
+
html.Div(id="critical-readout", className="diag-card"),
|
| 565 |
+
],
|
| 566 |
+
),
|
| 567 |
+
# Top row: w and u' heatmaps side-by-side. Streamline view lives
|
| 568 |
+
# below at full width so the trajectory pattern is legible at
|
| 569 |
+
# wide aspect ratios (it's the "money plot" of the tool).
|
| 570 |
+
html.Div(
|
| 571 |
+
className="mw-heatmaps",
|
| 572 |
+
children=[
|
| 573 |
+
dcc.Graph(id="w-plot", config={"displayModeBar": False}),
|
| 574 |
+
dcc.Graph(id="uprime-plot", config={"displayModeBar": False}),
|
| 575 |
+
],
|
| 576 |
+
),
|
| 577 |
+
html.Div(
|
| 578 |
+
className="mw-streamline-full",
|
| 579 |
+
children=[
|
| 580 |
+
dcc.Graph(id="streamline-plot", config={"displayModeBar": False}),
|
| 581 |
+
],
|
| 582 |
+
),
|
| 583 |
+
# Shared state for editable profiles.
|
| 584 |
+
dcc.Store(
|
| 585 |
+
id="profile-store",
|
| 586 |
+
data={
|
| 587 |
+
"z": zs0.tolist(),
|
| 588 |
+
"u": us0.tolist(),
|
| 589 |
+
"theta": thetas0.tolist(),
|
| 590 |
+
},
|
| 591 |
+
),
|
| 592 |
+
# Undo history — a stack of prior profile-store states. Each edit
|
| 593 |
+
# pushes the pre-edit state; undo pops the most recent entry.
|
| 594 |
+
dcc.Store(id="profile-history", data=[]),
|
| 595 |
+
# Raw HRRR column cached after a successful fetch: the east/north
|
| 596 |
+
# wind components (u, v), potential temperature, target heights,
|
| 597 |
+
# and meta info. Lets the "flow from" slider re-project the wind
|
| 598 |
+
# onto a new axis without another AWS round-trip.
|
| 599 |
+
dcc.Store(id="hrrr-raw-store", data={}),
|
| 600 |
+
html.Footer(
|
| 601 |
+
className="mw-footer",
|
| 602 |
+
children=[
|
| 603 |
+
html.Span("On the Profile tab, drag the gold circles left/right to edit u(z) and θ(z). "),
|
| 604 |
+
html.Span("Click 'Analyze flow' to update the fields."),
|
| 605 |
+
],
|
| 606 |
+
),
|
| 607 |
+
# Theory & About modal — hidden by default, shown when the user
|
| 608 |
+
# clicks the "📖 Theory & About" link in the header. The outer
|
| 609 |
+
# div is the dark backdrop (click-to-dismiss); the inner div is
|
| 610 |
+
# the card that actually contains the rendered README.
|
| 611 |
+
html.Div(
|
| 612 |
+
id="readme-modal",
|
| 613 |
+
className="mw-modal",
|
| 614 |
+
style={"display": "none"},
|
| 615 |
+
n_clicks=0,
|
| 616 |
+
children=[
|
| 617 |
+
html.Div(
|
| 618 |
+
id="readme-modal-card",
|
| 619 |
+
className="mw-modal-card",
|
| 620 |
+
# Swallow clicks inside the card so clicking text
|
| 621 |
+
# doesn't close the modal. Only the backdrop does.
|
| 622 |
+
n_clicks=0,
|
| 623 |
+
children=[
|
| 624 |
+
html.Button(
|
| 625 |
+
"×",
|
| 626 |
+
id="close-readme",
|
| 627 |
+
n_clicks=0,
|
| 628 |
+
className="mw-modal-close",
|
| 629 |
+
title="Close (Esc)",
|
| 630 |
+
),
|
| 631 |
+
dcc.Markdown(
|
| 632 |
+
_README_MD,
|
| 633 |
+
className="mw-readme-content",
|
| 634 |
+
link_target="_blank",
|
| 635 |
+
),
|
| 636 |
+
],
|
| 637 |
+
),
|
| 638 |
+
],
|
| 639 |
+
),
|
| 640 |
+
],
|
| 641 |
+
)
|
| 642 |
+
|
| 643 |
+
app.index_string = """
|
| 644 |
+
<!doctype html>
|
| 645 |
+
<html>
|
| 646 |
+
<head>
|
| 647 |
+
{%metas%}<title>{%title%}</title>{%favicon%}{%css%}
|
| 648 |
+
<style>
|
| 649 |
+
body { background: #0b0e14; color: #dfe3ea; font-family: -apple-system, system-ui, sans-serif; }
|
| 650 |
+
.mw-root { max-width: 1400px; margin: 0 auto; padding: 20px; }
|
| 651 |
+
.mw-header { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; }
|
| 652 |
+
.mw-header-text { display: flex; flex-direction: column; }
|
| 653 |
+
.mw-logo { width: 56px; height: 56px; border-radius: 6px; flex-shrink: 0; }
|
| 654 |
+
.mw-header h1 { margin: 0 0 4px 0; font-size: 24px; }
|
| 655 |
+
.mw-subtitle { color: #9aa3ad; font-size: 13px; }
|
| 656 |
+
.mw-subtitle a { color: #57b3ff; }
|
| 657 |
+
.mw-readme-link-row { margin-top: 6px; }
|
| 658 |
+
.mw-readme-link { background: #1e2835; border: 1px solid #2d3a4b; color: #9cd2ff; padding: 3px 12px; border-radius: 4px; font-size: 12px; cursor: pointer; font-family: inherit; }
|
| 659 |
+
.mw-readme-link:hover { background: #2a3a4e; color: #c7e4ff; border-color: #3b86e6; }
|
| 660 |
+
.mw-modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.72); display: flex; align-items: flex-start; justify-content: center; z-index: 9999; padding: 40px 20px; overflow-y: auto; }
|
| 661 |
+
.mw-modal-card { position: relative; background: #0f141c; border: 1px solid #2d3a4b; border-radius: 8px; max-width: 860px; width: 100%; padding: 24px 36px 28px 36px; color: #dfe3ea; box-shadow: 0 16px 40px rgba(0,0,0,0.6); }
|
| 662 |
+
.mw-modal-close { position: absolute; top: 8px; right: 12px; background: transparent; border: none; color: #9aa3ad; font-size: 26px; line-height: 1; cursor: pointer; padding: 4px 10px; font-family: inherit; }
|
| 663 |
+
.mw-modal-close:hover { color: #ffffff; }
|
| 664 |
+
.mw-readme-content h1 { font-size: 24px; color: #6ecbff; margin-top: 0; border-bottom: 1px solid #2d3a4b; padding-bottom: 8px; }
|
| 665 |
+
.mw-readme-content h2 { font-size: 18px; color: #6ecbff; margin-top: 22px; }
|
| 666 |
+
.mw-readme-content h3 { font-size: 15px; color: #9cd2ff; margin-top: 18px; }
|
| 667 |
+
.mw-readme-content p, .mw-readme-content li { color: #cbd3dc; font-size: 13px; line-height: 1.55; }
|
| 668 |
+
.mw-readme-content a { color: #57b3ff; }
|
| 669 |
+
.mw-readme-content code { background: #131a24; padding: 1px 5px; border-radius: 3px; color: #ffd685; font-family: Monaco, Menlo, "Courier New", monospace; font-size: 12px; }
|
| 670 |
+
.mw-readme-content pre { background: #131a24; padding: 10px 14px; border-radius: 4px; overflow-x: auto; border: 1px solid #1f2632; }
|
| 671 |
+
.mw-readme-content pre code { background: transparent; padding: 0; color: #dfe3ea; }
|
| 672 |
+
.mw-readme-content blockquote { border-left: 3px solid #3b86e6; margin: 12px 0; padding: 2px 14px; color: #c7ced6; background: #131a24; }
|
| 673 |
+
.mw-controls { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px 28px; background: #11161f; padding: 18px; border-radius: 8px; margin: 14px 0; }
|
| 674 |
+
.mw-controls.profile { grid-template-columns: 1fr 1fr 1fr; }
|
| 675 |
+
/* Two-layer tab mirrors the profile tab's 3-column grid: sliders | sliders | Actions. */
|
| 676 |
+
.mw-controls.two-layer { grid-template-columns: 1fr 1fr 1fr; }
|
| 677 |
+
.slider-row { display: flex; flex-direction: column; gap: 4px; }
|
| 678 |
+
.slider-header { display: flex; justify-content: space-between; font-size: 13px; }
|
| 679 |
+
.slider-label { color: #c7ced6; }
|
| 680 |
+
.slider-value { color: #6ecbff; font-variant-numeric: tabular-nums; }
|
| 681 |
+
.mw-presets { display: flex; gap: 8px; margin: 10px 0 0 0; }
|
| 682 |
+
.mw-presets button, .mw-analyze { background: #1e2835; border: 1px solid #2d3a4b; color: #dfe3ea; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
| 683 |
+
.mw-presets button:hover, .mw-analyze:hover { background: #2a3a4e; }
|
| 684 |
+
.mw-analyze { background: #2469c6; border-color: #3b86e6; font-weight: 600; padding: 8px 18px; }
|
| 685 |
+
.mw-analyze:hover { background: #3079d8; }
|
| 686 |
+
.mw-diagnostics { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin: 10px 0; }
|
| 687 |
+
.diag-card { background: #11161f; padding: 10px 16px; border-radius: 6px; font-size: 14px; }
|
| 688 |
+
.diag-card .v { color: #ffd685; font-weight: 700; margin-left: 8px; font-variant-numeric: tabular-nums; }
|
| 689 |
+
.diag-card .note { color: #9aa3ad; font-size: 12px; margin-left: 10px; }
|
| 690 |
+
.mw-plots { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
| 691 |
+
.mw-heatmaps { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
| 692 |
+
.mw-streamline-full { margin-top: 12px; }
|
| 693 |
+
.mw-streamline-full > .dash-graph { width: 100%; }
|
| 694 |
+
.mw-profile-editors { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 6px; }
|
| 695 |
+
.mw-profile-toolbar { display: flex; align-items: center; gap: 10px; margin-top: 10px; }
|
| 696 |
+
.mw-undo { background: #1e2835; border: 1px solid #2d3a4b; color: #dfe3ea; padding: 5px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
| 697 |
+
.mw-undo:hover:not(:disabled) { background: #2a3a4e; }
|
| 698 |
+
.mw-undo:disabled { opacity: 0.4; cursor: default; }
|
| 699 |
+
.mw-undo-status { color: #8d97a2; font-size: 12px; }
|
| 700 |
+
/* HRRR card takes the first 2 of 3 columns of .mw-controls.profile (so
|
| 701 |
+
its one-line input row has room), and the Actions card sits in the
|
| 702 |
+
remaining column to its right. */
|
| 703 |
+
.mw-hrrr-section { grid-column: span 2; }
|
| 704 |
+
.mw-actions-section { grid-column: span 1; display: flex; flex-direction: column; gap: 10px; }
|
| 705 |
+
.mw-action-btn { width: 100%; padding: 10px 14px; font-size: 14px; text-align: center; background: #1e2835; border: 1px solid #2d3a4b; color: #dfe3ea; border-radius: 6px; cursor: pointer; }
|
| 706 |
+
.mw-action-btn:hover { background: #2a3a4e; }
|
| 707 |
+
.mw-action-btn.mw-analyze { background: #2469c6; border-color: #3b86e6; font-weight: 600; }
|
| 708 |
+
.mw-action-btn.mw-analyze:hover { background: #3079d8; }
|
| 709 |
+
.mw-hrrr-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 6px; }
|
| 710 |
+
.mw-hrrr-row-compact { gap: 8px; row-gap: 4px; flex-wrap: nowrap; width: 100%; }
|
| 711 |
+
.mw-hrrr-lbl { font-size: 11px; color: #9aa3ad; min-width: 0; white-space: nowrap; flex-shrink: 0; }
|
| 712 |
+
.mw-hrrr-input { background: #0f1520; border: 1px solid #2d3a4b; color: #dfe3ea; padding: 2px 6px; border-radius: 4px; font-size: 11px; width: 70px; font-family: inherit; flex-shrink: 0; }
|
| 713 |
+
.mw-hrrr-input.wide { flex: 1 1 auto; min-width: 110px; width: auto; }
|
| 714 |
+
.mw-hrrr-btn { font-size: 11px; padding: 3px 12px; margin-left: auto; flex-shrink: 0; }
|
| 715 |
+
.mw-hrrr-status { color: #9aa3ad; font-size: 11px; margin-top: 4px; min-height: 14px; }
|
| 716 |
+
.mw-hrrr-status.error { color: #ff8a8a; }
|
| 717 |
+
.mw-hrrr-status.ok { color: #89d185; }
|
| 718 |
+
/* Flow-from slider row — label on the left, wide slider stretching to the
|
| 719 |
+
edge, current value pinned on the right. */
|
| 720 |
+
.mw-hrrr-dir-row { display: flex; align-items: center; gap: 10px; margin-top: 10px; width: 100%; }
|
| 721 |
+
.mw-hrrr-dir-row > label { min-width: 60px; flex-shrink: 0; }
|
| 722 |
+
.mw-hrrr-dir-slider { flex: 1 1 auto; min-width: 0; padding: 0 8px; }
|
| 723 |
+
.mw-hrrr-dir-val { color: #6ecbff; font-variant-numeric: tabular-nums; font-weight: 600; min-width: 56px; text-align: right; flex-shrink: 0; font-size: 12px; }
|
| 724 |
+
.mw-profile-diagnostics { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 8px; }
|
| 725 |
+
.mw-footer { margin-top: 20px; color: #8f98a3; font-size: 12px; }
|
| 726 |
+
.mw-global { display: grid; grid-template-columns: 130px 1fr 80px 1fr; align-items: center; gap: 14px; background: #131a24; padding: 10px 16px; border-radius: 6px; margin-top: 10px; }
|
| 727 |
+
.mw-global-label { font-size: 13px; color: #c7ced6; }
|
| 728 |
+
.mw-global-value { color: #6ecbff; font-variant-numeric: tabular-nums; font-weight: 600; }
|
| 729 |
+
.mw-global-note { font-size: 12px; color: #8f98a3; }
|
| 730 |
+
.section-title { font-size: 11px; font-weight: 600; color: #8d97a2; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 6px; }
|
| 731 |
+
.section { background: #161d29; padding: 12px; border-radius: 6px; }
|
| 732 |
+
</style>
|
| 733 |
+
</head>
|
| 734 |
+
<body>
|
| 735 |
+
{%app_entry%}
|
| 736 |
+
<footer>{%config%}{%scripts%}{%renderer%}</footer>
|
| 737 |
+
</body>
|
| 738 |
+
</html>
|
| 739 |
+
"""
|
| 740 |
+
|
| 741 |
+
_register_callbacks(app)
|
| 742 |
+
return app
|
| 743 |
+
|
| 744 |
+
|
| 745 |
+
# ---------------------------------------------------------------------------
|
| 746 |
+
# Callbacks
|
| 747 |
+
# ---------------------------------------------------------------------------
|
| 748 |
+
|
| 749 |
+
|
| 750 |
+
def _two_layer_controls():
|
| 751 |
+
d = TWO_LAYER_DEFAULTS
|
| 752 |
+
return html.Div(
|
| 753 |
+
# Three-column grid to mirror the profile tab: atmospheric sliders |
|
| 754 |
+
# terrain+domain+spectrum sliders | Actions column. The Actions
|
| 755 |
+
# column holds Reset/Analyze plus the preset buttons so the user's
|
| 756 |
+
# eye finds every "do something" control in the same place on both
|
| 757 |
+
# tabs.
|
| 758 |
+
className="mw-controls two-layer",
|
| 759 |
+
children=[
|
| 760 |
+
html.Div(
|
| 761 |
+
className="section",
|
| 762 |
+
children=[
|
| 763 |
+
html.Div("Atmospheric profile", className="section-title"),
|
| 764 |
+
_slider("U", "Surface wind (m/s)", 0, 100, 1, d["U"]),
|
| 765 |
+
_slider("L_upper", "L upper (×10⁻⁴)", 0, 50, 0.1, d["L_upper"]),
|
| 766 |
+
_slider("L_lower", "L lower (×10⁻⁴)", 0, 50, 0.1, d["L_lower"]),
|
| 767 |
+
_slider("H", "Interface ht. (km)", 0, 20, 0.1, d["H"]),
|
| 768 |
+
],
|
| 769 |
+
),
|
| 770 |
+
html.Div(
|
| 771 |
+
className="section",
|
| 772 |
+
children=[
|
| 773 |
+
html.Div("Terrain profile", className="section-title"),
|
| 774 |
+
_slider("mtn_h", "Max height (km)", 0, 3, 0.05, d["mtn_h"]),
|
| 775 |
+
_slider("mtn_a", "Half-width (km)", 0.25, 25, 0.25, d["mtn_a"]),
|
| 776 |
+
html.Div("Domain profile", className="section-title", style={"marginTop": "12px"}),
|
| 777 |
+
_slider("xdom", "Horizontal (km)", 5, 100, 1, d["xdom"]),
|
| 778 |
+
_slider("zdom", "Vertical (km)", 1, 20, 0.5, d["zdom"]),
|
| 779 |
+
html.Div("Spectral profile", className="section-title", style={"marginTop": "12px"}),
|
| 780 |
+
_slider("mink_k", "Min wave# (half-widths)", 0, 50, 0.5, d["mink_k"]),
|
| 781 |
+
_slider("maxk_k", "Max wave# (half-widths)", 0, 50, 0.5, d["maxk_k"]),
|
| 782 |
+
],
|
| 783 |
+
),
|
| 784 |
+
# Actions column — Reset / Analyze at the top, presets below.
|
| 785 |
+
# Shares the .mw-actions-section layout with the profile tab so
|
| 786 |
+
# both tabs look and feel the same.
|
| 787 |
+
html.Div(
|
| 788 |
+
className="section mw-actions-section",
|
| 789 |
+
children=[
|
| 790 |
+
html.Div("Actions", className="section-title"),
|
| 791 |
+
html.Button(
|
| 792 |
+
"Reset to defaults",
|
| 793 |
+
id="reset-two",
|
| 794 |
+
className="mw-action-btn",
|
| 795 |
+
title="Restore every slider on this tab to its initial value.",
|
| 796 |
+
),
|
| 797 |
+
html.Button(
|
| 798 |
+
"Analyze flow",
|
| 799 |
+
id="analyze-two",
|
| 800 |
+
className="mw-analyze mw-action-btn",
|
| 801 |
+
),
|
| 802 |
+
html.Div(
|
| 803 |
+
"Presets",
|
| 804 |
+
className="section-title",
|
| 805 |
+
style={"marginTop": "10px"},
|
| 806 |
+
),
|
| 807 |
+
html.Button(
|
| 808 |
+
"Uniform atmosphere",
|
| 809 |
+
id="preset-uniform",
|
| 810 |
+
className="mw-action-btn",
|
| 811 |
+
title="Hart's Example 1 — constant Scorer parameter, no trapping.",
|
| 812 |
+
),
|
| 813 |
+
html.Button(
|
| 814 |
+
"Trapped lee waves",
|
| 815 |
+
id="preset-trapped",
|
| 816 |
+
className="mw-action-btn",
|
| 817 |
+
title="Hart's Example 2 — strong lower-layer Scorer, trapped wave train downstream.",
|
| 818 |
+
),
|
| 819 |
+
html.Button(
|
| 820 |
+
"Near-downslope",
|
| 821 |
+
id="preset-downslope",
|
| 822 |
+
className="mw-action-btn",
|
| 823 |
+
title=(
|
| 824 |
+
"Strong lee waves approaching the downslope-windstorm "
|
| 825 |
+
"regime. Not from Hart's examples — linear Fourier/Scorer "
|
| 826 |
+
"theory cannot fully simulate real downslope windstorms "
|
| 827 |
+
"because they are fundamentally nonlinear."
|
| 828 |
+
),
|
| 829 |
+
),
|
| 830 |
+
html.Button(
|
| 831 |
+
"Near critical layer at 2 km",
|
| 832 |
+
id="preset-critical-2km",
|
| 833 |
+
className="mw-action-btn",
|
| 834 |
+
title=(
|
| 835 |
+
"Two-layer caricature of critical-level absorption. "
|
| 836 |
+
"Strong Scorer contrast across an interface at 2 km "
|
| 837 |
+
"(l_lower² >> k² >> l_upper²) reflects most wave "
|
| 838 |
+
"energy downward, mimicking Booker & Bretherton "
|
| 839 |
+
"(1967) absorption. A true critical level requires "
|
| 840 |
+
"U(z)=0, which two-layer mode cannot represent — "
|
| 841 |
+
"use the Profile tab for the physically faithful "
|
| 842 |
+
"wind-reversal setup."
|
| 843 |
+
),
|
| 844 |
+
),
|
| 845 |
+
],
|
| 846 |
+
),
|
| 847 |
+
],
|
| 848 |
+
)
|
| 849 |
+
|
| 850 |
+
|
| 851 |
+
def _profile_controls(zs, us, thetas):
|
| 852 |
+
return html.Div(
|
| 853 |
+
children=[
|
| 854 |
+
html.Div(
|
| 855 |
+
className="mw-controls profile",
|
| 856 |
+
children=[
|
| 857 |
+
html.Div(
|
| 858 |
+
className="section",
|
| 859 |
+
children=[
|
| 860 |
+
html.Div("Terrain", className="section-title"),
|
| 861 |
+
_slider("p_mtn_h", "Max height (km)", 0, 3, 0.05, 0.5),
|
| 862 |
+
_slider("p_mtn_a", "Half-width (km)", 0.25, 25, 0.25, 2.5),
|
| 863 |
+
],
|
| 864 |
+
),
|
| 865 |
+
html.Div(
|
| 866 |
+
className="section",
|
| 867 |
+
children=[
|
| 868 |
+
html.Div("Domain", className="section-title"),
|
| 869 |
+
_slider("p_xdom", "Horizontal (km)", 5, 100, 1, 40),
|
| 870 |
+
_slider("p_zdom", "Vertical (km)", 1, 20, 0.5, 10),
|
| 871 |
+
],
|
| 872 |
+
),
|
| 873 |
+
html.Div(
|
| 874 |
+
className="section",
|
| 875 |
+
children=[
|
| 876 |
+
html.Div("Spectrum", className="section-title"),
|
| 877 |
+
_slider("p_mink_k", "Min wave# (half-widths)", 0, 50, 0.5, 0),
|
| 878 |
+
_slider("p_maxk_k", "Max wave# (half-widths)", 0, 50, 0.5, 30),
|
| 879 |
+
],
|
| 880 |
+
),
|
| 881 |
+
html.Div(
|
| 882 |
+
className="section mw-hrrr-section",
|
| 883 |
+
children=[
|
| 884 |
+
html.Div("Initialize from HRRR (AWS)", className="section-title"),
|
| 885 |
+
html.Div(
|
| 886 |
+
className="mw-hrrr-row mw-hrrr-row-compact",
|
| 887 |
+
children=[
|
| 888 |
+
html.Label("Lat °N", className="mw-hrrr-lbl"),
|
| 889 |
+
dcc.Input(
|
| 890 |
+
id="hrrr-lat",
|
| 891 |
+
type="number",
|
| 892 |
+
value=40.0,
|
| 893 |
+
step=0.01,
|
| 894 |
+
className="mw-hrrr-input",
|
| 895 |
+
),
|
| 896 |
+
html.Label("Lon °E", className="mw-hrrr-lbl"),
|
| 897 |
+
dcc.Input(
|
| 898 |
+
id="hrrr-lon",
|
| 899 |
+
type="number",
|
| 900 |
+
value=-105.5,
|
| 901 |
+
step=0.01,
|
| 902 |
+
className="mw-hrrr-input",
|
| 903 |
+
),
|
| 904 |
+
html.Label(
|
| 905 |
+
"Cycle",
|
| 906 |
+
className="mw-hrrr-lbl",
|
| 907 |
+
title="UTC init time as YYYYMMDDHH, e.g. 2024060112.",
|
| 908 |
+
),
|
| 909 |
+
dcc.Input(
|
| 910 |
+
id="hrrr-datetime",
|
| 911 |
+
type="text",
|
| 912 |
+
value="",
|
| 913 |
+
placeholder="YYYYMMDDHH",
|
| 914 |
+
className="mw-hrrr-input wide",
|
| 915 |
+
),
|
| 916 |
+
html.Button(
|
| 917 |
+
"Fetch",
|
| 918 |
+
id="hrrr-fetch",
|
| 919 |
+
className="mw-analyze mw-hrrr-btn",
|
| 920 |
+
),
|
| 921 |
+
],
|
| 922 |
+
),
|
| 923 |
+
# Flow-from slider lives on its own row so it can
|
| 924 |
+
# stretch across the full card. Moving it updates
|
| 925 |
+
# the along-flow projection *without* triggering
|
| 926 |
+
# another HRRR download — the raw east/north wind
|
| 927 |
+
# components are cached in hrrr-raw-store below.
|
| 928 |
+
html.Div(
|
| 929 |
+
className="mw-hrrr-dir-row",
|
| 930 |
+
children=[
|
| 931 |
+
html.Label(
|
| 932 |
+
"Flow from",
|
| 933 |
+
className="mw-hrrr-lbl",
|
| 934 |
+
title=(
|
| 935 |
+
"Meteorological convention: direction "
|
| 936 |
+
"the wind is blowing FROM. 0=N, 90=E, "
|
| 937 |
+
"180=S, 270=W. Moving this slider "
|
| 938 |
+
"re-projects the cached HRRR wind "
|
| 939 |
+
"onto the new axis (no re-download)."
|
| 940 |
+
),
|
| 941 |
+
),
|
| 942 |
+
html.Div(
|
| 943 |
+
className="mw-hrrr-dir-slider",
|
| 944 |
+
children=dcc.Slider(
|
| 945 |
+
id="hrrr-dir",
|
| 946 |
+
min=0,
|
| 947 |
+
max=360,
|
| 948 |
+
step=1,
|
| 949 |
+
value=270,
|
| 950 |
+
marks={
|
| 951 |
+
0: "0° N",
|
| 952 |
+
90: "90° E",
|
| 953 |
+
180: "180° S",
|
| 954 |
+
270: "270° W",
|
| 955 |
+
360: "360° N",
|
| 956 |
+
},
|
| 957 |
+
tooltip={"placement": "bottom", "always_visible": False},
|
| 958 |
+
),
|
| 959 |
+
),
|
| 960 |
+
html.Span(id="hrrr-dir-val", className="mw-hrrr-dir-val"),
|
| 961 |
+
],
|
| 962 |
+
),
|
| 963 |
+
dcc.Loading(
|
| 964 |
+
id="hrrr-loading",
|
| 965 |
+
type="circle",
|
| 966 |
+
children=html.Div(
|
| 967 |
+
id="hrrr-status",
|
| 968 |
+
className="mw-hrrr-status",
|
| 969 |
+
),
|
| 970 |
+
),
|
| 971 |
+
],
|
| 972 |
+
),
|
| 973 |
+
# Actions panel — sits in the 3rd grid column beside the
|
| 974 |
+
# 2/3-width HRRR card so the Analyze button is always
|
| 975 |
+
# visible without scrolling past the fetch controls.
|
| 976 |
+
html.Div(
|
| 977 |
+
className="section mw-actions-section",
|
| 978 |
+
children=[
|
| 979 |
+
html.Div("Actions", className="section-title"),
|
| 980 |
+
html.Button(
|
| 981 |
+
"Reset profile",
|
| 982 |
+
id="reset-profile",
|
| 983 |
+
className="mw-action-btn",
|
| 984 |
+
),
|
| 985 |
+
html.Button(
|
| 986 |
+
"Analyze flow",
|
| 987 |
+
id="analyze-profile",
|
| 988 |
+
className="mw-analyze mw-action-btn",
|
| 989 |
+
),
|
| 990 |
+
],
|
| 991 |
+
),
|
| 992 |
+
],
|
| 993 |
+
),
|
| 994 |
+
html.Div(
|
| 995 |
+
className="mw-profile-toolbar",
|
| 996 |
+
children=[
|
| 997 |
+
html.Button(
|
| 998 |
+
"↶ Undo",
|
| 999 |
+
id="undo-profile",
|
| 1000 |
+
className="mw-undo",
|
| 1001 |
+
disabled=True,
|
| 1002 |
+
title="Revert the last profile edit (up to 50 steps).",
|
| 1003 |
+
),
|
| 1004 |
+
html.Span(id="undo-status", className="mw-undo-status"),
|
| 1005 |
+
],
|
| 1006 |
+
),
|
| 1007 |
+
html.Div(
|
| 1008 |
+
className="mw-profile-editors",
|
| 1009 |
+
children=[
|
| 1010 |
+
dcc.Graph(
|
| 1011 |
+
id="u-profile-graph",
|
| 1012 |
+
figure=_profile_editor_figure("Zonal wind u(z)", us, zs, xunit="m s⁻¹", xrange=U_RANGE),
|
| 1013 |
+
config={"edits": {"shapePosition": True}, "displayModeBar": False, "scrollZoom": False},
|
| 1014 |
+
),
|
| 1015 |
+
dcc.Graph(
|
| 1016 |
+
id="theta-profile-graph",
|
| 1017 |
+
figure=_profile_editor_figure(
|
| 1018 |
+
"Potential temperature θ(z)", thetas, zs, xunit="K", xrange=THETA_RANGE
|
| 1019 |
+
),
|
| 1020 |
+
config={"edits": {"shapePosition": True}, "displayModeBar": False, "scrollZoom": False},
|
| 1021 |
+
),
|
| 1022 |
+
],
|
| 1023 |
+
),
|
| 1024 |
+
html.Div(
|
| 1025 |
+
"Drag the gold circles left/right on each profile to change the u or θ "
|
| 1026 |
+
"value at that height. Heights are fixed at 18 evenly-spaced levels; the "
|
| 1027 |
+
"blue line shows the current profile and follows the points as you drag. "
|
| 1028 |
+
"The four diagnostics below update live — Scorer parameter L² (trapped "
|
| 1029 |
+
"waves need L² to decrease aloft), Richardson number Ri (< 0.25 flags "
|
| 1030 |
+
"shear instability), Brunt–Väisälä N² (stratification strength), and "
|
| 1031 |
+
"the potential-temperature lapse rate dθ/dz. Click 'Analyze flow' to "
|
| 1032 |
+
"update the wave solution.",
|
| 1033 |
+
style={"color": "#8f98a3", "fontSize": "12px", "marginTop": "6px"},
|
| 1034 |
+
),
|
| 1035 |
+
html.Div("Live profile diagnostics", className="section-title",
|
| 1036 |
+
style={"marginTop": "14px"}),
|
| 1037 |
+
html.Div(
|
| 1038 |
+
className="mw-profile-diagnostics",
|
| 1039 |
+
children=[
|
| 1040 |
+
dcc.Graph(id="diag-scorer", config={"displayModeBar": False}),
|
| 1041 |
+
dcc.Graph(id="diag-ri", config={"displayModeBar": False}),
|
| 1042 |
+
dcc.Graph(id="diag-n2", config={"displayModeBar": False}),
|
| 1043 |
+
dcc.Graph(id="diag-dthdz", config={"displayModeBar": False}),
|
| 1044 |
+
],
|
| 1045 |
+
),
|
| 1046 |
+
],
|
| 1047 |
+
)
|
| 1048 |
+
|
| 1049 |
+
|
| 1050 |
+
def _profile_editor_figure(title, values, zs, xunit, xrange):
|
| 1051 |
+
"""Build an editable profile figure.
|
| 1052 |
+
|
| 1053 |
+
Each point is represented as a draggable circle *shape* — Plotly supports
|
| 1054 |
+
shape dragging via the ``edits.shapePosition`` config flag — and a line
|
| 1055 |
+
trace connects the current values for visualization. When the user drags
|
| 1056 |
+
a circle, ``relayoutData`` fires with ``shapes[i].x0``/``x1`` keys, which
|
| 1057 |
+
the ``_profile_update`` callback parses back into the shared store. The
|
| 1058 |
+
store-driven ``_redraw_profiles`` callback then regenerates both figures
|
| 1059 |
+
so the connecting line follows the dragged points.
|
| 1060 |
+
"""
|
| 1061 |
+
# Sanitize any NaN/inf so a corrupted store value can't knock a circle
|
| 1062 |
+
# off screen. We do NOT clip to the nominal xrange here — if HRRR
|
| 1063 |
+
# delivers a value outside the default axis, extend the axis rather
|
| 1064 |
+
# than silently mangling the data. The drag callback applies its own
|
| 1065 |
+
# clamp so user-dragged points still stay on screen.
|
| 1066 |
+
values = np.asarray(values, dtype=float)
|
| 1067 |
+
values = np.where(np.isfinite(values), values, 0.5 * (xrange[0] + xrange[1]))
|
| 1068 |
+
zs_km = np.asarray(zs, dtype=float) / 1000.0
|
| 1069 |
+
|
| 1070 |
+
# Data-driven axis: take the wider of the default xrange and the actual
|
| 1071 |
+
# data span, with a little padding so edge points aren't on the border.
|
| 1072 |
+
if values.size:
|
| 1073 |
+
v_min = float(np.min(values))
|
| 1074 |
+
v_max = float(np.max(values))
|
| 1075 |
+
span = max(v_max - v_min, 1.0)
|
| 1076 |
+
pad = 0.05 * span
|
| 1077 |
+
axis_lo = min(xrange[0], v_min - pad)
|
| 1078 |
+
axis_hi = max(xrange[1], v_max + pad)
|
| 1079 |
+
else:
|
| 1080 |
+
axis_lo, axis_hi = xrange
|
| 1081 |
+
effective_xrange = (axis_lo, axis_hi)
|
| 1082 |
+
|
| 1083 |
+
fig = go.Figure()
|
| 1084 |
+
fig.add_trace(
|
| 1085 |
+
go.Scatter(
|
| 1086 |
+
x=values,
|
| 1087 |
+
y=zs_km,
|
| 1088 |
+
mode="lines+markers",
|
| 1089 |
+
marker=dict(size=8, color="#6ecbff", line=dict(color="white", width=1)),
|
| 1090 |
+
line=dict(color="#6ecbff", width=2),
|
| 1091 |
+
name=title,
|
| 1092 |
+
hoverinfo="skip",
|
| 1093 |
+
)
|
| 1094 |
+
)
|
| 1095 |
+
|
| 1096 |
+
# One draggable circle per profile point. Pixel sizing makes every
|
| 1097 |
+
# shape render as a visually round circle regardless of how stretched
|
| 1098 |
+
# the axes are; dragging updates shapes[i].xanchor in relayoutData.
|
| 1099 |
+
radius_px = 9
|
| 1100 |
+
shapes = []
|
| 1101 |
+
for v, zk in zip(values, zs_km):
|
| 1102 |
+
shapes.append(
|
| 1103 |
+
dict(
|
| 1104 |
+
type="circle",
|
| 1105 |
+
xref="x",
|
| 1106 |
+
yref="y",
|
| 1107 |
+
xsizemode="pixel",
|
| 1108 |
+
ysizemode="pixel",
|
| 1109 |
+
xanchor=float(v),
|
| 1110 |
+
yanchor=float(zk),
|
| 1111 |
+
x0=-radius_px,
|
| 1112 |
+
x1=radius_px,
|
| 1113 |
+
y0=-radius_px,
|
| 1114 |
+
y1=radius_px,
|
| 1115 |
+
fillcolor="#ffd685",
|
| 1116 |
+
line=dict(color="white", width=1.5),
|
| 1117 |
+
editable=True,
|
| 1118 |
+
layer="above",
|
| 1119 |
+
)
|
| 1120 |
+
)
|
| 1121 |
+
|
| 1122 |
+
fig.update_layout(
|
| 1123 |
+
title=title,
|
| 1124 |
+
xaxis_title=f"value ({xunit})",
|
| 1125 |
+
yaxis_title="height (km)",
|
| 1126 |
+
template="plotly_dark",
|
| 1127 |
+
margin=dict(l=60, r=20, t=50, b=50),
|
| 1128 |
+
height=PROFILE_FIG_HEIGHT_PX,
|
| 1129 |
+
dragmode=False,
|
| 1130 |
+
xaxis=dict(range=list(effective_xrange), fixedrange=True),
|
| 1131 |
+
yaxis=dict(range=[0, zs_km[-1]], fixedrange=True),
|
| 1132 |
+
shapes=shapes,
|
| 1133 |
+
)
|
| 1134 |
+
return fig
|
| 1135 |
+
|
| 1136 |
+
|
| 1137 |
+
def _profile_diagnostics(zs, us, thetas):
|
| 1138 |
+
"""Return (L², Ri, N², dθ/dz) arrays evaluated on the profile grid."""
|
| 1139 |
+
zs = np.asarray(zs, dtype=float)
|
| 1140 |
+
us = np.asarray(us, dtype=float)
|
| 1141 |
+
thetas = np.asarray(thetas, dtype=float)
|
| 1142 |
+
l2 = scorer_from_profile(zs, us, thetas)
|
| 1143 |
+
n2 = brunt_vaisala(zs, thetas)
|
| 1144 |
+
|
| 1145 |
+
# dU/dz and dθ/dz via the same 3-point finite difference used in
|
| 1146 |
+
# reference.py / scorer_from_profile so diagnostics stay consistent.
|
| 1147 |
+
n = zs.size
|
| 1148 |
+
dudz = np.zeros(n)
|
| 1149 |
+
dthdz = np.zeros(n)
|
| 1150 |
+
for i in range(n):
|
| 1151 |
+
if i == 0:
|
| 1152 |
+
dudz[i] = (us[1] - us[0]) / (zs[1] - zs[0])
|
| 1153 |
+
dthdz[i] = (thetas[1] - thetas[0]) / (zs[1] - zs[0])
|
| 1154 |
+
elif i == n - 1:
|
| 1155 |
+
dudz[i] = (us[-1] - us[-2]) / (zs[-1] - zs[-2])
|
| 1156 |
+
dthdz[i] = (thetas[-1] - thetas[-2]) / (zs[-1] - zs[-2])
|
| 1157 |
+
else:
|
| 1158 |
+
h1 = zs[i] - zs[i - 1]
|
| 1159 |
+
h2 = zs[i + 1] - zs[i]
|
| 1160 |
+
dudz[i] = (
|
| 1161 |
+
us[i + 1] * h1 ** 2
|
| 1162 |
+
- us[i - 1] * h2 ** 2
|
| 1163 |
+
+ us[i] * (h2 ** 2 - h1 ** 2)
|
| 1164 |
+
) / (h1 * h2 * (h1 + h2))
|
| 1165 |
+
dthdz[i] = (
|
| 1166 |
+
thetas[i + 1] * h1 ** 2
|
| 1167 |
+
- thetas[i - 1] * h2 ** 2
|
| 1168 |
+
+ thetas[i] * (h2 ** 2 - h1 ** 2)
|
| 1169 |
+
) / (h1 * h2 * (h1 + h2))
|
| 1170 |
+
|
| 1171 |
+
# Ri = N² / (dU/dz)². Guard against vanishing shear.
|
| 1172 |
+
with np.errstate(divide="ignore", invalid="ignore"):
|
| 1173 |
+
ri = np.where(np.abs(dudz) > 1e-6, n2 / (dudz ** 2), np.inf)
|
| 1174 |
+
return l2, ri, n2, dthdz
|
| 1175 |
+
|
| 1176 |
+
|
| 1177 |
+
def _diagnostic_figure(title, values, zs, xunit, color, xrange=None, log_x=False, ref_line=None):
|
| 1178 |
+
"""Small read-only profile plot of ``values(z)``."""
|
| 1179 |
+
values = np.asarray(values, dtype=float)
|
| 1180 |
+
zs_km = np.asarray(zs, dtype=float) / 1000.0
|
| 1181 |
+
|
| 1182 |
+
plot_x = values
|
| 1183 |
+
if log_x:
|
| 1184 |
+
# Use symmetric log to tolerate negative values without dropping them.
|
| 1185 |
+
plot_x = values
|
| 1186 |
+
|
| 1187 |
+
fig = go.Figure()
|
| 1188 |
+
fig.add_trace(
|
| 1189 |
+
go.Scatter(
|
| 1190 |
+
x=plot_x,
|
| 1191 |
+
y=zs_km,
|
| 1192 |
+
mode="lines+markers",
|
| 1193 |
+
marker=dict(size=6, color=color, line=dict(color="white", width=0.5)),
|
| 1194 |
+
line=dict(color=color, width=2),
|
| 1195 |
+
hovertemplate=f"%{{x:.3g}} {xunit}<br>%{{y:.2f}} km<extra></extra>",
|
| 1196 |
+
showlegend=False,
|
| 1197 |
+
)
|
| 1198 |
+
)
|
| 1199 |
+
if ref_line is not None:
|
| 1200 |
+
fig.add_vline(x=ref_line, line=dict(color="#ffd685", dash="dot", width=1))
|
| 1201 |
+
|
| 1202 |
+
xaxis_kwargs = dict(title=f"{xunit}")
|
| 1203 |
+
if log_x:
|
| 1204 |
+
xaxis_kwargs["type"] = "log"
|
| 1205 |
+
if xrange is not None:
|
| 1206 |
+
xaxis_kwargs["range"] = list(xrange)
|
| 1207 |
+
|
| 1208 |
+
fig.update_layout(
|
| 1209 |
+
title=title,
|
| 1210 |
+
yaxis_title="height (km)",
|
| 1211 |
+
template="plotly_dark",
|
| 1212 |
+
margin=dict(l=50, r=15, t=40, b=40),
|
| 1213 |
+
height=340,
|
| 1214 |
+
xaxis=xaxis_kwargs,
|
| 1215 |
+
yaxis=dict(range=[0, zs_km[-1]]),
|
| 1216 |
+
)
|
| 1217 |
+
return fig
|
| 1218 |
+
|
| 1219 |
+
|
| 1220 |
+
def _diagnostic_figures(store):
|
| 1221 |
+
"""Build all four diagnostic figures from the current profile store."""
|
| 1222 |
+
zs = np.asarray(store["z"], dtype=float)
|
| 1223 |
+
us = np.asarray(store["u"], dtype=float)
|
| 1224 |
+
thetas = np.asarray(store["theta"], dtype=float)
|
| 1225 |
+
l2, ri, n2, dthdz = _profile_diagnostics(zs, us, thetas)
|
| 1226 |
+
|
| 1227 |
+
# Scorer parameter. Display in units of 10⁻⁸ m⁻² so the magnitudes line
|
| 1228 |
+
# up with the two-layer L sliders: L_slider = L × 10⁴ m⁻¹ means
|
| 1229 |
+
# L_slider² = L² × 10⁸, so L²(display) directly equals L_slider².
|
| 1230 |
+
# E.g. the two-layer defaults of L_lower=10, L_upper=4 correspond to
|
| 1231 |
+
# L²_lower=100, L²_upper=16 on this plot.
|
| 1232 |
+
l2_scaled = l2 * 1e8
|
| 1233 |
+
l_fig = _diagnostic_figure(
|
| 1234 |
+
"Scorer parameter L²(z)",
|
| 1235 |
+
l2_scaled,
|
| 1236 |
+
zs,
|
| 1237 |
+
xunit="L² (×10⁻⁸ m⁻²) — same units as (two-layer L slider)²",
|
| 1238 |
+
color="#6ecbff",
|
| 1239 |
+
ref_line=0.0,
|
| 1240 |
+
)
|
| 1241 |
+
|
| 1242 |
+
# Richardson number. Real-world Ri values span many orders of magnitude
|
| 1243 |
+
# — a still-stable free-troposphere profile is easily 100s while the
|
| 1244 |
+
# instability threshold is 0.25. Use a log x-axis so that variation is
|
| 1245 |
+
# visible; clip negatives (from N² < 0 regions) to a small positive
|
| 1246 |
+
# floor and mark them with a trailing annotation instead.
|
| 1247 |
+
neg_mask = ri <= 0
|
| 1248 |
+
ri_pos = np.where(neg_mask, np.nan, ri)
|
| 1249 |
+
ri_pos = np.where(np.isfinite(ri_pos), ri_pos, 1e4)
|
| 1250 |
+
ri_pos = np.clip(ri_pos, 1e-2, 1e4)
|
| 1251 |
+
ri_fig = _diagnostic_figure(
|
| 1252 |
+
"Richardson number Ri(z)",
|
| 1253 |
+
ri_pos,
|
| 1254 |
+
zs,
|
| 1255 |
+
xunit="Ri (log scale, clipped 10⁻² – 10⁴)",
|
| 1256 |
+
color="#ffb86c",
|
| 1257 |
+
log_x=True,
|
| 1258 |
+
ref_line=0.25,
|
| 1259 |
+
)
|
| 1260 |
+
|
| 1261 |
+
# N² — units of s⁻², typical tropospheric values ~10⁻⁴; can be negative
|
| 1262 |
+
# if the user drags θ(z) into a superadiabatic configuration.
|
| 1263 |
+
n2_scaled = n2 * 1e4
|
| 1264 |
+
n2_fig = _diagnostic_figure(
|
| 1265 |
+
"Brunt–Väisälä N²(z)",
|
| 1266 |
+
n2_scaled,
|
| 1267 |
+
zs,
|
| 1268 |
+
xunit="N² (×10⁻⁴ s⁻²)",
|
| 1269 |
+
color="#a0e878",
|
| 1270 |
+
ref_line=0.0,
|
| 1271 |
+
)
|
| 1272 |
+
|
| 1273 |
+
# dθ/dz in K/km — the raw stratification before dividing by θ.
|
| 1274 |
+
dthdz_fig = _diagnostic_figure(
|
| 1275 |
+
"Lapse rate dθ/dz(z)",
|
| 1276 |
+
dthdz * 1000.0,
|
| 1277 |
+
zs,
|
| 1278 |
+
xunit="dθ/dz (K km⁻¹)",
|
| 1279 |
+
color="#f57aa5",
|
| 1280 |
+
ref_line=0.0,
|
| 1281 |
+
)
|
| 1282 |
+
return l_fig, ri_fig, n2_fig, dthdz_fig
|
| 1283 |
+
|
| 1284 |
+
|
| 1285 |
+
def _register_callbacks(app: Dash):
|
| 1286 |
+
# --- latitude readout ------------------------------------------------
|
| 1287 |
+
app.clientside_callback(
|
| 1288 |
+
"function(v) { return Math.round(v) + '\u00b0 N'; }",
|
| 1289 |
+
Output("latit-val", "children"),
|
| 1290 |
+
Input("latit", "value"),
|
| 1291 |
+
)
|
| 1292 |
+
|
| 1293 |
+
# --- Theory & About modal ----------------------------------------------
|
| 1294 |
+
# The show button opens the modal, the × button closes it. Returning an
|
| 1295 |
+
# empty style dict lets the .mw-modal CSS class (display: flex) take
|
| 1296 |
+
# effect; returning {"display": "none"} hides it.
|
| 1297 |
+
@app.callback(
|
| 1298 |
+
Output("readme-modal", "style"),
|
| 1299 |
+
[
|
| 1300 |
+
Input("show-readme", "n_clicks"),
|
| 1301 |
+
Input("close-readme", "n_clicks"),
|
| 1302 |
+
],
|
| 1303 |
+
prevent_initial_call=True,
|
| 1304 |
+
)
|
| 1305 |
+
def _toggle_readme(_show, _close):
|
| 1306 |
+
if ctx.triggered_id == "show-readme":
|
| 1307 |
+
return {}
|
| 1308 |
+
return {"display": "none"}
|
| 1309 |
+
|
| 1310 |
+
# Close the modal on Esc keypress as well. Clientside so it doesn't
|
| 1311 |
+
# require a round-trip; the keydown listener is attached once on load
|
| 1312 |
+
# and clicks the × button synthetically when Esc is pressed while the
|
| 1313 |
+
# modal is visible.
|
| 1314 |
+
app.clientside_callback(
|
| 1315 |
+
"""
|
| 1316 |
+
function() {
|
| 1317 |
+
if (window.__mw_readme_esc_attached) return window.dash_clientside.no_update;
|
| 1318 |
+
window.__mw_readme_esc_attached = true;
|
| 1319 |
+
document.addEventListener('keydown', function(e) {
|
| 1320 |
+
if (e.key !== 'Escape') return;
|
| 1321 |
+
var modal = document.getElementById('readme-modal');
|
| 1322 |
+
if (!modal) return;
|
| 1323 |
+
if (modal.style.display === 'none') return;
|
| 1324 |
+
var btn = document.getElementById('close-readme');
|
| 1325 |
+
if (btn) btn.click();
|
| 1326 |
+
});
|
| 1327 |
+
return window.dash_clientside.no_update;
|
| 1328 |
+
}
|
| 1329 |
+
""",
|
| 1330 |
+
Output("close-readme", "title"),
|
| 1331 |
+
Input("close-readme", "id"),
|
| 1332 |
+
)
|
| 1333 |
+
|
| 1334 |
+
# --- HRRR flow-from slider readout -----------------------------------
|
| 1335 |
+
# Shows the current azimuth next to the slider with a compass letter
|
| 1336 |
+
# (N/NE/E/.../NW). Purely a UI hint — the server callback still uses
|
| 1337 |
+
# the raw degrees when computing the along-flow projection.
|
| 1338 |
+
app.clientside_callback(
|
| 1339 |
+
"""
|
| 1340 |
+
function(v) {
|
| 1341 |
+
if (v === null || v === undefined) return '';
|
| 1342 |
+
var deg = Math.round(v);
|
| 1343 |
+
var dirs = ['N','NE','E','SE','S','SW','W','NW','N'];
|
| 1344 |
+
var idx = Math.round(deg / 45) % 8;
|
| 1345 |
+
return deg + '\u00b0 ' + dirs[idx];
|
| 1346 |
+
}
|
| 1347 |
+
""",
|
| 1348 |
+
Output("hrrr-dir-val", "children"),
|
| 1349 |
+
Input("hrrr-dir", "value"),
|
| 1350 |
+
)
|
| 1351 |
+
|
| 1352 |
+
# --- live slider readouts --------------------------------------------
|
| 1353 |
+
for sid, unit in [
|
| 1354 |
+
("U", " m/s"),
|
| 1355 |
+
("L_upper", ""),
|
| 1356 |
+
("L_lower", ""),
|
| 1357 |
+
("H", " km"),
|
| 1358 |
+
("mtn_h", " km"),
|
| 1359 |
+
("mtn_a", " km"),
|
| 1360 |
+
("xdom", " km"),
|
| 1361 |
+
("zdom", " km"),
|
| 1362 |
+
("mink_k", ""),
|
| 1363 |
+
("maxk_k", ""),
|
| 1364 |
+
("p_mtn_h", " km"),
|
| 1365 |
+
("p_mtn_a", " km"),
|
| 1366 |
+
("p_xdom", " km"),
|
| 1367 |
+
("p_zdom", " km"),
|
| 1368 |
+
("p_mink_k", ""),
|
| 1369 |
+
("p_maxk_k", ""),
|
| 1370 |
+
]:
|
| 1371 |
+
app.clientside_callback(
|
| 1372 |
+
f"function(v) {{ return (Math.round(v*100)/100) + '{unit}'; }}",
|
| 1373 |
+
Output(f"{sid}-val", "children"),
|
| 1374 |
+
Input(sid, "value"),
|
| 1375 |
+
)
|
| 1376 |
+
|
| 1377 |
+
# --- swap control panel on tab change --------------------------------
|
| 1378 |
+
# Both control panels are always in the DOM; we just toggle visibility.
|
| 1379 |
+
@app.callback(
|
| 1380 |
+
[Output("controls-two-layer", "style"), Output("controls-profile", "style")],
|
| 1381 |
+
Input("mode-tabs", "value"),
|
| 1382 |
+
)
|
| 1383 |
+
def _swap(mode):
|
| 1384 |
+
if mode == "two-layer":
|
| 1385 |
+
return {}, {"display": "none"}
|
| 1386 |
+
return {"display": "none"}, {}
|
| 1387 |
+
|
| 1388 |
+
# --- presets and reset apply to sliders ------------------------------
|
| 1389 |
+
# Both the three preset buttons and the "Reset to defaults" button in
|
| 1390 |
+
# the Actions column funnel through this callback. Reset is just
|
| 1391 |
+
# another preset whose values happen to be TWO_LAYER_DEFAULTS.
|
| 1392 |
+
@app.callback(
|
| 1393 |
+
[
|
| 1394 |
+
Output("U", "value"),
|
| 1395 |
+
Output("L_upper", "value"),
|
| 1396 |
+
Output("L_lower", "value"),
|
| 1397 |
+
Output("H", "value"),
|
| 1398 |
+
Output("mtn_h", "value"),
|
| 1399 |
+
Output("mtn_a", "value"),
|
| 1400 |
+
Output("xdom", "value"),
|
| 1401 |
+
Output("zdom", "value"),
|
| 1402 |
+
Output("mink_k", "value"),
|
| 1403 |
+
Output("maxk_k", "value"),
|
| 1404 |
+
],
|
| 1405 |
+
[
|
| 1406 |
+
Input("preset-uniform", "n_clicks"),
|
| 1407 |
+
Input("preset-trapped", "n_clicks"),
|
| 1408 |
+
Input("preset-downslope", "n_clicks"),
|
| 1409 |
+
Input("preset-critical-2km", "n_clicks"),
|
| 1410 |
+
Input("reset-two", "n_clicks"),
|
| 1411 |
+
],
|
| 1412 |
+
prevent_initial_call=True,
|
| 1413 |
+
)
|
| 1414 |
+
def _apply_preset(nu, nt, nd, nc, nr):
|
| 1415 |
+
from dash import ctx
|
| 1416 |
+
|
| 1417 |
+
trig = ctx.triggered_id
|
| 1418 |
+
if trig == "reset-two":
|
| 1419 |
+
p = TWO_LAYER_DEFAULTS
|
| 1420 |
+
else:
|
| 1421 |
+
key = {
|
| 1422 |
+
"preset-uniform": "uniform",
|
| 1423 |
+
"preset-trapped": "trapped",
|
| 1424 |
+
"preset-downslope": "downslope",
|
| 1425 |
+
"preset-critical-2km": "critical_caricature",
|
| 1426 |
+
}.get(trig)
|
| 1427 |
+
if key is None:
|
| 1428 |
+
return [no_update] * 10
|
| 1429 |
+
p = PRESETS[key]
|
| 1430 |
+
return [
|
| 1431 |
+
p["U"], p["L_upper"], p["L_lower"], p["H"],
|
| 1432 |
+
p["mtn_h"], p["mtn_a"], p["xdom"], p["zdom"],
|
| 1433 |
+
p["mink_k"], p["maxk_k"],
|
| 1434 |
+
]
|
| 1435 |
+
|
| 1436 |
+
# --- two-layer analyze ------------------------------------------------
|
| 1437 |
+
@app.callback(
|
| 1438 |
+
[
|
| 1439 |
+
Output("streamline-plot", "figure"),
|
| 1440 |
+
Output("w-plot", "figure"),
|
| 1441 |
+
Output("uprime-plot", "figure"),
|
| 1442 |
+
Output("scorer-readout", "children"),
|
| 1443 |
+
Output("rossby-readout", "children"),
|
| 1444 |
+
Output("nonlin-readout", "children"),
|
| 1445 |
+
Output("critical-readout", "children"),
|
| 1446 |
+
],
|
| 1447 |
+
[
|
| 1448 |
+
Input("analyze-two", "n_clicks"),
|
| 1449 |
+
Input("analyze-profile", "n_clicks"),
|
| 1450 |
+
],
|
| 1451 |
+
[
|
| 1452 |
+
State("mode-tabs", "value"),
|
| 1453 |
+
State("U", "value"),
|
| 1454 |
+
State("L_upper", "value"),
|
| 1455 |
+
State("L_lower", "value"),
|
| 1456 |
+
State("H", "value"),
|
| 1457 |
+
State("mtn_h", "value"),
|
| 1458 |
+
State("mtn_a", "value"),
|
| 1459 |
+
State("xdom", "value"),
|
| 1460 |
+
State("zdom", "value"),
|
| 1461 |
+
State("mink_k", "value"),
|
| 1462 |
+
State("maxk_k", "value"),
|
| 1463 |
+
State("p_mtn_h", "value"),
|
| 1464 |
+
State("p_mtn_a", "value"),
|
| 1465 |
+
State("p_xdom", "value"),
|
| 1466 |
+
State("p_zdom", "value"),
|
| 1467 |
+
State("p_mink_k", "value"),
|
| 1468 |
+
State("p_maxk_k", "value"),
|
| 1469 |
+
State("profile-store", "data"),
|
| 1470 |
+
State("latit", "value"),
|
| 1471 |
+
],
|
| 1472 |
+
prevent_initial_call=False,
|
| 1473 |
+
)
|
| 1474 |
+
def _run(
|
| 1475 |
+
n_two,
|
| 1476 |
+
n_prof,
|
| 1477 |
+
mode,
|
| 1478 |
+
U,
|
| 1479 |
+
L_upper,
|
| 1480 |
+
L_lower,
|
| 1481 |
+
H,
|
| 1482 |
+
mtn_h,
|
| 1483 |
+
mtn_a,
|
| 1484 |
+
xdom,
|
| 1485 |
+
zdom,
|
| 1486 |
+
mink_k,
|
| 1487 |
+
maxk_k,
|
| 1488 |
+
p_mtn_h,
|
| 1489 |
+
p_mtn_a,
|
| 1490 |
+
p_xdom,
|
| 1491 |
+
p_zdom,
|
| 1492 |
+
p_mink_k,
|
| 1493 |
+
p_maxk_k,
|
| 1494 |
+
store,
|
| 1495 |
+
latit_deg,
|
| 1496 |
+
):
|
| 1497 |
+
from dash import ctx
|
| 1498 |
+
trig = ctx.triggered_id
|
| 1499 |
+
print(
|
| 1500 |
+
f"[mountain-waves] _run fired: trigger={trig!r} mode={mode!r} "
|
| 1501 |
+
f"n_two={n_two} n_prof={n_prof} lat={latit_deg}",
|
| 1502 |
+
flush=True,
|
| 1503 |
+
)
|
| 1504 |
+
lat = latit_deg if latit_deg is not None else 45.0
|
| 1505 |
+
try:
|
| 1506 |
+
if mode == "profile":
|
| 1507 |
+
params = {
|
| 1508 |
+
"mtn_h": p_mtn_h if p_mtn_h is not None else 0.5,
|
| 1509 |
+
"mtn_a": p_mtn_a if p_mtn_a is not None else 2.5,
|
| 1510 |
+
"xdom": p_xdom if p_xdom is not None else 40,
|
| 1511 |
+
"zdom": p_zdom if p_zdom is not None else 10,
|
| 1512 |
+
"mink_k": p_mink_k if p_mink_k is not None else 0,
|
| 1513 |
+
"maxk_k": p_maxk_k if p_maxk_k is not None else 30,
|
| 1514 |
+
}
|
| 1515 |
+
zs = np.asarray(store["z"])
|
| 1516 |
+
us = np.asarray(store["u"])
|
| 1517 |
+
thetas = np.asarray(store["theta"])
|
| 1518 |
+
sfig, wfig, upfig, scorer, rossby = _profile_plots(params, zs, us, thetas, latit_deg=lat)
|
| 1519 |
+
else:
|
| 1520 |
+
params = {
|
| 1521 |
+
"U": U if U is not None else 20.0,
|
| 1522 |
+
"L_upper": L_upper if L_upper is not None else 4.0,
|
| 1523 |
+
"L_lower": L_lower if L_lower is not None else 10.0,
|
| 1524 |
+
"H": H if H is not None else 3.5,
|
| 1525 |
+
"mtn_h": mtn_h if mtn_h is not None else 0.5,
|
| 1526 |
+
"mtn_a": mtn_a if mtn_a is not None else 2.5,
|
| 1527 |
+
"xdom": xdom if xdom is not None else 40,
|
| 1528 |
+
"zdom": zdom if zdom is not None else 10,
|
| 1529 |
+
"mink_k": mink_k if mink_k is not None else 0,
|
| 1530 |
+
"maxk_k": maxk_k if maxk_k is not None else 30,
|
| 1531 |
+
}
|
| 1532 |
+
sfig, wfig, upfig, scorer, rossby = _two_layer_plots(params, latit_deg=lat)
|
| 1533 |
+
print(
|
| 1534 |
+
f"[mountain-waves] ok: scorer={scorer:.3f} rossby={rossby:.3f}",
|
| 1535 |
+
flush=True,
|
| 1536 |
+
)
|
| 1537 |
+
except Exception as exc:
|
| 1538 |
+
import traceback
|
| 1539 |
+
traceback.print_exc()
|
| 1540 |
+
print(f"[mountain-waves] _run FAILED: {exc!r}", flush=True)
|
| 1541 |
+
raise
|
| 1542 |
+
|
| 1543 |
+
# Nonlinearity indicator Nh/U = L * h (in the layer where the wave
|
| 1544 |
+
# is forced). Linear Fourier/Scorer theory breaks down around
|
| 1545 |
+
# Nh/U ≳ 0.85; beyond that, real flow would overturn or transition
|
| 1546 |
+
# to hydraulic/downslope-windstorm behavior that this model cannot
|
| 1547 |
+
# represent. See Smith (1985) and Hart's "Problems" note.
|
| 1548 |
+
if mode == "profile":
|
| 1549 |
+
zs = np.asarray(store["z"])
|
| 1550 |
+
thetas = np.asarray(store["theta"])
|
| 1551 |
+
us = np.asarray(store["u"])
|
| 1552 |
+
# crude surface N from finite-difference dθ/dz
|
| 1553 |
+
dth = (thetas[1] - thetas[0]) / (zs[1] - zs[0])
|
| 1554 |
+
n_sfc = math.sqrt(max(9.80665 / thetas[0] * dth, 0.0))
|
| 1555 |
+
u_sfc = abs(float(us[0])) or 1e-6
|
| 1556 |
+
h_m = (p_mtn_h if p_mtn_h is not None else 0.5) * 1000.0
|
| 1557 |
+
nhu = n_sfc * h_m / u_sfc
|
| 1558 |
+
else:
|
| 1559 |
+
# L = N/U for uniform U → N = L·U, and Nh/U = L·h.
|
| 1560 |
+
L_lower_m = params["L_lower"] * 1e-4
|
| 1561 |
+
nhu = L_lower_m * params["mtn_h"] * 1000.0
|
| 1562 |
+
|
| 1563 |
+
if nhu < 0.5:
|
| 1564 |
+
flag = "linear regime"
|
| 1565 |
+
elif nhu < 0.85:
|
| 1566 |
+
flag = "approaching nonlinear"
|
| 1567 |
+
elif nhu < 1.2:
|
| 1568 |
+
flag = "near breaking — results questionable"
|
| 1569 |
+
else:
|
| 1570 |
+
flag = "past breaking — model unreliable"
|
| 1571 |
+
|
| 1572 |
+
scorer_badge = html.Span(
|
| 1573 |
+
[
|
| 1574 |
+
html.B("Scorer condition"),
|
| 1575 |
+
html.Span(f"{scorer:.3f}", className="v"),
|
| 1576 |
+
html.Span("(trapped > 1)", className="note"),
|
| 1577 |
+
]
|
| 1578 |
+
)
|
| 1579 |
+
rossby_badge = html.Span(
|
| 1580 |
+
[
|
| 1581 |
+
html.B(f"Rossby number ({lat:.0f}°N)"),
|
| 1582 |
+
html.Span(f"{rossby:.2f}", className="v"),
|
| 1583 |
+
html.Span("(Coriolis significant if ≲ 1)", className="note"),
|
| 1584 |
+
]
|
| 1585 |
+
)
|
| 1586 |
+
nonlin_badge = html.Span(
|
| 1587 |
+
[
|
| 1588 |
+
html.B("Nh/U (nonlinearity)"),
|
| 1589 |
+
html.Span(f"{nhu:.2f}", className="v"),
|
| 1590 |
+
html.Span(f"({flag})", className="note"),
|
| 1591 |
+
]
|
| 1592 |
+
)
|
| 1593 |
+
# Critical-level detection — profile mode only. The two-layer mode
|
| 1594 |
+
# carries a single scalar U, so a "wind reversal" isn't even
|
| 1595 |
+
# representable there, and the badge just states that.
|
| 1596 |
+
if mode == "profile":
|
| 1597 |
+
from .reference import critical_levels as _critical_levels
|
| 1598 |
+
us_p = np.asarray(store["u"])
|
| 1599 |
+
zs_p = np.asarray(store["z"])
|
| 1600 |
+
crits = _critical_levels(zs_p, us_p)
|
| 1601 |
+
if crits:
|
| 1602 |
+
heights_km = ", ".join(f"{h/1000.0:.2f}" for h in crits)
|
| 1603 |
+
critical_badge = html.Span(
|
| 1604 |
+
[
|
| 1605 |
+
html.B("⚠️ Critical level(s)"),
|
| 1606 |
+
html.Span(f"z = {heights_km} km", className="v"),
|
| 1607 |
+
html.Span(
|
| 1608 |
+
"(U = 0 → linear theory breaks down; results near "
|
| 1609 |
+
"these heights are not physical)",
|
| 1610 |
+
className="note",
|
| 1611 |
+
),
|
| 1612 |
+
]
|
| 1613 |
+
)
|
| 1614 |
+
else:
|
| 1615 |
+
critical_badge = html.Span(
|
| 1616 |
+
[
|
| 1617 |
+
html.B("Critical levels"),
|
| 1618 |
+
html.Span("none", className="v"),
|
| 1619 |
+
html.Span("(U does not cross zero)", className="note"),
|
| 1620 |
+
]
|
| 1621 |
+
)
|
| 1622 |
+
else:
|
| 1623 |
+
critical_badge = html.Span(
|
| 1624 |
+
[
|
| 1625 |
+
html.B("Critical levels"),
|
| 1626 |
+
html.Span("n/a", className="v"),
|
| 1627 |
+
html.Span(
|
| 1628 |
+
"(two-layer mode uses a single U; switch to Profile "
|
| 1629 |
+
"mode to prescribe wind reversals)",
|
| 1630 |
+
className="note",
|
| 1631 |
+
),
|
| 1632 |
+
]
|
| 1633 |
+
)
|
| 1634 |
+
return sfig, wfig, upfig, scorer_badge, rossby_badge, nonlin_badge, critical_badge
|
| 1635 |
+
|
| 1636 |
+
# --- profile editing ---------------------------------------------------
|
| 1637 |
+
# Drag-on-graph → update store. Each profile point is a Plotly "shape",
|
| 1638 |
+
# and Plotly emits relayoutData entries like {"shapes[3].xanchor": ...}
|
| 1639 |
+
# when a shape is dragged. We read that, clamp it into the axis range
|
| 1640 |
+
# (so a stray drag off-screen cannot turn the profile into nonsense),
|
| 1641 |
+
# and push the previous store onto the undo history before committing.
|
| 1642 |
+
@app.callback(
|
| 1643 |
+
[
|
| 1644 |
+
Output("profile-store", "data", allow_duplicate=True),
|
| 1645 |
+
Output("profile-history", "data", allow_duplicate=True),
|
| 1646 |
+
],
|
| 1647 |
+
[
|
| 1648 |
+
Input("u-profile-graph", "relayoutData"),
|
| 1649 |
+
Input("theta-profile-graph", "relayoutData"),
|
| 1650 |
+
Input("reset-profile", "n_clicks"),
|
| 1651 |
+
Input("undo-profile", "n_clicks"),
|
| 1652 |
+
],
|
| 1653 |
+
[
|
| 1654 |
+
State("profile-store", "data"),
|
| 1655 |
+
State("profile-history", "data"),
|
| 1656 |
+
],
|
| 1657 |
+
prevent_initial_call=True,
|
| 1658 |
+
)
|
| 1659 |
+
def _profile_update(u_relay, th_relay, reset_clicks, undo_clicks, store, history):
|
| 1660 |
+
from dash import ctx
|
| 1661 |
+
|
| 1662 |
+
trig = ctx.triggered_id
|
| 1663 |
+
history = list(history or [])
|
| 1664 |
+
|
| 1665 |
+
def _push(snapshot):
|
| 1666 |
+
# Deduplicate and cap length.
|
| 1667 |
+
if history and history[-1] == snapshot:
|
| 1668 |
+
return
|
| 1669 |
+
history.append(snapshot)
|
| 1670 |
+
if len(history) > UNDO_HISTORY_MAX:
|
| 1671 |
+
del history[: len(history) - UNDO_HISTORY_MAX]
|
| 1672 |
+
|
| 1673 |
+
def _snapshot(s):
|
| 1674 |
+
# Deep-ish copy — plain lists/floats survive JSON round-tripping fine.
|
| 1675 |
+
return {
|
| 1676 |
+
"z": list(s["z"]),
|
| 1677 |
+
"u": list(s["u"]),
|
| 1678 |
+
"theta": list(s["theta"]),
|
| 1679 |
+
}
|
| 1680 |
+
|
| 1681 |
+
# ---- undo -------------------------------------------------------
|
| 1682 |
+
if trig == "undo-profile":
|
| 1683 |
+
if not history:
|
| 1684 |
+
return no_update, no_update
|
| 1685 |
+
prev = history.pop()
|
| 1686 |
+
return prev, history
|
| 1687 |
+
|
| 1688 |
+
# ---- reset ------------------------------------------------------
|
| 1689 |
+
if trig == "reset-profile":
|
| 1690 |
+
_push(_snapshot(store))
|
| 1691 |
+
zs, us, thetas = _init_profile_figures()
|
| 1692 |
+
return (
|
| 1693 |
+
{"z": zs.tolist(), "u": us.tolist(), "theta": thetas.tolist()},
|
| 1694 |
+
history,
|
| 1695 |
+
)
|
| 1696 |
+
|
| 1697 |
+
# ---- drag -------------------------------------------------------
|
| 1698 |
+
lo, hi = (U_RANGE if trig == "u-profile-graph" else THETA_RANGE)
|
| 1699 |
+
|
| 1700 |
+
def _has_shape_keys(relay):
|
| 1701 |
+
return bool(relay) and any(
|
| 1702 |
+
isinstance(k, str) and k.startswith("shapes[") for k in relay.keys()
|
| 1703 |
+
)
|
| 1704 |
+
|
| 1705 |
+
def _apply(relay, current):
|
| 1706 |
+
"""Pull new values out of a relayoutData payload and clamp them.
|
| 1707 |
+
|
| 1708 |
+
We ONLY honor ``shapes[i].xanchor`` here. Pixel-sized circles
|
| 1709 |
+
also emit ``shapes[i].x0`` / ``x1`` when the user accidentally
|
| 1710 |
+
grabs a resize handle — but those are *pixel* offsets, not data
|
| 1711 |
+
coordinates, so averaging them yields garbage (which was the
|
| 1712 |
+
bug behind "value shoots off-screen" reports). Rejecting
|
| 1713 |
+
resize emissions means an edge-grab just does nothing instead
|
| 1714 |
+
of corrupting the profile.
|
| 1715 |
+
"""
|
| 1716 |
+
if not relay:
|
| 1717 |
+
return current, False
|
| 1718 |
+
out = list(current)
|
| 1719 |
+
changed = False
|
| 1720 |
+
for i in range(len(out)):
|
| 1721 |
+
kxa = f"shapes[{i}].xanchor"
|
| 1722 |
+
if kxa not in relay:
|
| 1723 |
+
continue
|
| 1724 |
+
try:
|
| 1725 |
+
v = float(relay[kxa])
|
| 1726 |
+
except (TypeError, ValueError):
|
| 1727 |
+
continue
|
| 1728 |
+
if not math.isfinite(v):
|
| 1729 |
+
continue
|
| 1730 |
+
v = max(lo, min(hi, v))
|
| 1731 |
+
if v != out[i]:
|
| 1732 |
+
out[i] = v
|
| 1733 |
+
changed = True
|
| 1734 |
+
return out, changed
|
| 1735 |
+
|
| 1736 |
+
if trig == "u-profile-graph":
|
| 1737 |
+
relay, field = u_relay, "u"
|
| 1738 |
+
elif trig == "theta-profile-graph":
|
| 1739 |
+
relay, field = th_relay, "theta"
|
| 1740 |
+
else:
|
| 1741 |
+
return no_update, no_update
|
| 1742 |
+
|
| 1743 |
+
new_values, changed = _apply(relay, store[field])
|
| 1744 |
+
shape_edit = _has_shape_keys(relay)
|
| 1745 |
+
if changed:
|
| 1746 |
+
_push(_snapshot(store))
|
| 1747 |
+
store[field] = new_values
|
| 1748 |
+
if not changed and not shape_edit:
|
| 1749 |
+
# Nothing shape-related happened (pan, zoom, autosize) —
|
| 1750 |
+
# leave the store alone so _redraw_profiles doesn't fire.
|
| 1751 |
+
return no_update, no_update
|
| 1752 |
+
# Bump a revision stamp on every shape event so the redraw
|
| 1753 |
+
# callback fires even if the edit was rejected (e.g. a resize-
|
| 1754 |
+
# handle grab). This snaps any stretched oval back to its
|
| 1755 |
+
# canonical round shape.
|
| 1756 |
+
store["_rev"] = int(store.get("_rev", 0)) + 1
|
| 1757 |
+
return store, history
|
| 1758 |
+
|
| 1759 |
+
# Enable/disable the undo button and show a depth hint.
|
| 1760 |
+
@app.callback(
|
| 1761 |
+
[
|
| 1762 |
+
Output("undo-profile", "disabled"),
|
| 1763 |
+
Output("undo-status", "children"),
|
| 1764 |
+
],
|
| 1765 |
+
Input("profile-history", "data"),
|
| 1766 |
+
)
|
| 1767 |
+
def _undo_status(history):
|
| 1768 |
+
n = len(history or [])
|
| 1769 |
+
if n == 0:
|
| 1770 |
+
return True, ""
|
| 1771 |
+
return False, f"{n} edit{'s' if n != 1 else ''} in history"
|
| 1772 |
+
|
| 1773 |
+
# --- HRRR profile fetch ----------------------------------------------
|
| 1774 |
+
# Pulls U, V, T, HGT from the HRRR 0-h analysis on AWS (byte-range
|
| 1775 |
+
# subset via the .idx sidecar), computes θ, and caches the raw east/north
|
| 1776 |
+
# wind components in ``hrrr-raw-store``. The initial along-flow projection
|
| 1777 |
+
# uses the current ``hrrr-dir`` slider value; further slider moves reuse
|
| 1778 |
+
# the cached column (see ``_hrrr_redirect``) so no re-download is needed.
|
| 1779 |
+
@app.callback(
|
| 1780 |
+
[
|
| 1781 |
+
Output("profile-store", "data", allow_duplicate=True),
|
| 1782 |
+
Output("profile-history", "data", allow_duplicate=True),
|
| 1783 |
+
Output("hrrr-raw-store", "data"),
|
| 1784 |
+
Output("hrrr-status", "children"),
|
| 1785 |
+
Output("hrrr-status", "className"),
|
| 1786 |
+
],
|
| 1787 |
+
Input("hrrr-fetch", "n_clicks"),
|
| 1788 |
+
[
|
| 1789 |
+
State("hrrr-lat", "value"),
|
| 1790 |
+
State("hrrr-lon", "value"),
|
| 1791 |
+
State("hrrr-dir", "value"),
|
| 1792 |
+
State("hrrr-datetime", "value"),
|
| 1793 |
+
State("profile-store", "data"),
|
| 1794 |
+
State("profile-history", "data"),
|
| 1795 |
+
],
|
| 1796 |
+
prevent_initial_call=True,
|
| 1797 |
+
)
|
| 1798 |
+
def _hrrr_fetch(n_clicks, lat, lon, flow_deg, yyyymmddhh, store, history):
|
| 1799 |
+
if not n_clicks:
|
| 1800 |
+
return no_update, no_update, no_update, no_update, no_update
|
| 1801 |
+
|
| 1802 |
+
# Basic input validation — bail out early with a clear message
|
| 1803 |
+
# rather than making the user wait on a network round-trip.
|
| 1804 |
+
try:
|
| 1805 |
+
lat_f = float(lat)
|
| 1806 |
+
lon_f = float(lon)
|
| 1807 |
+
flow_f = float(flow_deg)
|
| 1808 |
+
except (TypeError, ValueError):
|
| 1809 |
+
return (
|
| 1810 |
+
no_update,
|
| 1811 |
+
no_update,
|
| 1812 |
+
no_update,
|
| 1813 |
+
"Lat / Lon / Flow dir must all be numbers.",
|
| 1814 |
+
"mw-hrrr-status error",
|
| 1815 |
+
)
|
| 1816 |
+
if not (-90.0 <= lat_f <= 90.0):
|
| 1817 |
+
return (
|
| 1818 |
+
no_update,
|
| 1819 |
+
no_update,
|
| 1820 |
+
no_update,
|
| 1821 |
+
f"Lat {lat_f} outside [-90, 90].",
|
| 1822 |
+
"mw-hrrr-status error",
|
| 1823 |
+
)
|
| 1824 |
+
|
| 1825 |
+
try:
|
| 1826 |
+
from .hrrr import along_flow_signed, fetch_profile
|
| 1827 |
+
except ImportError as exc:
|
| 1828 |
+
return (
|
| 1829 |
+
no_update,
|
| 1830 |
+
no_update,
|
| 1831 |
+
no_update,
|
| 1832 |
+
f"HRRR module failed to import: {exc}",
|
| 1833 |
+
"mw-hrrr-status error",
|
| 1834 |
+
)
|
| 1835 |
+
|
| 1836 |
+
zs_target = np.asarray(store["z"], dtype=float)
|
| 1837 |
+
try:
|
| 1838 |
+
z, u_raw, v_raw, theta, meta = fetch_profile(
|
| 1839 |
+
lat_f, lon_f, yyyymmddhh, z_target_m=zs_target
|
| 1840 |
+
)
|
| 1841 |
+
except Exception as exc:
|
| 1842 |
+
import traceback
|
| 1843 |
+
traceback.print_exc()
|
| 1844 |
+
return (
|
| 1845 |
+
no_update,
|
| 1846 |
+
no_update,
|
| 1847 |
+
no_update,
|
| 1848 |
+
f"Fetch failed: {exc}",
|
| 1849 |
+
"mw-hrrr-status error",
|
| 1850 |
+
)
|
| 1851 |
+
|
| 1852 |
+
# Don't clip HRRR data — the widened U_RANGE / THETA_RANGE should
|
| 1853 |
+
# accommodate most real columns, and silently clamping was masking
|
| 1854 |
+
# real wind and temperature structure (negative along-flow legs,
|
| 1855 |
+
# cold-air-mass surface θ, etc.). If a sample still falls outside
|
| 1856 |
+
# the axis, the editor auto-extends at render time.
|
| 1857 |
+
u_arr = np.asarray(u_raw, dtype=float)
|
| 1858 |
+
v_arr = np.asarray(v_raw, dtype=float)
|
| 1859 |
+
th_arr = np.asarray(theta, dtype=float)
|
| 1860 |
+
# Signed projection — wind reversals (e.g. an easterly jet when the
|
| 1861 |
+
# user specified flow_from_deg corresponding to westerly) produce
|
| 1862 |
+
# negative values, which the solver handles via the Scorer critical-
|
| 1863 |
+
# level clamp and the "⚠️ Critical level" diagnostic badge.
|
| 1864 |
+
u_along = along_flow_signed(u_arr, v_arr, flow_f)
|
| 1865 |
+
|
| 1866 |
+
# Push current state onto history so the fetch is undoable.
|
| 1867 |
+
history = list(history or [])
|
| 1868 |
+
snap = {"z": list(store["z"]), "u": list(store["u"]), "theta": list(store["theta"])}
|
| 1869 |
+
if not history or history[-1] != snap:
|
| 1870 |
+
history.append(snap)
|
| 1871 |
+
if len(history) > UNDO_HISTORY_MAX:
|
| 1872 |
+
del history[: len(history) - UNDO_HISTORY_MAX]
|
| 1873 |
+
|
| 1874 |
+
new_store = {
|
| 1875 |
+
"z": zs_target.tolist(),
|
| 1876 |
+
"u": u_along.tolist(),
|
| 1877 |
+
"theta": th_arr.tolist(),
|
| 1878 |
+
"_rev": int(store.get("_rev", 0)) + 1,
|
| 1879 |
+
}
|
| 1880 |
+
# Cache raw east/north wind (and θ / z) so the slider can re-project
|
| 1881 |
+
# without re-downloading. Meta is kept so the status message can be
|
| 1882 |
+
# refreshed after a reprojection.
|
| 1883 |
+
raw_store = {
|
| 1884 |
+
"z": zs_target.tolist(),
|
| 1885 |
+
"u": u_arr.tolist(),
|
| 1886 |
+
"v": v_arr.tolist(),
|
| 1887 |
+
"theta": th_arr.tolist(),
|
| 1888 |
+
"meta": {
|
| 1889 |
+
"s3_key": meta.get("s3_key"),
|
| 1890 |
+
"grid_lat": float(meta.get("grid_lat", float("nan"))),
|
| 1891 |
+
"grid_lon": float(meta.get("grid_lon", float("nan"))),
|
| 1892 |
+
"bytes": int(meta.get("bytes", 0)),
|
| 1893 |
+
"n_levels": int(meta.get("n_levels", 0)),
|
| 1894 |
+
"cycle": yyyymmddhh,
|
| 1895 |
+
},
|
| 1896 |
+
}
|
| 1897 |
+
mb = meta["bytes"] / 1e6
|
| 1898 |
+
msg = (
|
| 1899 |
+
f"HRRR {yyyymmddhh} at grid point "
|
| 1900 |
+
f"({meta['grid_lat']:.3f}°N, {meta['grid_lon']:.3f}°E) — "
|
| 1901 |
+
f"{meta['n_levels']} levels, {mb:.1f} MB fetched. "
|
| 1902 |
+
f"Flow from {flow_f:.0f}°."
|
| 1903 |
+
)
|
| 1904 |
+
return new_store, history, raw_store, msg, "mw-hrrr-status ok"
|
| 1905 |
+
|
| 1906 |
+
# --- HRRR reprojection on slider change ------------------------------
|
| 1907 |
+
# When the flow-from slider moves, reuse the cached raw column (if any)
|
| 1908 |
+
# and rewrite just the profile-store's u field. No network activity.
|
| 1909 |
+
@app.callback(
|
| 1910 |
+
[
|
| 1911 |
+
Output("profile-store", "data", allow_duplicate=True),
|
| 1912 |
+
Output("hrrr-status", "children", allow_duplicate=True),
|
| 1913 |
+
Output("hrrr-status", "className", allow_duplicate=True),
|
| 1914 |
+
],
|
| 1915 |
+
Input("hrrr-dir", "value"),
|
| 1916 |
+
[
|
| 1917 |
+
State("hrrr-raw-store", "data"),
|
| 1918 |
+
State("profile-store", "data"),
|
| 1919 |
+
],
|
| 1920 |
+
prevent_initial_call=True,
|
| 1921 |
+
)
|
| 1922 |
+
def _hrrr_redirect(flow_deg, raw, store):
|
| 1923 |
+
# No cached column yet — slider moves before any fetch should be
|
| 1924 |
+
# silent no-ops. (The next Fetch will pick up the current value.)
|
| 1925 |
+
if not raw or "u" not in raw or "v" not in raw:
|
| 1926 |
+
return no_update, no_update, no_update
|
| 1927 |
+
try:
|
| 1928 |
+
flow_f = float(flow_deg)
|
| 1929 |
+
except (TypeError, ValueError):
|
| 1930 |
+
return no_update, no_update, no_update
|
| 1931 |
+
|
| 1932 |
+
try:
|
| 1933 |
+
from .hrrr import along_flow_signed
|
| 1934 |
+
except ImportError:
|
| 1935 |
+
return no_update, no_update, no_update
|
| 1936 |
+
|
| 1937 |
+
u_raw = np.asarray(raw["u"], dtype=float)
|
| 1938 |
+
v_raw = np.asarray(raw["v"], dtype=float)
|
| 1939 |
+
# Signed projection — keep wind reversals instead of clipping to 0.
|
| 1940 |
+
u_along = along_flow_signed(u_raw, v_raw, flow_f)
|
| 1941 |
+
|
| 1942 |
+
new_store = {
|
| 1943 |
+
"z": list(raw["z"]),
|
| 1944 |
+
"u": u_along.tolist(),
|
| 1945 |
+
"theta": list(raw["theta"]),
|
| 1946 |
+
"_rev": int((store or {}).get("_rev", 0)) + 1,
|
| 1947 |
+
}
|
| 1948 |
+
meta = raw.get("meta", {}) or {}
|
| 1949 |
+
cycle = meta.get("cycle", "")
|
| 1950 |
+
mb = float(meta.get("bytes", 0)) / 1e6
|
| 1951 |
+
n_levels = int(meta.get("n_levels", 0))
|
| 1952 |
+
glat = meta.get("grid_lat")
|
| 1953 |
+
glon = meta.get("grid_lon")
|
| 1954 |
+
if glat is not None and glon is not None:
|
| 1955 |
+
loc = f"({glat:.3f}°N, {glon:.3f}°E)"
|
| 1956 |
+
else:
|
| 1957 |
+
loc = ""
|
| 1958 |
+
msg = (
|
| 1959 |
+
f"HRRR {cycle} at grid point {loc} — {n_levels} levels, "
|
| 1960 |
+
f"{mb:.1f} MB cached. Reprojected: flow from {flow_f:.0f}°."
|
| 1961 |
+
)
|
| 1962 |
+
return new_store, msg, "mw-hrrr-status ok"
|
| 1963 |
+
|
| 1964 |
+
# Store → figures. Whenever the store changes we regenerate both
|
| 1965 |
+
# editable profile figures (so the line follows the dragged shapes)
|
| 1966 |
+
# and all four diagnostic profiles (L², Ri, N², dθ/dz).
|
| 1967 |
+
@app.callback(
|
| 1968 |
+
[
|
| 1969 |
+
Output("u-profile-graph", "figure"),
|
| 1970 |
+
Output("theta-profile-graph", "figure"),
|
| 1971 |
+
Output("diag-scorer", "figure"),
|
| 1972 |
+
Output("diag-ri", "figure"),
|
| 1973 |
+
Output("diag-n2", "figure"),
|
| 1974 |
+
Output("diag-dthdz", "figure"),
|
| 1975 |
+
],
|
| 1976 |
+
Input("profile-store", "data"),
|
| 1977 |
+
)
|
| 1978 |
+
def _redraw_profiles(store):
|
| 1979 |
+
zs = np.asarray(store["z"])
|
| 1980 |
+
us = np.asarray(store["u"])
|
| 1981 |
+
thetas = np.asarray(store["theta"])
|
| 1982 |
+
u_fig = _profile_editor_figure("Zonal wind u(z)", us, zs, xunit="m s⁻¹", xrange=U_RANGE)
|
| 1983 |
+
th_fig = _profile_editor_figure(
|
| 1984 |
+
"Potential temperature θ(z)", thetas, zs, xunit="K", xrange=THETA_RANGE
|
| 1985 |
+
)
|
| 1986 |
+
l_fig, ri_fig, n2_fig, dthdz_fig = _diagnostic_figures(store)
|
| 1987 |
+
return u_fig, th_fig, l_fig, ri_fig, n2_fig, dthdz_fig
|
| 1988 |
+
|
| 1989 |
+
|
| 1990 |
+
def main():
|
| 1991 |
+
app = create_app()
|
| 1992 |
+
app.run(debug=False, host="127.0.0.1", port=8050)
|
| 1993 |
+
|
| 1994 |
+
|
| 1995 |
+
if __name__ == "__main__":
|
| 1996 |
+
main()
|
python/mountain_waves/assets/climas_icon.png
ADDED
|
|
python/mountain_waves/assets/climas_icon_64.png
ADDED
|
|
python/mountain_waves/assets/favicon.png
ADDED
|
|
python/mountain_waves/hrrr.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HRRR-on-AWS profile fetcher.
|
| 2 |
+
|
| 3 |
+
Pulls a vertical profile of wind and temperature out of the HRRR analysis
|
| 4 |
+
(0-h forecast) on the NODD public bucket ``noaa-hrrr-bdp-pds`` and returns
|
| 5 |
+
it as (z, u_along, theta) arrays ready to drop into the profile editor.
|
| 6 |
+
|
| 7 |
+
Design notes
|
| 8 |
+
------------
|
| 9 |
+
The full ``wrfprsf00.grib2`` is ~140 MB. We only need a handful of fields
|
| 10 |
+
(HGT, TMP, UGRD, VGRD on pressure levels) at a single grid cell, so we:
|
| 11 |
+
|
| 12 |
+
1. Fetch the ``.idx`` sidecar (a few kB) to find byte offsets.
|
| 13 |
+
2. Issue ranged GETs for just the GRIB messages we need (few MB total).
|
| 14 |
+
3. Splice them together into a local scratch file.
|
| 15 |
+
4. Open with cfgrib, pick the nearest grid column, and compute θ and the
|
| 16 |
+
user-specified flow-direction wind component.
|
| 17 |
+
|
| 18 |
+
Dependencies: ``boto3``, ``xarray``, ``cfgrib``. cfgrib needs the eccodes
|
| 19 |
+
C library; we pull it in from PyPI only (via ``eccodes`` + ``eccodeslib``
|
| 20 |
+
on macOS/Linux, or ``eccodes`` + ``ecmwflibs`` on Windows) rather than a
|
| 21 |
+
system package, so ``uv sync`` is enough and no ``brew install eccodes``
|
| 22 |
+
step is required. See ``pyproject.toml`` for the platform markers.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import io
|
| 28 |
+
import os
|
| 29 |
+
import tempfile
|
| 30 |
+
from dataclasses import dataclass
|
| 31 |
+
from datetime import datetime
|
| 32 |
+
from typing import List, Tuple
|
| 33 |
+
|
| 34 |
+
import numpy as np
|
| 35 |
+
|
| 36 |
+
BUCKET = "noaa-hrrr-bdp-pds"
|
| 37 |
+
R_OVER_CP = 0.2854 # R_d / c_p for dry air
|
| 38 |
+
|
| 39 |
+
# Variables and level type we care about.
|
| 40 |
+
_WANTED_VARS = ("HGT", "TMP", "UGRD", "VGRD")
|
| 41 |
+
_LEVEL_SUFFIX = "mb"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@dataclass
|
| 45 |
+
class _IdxRecord:
|
| 46 |
+
num: int
|
| 47 |
+
start: int
|
| 48 |
+
var: str
|
| 49 |
+
level_mb: float
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _parse_yyyymmddhh(s: str) -> datetime:
|
| 53 |
+
s = (s or "").strip()
|
| 54 |
+
if len(s) != 10 or not s.isdigit():
|
| 55 |
+
raise ValueError(
|
| 56 |
+
f"Expected YYYYMMDDHH (10 digits); got {s!r}. "
|
| 57 |
+
f"Example: 2024060112"
|
| 58 |
+
)
|
| 59 |
+
return datetime.strptime(s, "%Y%m%d%H")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _s3_key(dt: datetime) -> str:
|
| 63 |
+
return f"hrrr.{dt:%Y%m%d}/conus/hrrr.t{dt:%H}z.wrfprsf00.grib2"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _unsigned_s3_client():
|
| 67 |
+
"""boto3 client configured for anonymous (unsigned) access."""
|
| 68 |
+
import boto3
|
| 69 |
+
from botocore import UNSIGNED
|
| 70 |
+
from botocore.config import Config
|
| 71 |
+
|
| 72 |
+
return boto3.client("s3", config=Config(signature_version=UNSIGNED))
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _parse_idx(idx_text: str) -> Tuple[List[_IdxRecord], List[int]]:
|
| 76 |
+
"""Parse the HRRR .idx file into (wanted_records, all_start_bytes).
|
| 77 |
+
|
| 78 |
+
Format of each line (colon-separated):
|
| 79 |
+
record_num:start_byte:d=YYYYMMDDHH:VAR:LEVEL:FCST:anl
|
| 80 |
+
"""
|
| 81 |
+
wanted: List[_IdxRecord] = []
|
| 82 |
+
all_starts: List[int] = []
|
| 83 |
+
for line in idx_text.splitlines():
|
| 84 |
+
parts = line.split(":")
|
| 85 |
+
if len(parts) < 6 or not parts[1].isdigit():
|
| 86 |
+
continue
|
| 87 |
+
start = int(parts[1])
|
| 88 |
+
all_starts.append(start)
|
| 89 |
+
var = parts[3]
|
| 90 |
+
level = parts[4].strip()
|
| 91 |
+
if var not in _WANTED_VARS or not level.endswith(_LEVEL_SUFFIX):
|
| 92 |
+
continue
|
| 93 |
+
try:
|
| 94 |
+
level_mb = float(level.split()[0])
|
| 95 |
+
except ValueError:
|
| 96 |
+
continue
|
| 97 |
+
wanted.append(_IdxRecord(num=int(parts[0]), start=start, var=var, level_mb=level_mb))
|
| 98 |
+
return wanted, sorted(all_starts)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _byte_ranges(records: List[_IdxRecord], all_starts: List[int]) -> List[Tuple[int, int]]:
|
| 102 |
+
"""Convert each wanted record's start byte to a (start, end) range.
|
| 103 |
+
|
| 104 |
+
End byte is the next record's start minus one, or open-ended for the
|
| 105 |
+
final record. We collapse adjacent ranges into contiguous chunks to
|
| 106 |
+
cut down on the number of HTTP calls.
|
| 107 |
+
"""
|
| 108 |
+
ranges: List[Tuple[int, int]] = []
|
| 109 |
+
for r in records:
|
| 110 |
+
i = all_starts.index(r.start)
|
| 111 |
+
if i + 1 < len(all_starts):
|
| 112 |
+
end = all_starts[i + 1] - 1
|
| 113 |
+
else:
|
| 114 |
+
end = -1 # open-ended
|
| 115 |
+
ranges.append((r.start, end))
|
| 116 |
+
# Merge contiguous ranges to reduce request count.
|
| 117 |
+
ranges.sort()
|
| 118 |
+
merged: List[Tuple[int, int]] = []
|
| 119 |
+
for start, end in ranges:
|
| 120 |
+
if merged and end != -1 and merged[-1][1] != -1 and start == merged[-1][1] + 1:
|
| 121 |
+
merged[-1] = (merged[-1][0], end)
|
| 122 |
+
else:
|
| 123 |
+
merged.append((start, end))
|
| 124 |
+
return merged
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _download_subset(client, key: str, ranges: List[Tuple[int, int]]) -> bytes:
|
| 128 |
+
buf = io.BytesIO()
|
| 129 |
+
for start, end in ranges:
|
| 130 |
+
header = f"bytes={start}-" + ("" if end == -1 else str(end))
|
| 131 |
+
obj = client.get_object(Bucket=BUCKET, Key=key, Range=header)
|
| 132 |
+
buf.write(obj["Body"].read())
|
| 133 |
+
return buf.getvalue()
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def _nearest_ij(lats: np.ndarray, lons: np.ndarray, lat0: float, lon0: float) -> Tuple[int, int]:
|
| 137 |
+
"""Nearest-neighbor grid cell to (lat0, lon0) on a 2-D HRRR grid."""
|
| 138 |
+
lon0 = ((lon0 + 180) % 360) - 180
|
| 139 |
+
# Convert HRRR lons to -180..180 as well.
|
| 140 |
+
lons = ((lons + 180) % 360) - 180
|
| 141 |
+
d2 = (lats - lat0) ** 2 + (lons - lon0) ** 2
|
| 142 |
+
j, i = np.unravel_index(np.argmin(d2), d2.shape)
|
| 143 |
+
return int(j), int(i)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def along_flow_signed(
|
| 147 |
+
u: np.ndarray, v: np.ndarray, flow_from_deg: float
|
| 148 |
+
) -> np.ndarray:
|
| 149 |
+
"""Signed along-flow wind component for the mountain-wave solver.
|
| 150 |
+
|
| 151 |
+
``flow_from_deg`` follows the standard meteorological convention — the
|
| 152 |
+
azimuth the wind is blowing *from* (270° = westerly, 160° = from the SSE).
|
| 153 |
+
The returned scalar is the signed component of the wind parallel to
|
| 154 |
+
that "from" direction: **positive** when the wind is blowing *from* a
|
| 155 |
+
direction within 90° of ``flow_from_deg``, and **negative** when the
|
| 156 |
+
wind reverses relative to that reference direction. Callers that
|
| 157 |
+
depended on the old zero-clipped behavior should take
|
| 158 |
+
``np.maximum(along_flow_signed(...), 0.0)`` explicitly; the solver
|
| 159 |
+
itself now tolerates negative U via the Scorer-parameter critical-level
|
| 160 |
+
clamp, so wind reversals aloft should pass through unmodified and be
|
| 161 |
+
surfaced to the user as actual reversals.
|
| 162 |
+
|
| 163 |
+
Derivation: a wind with components ``(u, v)`` (east- and north-positive)
|
| 164 |
+
blowing *from* azimuth ``φ_act`` has magnitude ``s`` and
|
| 165 |
+
``(u, v) = -s · (sin φ_act, cos φ_act)``. Projecting onto the unit
|
| 166 |
+
vector pointing in the direction the flow is going when it comes from
|
| 167 |
+
``φ_spec`` (i.e. ``φ_spec + 180``) gives ``s · cos(φ_act − φ_spec)``.
|
| 168 |
+
That evaluates to ``-(u sin φ_spec + v cos φ_spec)``, positive when
|
| 169 |
+
actual and specified "from" directions are parallel and negative when
|
| 170 |
+
antiparallel. No clamping is applied.
|
| 171 |
+
"""
|
| 172 |
+
rad = np.deg2rad(flow_from_deg)
|
| 173 |
+
return -(np.asarray(u) * np.sin(rad) + np.asarray(v) * np.cos(rad))
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# Backward-compatibility alias — the old name is retained so external
|
| 177 |
+
# imports keep working, but it now returns the *signed* along-flow
|
| 178 |
+
# component (no zero-clip). Callers that genuinely need the clipped
|
| 179 |
+
# variant must apply ``np.maximum(_, 0.0)`` themselves.
|
| 180 |
+
along_flow_positive = along_flow_signed
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def _theta(T_K: np.ndarray, p_hpa: np.ndarray) -> np.ndarray:
|
| 184 |
+
"""Potential temperature (K) referenced to 1000 hPa."""
|
| 185 |
+
return T_K * (1000.0 / p_hpa) ** R_OVER_CP
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def fetch_profile(
|
| 189 |
+
lat: float,
|
| 190 |
+
lon: float,
|
| 191 |
+
yyyymmddhh: str,
|
| 192 |
+
z_target_m: np.ndarray | None = None,
|
| 193 |
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, dict]:
|
| 194 |
+
"""Return ``(z, u, v, theta, meta)`` for the HRRR column at (lat, lon).
|
| 195 |
+
|
| 196 |
+
Raw east/north wind components are returned (not the along-flow
|
| 197 |
+
projection) so the caller can re-project onto any user-chosen flow
|
| 198 |
+
direction cheaply without re-downloading. Use :func:`along_flow_signed`
|
| 199 |
+
to turn ``(u, v, flow_from_deg)`` into the mountain-wave input; wind
|
| 200 |
+
reversals produce negative values, which the solver handles via the
|
| 201 |
+
Scorer critical-level clamp rather than silently clipping to zero.
|
| 202 |
+
|
| 203 |
+
Parameters
|
| 204 |
+
----------
|
| 205 |
+
lat, lon : float
|
| 206 |
+
Point of interest in decimal degrees. ``lon`` may be ±180 or 0..360.
|
| 207 |
+
yyyymmddhh : str
|
| 208 |
+
UTC cycle time, e.g. ``"2024060112"``.
|
| 209 |
+
z_target_m : np.ndarray, optional
|
| 210 |
+
If given, the profile is linearly interpolated onto these heights
|
| 211 |
+
(meters above ground level). If ``None``, the native HRRR
|
| 212 |
+
pressure-level heights (AGL) are returned as ``z``.
|
| 213 |
+
|
| 214 |
+
Returns
|
| 215 |
+
-------
|
| 216 |
+
z : np.ndarray
|
| 217 |
+
Heights in m AGL.
|
| 218 |
+
u, v : np.ndarray
|
| 219 |
+
East- and north-positive wind components in m/s.
|
| 220 |
+
theta : np.ndarray
|
| 221 |
+
Potential temperature in K.
|
| 222 |
+
meta : dict
|
| 223 |
+
Diagnostic info (nearest grid lat/lon, S3 key, bytes transferred).
|
| 224 |
+
"""
|
| 225 |
+
try:
|
| 226 |
+
import xarray as xr # noqa: F401 (used via cfgrib backend)
|
| 227 |
+
except ImportError as exc: # pragma: no cover
|
| 228 |
+
raise RuntimeError(
|
| 229 |
+
"xarray is required to read HRRR GRIB2. "
|
| 230 |
+
"Install with: pip install xarray cfgrib"
|
| 231 |
+
) from exc
|
| 232 |
+
|
| 233 |
+
dt = _parse_yyyymmddhh(yyyymmddhh)
|
| 234 |
+
key = _s3_key(dt)
|
| 235 |
+
client = _unsigned_s3_client()
|
| 236 |
+
|
| 237 |
+
# 1. Fetch the .idx sidecar.
|
| 238 |
+
try:
|
| 239 |
+
idx_obj = client.get_object(Bucket=BUCKET, Key=key + ".idx")
|
| 240 |
+
except Exception as exc:
|
| 241 |
+
raise RuntimeError(
|
| 242 |
+
f"HRRR .idx not found at s3://{BUCKET}/{key}.idx "
|
| 243 |
+
f"(cycle may not exist yet). Error: {exc}"
|
| 244 |
+
) from exc
|
| 245 |
+
idx_text = idx_obj["Body"].read().decode("utf-8", errors="replace")
|
| 246 |
+
records, all_starts = _parse_idx(idx_text)
|
| 247 |
+
if not records:
|
| 248 |
+
raise RuntimeError(f"No HGT/TMP/UGRD/VGRD pressure-level records in idx for {key}")
|
| 249 |
+
|
| 250 |
+
# 2. Byte-range fetch just the records we need.
|
| 251 |
+
ranges = _byte_ranges(records, all_starts)
|
| 252 |
+
blob = _download_subset(client, key, ranges)
|
| 253 |
+
bytes_downloaded = len(blob)
|
| 254 |
+
|
| 255 |
+
# 3. Splice into a scratch file and open with cfgrib.
|
| 256 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".grib2") as f:
|
| 257 |
+
f.write(blob)
|
| 258 |
+
grib_path = f.name
|
| 259 |
+
|
| 260 |
+
try:
|
| 261 |
+
import xarray as xr
|
| 262 |
+
|
| 263 |
+
ds = xr.open_dataset(
|
| 264 |
+
grib_path,
|
| 265 |
+
engine="cfgrib",
|
| 266 |
+
backend_kwargs={
|
| 267 |
+
"indexpath": "", # don't leave .idx files around
|
| 268 |
+
"filter_by_keys": {"typeOfLevel": "isobaricInhPa"},
|
| 269 |
+
},
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
# cfgrib exposes variables as {'gh' or 'HGT', 't', 'u', 'v'} depending on
|
| 273 |
+
# shortName/cfName. Find them robustly.
|
| 274 |
+
def _pick(ds, candidates):
|
| 275 |
+
for name in candidates:
|
| 276 |
+
if name in ds.variables:
|
| 277 |
+
return ds[name]
|
| 278 |
+
raise KeyError(f"None of {candidates} found in dataset: {list(ds.variables)}")
|
| 279 |
+
|
| 280 |
+
# cfgrib reads data lazily — it keeps the grib file open and re-reads
|
| 281 |
+
# when .values is touched. We delete the scratch file below, so we
|
| 282 |
+
# must materialize every array we care about *before* the unlink.
|
| 283 |
+
hgt_a = _pick(ds, ["gh", "HGT", "h"]).values
|
| 284 |
+
tmp_a = _pick(ds, ["t", "TMP"]).values
|
| 285 |
+
ugrd_a = _pick(ds, ["u", "UGRD"]).values
|
| 286 |
+
vgrd_a = _pick(ds, ["v", "VGRD"]).values
|
| 287 |
+
p_dim = "isobaricInhPa"
|
| 288 |
+
pressures_full = ds[p_dim].values.astype(float) # hPa
|
| 289 |
+
lats_full = ds["latitude"].values
|
| 290 |
+
lons_full = ds["longitude"].values
|
| 291 |
+
ds.close()
|
| 292 |
+
finally:
|
| 293 |
+
try:
|
| 294 |
+
os.unlink(grib_path)
|
| 295 |
+
except OSError:
|
| 296 |
+
pass
|
| 297 |
+
|
| 298 |
+
# 4. Pick the nearest column.
|
| 299 |
+
j, i = _nearest_ij(lats_full, lons_full, lat, lon)
|
| 300 |
+
grid_lat = float(lats_full[j, i])
|
| 301 |
+
grid_lon = float(((lons_full[j, i] + 180) % 360) - 180)
|
| 302 |
+
pressures = pressures_full
|
| 303 |
+
|
| 304 |
+
h_col = hgt_a[:, j, i] # geopotential height, m (MSL)
|
| 305 |
+
t_col = tmp_a[:, j, i] # K
|
| 306 |
+
u_col = ugrd_a[:, j, i]
|
| 307 |
+
v_col = vgrd_a[:, j, i]
|
| 308 |
+
|
| 309 |
+
# Sort by pressure descending (so surface first, top last).
|
| 310 |
+
order = np.argsort(-pressures)
|
| 311 |
+
pressures = pressures[order]
|
| 312 |
+
h_col = h_col[order]
|
| 313 |
+
t_col = t_col[order]
|
| 314 |
+
u_col = u_col[order]
|
| 315 |
+
v_col = v_col[order]
|
| 316 |
+
|
| 317 |
+
# Keep only levels at or above ground (HRRR pressure levels below the
|
| 318 |
+
# surface are filled with extrapolated values — drop those by requiring
|
| 319 |
+
# monotonic height increase from the surface up).
|
| 320 |
+
sfc_h = float(np.min(h_col))
|
| 321 |
+
valid = h_col >= sfc_h - 1.0
|
| 322 |
+
h_col = h_col[valid]
|
| 323 |
+
t_col = t_col[valid]
|
| 324 |
+
u_col = u_col[valid]
|
| 325 |
+
v_col = v_col[valid]
|
| 326 |
+
pressures = pressures[valid]
|
| 327 |
+
|
| 328 |
+
# Make strictly monotonic increasing in height (in case of ties).
|
| 329 |
+
order = np.argsort(h_col)
|
| 330 |
+
h_col = h_col[order]
|
| 331 |
+
t_col = t_col[order]
|
| 332 |
+
u_col = u_col[order]
|
| 333 |
+
v_col = v_col[order]
|
| 334 |
+
pressures = pressures[order]
|
| 335 |
+
|
| 336 |
+
# Convert MSL heights to AGL by subtracting the lowest valid level.
|
| 337 |
+
z_agl = h_col - h_col[0]
|
| 338 |
+
|
| 339 |
+
theta = _theta(t_col, pressures)
|
| 340 |
+
|
| 341 |
+
if z_target_m is not None:
|
| 342 |
+
z_target = np.asarray(z_target_m, dtype=float)
|
| 343 |
+
# Clip target range to what HRRR actually covers at this point.
|
| 344 |
+
z_clipped = np.clip(z_target, float(z_agl[0]), float(z_agl[-1]))
|
| 345 |
+
u_out = np.interp(z_clipped, z_agl, u_col)
|
| 346 |
+
v_out = np.interp(z_clipped, z_agl, v_col)
|
| 347 |
+
th_out = np.interp(z_clipped, z_agl, theta)
|
| 348 |
+
z_out = z_target.copy()
|
| 349 |
+
else:
|
| 350 |
+
z_out = z_agl
|
| 351 |
+
u_out = u_col
|
| 352 |
+
v_out = v_col
|
| 353 |
+
th_out = theta
|
| 354 |
+
|
| 355 |
+
meta = {
|
| 356 |
+
"s3_key": key,
|
| 357 |
+
"grid_lat": grid_lat,
|
| 358 |
+
"grid_lon": grid_lon,
|
| 359 |
+
"bytes": bytes_downloaded,
|
| 360 |
+
"n_levels": int(z_agl.size),
|
| 361 |
+
"sfc_height_msl": float(h_col[0]),
|
| 362 |
+
}
|
| 363 |
+
return (
|
| 364 |
+
z_out.astype(float),
|
| 365 |
+
u_out.astype(float),
|
| 366 |
+
v_out.astype(float),
|
| 367 |
+
th_out.astype(float),
|
| 368 |
+
meta,
|
| 369 |
+
)
|
python/mountain_waves/profile.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Helpers for building and interpreting theta(z) / u(z) profiles."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from typing import List
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
G = 9.80665
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def default_profile_heights(zdom_km: float = 10.0, n_points: int = 9) -> np.ndarray:
|
| 14 |
+
"""Return ``n_points`` evenly spaced heights (meters) from 0 to ``zdom_km`` km."""
|
| 15 |
+
return np.linspace(0.0, zdom_km * 1000.0, n_points)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def default_u_profile(zs: np.ndarray, u_surface: float = 20.0, shear: float = 0.5) -> np.ndarray:
|
| 19 |
+
"""A gently sheared wind profile: ``u(z) = u_surface + shear * z_km``."""
|
| 20 |
+
zs_km = zs / 1000.0
|
| 21 |
+
return u_surface + shear * zs_km
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def default_theta_profile(
|
| 25 |
+
zs: np.ndarray,
|
| 26 |
+
theta_surface: float = 290.0,
|
| 27 |
+
lapse_lower: float = 3.0,
|
| 28 |
+
lapse_upper: float = 6.0,
|
| 29 |
+
interface_km: float = 3.5,
|
| 30 |
+
) -> np.ndarray:
|
| 31 |
+
"""A two-regime potential-temperature profile.
|
| 32 |
+
|
| 33 |
+
Stability contrast across ``interface_km`` mirrors the trapped-wave case
|
| 34 |
+
in the original MATLAB example: weaker stability below, stronger above.
|
| 35 |
+
"""
|
| 36 |
+
zs_km = zs / 1000.0
|
| 37 |
+
theta = np.empty_like(zs_km)
|
| 38 |
+
for i, zkm in enumerate(zs_km):
|
| 39 |
+
if zkm <= interface_km:
|
| 40 |
+
theta[i] = theta_surface + lapse_lower * zkm
|
| 41 |
+
else:
|
| 42 |
+
theta[i] = (
|
| 43 |
+
theta_surface
|
| 44 |
+
+ lapse_lower * interface_km
|
| 45 |
+
+ lapse_upper * (zkm - interface_km)
|
| 46 |
+
)
|
| 47 |
+
return theta
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def brunt_vaisala(z: np.ndarray, theta: np.ndarray) -> np.ndarray:
|
| 51 |
+
"""Finite-difference Brunt–Väisälä frequency squared (s⁻²)."""
|
| 52 |
+
z = np.asarray(z, dtype=float)
|
| 53 |
+
theta = np.asarray(theta, dtype=float)
|
| 54 |
+
n = z.size
|
| 55 |
+
n2 = np.empty(n)
|
| 56 |
+
for i in range(n):
|
| 57 |
+
if i == 0:
|
| 58 |
+
dthdz = (theta[1] - theta[0]) / (z[1] - z[0])
|
| 59 |
+
elif i == n - 1:
|
| 60 |
+
dthdz = (theta[-1] - theta[-2]) / (z[-1] - z[-2])
|
| 61 |
+
else:
|
| 62 |
+
dthdz = (theta[i + 1] - theta[i - 1]) / (z[i + 1] - z[i - 1])
|
| 63 |
+
n2[i] = (G / theta[i]) * dthdz
|
| 64 |
+
return n2
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def scorer_from_profile(z, u, theta) -> np.ndarray:
|
| 68 |
+
"""Wrapper around the reference implementation for use in the UI."""
|
| 69 |
+
from .reference import scorer_from_profile as _imp
|
| 70 |
+
return _imp(z, u, theta)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@dataclass
|
| 74 |
+
class WaveProfile:
|
| 75 |
+
"""Container for an edited profile displayed in the Dash app."""
|
| 76 |
+
|
| 77 |
+
z: np.ndarray = field(default_factory=lambda: default_profile_heights())
|
| 78 |
+
u: np.ndarray = field(default_factory=lambda: default_u_profile(default_profile_heights()))
|
| 79 |
+
theta: np.ndarray = field(default_factory=lambda: default_theta_profile(default_profile_heights()))
|
| 80 |
+
|
| 81 |
+
def as_lists(self) -> dict:
|
| 82 |
+
return {"z": list(map(float, self.z)), "u": list(map(float, self.u)), "theta": list(map(float, self.theta))}
|
| 83 |
+
|
| 84 |
+
@classmethod
|
| 85 |
+
def from_lists(cls, store: dict) -> "WaveProfile":
|
| 86 |
+
return cls(
|
| 87 |
+
z=np.asarray(store["z"], dtype=float),
|
| 88 |
+
u=np.asarray(store["u"], dtype=float),
|
| 89 |
+
theta=np.asarray(store["theta"], dtype=float),
|
| 90 |
+
)
|
python/mountain_waves/reference.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pure-Python reference implementation of the mountain-wave solver.
|
| 2 |
+
|
| 3 |
+
This module is a direct transcription of the MATLAB routines ``tlwplot.m``
|
| 4 |
+
and ``stream.m`` written by Dr. Robert E. (Bob) Hart in 1995 as a Penn
|
| 5 |
+
State Meteo 574 seminar project (see
|
| 6 |
+
https://moe.met.fsu.edu/~rhart/mtnwave.html). The two-layer routine here
|
| 7 |
+
mirrors Hart's MATLAB code line-for-line (reformulated in NumPy); the
|
| 8 |
+
multi-layer routine is a natural generalization using the same Fourier +
|
| 9 |
+
transfer-matrix scheme.
|
| 10 |
+
|
| 11 |
+
It exists for two reasons:
|
| 12 |
+
|
| 13 |
+
1. It's the fallback when the Rust extension isn't built.
|
| 14 |
+
2. It's the reference used by ``validate.py`` to confirm the Rust core
|
| 15 |
+
produces bit-similar results.
|
| 16 |
+
|
| 17 |
+
The multi-layer solver implements the same transfer-matrix scheme as the
|
| 18 |
+
Rust version so they can be compared.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import math
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
|
| 27 |
+
G = 9.80665
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
# Two-layer analytic solver (port of tlwplot.m)
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def compute_two_layer(
|
| 36 |
+
l_upper: float,
|
| 37 |
+
l_lower: float,
|
| 38 |
+
u: float,
|
| 39 |
+
h: float,
|
| 40 |
+
a: float,
|
| 41 |
+
ho: float,
|
| 42 |
+
xdom: float,
|
| 43 |
+
zdom: float,
|
| 44 |
+
mink: float,
|
| 45 |
+
maxk: float,
|
| 46 |
+
npts: int = 100,
|
| 47 |
+
):
|
| 48 |
+
"""Return ``(x, z, w, u_prime)`` for flow over a witch-of-Agnesi mountain.
|
| 49 |
+
|
| 50 |
+
Arrays match the MATLAB conventions: ``x`` has shape ``(npts + 1,)``,
|
| 51 |
+
``z`` has shape ``(npts + 1,)``, and both ``w`` and ``u_prime`` have
|
| 52 |
+
shape ``(z.size, x.size)`` indexed as ``[z_index, x_index]``.
|
| 53 |
+
|
| 54 |
+
``u_prime`` is the wave-induced horizontal wind perturbation, obtained
|
| 55 |
+
from linearized continuity ``∂u'/∂x + ∂w/∂z = 0``. Per wavenumber,
|
| 56 |
+
``u'_k = −(i/k) · ∂ŵ_k/∂z``; we analytically differentiate the two-layer
|
| 57 |
+
eigenfunctions (``A e^{−n z}`` above the interface, ``C e^{i m z} +
|
| 58 |
+
D e^{−i m z}`` below) and accumulate the same trapezoidal k-integration
|
| 59 |
+
used for ``w``.
|
| 60 |
+
"""
|
| 61 |
+
dk = 0.367 / a
|
| 62 |
+
nk = max(1, int((maxk - mink) // dk))
|
| 63 |
+
|
| 64 |
+
minx = -0.25 * xdom
|
| 65 |
+
maxx = 0.75 * xdom
|
| 66 |
+
|
| 67 |
+
dx = (maxx - minx) / npts
|
| 68 |
+
dz = zdom / npts
|
| 69 |
+
|
| 70 |
+
x = minx + dx * np.arange(npts + 1)
|
| 71 |
+
z = dz * np.arange(npts + 1)
|
| 72 |
+
|
| 73 |
+
X, Z = np.meshgrid(x, z) # shape (npts+1, npts+1), Z[i, j] varies along i
|
| 74 |
+
|
| 75 |
+
matrix1 = np.zeros_like(X, dtype=complex)
|
| 76 |
+
matrix3 = np.zeros_like(X, dtype=complex)
|
| 77 |
+
matrix1_u = np.zeros_like(X, dtype=complex)
|
| 78 |
+
matrix3_u = np.zeros_like(X, dtype=complex)
|
| 79 |
+
ht = 0.0
|
| 80 |
+
|
| 81 |
+
for kloop in range(nk + 1):
|
| 82 |
+
kk = mink + dk * kloop
|
| 83 |
+
m = np.sqrt(complex(l_lower ** 2 - kk ** 2))
|
| 84 |
+
n = np.sqrt(complex(kk ** 2 - l_upper ** 2))
|
| 85 |
+
denom = m + 1j * n
|
| 86 |
+
if abs(denom) < 1e-300:
|
| 87 |
+
r = complex(9e99, 0.0)
|
| 88 |
+
else:
|
| 89 |
+
r = (m - 1j * n) / denom
|
| 90 |
+
R = r * np.exp(2j * m * h)
|
| 91 |
+
A = (1 + r) * np.exp(h * n + 1j * h * m) / (1 + R)
|
| 92 |
+
C = 1.0 / (1 + R)
|
| 93 |
+
D = R * C
|
| 94 |
+
|
| 95 |
+
ksign = abs(kk)
|
| 96 |
+
hs = np.pi * a * ho * np.exp(-a * ksign)
|
| 97 |
+
ht += np.pi * dk * a * np.exp(-a * ksign) if kloop > 0 else 0.0
|
| 98 |
+
|
| 99 |
+
above = A * np.exp(-Z * n) * (Z > h)
|
| 100 |
+
below = (C * np.exp(1j * Z * m) + D * np.exp(-1j * Z * m)) * (Z <= h)
|
| 101 |
+
matrix2 = (-1j * kk * hs * u * (above + below)) * np.exp(-1j * X * kk)
|
| 102 |
+
|
| 103 |
+
# Analytic z-derivative of the eigenfunctions (the two branches are
|
| 104 |
+
# continuous at z=h by construction, so the jump in the step factor
|
| 105 |
+
# contributes nothing to the derivative inside each region).
|
| 106 |
+
dabove = (-n) * A * np.exp(-Z * n) * (Z > h)
|
| 107 |
+
dbelow = (1j * m) * (C * np.exp(1j * Z * m) - D * np.exp(-1j * Z * m)) * (Z <= h)
|
| 108 |
+
# u'_k(x, z) = (-i/k) · ∂ŵ_k/∂z. Combining the −ik factor baked into
|
| 109 |
+
# matrix2's ŵ formula with the −i/k in front yields −hs·U·∂(above+
|
| 110 |
+
# below)/∂z. This removes the apparent 1/k singularity at k=0 — the
|
| 111 |
+
# result is analytic there — and avoids division edge cases.
|
| 112 |
+
matrix2_u = (-hs * u) * (dabove + dbelow) * np.exp(-1j * X * kk)
|
| 113 |
+
|
| 114 |
+
if kloop > 0:
|
| 115 |
+
matrix3 += 0.5 * (matrix1 + matrix2) * dk
|
| 116 |
+
matrix3_u += 0.5 * (matrix1_u + matrix2_u) * dk
|
| 117 |
+
matrix1 = matrix2
|
| 118 |
+
matrix1_u = matrix2_u
|
| 119 |
+
|
| 120 |
+
if ht == 0.0:
|
| 121 |
+
ht = 1.0
|
| 122 |
+
w = np.real(matrix3 / ht)
|
| 123 |
+
u_prime = np.real(matrix3_u / ht)
|
| 124 |
+
return x, z, w, u_prime
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# ---------------------------------------------------------------------------
|
| 128 |
+
# Multi-layer profile solver
|
| 129 |
+
# ---------------------------------------------------------------------------
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# Minimum |U| used in the Scorer-parameter denominator. In pure linear
|
| 133 |
+
# theory, U(z) = 0 is a critical level where l² = N²/U² − (U″/U) is
|
| 134 |
+
# singular; linear Scorer/Taylor-Goldstein cannot honestly solve across
|
| 135 |
+
# such a level. In a teaching tool we *want* students to be able to set
|
| 136 |
+
# up a wind-reversal profile and see what happens away from the critical
|
| 137 |
+
# level rather than have the whole solve NaN out. We clamp |U| to this
|
| 138 |
+
# floor (preserving sign) when evaluating the Scorer coefficients. Away
|
| 139 |
+
# from U≈0 this is a no-op; within ±0.5 m/s it caps l² at a large but
|
| 140 |
+
# finite value and the UI emits a "critical level detected" warning so
|
| 141 |
+
# nobody is misled into treating the capped zone as physical.
|
| 142 |
+
U_FLOOR_SCORER = 0.5 # m/s
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _u_clamped_for_scorer(uu: float) -> float:
|
| 146 |
+
"""Return ``uu`` with ``|uu|`` lifted to ``U_FLOOR_SCORER``; sign preserved."""
|
| 147 |
+
if uu >= 0.0:
|
| 148 |
+
return max(uu, U_FLOOR_SCORER)
|
| 149 |
+
return min(uu, -U_FLOOR_SCORER)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def scorer_from_profile(z_profile, u_profile, theta_profile):
|
| 153 |
+
"""Return Scorer parameter L^2(z) computed from profile data.
|
| 154 |
+
|
| 155 |
+
Handles wind reversals (sign changes in ``u_profile``) by clamping the
|
| 156 |
+
magnitude of ``U`` at ``U_FLOOR_SCORER`` when it evaluates the
|
| 157 |
+
``N²/U² − U″/U`` combination. This keeps the solver numerically
|
| 158 |
+
well-behaved across a critical level (``U = 0``) at the cost of a
|
| 159 |
+
physically sharp feature there — see the ``critical_levels`` helper
|
| 160 |
+
below for the companion diagnostic surfaced in the UI.
|
| 161 |
+
"""
|
| 162 |
+
z = np.asarray(z_profile, dtype=float)
|
| 163 |
+
u = np.asarray(u_profile, dtype=float)
|
| 164 |
+
theta = np.asarray(theta_profile, dtype=float)
|
| 165 |
+
n = z.size
|
| 166 |
+
l2 = np.zeros(n)
|
| 167 |
+
for i in range(n):
|
| 168 |
+
if i == 0:
|
| 169 |
+
dthdz = (theta[1] - theta[0]) / (z[1] - z[0])
|
| 170 |
+
if n >= 3:
|
| 171 |
+
h1 = z[1] - z[0]
|
| 172 |
+
h2 = z[2] - z[1]
|
| 173 |
+
d2u = 2.0 * (u[2] * h1 - u[1] * (h1 + h2) + u[0] * h2) / (h1 * h2 * (h1 + h2))
|
| 174 |
+
else:
|
| 175 |
+
d2u = 0.0
|
| 176 |
+
elif i == n - 1:
|
| 177 |
+
dthdz = (theta[-1] - theta[-2]) / (z[-1] - z[-2])
|
| 178 |
+
if n >= 3:
|
| 179 |
+
h1 = z[-2] - z[-3]
|
| 180 |
+
h2 = z[-1] - z[-2]
|
| 181 |
+
d2u = 2.0 * (u[-1] * h1 - u[-2] * (h1 + h2) + u[-3] * h2) / (h1 * h2 * (h1 + h2))
|
| 182 |
+
else:
|
| 183 |
+
d2u = 0.0
|
| 184 |
+
else:
|
| 185 |
+
h1 = z[i] - z[i - 1]
|
| 186 |
+
h2 = z[i + 1] - z[i]
|
| 187 |
+
dthdz = (
|
| 188 |
+
theta[i + 1] * h1 ** 2
|
| 189 |
+
- theta[i - 1] * h2 ** 2
|
| 190 |
+
+ theta[i] * (h2 ** 2 - h1 ** 2)
|
| 191 |
+
) / (h1 * h2 * (h1 + h2))
|
| 192 |
+
d2u = 2.0 * (u[i + 1] * h1 - u[i] * (h1 + h2) + u[i - 1] * h2) / (h1 * h2 * (h1 + h2))
|
| 193 |
+
n2 = (G / theta[i]) * dthdz
|
| 194 |
+
uu = _u_clamped_for_scorer(u[i])
|
| 195 |
+
l2[i] = n2 / uu ** 2 - d2u / uu
|
| 196 |
+
return l2
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def critical_levels(z_profile, u_profile):
|
| 200 |
+
"""Return heights (m) where ``u_profile`` crosses zero, linearly interpolated.
|
| 201 |
+
|
| 202 |
+
A "critical level" for steady, 2-D, horizontally uniform linear mountain
|
| 203 |
+
waves is a height where the mean flow vanishes (``U = 0``). Linear
|
| 204 |
+
Scorer/Taylor-Goldstein theory is singular there — wave energy is
|
| 205 |
+
absorbed rather than propagated (Booker & Bretherton 1967) — so any
|
| 206 |
+
result the solver returns *near* a critical level should be read as
|
| 207 |
+
"this is where the linear model breaks down," not as a prediction.
|
| 208 |
+
|
| 209 |
+
Caller (the Dash UI) surfaces the returned heights in a diagnostics
|
| 210 |
+
badge so students can see where their profile is violating the
|
| 211 |
+
assumptions of the model.
|
| 212 |
+
"""
|
| 213 |
+
z = np.asarray(z_profile, dtype=float)
|
| 214 |
+
u = np.asarray(u_profile, dtype=float)
|
| 215 |
+
heights = []
|
| 216 |
+
for i in range(1, z.size):
|
| 217 |
+
u_prev, u_curr = u[i - 1], u[i]
|
| 218 |
+
# Treat exact zeros as crossings at that sample.
|
| 219 |
+
if u_curr == 0.0:
|
| 220 |
+
heights.append(float(z[i]))
|
| 221 |
+
continue
|
| 222 |
+
if u_prev == 0.0:
|
| 223 |
+
# Already recorded by the previous iteration's "u_curr == 0" branch.
|
| 224 |
+
continue
|
| 225 |
+
if (u_prev > 0.0 and u_curr < 0.0) or (u_prev < 0.0 and u_curr > 0.0):
|
| 226 |
+
# Linear interp to the zero crossing.
|
| 227 |
+
t = u_prev / (u_prev - u_curr)
|
| 228 |
+
heights.append(float(z[i - 1] + t * (z[i] - z[i - 1])))
|
| 229 |
+
return heights
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def compute_from_profile(
|
| 233 |
+
z_profile,
|
| 234 |
+
u_profile,
|
| 235 |
+
theta_profile,
|
| 236 |
+
a: float,
|
| 237 |
+
ho: float,
|
| 238 |
+
xdom: float,
|
| 239 |
+
zdom: float,
|
| 240 |
+
mink: float,
|
| 241 |
+
maxk: float,
|
| 242 |
+
npts: int = 100,
|
| 243 |
+
):
|
| 244 |
+
"""Arbitrary u(z)/theta(z) mountain-wave solver using transfer matrices.
|
| 245 |
+
|
| 246 |
+
Returns ``(x, z, w, u_prime)``. The atmosphere is split into
|
| 247 |
+
piecewise-constant L² layers centered on the profile points. Inside
|
| 248 |
+
each layer the wave-transform equation reduces to an exponential
|
| 249 |
+
ansatz; continuity of ŵ and ŵ' at interfaces plus a radiation / decay
|
| 250 |
+
condition aloft closes the system.
|
| 251 |
+
|
| 252 |
+
The wave-induced horizontal wind perturbation ``u_prime`` is obtained
|
| 253 |
+
in the same Fourier loop: for each wavenumber ``k ≠ 0`` we take the
|
| 254 |
+
analytic z-derivative of the per-layer ŵ basis (``σ_j · (−a_j
|
| 255 |
+
e^{−σ_j Δz} + b_j e^{+σ_j Δz})``) and multiply by ``−i/k`` from the
|
| 256 |
+
linearized continuity relation ``u'_k = −(i/k) · ∂ŵ_k/∂z``.
|
| 257 |
+
"""
|
| 258 |
+
zp = np.asarray(z_profile, dtype=float)
|
| 259 |
+
up = np.asarray(u_profile, dtype=float)
|
| 260 |
+
tp = np.asarray(theta_profile, dtype=float)
|
| 261 |
+
l2 = scorer_from_profile(zp, up, tp)
|
| 262 |
+
u_surface = float(up[0])
|
| 263 |
+
|
| 264 |
+
nlayers = zp.size
|
| 265 |
+
layer_bot = np.empty(nlayers)
|
| 266 |
+
layer_top = np.empty(nlayers)
|
| 267 |
+
for j in range(nlayers):
|
| 268 |
+
layer_bot[j] = 0.0 if j == 0 else 0.5 * (zp[j - 1] + zp[j])
|
| 269 |
+
layer_top[j] = np.inf if j == nlayers - 1 else 0.5 * (zp[j] + zp[j + 1])
|
| 270 |
+
|
| 271 |
+
dk = 0.367 / a
|
| 272 |
+
nk = max(1, int((maxk - mink) // dk))
|
| 273 |
+
nslab = nk + 1
|
| 274 |
+
|
| 275 |
+
minx = -0.25 * xdom
|
| 276 |
+
maxx = 0.75 * xdom
|
| 277 |
+
dx = (maxx - minx) / npts
|
| 278 |
+
dz = zdom / npts
|
| 279 |
+
x = minx + dx * np.arange(npts + 1)
|
| 280 |
+
z = dz * np.arange(npts + 1)
|
| 281 |
+
|
| 282 |
+
# Layer index for each vertical grid point
|
| 283 |
+
layer_of = np.zeros(z.size, dtype=int)
|
| 284 |
+
for j, zj in enumerate(z):
|
| 285 |
+
idx = nlayers - 1
|
| 286 |
+
for lj in range(nlayers):
|
| 287 |
+
if zj < layer_top[lj]:
|
| 288 |
+
idx = lj
|
| 289 |
+
break
|
| 290 |
+
layer_of[j] = idx
|
| 291 |
+
|
| 292 |
+
matrix1 = np.zeros((z.size, x.size), dtype=complex)
|
| 293 |
+
matrix3 = np.zeros((z.size, x.size), dtype=complex)
|
| 294 |
+
matrix1_u = np.zeros((z.size, x.size), dtype=complex)
|
| 295 |
+
matrix3_u = np.zeros((z.size, x.size), dtype=complex)
|
| 296 |
+
ht = 0.0
|
| 297 |
+
|
| 298 |
+
for kloop in range(nslab):
|
| 299 |
+
kk = mink + dk * kloop
|
| 300 |
+
ksign = abs(kk)
|
| 301 |
+
hs = np.pi * a * ho * np.exp(-a * ksign)
|
| 302 |
+
if kloop > 0:
|
| 303 |
+
ht += np.pi * dk * a * np.exp(-a * ksign)
|
| 304 |
+
|
| 305 |
+
if kk == 0.0:
|
| 306 |
+
# DC mode has no wave contribution; u' also vanishes here.
|
| 307 |
+
matrix2 = np.zeros_like(matrix1)
|
| 308 |
+
matrix2_u = np.zeros_like(matrix1)
|
| 309 |
+
else:
|
| 310 |
+
# Principal-branch sigma: in each layer, the "a" coefficient
|
| 311 |
+
# multiplies exp(-sigma*dz), which is always the outgoing /
|
| 312 |
+
# decaying branch when Im(sigma) >= 0.
|
| 313 |
+
sigma = np.empty(nlayers, dtype=complex)
|
| 314 |
+
for j in range(nlayers):
|
| 315 |
+
s = np.sqrt(complex(kk ** 2 - l2[j]))
|
| 316 |
+
if s.imag < 0:
|
| 317 |
+
s = -s
|
| 318 |
+
sigma[j] = s
|
| 319 |
+
|
| 320 |
+
aj = np.zeros(nlayers, dtype=complex)
|
| 321 |
+
bj = np.zeros(nlayers, dtype=complex)
|
| 322 |
+
aj[-1] = 1.0
|
| 323 |
+
bj[-1] = 0.0
|
| 324 |
+
for j in range(nlayers - 2, -1, -1):
|
| 325 |
+
dz_j = layer_top[j] - layer_bot[j]
|
| 326 |
+
e_minus = np.exp(-sigma[j] * dz_j)
|
| 327 |
+
e_plus = np.exp(sigma[j] * dz_j)
|
| 328 |
+
alpha = aj[j + 1] + bj[j + 1]
|
| 329 |
+
beta = -aj[j + 1] + bj[j + 1]
|
| 330 |
+
ratio = sigma[j + 1] / sigma[j]
|
| 331 |
+
aj[j] = 0.5 * (alpha - ratio * beta) * e_plus
|
| 332 |
+
bj[j] = 0.5 * (alpha + ratio * beta) * e_minus
|
| 333 |
+
|
| 334 |
+
w_surface = aj[0] + bj[0]
|
| 335 |
+
if abs(w_surface) < 1e-300:
|
| 336 |
+
amp = 0.0 + 0.0j
|
| 337 |
+
else:
|
| 338 |
+
amp = -1j * kk * u_surface * hs / w_surface
|
| 339 |
+
aj *= amp
|
| 340 |
+
bj *= amp
|
| 341 |
+
|
| 342 |
+
# Build ŵ and ∂ŵ/∂z on the vertical grid. The per-layer basis
|
| 343 |
+
# ŵ_j(z) = a_j e^{−σ_j Δz} + b_j e^{+σ_j Δz}
|
| 344 |
+
# differentiates cleanly to
|
| 345 |
+
# ∂ŵ_j/∂z = σ_j · (−a_j e^{−σ_j Δz} + b_j e^{+σ_j Δz})
|
| 346 |
+
# and the continuity relation gives u'_k = −(i/k) · ∂ŵ/∂z.
|
| 347 |
+
zfac = np.zeros(z.size, dtype=complex)
|
| 348 |
+
zfac_u = np.zeros(z.size, dtype=complex)
|
| 349 |
+
inv_ik = -1j / kk
|
| 350 |
+
for j in range(z.size):
|
| 351 |
+
lj = layer_of[j]
|
| 352 |
+
dz_l = z[j] - layer_bot[lj]
|
| 353 |
+
e_minus = np.exp(-sigma[lj] * dz_l)
|
| 354 |
+
e_plus = np.exp(sigma[lj] * dz_l)
|
| 355 |
+
zfac[j] = aj[lj] * e_minus + bj[lj] * e_plus
|
| 356 |
+
dwdz = sigma[lj] * (-aj[lj] * e_minus + bj[lj] * e_plus)
|
| 357 |
+
zfac_u[j] = inv_ik * dwdz
|
| 358 |
+
|
| 359 |
+
xfac = np.exp(-1j * x * kk)
|
| 360 |
+
matrix2 = np.outer(zfac, xfac)
|
| 361 |
+
matrix2_u = np.outer(zfac_u, xfac)
|
| 362 |
+
|
| 363 |
+
if kloop > 0:
|
| 364 |
+
matrix3 += 0.5 * (matrix1 + matrix2) * dk
|
| 365 |
+
matrix3_u += 0.5 * (matrix1_u + matrix2_u) * dk
|
| 366 |
+
matrix1 = matrix2
|
| 367 |
+
matrix1_u = matrix2_u
|
| 368 |
+
|
| 369 |
+
if ht == 0.0:
|
| 370 |
+
ht = 1.0
|
| 371 |
+
w = np.real(matrix3 / ht)
|
| 372 |
+
u_prime = np.real(matrix3_u / ht)
|
| 373 |
+
return x, z, w, u_prime
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
# ---------------------------------------------------------------------------
|
| 377 |
+
# Streamline tracer (port of stream.m)
|
| 378 |
+
# ---------------------------------------------------------------------------
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def streamlines(x, z, u, w, num: int = 10):
|
| 382 |
+
"""Return ``num`` streamlines as ``[(xs, ys), ...]`` polylines.
|
| 383 |
+
|
| 384 |
+
``u`` may be a scalar (uniform mean flow, used for the two-layer solver)
|
| 385 |
+
or a 1-D array of length ``nz`` giving the mean wind at each render-grid
|
| 386 |
+
height. In linear wave theory the parcel displacement at height ``z₀`` is
|
| 387 |
+
``η(x, z₀) = (1/U(z₀)) · ∫ w(x', z₀) dx'``, so the time step used to
|
| 388 |
+
integrate along each streamline depends on the wind at that streamline's
|
| 389 |
+
height — not on the surface wind. Using a single scalar ``U_surface`` for
|
| 390 |
+
every streamline (as Hart's MATLAB ``stream.m`` did because the two-layer
|
| 391 |
+
case assumed uniform ``U``) over-amplifies upper streamlines whenever the
|
| 392 |
+
real profile has shear.
|
| 393 |
+
|
| 394 |
+
We guard against near-zero ``U(z₀)`` (which would blow up the tracer) with
|
| 395 |
+
a 0.1 m/s floor — a parcel literally at rest cannot trace a linear
|
| 396 |
+
streamline in this framework, so we just freeze it there.
|
| 397 |
+
"""
|
| 398 |
+
x = np.asarray(x)
|
| 399 |
+
z = np.asarray(z)
|
| 400 |
+
w = np.asarray(w)
|
| 401 |
+
nx = x.size
|
| 402 |
+
nz = z.size
|
| 403 |
+
if nx < 2 or nz < 2 or num == 0:
|
| 404 |
+
return []
|
| 405 |
+
minx = float(x[0])
|
| 406 |
+
dx = float(x[1] - x[0])
|
| 407 |
+
|
| 408 |
+
u_arr = np.atleast_1d(np.asarray(u, dtype=float))
|
| 409 |
+
if u_arr.size == 1:
|
| 410 |
+
u_by_row = np.full(nz, float(u_arr[0]))
|
| 411 |
+
elif u_arr.size == nz:
|
| 412 |
+
u_by_row = u_arr
|
| 413 |
+
else:
|
| 414 |
+
# Caller gave an array of the wrong length — fall back to the mean so
|
| 415 |
+
# the plot still renders rather than raising mid-draw.
|
| 416 |
+
u_by_row = np.full(nz, float(np.mean(u_arr)))
|
| 417 |
+
|
| 418 |
+
dh = nz / num
|
| 419 |
+
|
| 420 |
+
lines = []
|
| 421 |
+
for j in range(num):
|
| 422 |
+
ycell = 1.0 + dh * j
|
| 423 |
+
if ycell < 1.0:
|
| 424 |
+
ycell = 1.0
|
| 425 |
+
if ycell > nz:
|
| 426 |
+
ycell = nz
|
| 427 |
+
yci = int(round(ycell) - 1)
|
| 428 |
+
yci = max(0, min(nz - 1, yci))
|
| 429 |
+
u_local = float(u_by_row[yci])
|
| 430 |
+
# 0.1 m/s floor prevents 1/u blowups at stagnant layers.
|
| 431 |
+
u_local = u_local if abs(u_local) > 0.1 else math.copysign(0.1, u_local) if u_local != 0 else 0.1
|
| 432 |
+
tstep = dx / u_local
|
| 433 |
+
xs = np.empty(nx)
|
| 434 |
+
ys = np.empty(nx)
|
| 435 |
+
xs[0] = minx
|
| 436 |
+
ys[0] = z[yci]
|
| 437 |
+
for i in range(1, nx):
|
| 438 |
+
xs[i] = x[i]
|
| 439 |
+
ys[i] = ys[i - 1] + tstep * w[yci, i]
|
| 440 |
+
lines.append((xs, ys))
|
| 441 |
+
return lines
|
python/mountain_waves/solver.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Backend selector: prefer the Rust extension, fall back to pure Python.
|
| 2 |
+
|
| 3 |
+
Both paths expose identical call signatures so the Dash app doesn't need
|
| 4 |
+
to care which one is running. ``backend_name()`` tells you which it is.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from typing import List, Tuple
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
try: # pragma: no cover - import-time branching
|
| 14 |
+
from . import _core as _rust # type: ignore
|
| 15 |
+
|
| 16 |
+
_BACKEND = "rust"
|
| 17 |
+
_RUST_IMPORT_ERROR: str | None = None
|
| 18 |
+
except ImportError as _exc:
|
| 19 |
+
_rust = None # type: ignore
|
| 20 |
+
_BACKEND = "python"
|
| 21 |
+
# Keep the real exception message around so the launcher can log it.
|
| 22 |
+
# ImportError on a Rust extension almost always means either the wheel
|
| 23 |
+
# never installed into site-packages, or it installed but a runtime
|
| 24 |
+
# dep (libc/libgomp/libpython ABI) can't be resolved. Silently falling
|
| 25 |
+
# back to the Python reference on a production deploy hides real bugs.
|
| 26 |
+
_RUST_IMPORT_ERROR = f"{type(_exc).__name__}: {_exc}"
|
| 27 |
+
import sys as _sys
|
| 28 |
+
print(
|
| 29 |
+
f"[mountain-waves] Rust _core import failed, using Python fallback: "
|
| 30 |
+
f"{_RUST_IMPORT_ERROR}",
|
| 31 |
+
file=_sys.stderr,
|
| 32 |
+
flush=True,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
from . import reference as _ref
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def backend_name() -> str:
|
| 39 |
+
"""Return ``"rust"`` if the compiled extension is in use, else ``"python"``."""
|
| 40 |
+
return _BACKEND
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _ensure_4tuple_two_layer(result, args):
|
| 44 |
+
"""Coerce older Rust binaries (returning 3-tuples) into the 4-tuple ABI.
|
| 45 |
+
|
| 46 |
+
The Rust extension was extended to return u_prime alongside w. If the
|
| 47 |
+
user hasn't rebuilt the .so yet, fall back to the Python reference for
|
| 48 |
+
the full solve so u_prime is still correct rather than silently zero.
|
| 49 |
+
"""
|
| 50 |
+
if isinstance(result, tuple) and len(result) == 4:
|
| 51 |
+
return result
|
| 52 |
+
# Stale binary — recompute via Python reference so u' is accurate.
|
| 53 |
+
return _ref.compute_two_layer(*args)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _ensure_4tuple_profile(result, args):
|
| 57 |
+
if isinstance(result, tuple) and len(result) == 4:
|
| 58 |
+
return result
|
| 59 |
+
return _ref.compute_from_profile(*args)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def compute_two_layer(
|
| 63 |
+
l_upper: float,
|
| 64 |
+
l_lower: float,
|
| 65 |
+
u: float,
|
| 66 |
+
h: float,
|
| 67 |
+
a: float,
|
| 68 |
+
ho: float,
|
| 69 |
+
xdom: float,
|
| 70 |
+
zdom: float,
|
| 71 |
+
mink: float,
|
| 72 |
+
maxk: float,
|
| 73 |
+
npts: int = 100,
|
| 74 |
+
):
|
| 75 |
+
args = (l_upper, l_lower, u, h, a, ho, xdom, zdom, mink, maxk, npts)
|
| 76 |
+
if _rust is not None:
|
| 77 |
+
return _ensure_4tuple_two_layer(_rust.compute_two_layer(*args), args)
|
| 78 |
+
return _ref.compute_two_layer(*args)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def compute_from_profile(
|
| 82 |
+
z_profile,
|
| 83 |
+
u_profile,
|
| 84 |
+
theta_profile,
|
| 85 |
+
a: float,
|
| 86 |
+
ho: float,
|
| 87 |
+
xdom: float,
|
| 88 |
+
zdom: float,
|
| 89 |
+
mink: float,
|
| 90 |
+
maxk: float,
|
| 91 |
+
npts: int = 100,
|
| 92 |
+
):
|
| 93 |
+
zp = np.ascontiguousarray(np.asarray(z_profile, dtype=np.float64))
|
| 94 |
+
up = np.ascontiguousarray(np.asarray(u_profile, dtype=np.float64))
|
| 95 |
+
tp = np.ascontiguousarray(np.asarray(theta_profile, dtype=np.float64))
|
| 96 |
+
args = (zp, up, tp, a, ho, xdom, zdom, mink, maxk, npts)
|
| 97 |
+
if _rust is not None:
|
| 98 |
+
return _ensure_4tuple_profile(_rust.compute_from_profile(*args), args)
|
| 99 |
+
return _ref.compute_from_profile(*args)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def streamlines(x, z, u, w, num: int = 10) -> List[Tuple[np.ndarray, np.ndarray]]:
|
| 103 |
+
"""Trace ``num`` linearized streamlines through the ``w(x, z)`` field.
|
| 104 |
+
|
| 105 |
+
``u`` can be a scalar (uniform mean flow, as in the two-layer analytic)
|
| 106 |
+
or a 1-D array of length ``nz`` giving the mean wind at each render-grid
|
| 107 |
+
height. When an array is given we route through the Python tracer so the
|
| 108 |
+
per-streamline advection speed is ``U(z₀)``, not ``U_surface``.
|
| 109 |
+
"""
|
| 110 |
+
x = np.ascontiguousarray(np.asarray(x, dtype=np.float64))
|
| 111 |
+
z = np.ascontiguousarray(np.asarray(z, dtype=np.float64))
|
| 112 |
+
w = np.ascontiguousarray(np.asarray(w, dtype=np.float64))
|
| 113 |
+
u_arr = np.atleast_1d(np.asarray(u, dtype=np.float64))
|
| 114 |
+
# Rust streamlines() only accepts scalar u. For array-valued u (profile
|
| 115 |
+
# mode with shear) dispatch to the Python tracer — it's only num*nx
|
| 116 |
+
# floating-point adds, so the perf difference is negligible.
|
| 117 |
+
if u_arr.size > 1:
|
| 118 |
+
return _ref.streamlines(x, z, u_arr, w, num)
|
| 119 |
+
u_scalar = float(u_arr[0])
|
| 120 |
+
if _rust is not None:
|
| 121 |
+
return _rust.streamlines(x, z, u_scalar, w, num)
|
| 122 |
+
return _ref.streamlines(x, z, u_scalar, w, num)
|
run.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Launcher for the Mountain Waves Dash app.
|
| 3 |
+
|
| 4 |
+
Mountain Waves is a Rust + Python port of Dr. Robert E. (Bob) Hart's 1995
|
| 5 |
+
MATLAB mountain-wave model (https://moe.met.fsu.edu/~rhart/mtnwave.html).
|
| 6 |
+
|
| 7 |
+
Usage (with uv — recommended):
|
| 8 |
+
uv run python run.py # start the web UI on http://127.0.0.1:8050
|
| 9 |
+
uv run python run.py --port 9000 # alternate port
|
| 10 |
+
uv run python run.py --host 0.0.0.0 # expose on LAN
|
| 11 |
+
|
| 12 |
+
Plain Python also works once dependencies are installed:
|
| 13 |
+
python run.py
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import argparse
|
| 19 |
+
import sys
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
# Make the in-tree Python package importable without installing.
|
| 23 |
+
ROOT = Path(__file__).resolve().parent
|
| 24 |
+
PKG_DIR = ROOT / "python"
|
| 25 |
+
if str(PKG_DIR) not in sys.path:
|
| 26 |
+
sys.path.insert(0, str(PKG_DIR))
|
| 27 |
+
|
| 28 |
+
from mountain_waves.app import create_app # noqa: E402
|
| 29 |
+
from mountain_waves import backend_name # noqa: E402
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def main() -> int:
|
| 33 |
+
p = argparse.ArgumentParser()
|
| 34 |
+
p.add_argument("--host", default="127.0.0.1")
|
| 35 |
+
p.add_argument("--port", type=int, default=8050)
|
| 36 |
+
p.add_argument("--debug", action="store_true")
|
| 37 |
+
args = p.parse_args()
|
| 38 |
+
|
| 39 |
+
print(f"[mountain-waves] compute backend: {backend_name()}")
|
| 40 |
+
if backend_name() == "python":
|
| 41 |
+
print(
|
| 42 |
+
"[mountain-waves] running on the pure-Python reference solver.\n"
|
| 43 |
+
" Build the Rust core with `uv run maturin develop --release --uv`\n"
|
| 44 |
+
" (or `maturin develop --release`) for a ~20x speedup."
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
app = create_app()
|
| 48 |
+
app.run(host=args.host, port=args.port, debug=args.debug)
|
| 49 |
+
return 0
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
if __name__ == "__main__":
|
| 53 |
+
raise SystemExit(main())
|
src/lib.rs
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! # mountain_waves core
|
| 2 |
+
//!
|
| 3 |
+
//! Rust port of Dr. Robert E. (Bob) Hart's 1995 MATLAB mountain-wave model
|
| 4 |
+
//! (`tlwplot.m`, `tlwmenu.m`, `stream.m`). Hart developed the original tool
|
| 5 |
+
//! as a Penn State Meteo 574 seminar project under Dr. Peter Bannon; the
|
| 6 |
+
//! numerical scheme, user-interface layout, and example cases all come from
|
| 7 |
+
//! his work. Documentation and MATLAB sources:
|
| 8 |
+
//! <https://moe.met.fsu.edu/~rhart/mtnwave.html>. Contact: `rhart@fsu.edu`.
|
| 9 |
+
//!
|
| 10 |
+
//! Rust compute core for the 2-D mountain-wave visualization tool. This crate
|
| 11 |
+
//! is compiled as a CPython extension module (`mountain_waves._core`) via
|
| 12 |
+
//! PyO3 and exposes three callables to Python:
|
| 13 |
+
//!
|
| 14 |
+
//! * [`compute_two_layer`] — exact analytic two-layer Scorer-parameter
|
| 15 |
+
//! solution, a direct port of `tlwplot.m`.
|
| 16 |
+
//! * [`compute_from_profile`] — multi-layer Taylor–Goldstein solver that
|
| 17 |
+
//! accepts arbitrary `u(z)` and `theta(z)` profiles.
|
| 18 |
+
//! * [`streamlines`] — streamline-tracing helper that integrates a constant
|
| 19 |
+
//! `U` + perturbation `w(x, z)` field, returning a list of polylines.
|
| 20 |
+
//!
|
| 21 |
+
//! Arrays cross the Python boundary as NumPy arrays (via the `numpy` crate)
|
| 22 |
+
//! to avoid per-point allocation. Wavenumber integration is parallelized
|
| 23 |
+
//! over wavenumber samples with Rayon.
|
| 24 |
+
|
| 25 |
+
use ndarray::{Array1, Array2};
|
| 26 |
+
use num_complex::Complex64;
|
| 27 |
+
use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2};
|
| 28 |
+
use pyo3::prelude::*;
|
| 29 |
+
use rayon::prelude::*;
|
| 30 |
+
|
| 31 |
+
const I: Complex64 = Complex64::new(0.0, 1.0);
|
| 32 |
+
|
| 33 |
+
fn c(x: f64) -> Complex64 {
|
| 34 |
+
Complex64::new(x, 0.0)
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// ---------------------------------------------------------------------------
|
| 38 |
+
// Two-layer analytic solver (port of tlwplot.m)
|
| 39 |
+
// ---------------------------------------------------------------------------
|
| 40 |
+
|
| 41 |
+
/// Compute vertical velocity `w(x, z)` and horizontal perturbation
|
| 42 |
+
/// `u'(x, z)` for flow over a witch-of-Agnesi mountain in a two-layer
|
| 43 |
+
/// atmosphere. `u'` is obtained per-wavenumber from
|
| 44 |
+
/// `u'_k = −(i/k) · ∂ŵ_k/∂z`, which for this two-layer eigenbasis
|
| 45 |
+
/// simplifies to `−h_s · U · ∂(above+below)/∂z` (the `1/k` and `k` cancel).
|
| 46 |
+
///
|
| 47 |
+
/// Arrays returned:
|
| 48 |
+
///
|
| 49 |
+
/// * `x` — shape `(npts + 1,)`, meters
|
| 50 |
+
/// * `z` — shape `(npts + 1,)`, meters
|
| 51 |
+
/// * `w` — shape `(nz, nx)` with rows along z, columns along x (m s⁻¹)
|
| 52 |
+
/// * `u_prime` — same shape, m s⁻¹
|
| 53 |
+
#[pyfunction]
|
| 54 |
+
#[pyo3(signature = (l_upper, l_lower, u, h, a, ho, xdom, zdom, mink, maxk, npts=100))]
|
| 55 |
+
#[allow(clippy::too_many_arguments)]
|
| 56 |
+
fn compute_two_layer<'py>(
|
| 57 |
+
py: Python<'py>,
|
| 58 |
+
l_upper: f64,
|
| 59 |
+
l_lower: f64,
|
| 60 |
+
u: f64,
|
| 61 |
+
h: f64,
|
| 62 |
+
a: f64,
|
| 63 |
+
ho: f64,
|
| 64 |
+
xdom: f64,
|
| 65 |
+
zdom: f64,
|
| 66 |
+
mink: f64,
|
| 67 |
+
maxk: f64,
|
| 68 |
+
npts: usize,
|
| 69 |
+
) -> PyResult<(
|
| 70 |
+
Bound<'py, PyArray1<f64>>,
|
| 71 |
+
Bound<'py, PyArray1<f64>>,
|
| 72 |
+
Bound<'py, PyArray2<f64>>,
|
| 73 |
+
Bound<'py, PyArray2<f64>>,
|
| 74 |
+
)> {
|
| 75 |
+
let (x, z, w, uprime) = py.allow_threads(|| {
|
| 76 |
+
two_layer(l_upper, l_lower, u, h, a, ho, xdom, zdom, mink, maxk, npts)
|
| 77 |
+
});
|
| 78 |
+
Ok((
|
| 79 |
+
x.into_pyarray_bound(py),
|
| 80 |
+
z.into_pyarray_bound(py),
|
| 81 |
+
w.into_pyarray_bound(py),
|
| 82 |
+
uprime.into_pyarray_bound(py),
|
| 83 |
+
))
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
fn two_layer(
|
| 87 |
+
l_upper: f64,
|
| 88 |
+
l_lower: f64,
|
| 89 |
+
u: f64,
|
| 90 |
+
h: f64,
|
| 91 |
+
a: f64,
|
| 92 |
+
ho: f64,
|
| 93 |
+
xdom: f64,
|
| 94 |
+
zdom: f64,
|
| 95 |
+
mink: f64,
|
| 96 |
+
maxk: f64,
|
| 97 |
+
npts: usize,
|
| 98 |
+
) -> (Array1<f64>, Array1<f64>, Array2<f64>, Array2<f64>) {
|
| 99 |
+
let dk = 0.367 / a;
|
| 100 |
+
let nk = (((maxk - mink) / dk).floor() as usize).max(1);
|
| 101 |
+
let nslab = nk + 1;
|
| 102 |
+
|
| 103 |
+
let minx = -0.25 * xdom;
|
| 104 |
+
let maxx = 0.75 * xdom;
|
| 105 |
+
let nx = npts + 1;
|
| 106 |
+
let nz = npts + 1;
|
| 107 |
+
let dx = (maxx - minx) / npts as f64;
|
| 108 |
+
let dz = zdom / npts as f64;
|
| 109 |
+
|
| 110 |
+
let x = Array1::from_iter((0..nx).map(|i| minx + dx * i as f64));
|
| 111 |
+
let z = Array1::from_iter((0..nz).map(|j| dz * j as f64));
|
| 112 |
+
|
| 113 |
+
// Each worker returns TWO complex slabs: one for ŵ, one for u'_complex.
|
| 114 |
+
let slabs: Vec<(Vec<Complex64>, Vec<Complex64>)> = (0..nslab)
|
| 115 |
+
.into_par_iter()
|
| 116 |
+
.map(|idx| {
|
| 117 |
+
let kk = mink + dk * idx as f64;
|
| 118 |
+
let ksign = kk.abs();
|
| 119 |
+
|
| 120 |
+
let m = c(l_lower * l_lower - kk * kk).sqrt();
|
| 121 |
+
let n = c(kk * kk - l_upper * l_upper).sqrt();
|
| 122 |
+
|
| 123 |
+
let denom = m + I * n;
|
| 124 |
+
let r = if denom.norm() < 1e-300 {
|
| 125 |
+
c(9e99)
|
| 126 |
+
} else {
|
| 127 |
+
(m - I * n) / denom
|
| 128 |
+
};
|
| 129 |
+
let big_r = r * (c(2.0) * I * m * h).exp();
|
| 130 |
+
|
| 131 |
+
let a_coef = (c(1.0) + r) * (c(h) * n + I * c(h) * m).exp() / (c(1.0) + big_r);
|
| 132 |
+
let c_coef = c(1.0) / (c(1.0) + big_r);
|
| 133 |
+
let d_coef = big_r * c_coef;
|
| 134 |
+
|
| 135 |
+
let hs = std::f64::consts::PI * a * ho * (-a * ksign).exp();
|
| 136 |
+
|
| 137 |
+
let mut slab_w = vec![Complex64::new(0.0, 0.0); nz * nx];
|
| 138 |
+
let mut slab_u = vec![Complex64::new(0.0, 0.0); nz * nx];
|
| 139 |
+
let prefactor_w = -I * c(kk) * c(hs * u);
|
| 140 |
+
// u'_k contribution: (-i/k) · ∂ŵ_k/∂z — the −ik factor inside
|
| 141 |
+
// ŵ cancels with the 1/k in the continuity relation, giving
|
| 142 |
+
// −h_s·U·∂(above+below)/∂z with no k-dependent prefactor.
|
| 143 |
+
let prefactor_u = c(-hs * u);
|
| 144 |
+
|
| 145 |
+
for (j, &zj) in z.iter().enumerate() {
|
| 146 |
+
let (zfac, dzfac) = if zj <= h {
|
| 147 |
+
let e_plus = (I * c(zj) * m).exp();
|
| 148 |
+
let e_minus = (-I * c(zj) * m).exp();
|
| 149 |
+
let below = c_coef * e_plus + d_coef * e_minus;
|
| 150 |
+
let dbelow = I * m * (c_coef * e_plus - d_coef * e_minus);
|
| 151 |
+
(below, dbelow)
|
| 152 |
+
} else {
|
| 153 |
+
let e = (-c(zj) * n).exp();
|
| 154 |
+
let above = a_coef * e;
|
| 155 |
+
let dabove = -n * a_coef * e;
|
| 156 |
+
(above, dabove)
|
| 157 |
+
};
|
| 158 |
+
let row_scale_w = prefactor_w * zfac;
|
| 159 |
+
let row_scale_u = prefactor_u * dzfac;
|
| 160 |
+
let row_start = j * nx;
|
| 161 |
+
for (i, &xi) in x.iter().enumerate() {
|
| 162 |
+
let xfac = (-I * c(xi * kk)).exp();
|
| 163 |
+
slab_w[row_start + i] = row_scale_w * xfac;
|
| 164 |
+
slab_u[row_start + i] = row_scale_u * xfac;
|
| 165 |
+
}
|
| 166 |
+
}
|
| 167 |
+
(slab_w, slab_u)
|
| 168 |
+
})
|
| 169 |
+
.collect();
|
| 170 |
+
|
| 171 |
+
let ht_samples: Vec<f64> = (0..nslab)
|
| 172 |
+
.map(|idx| {
|
| 173 |
+
let kk = mink + dk * idx as f64;
|
| 174 |
+
std::f64::consts::PI * dk * a * (-a * kk.abs()).exp()
|
| 175 |
+
})
|
| 176 |
+
.collect();
|
| 177 |
+
|
| 178 |
+
// Trapezoidal integration along the wavenumber axis for both fields.
|
| 179 |
+
let mut wc = vec![Complex64::new(0.0, 0.0); nz * nx];
|
| 180 |
+
let mut uc = vec![Complex64::new(0.0, 0.0); nz * nx];
|
| 181 |
+
for kloop in 1..nslab {
|
| 182 |
+
for i in 0..(nz * nx) {
|
| 183 |
+
wc[i] += c(0.5 * dk) * (slabs[kloop - 1].0[i] + slabs[kloop].0[i]);
|
| 184 |
+
uc[i] += c(0.5 * dk) * (slabs[kloop - 1].1[i] + slabs[kloop].1[i]);
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
let ht: f64 = ht_samples[1..].iter().sum();
|
| 188 |
+
let inv_ht = if ht.abs() > 0.0 { 1.0 / ht } else { 1.0 };
|
| 189 |
+
|
| 190 |
+
let mut w = Array2::<f64>::zeros((nz, nx));
|
| 191 |
+
let mut u_prime = Array2::<f64>::zeros((nz, nx));
|
| 192 |
+
for j in 0..nz {
|
| 193 |
+
for i in 0..nx {
|
| 194 |
+
w[[j, i]] = wc[j * nx + i].re * inv_ht;
|
| 195 |
+
u_prime[[j, i]] = uc[j * nx + i].re * inv_ht;
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
(x, z, w, u_prime)
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
// ---------------------------------------------------------------------------
|
| 203 |
+
// Multi-layer profile solver
|
| 204 |
+
// ---------------------------------------------------------------------------
|
| 205 |
+
|
| 206 |
+
/// Multi-layer Taylor–Goldstein solver accepting arbitrary `u(z)` / `theta(z)`.
|
| 207 |
+
///
|
| 208 |
+
/// The solver uses the basis
|
| 209 |
+
///
|
| 210 |
+
/// ŵ_j(z) = a_j exp(-σ_j (z - z_bot_j)) + b_j exp(+σ_j (z - z_bot_j))
|
| 211 |
+
///
|
| 212 |
+
/// inside layer `j`, with `σ_j = sqrt(k² - L_j²)` taken on the principal
|
| 213 |
+
/// branch (non-negative imaginary part). In this basis the `a` coefficient
|
| 214 |
+
/// is always the outgoing branch — it decays upward when the layer is
|
| 215 |
+
/// evanescent and has downward phase velocity (= upward group velocity) when
|
| 216 |
+
/// the layer is propagating. The radiation / decay condition at the top of
|
| 217 |
+
/// the domain is therefore simply `b_top = 0`.
|
| 218 |
+
#[pyfunction]
|
| 219 |
+
#[pyo3(signature = (z_profile, u_profile, theta_profile, a, ho, xdom, zdom, mink, maxk, npts=100))]
|
| 220 |
+
#[allow(clippy::too_many_arguments)]
|
| 221 |
+
fn compute_from_profile<'py>(
|
| 222 |
+
py: Python<'py>,
|
| 223 |
+
z_profile: PyReadonlyArray1<'py, f64>,
|
| 224 |
+
u_profile: PyReadonlyArray1<'py, f64>,
|
| 225 |
+
theta_profile: PyReadonlyArray1<'py, f64>,
|
| 226 |
+
a: f64,
|
| 227 |
+
ho: f64,
|
| 228 |
+
xdom: f64,
|
| 229 |
+
zdom: f64,
|
| 230 |
+
mink: f64,
|
| 231 |
+
maxk: f64,
|
| 232 |
+
npts: usize,
|
| 233 |
+
) -> PyResult<(
|
| 234 |
+
Bound<'py, PyArray1<f64>>,
|
| 235 |
+
Bound<'py, PyArray1<f64>>,
|
| 236 |
+
Bound<'py, PyArray2<f64>>,
|
| 237 |
+
Bound<'py, PyArray2<f64>>,
|
| 238 |
+
)> {
|
| 239 |
+
let zp = z_profile.as_array().to_owned();
|
| 240 |
+
let up = u_profile.as_array().to_owned();
|
| 241 |
+
let tp = theta_profile.as_array().to_owned();
|
| 242 |
+
let (x, z, w, uprime) = py.allow_threads(|| {
|
| 243 |
+
multi_layer(&zp, &up, &tp, a, ho, xdom, zdom, mink, maxk, npts)
|
| 244 |
+
});
|
| 245 |
+
Ok((
|
| 246 |
+
x.into_pyarray_bound(py),
|
| 247 |
+
z.into_pyarray_bound(py),
|
| 248 |
+
w.into_pyarray_bound(py),
|
| 249 |
+
uprime.into_pyarray_bound(py),
|
| 250 |
+
))
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
// Minimum |U| used in the Scorer-parameter denominator. At a critical
|
| 254 |
+
// level (U = 0) linear Scorer/Taylor-Goldstein is singular — we clamp
|
| 255 |
+
// |U| to U_FLOOR_SCORER (sign-preserving) so students can set up
|
| 256 |
+
// wind-reversal profiles and still see the solver output away from the
|
| 257 |
+
// critical level. The Python UI surfaces "⚠️ critical level at z=..."
|
| 258 |
+
// from the companion `critical_levels` helper in reference.py so nothing
|
| 259 |
+
// is silently smoothed over. Keep in sync with `U_FLOOR_SCORER` in
|
| 260 |
+
// python/mountain_waves/reference.py.
|
| 261 |
+
const U_FLOOR_SCORER: f64 = 0.5;
|
| 262 |
+
|
| 263 |
+
fn u_clamped_for_scorer(uu: f64) -> f64 {
|
| 264 |
+
if uu >= 0.0 {
|
| 265 |
+
uu.max(U_FLOOR_SCORER)
|
| 266 |
+
} else {
|
| 267 |
+
uu.min(-U_FLOOR_SCORER)
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
fn scorer_from_profile(z: &Array1<f64>, u: &Array1<f64>, theta: &Array1<f64>) -> Array1<f64> {
|
| 272 |
+
const G: f64 = 9.80665;
|
| 273 |
+
let n = z.len();
|
| 274 |
+
let mut l2 = Array1::<f64>::zeros(n);
|
| 275 |
+
for i in 0..n {
|
| 276 |
+
let (dthdz, d2udz2) = if i == 0 {
|
| 277 |
+
let dth = (theta[1] - theta[0]) / (z[1] - z[0]);
|
| 278 |
+
let d2u = if n >= 3 {
|
| 279 |
+
let h1 = z[1] - z[0];
|
| 280 |
+
let h2 = z[2] - z[1];
|
| 281 |
+
2.0 * (u[2] * h1 - u[1] * (h1 + h2) + u[0] * h2) / (h1 * h2 * (h1 + h2))
|
| 282 |
+
} else {
|
| 283 |
+
0.0
|
| 284 |
+
};
|
| 285 |
+
(dth, d2u)
|
| 286 |
+
} else if i == n - 1 {
|
| 287 |
+
let dth = (theta[n - 1] - theta[n - 2]) / (z[n - 1] - z[n - 2]);
|
| 288 |
+
let d2u = if n >= 3 {
|
| 289 |
+
let h1 = z[n - 2] - z[n - 3];
|
| 290 |
+
let h2 = z[n - 1] - z[n - 2];
|
| 291 |
+
2.0 * (u[n - 1] * h1 - u[n - 2] * (h1 + h2) + u[n - 3] * h2) / (h1 * h2 * (h1 + h2))
|
| 292 |
+
} else {
|
| 293 |
+
0.0
|
| 294 |
+
};
|
| 295 |
+
(dth, d2u)
|
| 296 |
+
} else {
|
| 297 |
+
let h1 = z[i] - z[i - 1];
|
| 298 |
+
let h2 = z[i + 1] - z[i];
|
| 299 |
+
let dth = (theta[i + 1] * h1 * h1 - theta[i - 1] * h2 * h2
|
| 300 |
+
+ theta[i] * (h2 * h2 - h1 * h1))
|
| 301 |
+
/ (h1 * h2 * (h1 + h2));
|
| 302 |
+
let d2u = 2.0 * (u[i + 1] * h1 - u[i] * (h1 + h2) + u[i - 1] * h2) / (h1 * h2 * (h1 + h2));
|
| 303 |
+
(dth, d2u)
|
| 304 |
+
};
|
| 305 |
+
let n2 = (G / theta[i]) * dthdz;
|
| 306 |
+
let uu = u_clamped_for_scorer(u[i]);
|
| 307 |
+
l2[i] = n2 / (uu * uu) - d2udz2 / uu;
|
| 308 |
+
}
|
| 309 |
+
l2
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
/// Principal branch of sqrt chosen so that Im(result) ≥ 0 (or Re ≥ 0 when
|
| 313 |
+
/// argument is a non-negative real). This corresponds to the outgoing /
|
| 314 |
+
/// decaying branch of exp(-σz) for mountain-wave radiation conditions.
|
| 315 |
+
fn principal_sigma(k2_minus_l2: f64) -> Complex64 {
|
| 316 |
+
let s = c(k2_minus_l2).sqrt();
|
| 317 |
+
if s.im < 0.0 {
|
| 318 |
+
-s
|
| 319 |
+
} else {
|
| 320 |
+
s
|
| 321 |
+
}
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
fn multi_layer(
|
| 325 |
+
z_profile: &Array1<f64>,
|
| 326 |
+
u_profile: &Array1<f64>,
|
| 327 |
+
theta_profile: &Array1<f64>,
|
| 328 |
+
a: f64,
|
| 329 |
+
ho: f64,
|
| 330 |
+
xdom: f64,
|
| 331 |
+
zdom: f64,
|
| 332 |
+
mink: f64,
|
| 333 |
+
maxk: f64,
|
| 334 |
+
npts: usize,
|
| 335 |
+
) -> (Array1<f64>, Array1<f64>, Array2<f64>, Array2<f64>) {
|
| 336 |
+
let l2_profile = scorer_from_profile(z_profile, u_profile, theta_profile);
|
| 337 |
+
let u_surface = u_profile[0];
|
| 338 |
+
|
| 339 |
+
let nlayers = z_profile.len();
|
| 340 |
+
let mut layer_bot = Array1::<f64>::zeros(nlayers);
|
| 341 |
+
let mut layer_top = Array1::<f64>::zeros(nlayers);
|
| 342 |
+
for j in 0..nlayers {
|
| 343 |
+
layer_bot[j] = if j == 0 {
|
| 344 |
+
0.0
|
| 345 |
+
} else {
|
| 346 |
+
0.5 * (z_profile[j - 1] + z_profile[j])
|
| 347 |
+
};
|
| 348 |
+
layer_top[j] = if j == nlayers - 1 {
|
| 349 |
+
f64::INFINITY
|
| 350 |
+
} else {
|
| 351 |
+
0.5 * (z_profile[j] + z_profile[j + 1])
|
| 352 |
+
};
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
let dk = 0.367 / a;
|
| 356 |
+
let nk = (((maxk - mink) / dk).floor() as usize).max(1);
|
| 357 |
+
let nslab = nk + 1;
|
| 358 |
+
|
| 359 |
+
let minx = -0.25 * xdom;
|
| 360 |
+
let maxx = 0.75 * xdom;
|
| 361 |
+
let nx = npts + 1;
|
| 362 |
+
let nz = npts + 1;
|
| 363 |
+
let dx = (maxx - minx) / npts as f64;
|
| 364 |
+
let dz_grid = zdom / npts as f64;
|
| 365 |
+
|
| 366 |
+
let x = Array1::from_iter((0..nx).map(|i| minx + dx * i as f64));
|
| 367 |
+
let z = Array1::from_iter((0..nz).map(|j| dz_grid * j as f64));
|
| 368 |
+
|
| 369 |
+
let layer_of: Vec<usize> = z
|
| 370 |
+
.iter()
|
| 371 |
+
.map(|&zj| {
|
| 372 |
+
let mut idx = nlayers - 1;
|
| 373 |
+
for l in 0..nlayers {
|
| 374 |
+
if zj < layer_top[l] {
|
| 375 |
+
idx = l;
|
| 376 |
+
break;
|
| 377 |
+
}
|
| 378 |
+
}
|
| 379 |
+
idx
|
| 380 |
+
})
|
| 381 |
+
.collect();
|
| 382 |
+
|
| 383 |
+
// Each k sample produces slabs for BOTH ŵ and u'_complex. u'_k is
|
| 384 |
+
// computed from the analytic z-derivative of the layer eigenbasis:
|
| 385 |
+
// ∂ŵ_j/∂z = σ_j · (−a_j e^{−σ Δz} + b_j e^{+σ Δz})
|
| 386 |
+
// multiplied by −i/k per the linearized continuity relation.
|
| 387 |
+
let slabs: Vec<(Vec<Complex64>, Vec<Complex64>)> = (0..nslab)
|
| 388 |
+
.into_par_iter()
|
| 389 |
+
.map(|idx| {
|
| 390 |
+
let kk = mink + dk * idx as f64;
|
| 391 |
+
let mut slab_w = vec![Complex64::new(0.0, 0.0); nz * nx];
|
| 392 |
+
let mut slab_u = vec![Complex64::new(0.0, 0.0); nz * nx];
|
| 393 |
+
if kk == 0.0 {
|
| 394 |
+
return (slab_w, slab_u);
|
| 395 |
+
}
|
| 396 |
+
let ksign = kk.abs();
|
| 397 |
+
|
| 398 |
+
let sigma: Vec<Complex64> = (0..nlayers)
|
| 399 |
+
.map(|j| principal_sigma(kk * kk - l2_profile[j]))
|
| 400 |
+
.collect();
|
| 401 |
+
|
| 402 |
+
// Top-down sweep: a_top = 1, b_top = 0.
|
| 403 |
+
let mut aj = vec![Complex64::new(0.0, 0.0); nlayers];
|
| 404 |
+
let mut bj = vec![Complex64::new(0.0, 0.0); nlayers];
|
| 405 |
+
aj[nlayers - 1] = c(1.0);
|
| 406 |
+
bj[nlayers - 1] = c(0.0);
|
| 407 |
+
|
| 408 |
+
for j in (0..nlayers - 1).rev() {
|
| 409 |
+
let dz_j = layer_top[j] - layer_bot[j];
|
| 410 |
+
let e_minus = (-sigma[j] * dz_j).exp();
|
| 411 |
+
let e_plus = (sigma[j] * dz_j).exp();
|
| 412 |
+
let alpha = aj[j + 1] + bj[j + 1];
|
| 413 |
+
let beta = -aj[j + 1] + bj[j + 1];
|
| 414 |
+
let ratio = sigma[j + 1] / sigma[j];
|
| 415 |
+
aj[j] = 0.5 * (alpha - ratio * beta) * e_plus;
|
| 416 |
+
bj[j] = 0.5 * (alpha + ratio * beta) * e_minus;
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
let hs = std::f64::consts::PI * a * ho * (-a * ksign).exp();
|
| 420 |
+
let w_surface_unnorm = aj[0] + bj[0];
|
| 421 |
+
let amp = if w_surface_unnorm.norm() < 1e-300 {
|
| 422 |
+
Complex64::new(0.0, 0.0)
|
| 423 |
+
} else {
|
| 424 |
+
-I * c(kk * u_surface * hs) / w_surface_unnorm
|
| 425 |
+
};
|
| 426 |
+
for j in 0..nlayers {
|
| 427 |
+
aj[j] *= amp;
|
| 428 |
+
bj[j] *= amp;
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
// Assemble slabs. u'_complex = (−i/k) · ∂ŵ/∂z per layer.
|
| 432 |
+
let inv_ik = -I / c(kk);
|
| 433 |
+
let xfac: Vec<Complex64> =
|
| 434 |
+
x.iter().map(|&xi| (-I * c(xi * kk)).exp()).collect();
|
| 435 |
+
|
| 436 |
+
for j in 0..nz {
|
| 437 |
+
let l = layer_of[j];
|
| 438 |
+
let dz_l = z[j] - layer_bot[l];
|
| 439 |
+
let e_minus = (-sigma[l] * dz_l).exp();
|
| 440 |
+
let e_plus = (sigma[l] * dz_l).exp();
|
| 441 |
+
let zval_w = aj[l] * e_minus + bj[l] * e_plus;
|
| 442 |
+
let dwdz = sigma[l] * (-aj[l] * e_minus + bj[l] * e_plus);
|
| 443 |
+
let zval_u = inv_ik * dwdz;
|
| 444 |
+
let row_start = j * nx;
|
| 445 |
+
for i in 0..nx {
|
| 446 |
+
slab_w[row_start + i] = zval_w * xfac[i];
|
| 447 |
+
slab_u[row_start + i] = zval_u * xfac[i];
|
| 448 |
+
}
|
| 449 |
+
}
|
| 450 |
+
(slab_w, slab_u)
|
| 451 |
+
})
|
| 452 |
+
.collect();
|
| 453 |
+
|
| 454 |
+
let ht_samples: Vec<f64> = (0..nslab)
|
| 455 |
+
.map(|idx| {
|
| 456 |
+
let kk = mink + dk * idx as f64;
|
| 457 |
+
std::f64::consts::PI * dk * a * (-a * kk.abs()).exp()
|
| 458 |
+
})
|
| 459 |
+
.collect();
|
| 460 |
+
let ht: f64 = ht_samples[1..].iter().sum();
|
| 461 |
+
let inv_ht = if ht.abs() > 0.0 { 1.0 / ht } else { 1.0 };
|
| 462 |
+
|
| 463 |
+
let mut wc = vec![Complex64::new(0.0, 0.0); nz * nx];
|
| 464 |
+
let mut uc = vec![Complex64::new(0.0, 0.0); nz * nx];
|
| 465 |
+
for kloop in 1..nslab {
|
| 466 |
+
for i in 0..(nz * nx) {
|
| 467 |
+
wc[i] += c(0.5 * dk) * (slabs[kloop - 1].0[i] + slabs[kloop].0[i]);
|
| 468 |
+
uc[i] += c(0.5 * dk) * (slabs[kloop - 1].1[i] + slabs[kloop].1[i]);
|
| 469 |
+
}
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
let mut w = Array2::<f64>::zeros((nz, nx));
|
| 473 |
+
let mut u_prime = Array2::<f64>::zeros((nz, nx));
|
| 474 |
+
for j in 0..nz {
|
| 475 |
+
for i in 0..nx {
|
| 476 |
+
w[[j, i]] = wc[j * nx + i].re * inv_ht;
|
| 477 |
+
u_prime[[j, i]] = uc[j * nx + i].re * inv_ht;
|
| 478 |
+
}
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
(x, z, w, u_prime)
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
// ---------------------------------------------------------------------------
|
| 485 |
+
// Streamline tracer
|
| 486 |
+
// ---------------------------------------------------------------------------
|
| 487 |
+
|
| 488 |
+
#[pyfunction]
|
| 489 |
+
#[pyo3(signature = (x, z, u, w, num=10))]
|
| 490 |
+
fn streamlines<'py>(
|
| 491 |
+
py: Python<'py>,
|
| 492 |
+
x: PyReadonlyArray1<'py, f64>,
|
| 493 |
+
z: PyReadonlyArray1<'py, f64>,
|
| 494 |
+
u: f64,
|
| 495 |
+
w: PyReadonlyArray2<'py, f64>,
|
| 496 |
+
num: usize,
|
| 497 |
+
) -> PyResult<Vec<(Bound<'py, PyArray1<f64>>, Bound<'py, PyArray1<f64>>)>> {
|
| 498 |
+
let xa = x.as_array().to_owned();
|
| 499 |
+
let za = z.as_array().to_owned();
|
| 500 |
+
let wa = w.as_array().to_owned();
|
| 501 |
+
let lines = py.allow_threads(|| trace_streamlines(&xa, &za, u, &wa, num));
|
| 502 |
+
Ok(lines
|
| 503 |
+
.into_iter()
|
| 504 |
+
.map(|(xs, ys)| (xs.into_pyarray_bound(py), ys.into_pyarray_bound(py)))
|
| 505 |
+
.collect())
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
fn trace_streamlines(
|
| 509 |
+
x: &Array1<f64>,
|
| 510 |
+
z: &Array1<f64>,
|
| 511 |
+
u: f64,
|
| 512 |
+
w: &Array2<f64>,
|
| 513 |
+
num: usize,
|
| 514 |
+
) -> Vec<(Array1<f64>, Array1<f64>)> {
|
| 515 |
+
let nx = x.len();
|
| 516 |
+
let nz = z.len();
|
| 517 |
+
if nx < 2 || nz < 2 || num == 0 {
|
| 518 |
+
return Vec::new();
|
| 519 |
+
}
|
| 520 |
+
let minx = x[0];
|
| 521 |
+
let dx = x[1] - x[0];
|
| 522 |
+
let tstep = dx / u;
|
| 523 |
+
let dh = nz as f64 / num as f64;
|
| 524 |
+
|
| 525 |
+
let mut lines = Vec::with_capacity(num);
|
| 526 |
+
for j in 0..num {
|
| 527 |
+
let mut ycell = 1.0 + dh * j as f64;
|
| 528 |
+
if ycell < 1.0 {
|
| 529 |
+
ycell = 1.0;
|
| 530 |
+
}
|
| 531 |
+
if ycell > nz as f64 {
|
| 532 |
+
ycell = nz as f64;
|
| 533 |
+
}
|
| 534 |
+
let yci = (ycell.round() as isize - 1).clamp(0, nz as isize - 1) as usize;
|
| 535 |
+
let mut xs = Array1::<f64>::zeros(nx);
|
| 536 |
+
let mut ys = Array1::<f64>::zeros(nx);
|
| 537 |
+
xs[0] = minx;
|
| 538 |
+
ys[0] = z[yci];
|
| 539 |
+
for i in 1..nx {
|
| 540 |
+
xs[i] = x[i];
|
| 541 |
+
ys[i] = ys[i - 1] + tstep * w[[yci, i]];
|
| 542 |
+
}
|
| 543 |
+
lines.push((xs, ys));
|
| 544 |
+
}
|
| 545 |
+
lines
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
// ---------------------------------------------------------------------------
|
| 549 |
+
// Module registration
|
| 550 |
+
// ---------------------------------------------------------------------------
|
| 551 |
+
|
| 552 |
+
#[pymodule]
|
| 553 |
+
fn _core(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
| 554 |
+
m.add_function(wrap_pyfunction!(compute_two_layer, m)?)?;
|
| 555 |
+
m.add_function(wrap_pyfunction!(compute_from_profile, m)?)?;
|
| 556 |
+
m.add_function(wrap_pyfunction!(streamlines, m)?)?;
|
| 557 |
+
m.add("__doc__", "Mountain Waves Rust compute core.")?;
|
| 558 |
+
Ok(())
|
| 559 |
+
}
|
stream.m
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
function[]=stream(x,y,U,v,num);
|
| 2 |
+
%-------------------------------------------------------%
|
| 3 |
+
% Matlab subroutine stream.m %
|
| 4 |
+
% Called by: tlwplot.m %
|
| 5 |
+
% %
|
| 6 |
+
% by Robert Hart for Meteo 574 / Fall 95 %
|
| 7 |
+
% Penn State University Meteorology %
|
| 8 |
+
% %
|
| 9 |
+
% This subroutine performs a streamline analysis %
|
| 10 |
+
% of the wind field. %
|
| 11 |
+
% %
|
| 12 |
+
% Parameters passed to this routine: %
|
| 13 |
+
% ---------------------------------- %
|
| 14 |
+
% x - array of x-gridpoints %
|
| 15 |
+
% y - array of y-gridpoints %
|
| 16 |
+
% U - speed of x-direction wind (constant) %
|
| 17 |
+
% v - array of y-direction wind %
|
| 18 |
+
% num - # of evenly spaced streamlines to draw %
|
| 19 |
+
% %
|
| 20 |
+
% NOTE: Subroutine is written for a constant %
|
| 21 |
+
% horizontal wind velocity. %
|
| 22 |
+
%-------------------------------------------------------%
|
| 23 |
+
hold; % hold figure
|
| 24 |
+
xsize=size(x);
|
| 25 |
+
xsize=xsize(1); % size of x-grid
|
| 26 |
+
ysize=size(y);
|
| 27 |
+
ysize=ysize(2); % size of y-grid
|
| 28 |
+
miny=y(1); % min. y-value
|
| 29 |
+
maxy=y(ysize); % max. y-value
|
| 30 |
+
minx=x(1); % min. x-value
|
| 31 |
+
maxx=x(1,xsize); % max. x-value
|
| 32 |
+
|
| 33 |
+
dx=(maxx-minx)/xsize; % x-dir gridspacing
|
| 34 |
+
dy=(maxy-miny)/ysize; % y-dir grid spacing
|
| 35 |
+
dh=ysize/num; % streamline spacing
|
| 36 |
+
|
| 37 |
+
tstep=dx/U; % time to cross cell
|
| 38 |
+
|
| 39 |
+
mtncolor=[.02 .77 .02]; % color of mountain (lt green here)
|
| 40 |
+
|
| 41 |
+
ycell=1; % initial location
|
| 42 |
+
for j=1:num % streamline loop
|
| 43 |
+
ycell=1+dh*(j-1); % current y-location
|
| 44 |
+
if ycell < 1 % don't go outside array
|
| 45 |
+
ycell=1;
|
| 46 |
+
end;
|
| 47 |
+
if ycell > ysize % don't go outside array
|
| 48 |
+
ycell=ysize;
|
| 49 |
+
end;
|
| 50 |
+
ax=[]; % initialize lineplot arrays
|
| 51 |
+
ay=[];
|
| 52 |
+
ax(1)=minx;
|
| 53 |
+
ay(1)=y(round(ycell));
|
| 54 |
+
ycell=int8(ycell);
|
| 55 |
+
for i=2:xsize; % streamline tracing loop
|
| 56 |
+
ax(i)=x(ycell,i); % calculate displacement
|
| 57 |
+
ay(i)=ay(i-1)+tstep*v(ycell,i);
|
| 58 |
+
end; % end of loop
|
| 59 |
+
if j==1 % if first streamline
|
| 60 |
+
ax(xsize+1)=maxx;
|
| 61 |
+
ay(ysize+1)=miny;
|
| 62 |
+
fill(ax,ay,mtncolor); % solid-fill mountain
|
| 63 |
+
else % otherwise,
|
| 64 |
+
line(ax,ay); % draw streamline!
|
| 65 |
+
end; % end of loop
|
| 66 |
+
end; % the end;
|
tlwmenu.m
ADDED
|
@@ -0,0 +1,800 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
function tlwmenu(command)
|
| 2 |
+
%---------------------------------------------------------------
|
| 3 |
+
% Matlab Subroutine tlwmenu.M
|
| 4 |
+
%
|
| 5 |
+
% by Robert Hart for Meteo 574 / Fall 1995
|
| 6 |
+
% Penn State University Meteorology
|
| 7 |
+
% Updated 1 March 2018 to fix display bug in newer matlab versions
|
| 8 |
+
% Updated 2 April 2018 to correctly display Rossby number
|
| 9 |
+
%
|
| 10 |
+
% Interactive menu system used to control tlwplot.m
|
| 11 |
+
% a program which calculates and displays the airflow over
|
| 12 |
+
% a mountain in the presence of a two-layer atmosphere.
|
| 13 |
+
%
|
| 14 |
+
% To execute: tlwmenu
|
| 15 |
+
%---------------------------------------------------------------
|
| 16 |
+
|
| 17 |
+
omega=7.292e-5;
|
| 18 |
+
latit=pi/2;
|
| 19 |
+
f=2*omega*sin(latit);
|
| 20 |
+
|
| 21 |
+
if nargin == 0
|
| 22 |
+
command = 'new';
|
| 23 |
+
end
|
| 24 |
+
|
| 25 |
+
if isstr(command)
|
| 26 |
+
if strcmp(lower(command),'initialize') | strcmp(lower(command),'new')
|
| 27 |
+
command = 0;
|
| 28 |
+
elseif strcmp(lower(command),lower('h_quit'))
|
| 29 |
+
command = 1;
|
| 30 |
+
elseif strcmp(lower(command),lower('h_info'))
|
| 31 |
+
command = 2;
|
| 32 |
+
elseif strcmp(lower(command),lower('h_analyze'))
|
| 33 |
+
command = 3;
|
| 34 |
+
elseif strcmp(lower(command),lower('h_maxk'))
|
| 35 |
+
command = 4;
|
| 36 |
+
elseif strcmp(lower(command),lower('h_mink'))
|
| 37 |
+
command = 5;
|
| 38 |
+
elseif strcmp(lower(command),lower('h_H'))
|
| 39 |
+
command = 6;
|
| 40 |
+
elseif strcmp(lower(command),lower('h_Llower'))
|
| 41 |
+
command = 7;
|
| 42 |
+
elseif strcmp(lower(command),lower('h_wndspd'))
|
| 43 |
+
command = 8;
|
| 44 |
+
elseif strcmp(lower(command),lower('h_Lupper'))
|
| 45 |
+
command = 9;
|
| 46 |
+
elseif strcmp(lower(command),lower('h_half'))
|
| 47 |
+
command = 10;
|
| 48 |
+
elseif strcmp(lower(command),lower('h_maxht'))
|
| 49 |
+
command = 11;
|
| 50 |
+
elseif strcmp(lower(command),lower('h_xwidth'))
|
| 51 |
+
command = 12;
|
| 52 |
+
elseif strcmp(lower(command),lower('h_vert'))
|
| 53 |
+
command = 13;
|
| 54 |
+
end
|
| 55 |
+
end
|
| 56 |
+
|
| 57 |
+
if command ~= 0
|
| 58 |
+
h_fig_list = findobj(get(0,'children'),'flat',...
|
| 59 |
+
'tag','tlwmenu');
|
| 60 |
+
if length(h_fig_list) > 1
|
| 61 |
+
h_fig_list = gcf;
|
| 62 |
+
elseif length(h_fig_list) == 0
|
| 63 |
+
error('There are no figures with Tag = tlwmenu.');
|
| 64 |
+
end
|
| 65 |
+
handle_list = get(h_fig_list,'userdata');
|
| 66 |
+
if length(handle_list) > 0
|
| 67 |
+
h_quit = handle_list(1);
|
| 68 |
+
h_disp = handle_list(2);
|
| 69 |
+
h_analyze = handle_list(3);
|
| 70 |
+
h_maxk = handle_list(4);
|
| 71 |
+
h_mink = handle_list(5);
|
| 72 |
+
h_H = handle_list(6);
|
| 73 |
+
h_Llower = handle_list(7);
|
| 74 |
+
h_wndspd = handle_list(8);
|
| 75 |
+
h_Lupper = handle_list(9);
|
| 76 |
+
h_half = handle_list(10);
|
| 77 |
+
h_maxht = handle_list(11);
|
| 78 |
+
h_xwidth = handle_list(12);
|
| 79 |
+
h_vert = handle_list(13);
|
| 80 |
+
h_uic_6 = handle_list(14);
|
| 81 |
+
h_uic_25 = handle_list(15);
|
| 82 |
+
h_uic_22 = handle_list(16);
|
| 83 |
+
h_uic_20 = handle_list(17);
|
| 84 |
+
h_uic_3 = handle_list(18);
|
| 85 |
+
h_uic_15 = handle_list(19);
|
| 86 |
+
h_uic_11 = handle_list(20);
|
| 87 |
+
h_uic_19 = handle_list(21);
|
| 88 |
+
h_uic_10 = handle_list(22);
|
| 89 |
+
h_uic_13 = handle_list(23);
|
| 90 |
+
h_uic_9 = handle_list(24);
|
| 91 |
+
h_title = handle_list(25);
|
| 92 |
+
h_mxkval = handle_list(26);
|
| 93 |
+
h_separ = handle_list(27);
|
| 94 |
+
h_uic_8 = handle_list(28);
|
| 95 |
+
h_uic_1 = handle_list(29);
|
| 96 |
+
h_mnkval = handle_list(30);
|
| 97 |
+
h_wvalue = handle_list(31);
|
| 98 |
+
h_Lupval = handle_list(32);
|
| 99 |
+
h_Lloval = handle_list(33);
|
| 100 |
+
h_Hval = handle_list(34);
|
| 101 |
+
h_scorer = handle_list(35);
|
| 102 |
+
h_wtitle = handle_list(36);
|
| 103 |
+
h_Lupttl = handle_list(37);
|
| 104 |
+
h_Llottl = handle_list(38);
|
| 105 |
+
h_Htitle = handle_list(39);
|
| 106 |
+
h_rossby = handle_list(40);
|
| 107 |
+
h_uic_7 = handle_list(41);
|
| 108 |
+
h_mtnval = handle_list(42);
|
| 109 |
+
h_hlfval = handle_list(43);
|
| 110 |
+
h_uic_2 = handle_list(44);
|
| 111 |
+
h_uic_4 = handle_list(45);
|
| 112 |
+
h_uic_14 = handle_list(46);
|
| 113 |
+
h_uic_17 = handle_list(47);
|
| 114 |
+
h_verval = handle_list(48);
|
| 115 |
+
h_horval = handle_list(49);
|
| 116 |
+
end
|
| 117 |
+
end
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
if command == 0
|
| 121 |
+
|
| 122 |
+
fig = figure('position',[ 320 240 600 450 ],...
|
| 123 |
+
'resize','on','tag','tlwmenu',...
|
| 124 |
+
'visible','on');
|
| 125 |
+
|
| 126 |
+
% Uicontrol Object Creation
|
| 127 |
+
|
| 128 |
+
h_quit = uicontrol(...
|
| 129 |
+
'Units','normalized',...
|
| 130 |
+
'CallBack','tlwmenu(''h_quit'');',...
|
| 131 |
+
'Position',[ 0.672 0.027 0.279 0.06 ],...
|
| 132 |
+
'String','Quit',...
|
| 133 |
+
'Style','pushbutton',...
|
| 134 |
+
'Tag','h_quit',...
|
| 135 |
+
'UserData','');
|
| 136 |
+
h_info = uicontrol(...
|
| 137 |
+
'Units','normalized',...
|
| 138 |
+
'CallBack','tlwmenu(''h_info'');',...
|
| 139 |
+
'Position',[ 0.014 0.027 0.265 0.062 ],...
|
| 140 |
+
'String','Info',...
|
| 141 |
+
'Style','pushbutton',...
|
| 142 |
+
'Tag','h_info',...
|
| 143 |
+
'UserData','');
|
| 144 |
+
h_analyze = uicontrol(...
|
| 145 |
+
'Units','normalized',...
|
| 146 |
+
'CallBack','tlwmenu(''h_analyze'');',...
|
| 147 |
+
'Position',[ 0.335 0.029 0.293 0.056 ],...
|
| 148 |
+
'String','Analyze Flow',...
|
| 149 |
+
'Style','pushbutton',...
|
| 150 |
+
'Tag','h_analyze',...
|
| 151 |
+
'UserData','');
|
| 152 |
+
h_maxk = uicontrol(...
|
| 153 |
+
'Units','normalized',...
|
| 154 |
+
'CallBack','tlwmenu(''h_maxk'');',...
|
| 155 |
+
'Max',[ 50 ],...
|
| 156 |
+
'Min',[ 0 ],...
|
| 157 |
+
'Position',[ 0.75 0.181 0.2 0.025 ],...
|
| 158 |
+
'Style','slider',...
|
| 159 |
+
'Value',[ 30 ],...
|
| 160 |
+
'Tag','h_maxk',...
|
| 161 |
+
'UserData','');
|
| 162 |
+
h_mink = uicontrol(...
|
| 163 |
+
'Units','normalized',...
|
| 164 |
+
'CallBack','tlwmenu(''h_mink'');',...
|
| 165 |
+
'Max',[ 50 ],...
|
| 166 |
+
'Min',[ 0 ],...
|
| 167 |
+
'Position',[ 0.75 0.277 0.2 0.025 ],...
|
| 168 |
+
'Style','slider',...
|
| 169 |
+
'Value',[ 0 ],...
|
| 170 |
+
'Tag','h_mink',...
|
| 171 |
+
'UserData','');
|
| 172 |
+
h_H = uicontrol(...
|
| 173 |
+
'Units','normalized',...
|
| 174 |
+
'CallBack','tlwmenu(''h_H'');',...
|
| 175 |
+
'Max',[ 20 ],...
|
| 176 |
+
'Position',[ 0.25 0.454 0.2 0.025 ],...
|
| 177 |
+
'Style','slider',...
|
| 178 |
+
'Value',[ 3.5 ],...
|
| 179 |
+
'Tag','h_H',...
|
| 180 |
+
'UserData','');
|
| 181 |
+
h_Llower = uicontrol(...
|
| 182 |
+
'Units','normalized',...
|
| 183 |
+
'CallBack','tlwmenu(''h_Llower'');',...
|
| 184 |
+
'Max',[ 50 ],...
|
| 185 |
+
'Position',[ 0.25 0.563 0.2 0.025 ],...
|
| 186 |
+
'Style','slider',...
|
| 187 |
+
'Value',[ 10 ],...
|
| 188 |
+
'Tag','h_Llower',...
|
| 189 |
+
'UserData','');
|
| 190 |
+
h_wndspd = uicontrol(...
|
| 191 |
+
'Units','normalized',...
|
| 192 |
+
'CallBack','tlwmenu(''h_wndspd'');',...
|
| 193 |
+
'Max',[ 100 ],...
|
| 194 |
+
'Position',[ 0.25 0.766 0.2 0.025 ],...
|
| 195 |
+
'String','Surface Wind Speed',...
|
| 196 |
+
'Style','slider',...
|
| 197 |
+
'Value',[ 20 ],...
|
| 198 |
+
'Tag','h_wndspd',...
|
| 199 |
+
'UserData','');
|
| 200 |
+
h_Lupper = uicontrol(...
|
| 201 |
+
'Units','normalized',...
|
| 202 |
+
'CallBack','tlwmenu(''h_Lupper'');',...
|
| 203 |
+
'Max',[ 50 ],...
|
| 204 |
+
'Position',[ 0.25 0.666 0.2 0.025 ],...
|
| 205 |
+
'Style','slider',...
|
| 206 |
+
'Value',[ 4 ],...
|
| 207 |
+
'Tag','h_Lupper',...
|
| 208 |
+
'UserData','');
|
| 209 |
+
h_half = uicontrol(...
|
| 210 |
+
'Units','normalized',...
|
| 211 |
+
'CallBack','tlwmenu(''h_half'');',...
|
| 212 |
+
'Max',[ 25 ],...
|
| 213 |
+
'Position',[ 0.75 0.702 0.2 0.025 ],...
|
| 214 |
+
'Style','slider',...
|
| 215 |
+
'Value',[ 2.5 ],...
|
| 216 |
+
'Tag','h_half',...
|
| 217 |
+
'UserData','');
|
| 218 |
+
h_maxht = uicontrol(...
|
| 219 |
+
'Units','normalized',...
|
| 220 |
+
'CallBack','tlwmenu(''h_maxht'');',...
|
| 221 |
+
'Max',[ 3 ],...
|
| 222 |
+
'Position',[ 0.75 0.785 0.2 0.025 ],...
|
| 223 |
+
'Style','slider',...
|
| 224 |
+
'Value',[ 0.5 ],...
|
| 225 |
+
'Tag','h_maxht',...
|
| 226 |
+
'UserData','');
|
| 227 |
+
h_xwidth = uicontrol(...
|
| 228 |
+
'Units','normalized',...
|
| 229 |
+
'CallBack','tlwmenu(''h_xwidth'');',...
|
| 230 |
+
'Max',[ 100 ],...
|
| 231 |
+
'Position',[ 0.75 0.52 0.2 0.025 ],...
|
| 232 |
+
'Style','slider',...
|
| 233 |
+
'Value',[ 40 ],...
|
| 234 |
+
'Tag','h_xwidth',...
|
| 235 |
+
'UserData','');
|
| 236 |
+
h_vert = uicontrol(...
|
| 237 |
+
'Units','normalized',...
|
| 238 |
+
'CallBack','tlwmenu(''h_vert'');',...
|
| 239 |
+
'Max',[ 20 ],...
|
| 240 |
+
'Position',[ 0.75 0.44 0.2 0.025 ],...
|
| 241 |
+
'Style','slider',...
|
| 242 |
+
'Value',[ 10 ],...
|
| 243 |
+
'Tag','h_vert',...
|
| 244 |
+
'UserData','');
|
| 245 |
+
h_uic_6 = uicontrol(...
|
| 246 |
+
'Units','normalized',...
|
| 247 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 248 |
+
'CallBack','tlwmenu(''h_uic_6'');',...
|
| 249 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 250 |
+
'Position',[ 0.049 0.139 0.221 0.101 ],...
|
| 251 |
+
'String','Rossby Number: (@45 degrees N):',...
|
| 252 |
+
'Style','text',...
|
| 253 |
+
'Tag','h_uic_6',...
|
| 254 |
+
'UserData','');
|
| 255 |
+
h_uic_25 = uicontrol(...
|
| 256 |
+
'Units','normalized',...
|
| 257 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 258 |
+
'CallBack','tlwmenu(''h_uic_25'');',...
|
| 259 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 260 |
+
'Position',[ 0.482 0.148 0.157 0.071 ],...
|
| 261 |
+
'String','Max. Wave# (half-widths):',...
|
| 262 |
+
'Style','text',...
|
| 263 |
+
'Tag','h_uic_25',...
|
| 264 |
+
'UserData','');
|
| 265 |
+
h_uic_22 = uicontrol(...
|
| 266 |
+
'Units','normalized',...
|
| 267 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 268 |
+
'CallBack','tlwmenu(''h_uic_22'');',...
|
| 269 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 270 |
+
'Position',[ 0.482 0.25 0.157 0.071 ],...
|
| 271 |
+
'String','Min. Wave# (half-widths):',...
|
| 272 |
+
'Style','text',...
|
| 273 |
+
'Tag','h_uic_22',...
|
| 274 |
+
'UserData','');
|
| 275 |
+
h_uic_20 = uicontrol(...
|
| 276 |
+
'Units','normalized',...
|
| 277 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 278 |
+
'CallBack','tlwmenu(''h_uic_20'');',...
|
| 279 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 280 |
+
'Position',[ 0.576 0.345 0.299 0.037 ],...
|
| 281 |
+
'String','SPECTRAL PROFILE',...
|
| 282 |
+
'Style','text',...
|
| 283 |
+
'Tag','h_uic_20',...
|
| 284 |
+
'UserData','');
|
| 285 |
+
h_uic_3 = uicontrol(...
|
| 286 |
+
'Units','normalized',...
|
| 287 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 288 |
+
'CallBack','tlwmenu(''h_uic_3'');',...
|
| 289 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 290 |
+
'Position',[ 0.049 0.286 0.221 0.098 ],...
|
| 291 |
+
'String','Scorer Condition: (Trapped > 1)',...
|
| 292 |
+
'Style','text',...
|
| 293 |
+
'Tag','h_uic_3',...
|
| 294 |
+
'UserData','');
|
| 295 |
+
h_uic_15 = uicontrol(...
|
| 296 |
+
'Units','normalized',...
|
| 297 |
+
'BackgroundColor',[ 0.2 0.7 0.7 ],...
|
| 298 |
+
'CallBack','tlwmenu(''h_uic_15'');',...
|
| 299 |
+
'Position',[ 0.007 0.117 0.015 0.814 ],...
|
| 300 |
+
'Style','text',...
|
| 301 |
+
'Units','normalized',...
|
| 302 |
+
'Tag','h_uic_15',...
|
| 303 |
+
'UserData','');
|
| 304 |
+
h_uic_11 = uicontrol(...
|
| 305 |
+
'Units','normalized',...
|
| 306 |
+
'BackgroundColor',[ 0.2 0.7 0.7 ],...
|
| 307 |
+
'CallBack','tlwmenu(''h_uic_11'');',...
|
| 308 |
+
'Position',[ 0.476 0.634 0.509 0.013 ],...
|
| 309 |
+
'Style','text',...
|
| 310 |
+
'Tag','h_uic_11',...
|
| 311 |
+
'UserData','');
|
| 312 |
+
h_uic_19 = uicontrol(...
|
| 313 |
+
'Units','normalized',...
|
| 314 |
+
'BackgroundColor',[ 0.2 0.7 0.7 ],...
|
| 315 |
+
'CallBack','tlwmenu(''h_uic_19'');',...
|
| 316 |
+
'Position',[ 0.477 0.395 0.515 0.01 ],...
|
| 317 |
+
'Style','text',...
|
| 318 |
+
'Tag','h_uic_19',...
|
| 319 |
+
'UserData','');
|
| 320 |
+
h_uic_10 = uicontrol(...
|
| 321 |
+
'Units','normalized',...
|
| 322 |
+
'BackgroundColor',[ 0.2 0.7 0.7 ],...
|
| 323 |
+
'CallBack','tlwmenu(''h_uic_10'');',...
|
| 324 |
+
'Position',[ 0.008 0.112 0.986 0.012 ],...
|
| 325 |
+
'Style','text',...
|
| 326 |
+
'Tag','h_uic_10',...
|
| 327 |
+
'UserData','');
|
| 328 |
+
h_uic_13 = uicontrol(...
|
| 329 |
+
'Units','normalized',...
|
| 330 |
+
'BackgroundColor',[ 0.2 0.7 0.7 ],...
|
| 331 |
+
'CallBack','tlwmenu(''h_uic_13'');',...
|
| 332 |
+
'Position',[ 0.983 0.12 0.01 0.82 ],...
|
| 333 |
+
'Style','text',...
|
| 334 |
+
'Tag','h_uic_13',...
|
| 335 |
+
'UserData','');
|
| 336 |
+
h_uic_9 = uicontrol(...
|
| 337 |
+
'Units','normalized',...
|
| 338 |
+
'BackgroundColor',[ 0.2 0.7 0.7 ],...
|
| 339 |
+
'CallBack','tlwmenu(''h_uic_9'');',...
|
| 340 |
+
'Position',[ 0.007 0.922 0.984 0.019 ],...
|
| 341 |
+
'Style','text',...
|
| 342 |
+
'Tag','h_uic_9',...
|
| 343 |
+
'UserData','');
|
| 344 |
+
h_title = uicontrol(...
|
| 345 |
+
'Units','normalized',...
|
| 346 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 347 |
+
'CallBack','tlwmenu(''h_title'');',...
|
| 348 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 349 |
+
'Position',[ 0.01 0.939 0.95 0.04 ],...
|
| 350 |
+
'String','INTERACTIVE MODEL FOR 2-LAYER FLOW OVER AN ISOLATED MOUNTAIN',...
|
| 351 |
+
'Style','text',...
|
| 352 |
+
'Tag','h_title',...
|
| 353 |
+
'UserData','');
|
| 354 |
+
h_mxkval = uicontrol(...
|
| 355 |
+
'Units','normalized',...
|
| 356 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 357 |
+
'CallBack','tlwmenu(''h_mxkval'');',...
|
| 358 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 359 |
+
'Position',[ 0.65 0.17 0.075 0.04 ],...
|
| 360 |
+
'String','30',...
|
| 361 |
+
'Style','text',...
|
| 362 |
+
'Tag','h_mxkval',...
|
| 363 |
+
'UserData','');
|
| 364 |
+
h_separ = uicontrol(...
|
| 365 |
+
'Units','normalized',...
|
| 366 |
+
'BackgroundColor',[ 0.1 0.7 0.7 ],...
|
| 367 |
+
'CallBack','tlwmenu(''h_separ'');',...
|
| 368 |
+
'Position',[ 0.468 0.118 0.01 0.805 ],...
|
| 369 |
+
'Style','text',...
|
| 370 |
+
'Tag','h_separ',...
|
| 371 |
+
'UserData','');
|
| 372 |
+
h_uic_8 = uicontrol(...
|
| 373 |
+
'Units','normalized',...
|
| 374 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 375 |
+
'CallBack','tlwmenu(''h_uic_8'');',...
|
| 376 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 377 |
+
'Position',[ 0.024 0.862 0.435 0.033 ],...
|
| 378 |
+
'String','ATMOSPHERIC PROFILE',...
|
| 379 |
+
'Style','text',...
|
| 380 |
+
'Tag','h_uic_8',...
|
| 381 |
+
'UserData','');
|
| 382 |
+
h_uic_1 = uicontrol(...
|
| 383 |
+
'Units','normalized',...
|
| 384 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 385 |
+
'CallBack','tlwmenu(''h_uic_1'');',...
|
| 386 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 387 |
+
'Position',[ 0.585 0.848 0.306 0.052 ],...
|
| 388 |
+
'String','TERRAIN PROFILE',...
|
| 389 |
+
'Style','text',...
|
| 390 |
+
'Tag','h_uic_1',...
|
| 391 |
+
'UserData','');
|
| 392 |
+
h_mnkval = uicontrol(...
|
| 393 |
+
'Units','normalized',...
|
| 394 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 395 |
+
'CallBack','tlwmenu(''h_mnkval'');',...
|
| 396 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 397 |
+
'Position',[ 0.65 0.265 0.075 0.04 ],...
|
| 398 |
+
'String','0',...
|
| 399 |
+
'Style','text',...
|
| 400 |
+
'Tag','h_mnkval',...
|
| 401 |
+
'UserData','');
|
| 402 |
+
h_wvalue = uicontrol(...
|
| 403 |
+
'Units','normalized',...
|
| 404 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 405 |
+
'CallBack','tlwmenu(''h_wvalue'');',...
|
| 406 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 407 |
+
'Position',[ 0.17 0.756 0.06 0.04 ],...
|
| 408 |
+
'String','20',...
|
| 409 |
+
'Style','text',...
|
| 410 |
+
'Tag','h_wvalue',...
|
| 411 |
+
'UserData','');
|
| 412 |
+
h_Lupval = uicontrol(...
|
| 413 |
+
'Units','normalized',...
|
| 414 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 415 |
+
'CallBack','tlwmenu(''h_Lupval'');',...
|
| 416 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 417 |
+
'Position',[ 0.17 0.658 0.06 0.04 ],...
|
| 418 |
+
'String','4',...
|
| 419 |
+
'Style','text',...
|
| 420 |
+
'Tag','h_Lupval',...
|
| 421 |
+
'UserData','');
|
| 422 |
+
h_Lloval = uicontrol(...
|
| 423 |
+
'Units','normalized',...
|
| 424 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 425 |
+
'CallBack','tlwmenu(''h_Lloval'');',...
|
| 426 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 427 |
+
'Position',[ 0.17 0.555 0.06 0.04 ],...
|
| 428 |
+
'String','10',...
|
| 429 |
+
'Style','text',...
|
| 430 |
+
'Tag','h_Lloval',...
|
| 431 |
+
'UserData','');
|
| 432 |
+
h_Hval = uicontrol(...
|
| 433 |
+
'Units','normalized',...
|
| 434 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 435 |
+
'CallBack','tlwmenu(''h_Hval'');',...
|
| 436 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 437 |
+
'Position',[ 0.17 0.44 0.06 0.04 ],...
|
| 438 |
+
'String','3.5',...
|
| 439 |
+
'Style','text',...
|
| 440 |
+
'Tag','h_Hval',...
|
| 441 |
+
'UserData','');
|
| 442 |
+
h_scorer = uicontrol(...
|
| 443 |
+
'Units','normalized',...
|
| 444 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 445 |
+
'CallBack','tlwmenu(''h_scorer'');',...
|
| 446 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 447 |
+
'Position',[ 0.29 0.32 0.12 0.05 ],...
|
| 448 |
+
'String','3.034',...
|
| 449 |
+
'Style','text',...
|
| 450 |
+
'Tag','h_scorer',...
|
| 451 |
+
'UserData','');
|
| 452 |
+
h_wtitle = uicontrol(...
|
| 453 |
+
'Units','normalized',...
|
| 454 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 455 |
+
'CallBack','tlwmenu(''h_wtitle'');',...
|
| 456 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 457 |
+
'Position',[ 0.03 0.7 0.12 0.1 ],...
|
| 458 |
+
'String','Sfc Wind (m/s):',...
|
| 459 |
+
'Style','text',...
|
| 460 |
+
'Tag','h_wtitle',...
|
| 461 |
+
'UserData','');
|
| 462 |
+
h_Lupttl = uicontrol(...
|
| 463 |
+
'Units','normalized',...
|
| 464 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 465 |
+
'CallBack','tlwmenu(''h_Lupttl'');',...
|
| 466 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 467 |
+
'Position',[ 0.03 0.603 0.12 0.1 ],...
|
| 468 |
+
'String','L Upper (10^-4):',...
|
| 469 |
+
'Style','text',...
|
| 470 |
+
'Tag','h_Lupttl',...
|
| 471 |
+
'UserData','');
|
| 472 |
+
h_Llottl = uicontrol(...
|
| 473 |
+
'Units','normalized',...
|
| 474 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 475 |
+
'CallBack','tlwmenu(''h_Llottl'');',...
|
| 476 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 477 |
+
'Position',[ 0.03 0.5 0.12 0.1 ],...
|
| 478 |
+
'String','L Lower (10^-4):',...
|
| 479 |
+
'Style','text',...
|
| 480 |
+
'Tag','h_Llottl',...
|
| 481 |
+
'UserData','');
|
| 482 |
+
h_Htitle = uicontrol(...
|
| 483 |
+
'Units','normalized',...
|
| 484 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 485 |
+
'CallBack','tlwmenu(''h_Htitle'');',...
|
| 486 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 487 |
+
'Position',[ 0.03 0.395 0.12 0.1 ],...
|
| 488 |
+
'String','Interface Ht. (km):',...
|
| 489 |
+
'Style','text',...
|
| 490 |
+
'Tag','h_Htitle',...
|
| 491 |
+
'UserData','');
|
| 492 |
+
h_rossby = uicontrol(...
|
| 493 |
+
'Units','normalized',...
|
| 494 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 495 |
+
'CallBack','tlwmenu(''h_rossby'');',...
|
| 496 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 497 |
+
'Position',[ 0.289 0.177 0.12 0.05 ],...
|
| 498 |
+
'String','14.55',...
|
| 499 |
+
'Style','text',...
|
| 500 |
+
'Tag','h_rossby',...
|
| 501 |
+
'UserData','');
|
| 502 |
+
h_uic_7 = uicontrol(...
|
| 503 |
+
'Units','normalized',...
|
| 504 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 505 |
+
'CallBack','tlwmenu(''h_uic_7'');',...
|
| 506 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 507 |
+
'Position',[ 0.487 0.682 0.14 0.066 ],...
|
| 508 |
+
'String','Half-width (km)',...
|
| 509 |
+
'Style','text',...
|
| 510 |
+
'Tag','h_uic_7',...
|
| 511 |
+
'UserData','');
|
| 512 |
+
h_mtnval = uicontrol(...
|
| 513 |
+
'Units','normalized',...
|
| 514 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 515 |
+
'CallBack','tlwmenu(''h_mtnval'');',...
|
| 516 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 517 |
+
'Position',[ 0.65 0.785 0.09 0.032 ],...
|
| 518 |
+
'String','0.5',...
|
| 519 |
+
'Style','text',...
|
| 520 |
+
'Tag','h_mtnval',...
|
| 521 |
+
'UserData','');
|
| 522 |
+
h_hlfval = uicontrol(...
|
| 523 |
+
'Units','normalized',...
|
| 524 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 525 |
+
'CallBack','tlwmenu(''h_hlfval'');',...
|
| 526 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 527 |
+
'Position',[ 0.65 0.699 0.09 0.032 ],...
|
| 528 |
+
'String','2.5',...
|
| 529 |
+
'Style','text',...
|
| 530 |
+
'Tag','h_hlfval',...
|
| 531 |
+
'UserData','');
|
| 532 |
+
h_uic_2 = uicontrol(...
|
| 533 |
+
'Units','normalized',...
|
| 534 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 535 |
+
'CallBack','tlwmenu(''h_uic_2'');',...
|
| 536 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 537 |
+
'Position',[ 0.528 0.593 0.417 0.032 ],...
|
| 538 |
+
'String','DOMAIN PROFILE',...
|
| 539 |
+
'Style','text',...
|
| 540 |
+
'Tag','h_uic_2',...
|
| 541 |
+
'UserData','');
|
| 542 |
+
h_uic_4 = uicontrol(...
|
| 543 |
+
'Units','normalized',...
|
| 544 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 545 |
+
'CallBack','tlwmenu(''h_uic_4'');',...
|
| 546 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 547 |
+
'Position',[ 0.487 0.767 0.14 0.067 ],...
|
| 548 |
+
'String','Max. Height (km)',...
|
| 549 |
+
'Style','text',...
|
| 550 |
+
'Tag','h_uic_4',...
|
| 551 |
+
'UserData','');
|
| 552 |
+
h_uic_14 = uicontrol(...
|
| 553 |
+
'Units','normalized',...
|
| 554 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 555 |
+
'CallBack','tlwmenu(''h_uic_14'');',...
|
| 556 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 557 |
+
'Position',[ 0.488 0.5 0.135 0.073 ],...
|
| 558 |
+
'String','Horizontal (km):',...
|
| 559 |
+
'Style','text',...
|
| 560 |
+
'Tag','h_uic_14',...
|
| 561 |
+
'UserData','');
|
| 562 |
+
h_uic_17 = uicontrol(...
|
| 563 |
+
'Units','normalized',...
|
| 564 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 565 |
+
'CallBack','tlwmenu(''h_uic_17'');',...
|
| 566 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 567 |
+
'Position',[ 0.488 0.417 0.135 0.073 ],...
|
| 568 |
+
'String','Vertical (km):',...
|
| 569 |
+
'Style','text',...
|
| 570 |
+
'Tag','h_uic_17',...
|
| 571 |
+
'UserData','');
|
| 572 |
+
h_verval = uicontrol(...
|
| 573 |
+
'Units','normalized',...
|
| 574 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 575 |
+
'CallBack','tlwmenu(''h_verval'');',...
|
| 576 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 577 |
+
'Position',[ 0.65 0.425 0.08 0.045 ],...
|
| 578 |
+
'String','10',...
|
| 579 |
+
'Style','text',...
|
| 580 |
+
'Tag','h_verval',...
|
| 581 |
+
'UserData','');
|
| 582 |
+
h_horval = uicontrol(...
|
| 583 |
+
'Units','normalized',...
|
| 584 |
+
'BackgroundColor',[ 0 0 0 ],...
|
| 585 |
+
'CallBack','tlwmenu(''h_horval'');',...
|
| 586 |
+
'ForegroundColor',[ 1 1 1 ],...
|
| 587 |
+
'Position',[ 0.65 0.51 0.08 0.045 ],...
|
| 588 |
+
'String','40',...
|
| 589 |
+
'Style','text',...
|
| 590 |
+
'Tag','h_horval',...
|
| 591 |
+
'UserData','');
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
% Menu Object Creation
|
| 595 |
+
|
| 596 |
+
|
| 597 |
+
% Axes and Text Object Creation
|
| 598 |
+
|
| 599 |
+
|
| 600 |
+
|
| 601 |
+
|
| 602 |
+
handle_list = [ ...
|
| 603 |
+
h_quit h_info h_analyze ...
|
| 604 |
+
h_maxk h_mink h_H h_Llower ...
|
| 605 |
+
h_wndspd h_Lupper h_half h_maxht ...
|
| 606 |
+
h_xwidth h_vert h_uic_6 h_uic_25 ...
|
| 607 |
+
h_uic_22 h_uic_20 h_uic_3 h_uic_15 ...
|
| 608 |
+
h_uic_11 h_uic_19 h_uic_10 h_uic_13 ...
|
| 609 |
+
h_uic_9 h_title h_mxkval h_separ ...
|
| 610 |
+
h_uic_8 h_uic_1 h_mnkval h_wvalue ...
|
| 611 |
+
h_Lupval h_Lloval h_Hval h_scorer ...
|
| 612 |
+
h_wtitle h_Lupttl h_Llottl h_Htitle ...
|
| 613 |
+
h_rossby h_uic_7 h_mtnval h_hlfval ...
|
| 614 |
+
h_uic_2 h_uic_4 h_uic_14 h_uic_17 ...
|
| 615 |
+
h_verval h_horval ...
|
| 616 |
+
];
|
| 617 |
+
|
| 618 |
+
set(gcf,'userdata',handle_list);
|
| 619 |
+
pause(.1); drawnow; pause(.1);
|
| 620 |
+
|
| 621 |
+
set(gcf,'visible','on');
|
| 622 |
+
|
| 623 |
+
|
| 624 |
+
elseif command == 1
|
| 625 |
+
close(gcf);
|
| 626 |
+
elseif command == 2
|
| 627 |
+
ttlStr='Mountain Lee Wave Model Help/Info';
|
| 628 |
+
hlpStr=...
|
| 629 |
+
[' '
|
| 630 |
+
'--------------------------------------------'
|
| 631 |
+
' INTRODUCTION '
|
| 632 |
+
'--------------------------------------------'
|
| 633 |
+
'This is an interactive model for visualizing'
|
| 634 |
+
'airflow over an isolated mountain in the '
|
| 635 |
+
'presence of an atmosphere having varying '
|
| 636 |
+
'thermodynamical vertical structure. '
|
| 637 |
+
' '
|
| 638 |
+
'The atmosphere is divided into two layers, '
|
| 639 |
+
'each having a uniform Scorer parameter. '
|
| 640 |
+
' '
|
| 641 |
+
'A witch of agnesi shape has been chosen '
|
| 642 |
+
'for the terrain. '
|
| 643 |
+
' '
|
| 644 |
+
'The resulting airflow over the mountain '
|
| 645 |
+
'is then calculated as a Fourier sum of the '
|
| 646 |
+
'individual airflow patterns for each '
|
| 647 |
+
'wavenumber in the spectral domain. '
|
| 648 |
+
' '
|
| 649 |
+
'The structure of the atmosphere, terrain '
|
| 650 |
+
'domain, or wave spectrum can be altered '
|
| 651 |
+
'by moving the sliders to the requested '
|
| 652 |
+
'values. Once ANALYZE FLOW is clicked, a '
|
| 653 |
+
'contour plot of vertical velocity and an '
|
| 654 |
+
'estimate of the corresponding streamlines is'
|
| 655 |
+
'be plotted, using the profiles set by the '
|
| 656 |
+
'user (and assuming steady flow). '
|
| 657 |
+
' '
|
| 658 |
+
'BUTTONS: '
|
| 659 |
+
'INFO - This help screen. '
|
| 660 |
+
'ANALYZE FLOW - Calculate and display airflow'
|
| 661 |
+
' based on chosen parameters. '
|
| 662 |
+
'QUIT - Close and exit menu. '
|
| 663 |
+
' '
|
| 664 |
+
'The following is a description of each of '
|
| 665 |
+
'the sliders on the menu. '
|
| 666 |
+
'--------------------------------------------'
|
| 667 |
+
' ATMOSPHERIC PROFILE: '
|
| 668 |
+
'--------------------------------------------'
|
| 669 |
+
'Sfc Wind: '
|
| 670 |
+
' the surface wind speed in m/s '
|
| 671 |
+
' '
|
| 672 |
+
'Lupper: '
|
| 673 |
+
' Scorer parameter of the upper layer, '
|
| 674 |
+
' in multiples of 10^-4. '
|
| 675 |
+
' '
|
| 676 |
+
'Llower: '
|
| 677 |
+
' Scorer parameter of the lower layer, '
|
| 678 |
+
' in multiples of 10^-4 '
|
| 679 |
+
' '
|
| 680 |
+
'Interface Height: '
|
| 681 |
+
' Height above the ground (in km) of the '
|
| 682 |
+
' interface between the two layers. '
|
| 683 |
+
' '
|
| 684 |
+
'Scorer Condition: '
|
| 685 |
+
' Condition found by Scorer which must '
|
| 686 |
+
' be satisfied for the resulting '
|
| 687 |
+
' airflow to contain trapped waves. '
|
| 688 |
+
' Mathematically, '
|
| 689 |
+
' 4*(H/pi)^2*(Llower^2 -Lupper^2 ) > 1 '
|
| 690 |
+
' where H is the interface height. '
|
| 691 |
+
' '
|
| 692 |
+
'Rossby Number: '
|
| 693 |
+
' The rossby number of the flow at 45 deg.'
|
| 694 |
+
' This model assumes no rotation; '
|
| 695 |
+
' therefore, this number is displayed to '
|
| 696 |
+
' let the user know when the chosen '
|
| 697 |
+
' combination of wind and domain are such '
|
| 698 |
+
' that coriolis accelerations make the '
|
| 699 |
+
' calculated plots questionable. A number '
|
| 700 |
+
' less than 1 indicates the rotational '
|
| 701 |
+
' effects are significant. '
|
| 702 |
+
'--------------------------------------------'
|
| 703 |
+
' TERRAIN PROFILE: '
|
| 704 |
+
'--------------------------------------------'
|
| 705 |
+
'Max Height: '
|
| 706 |
+
' The maximum height of the isolated '
|
| 707 |
+
' mountain, in kilometers. '
|
| 708 |
+
' '
|
| 709 |
+
'Half-width: '
|
| 710 |
+
' The horizontal distance from the center '
|
| 711 |
+
' of the mountain at which the mountain '
|
| 712 |
+
' height decreases to one-half the maximum'
|
| 713 |
+
' height. (in km). '
|
| 714 |
+
'--------------------------------------------'
|
| 715 |
+
' DOMAIN PROFILE: '
|
| 716 |
+
'--------------------------------------------'
|
| 717 |
+
'Horizontal: '
|
| 718 |
+
' The width (in km) of the horizontal '
|
| 719 |
+
' domain to be analyzed. '
|
| 720 |
+
' '
|
| 721 |
+
'Vertical: '
|
| 722 |
+
' The height (in km) of the vertical '
|
| 723 |
+
' domain to be analyzed. '
|
| 724 |
+
'--------------------------------------------'
|
| 725 |
+
' SPECTRAL PROFILE: '
|
| 726 |
+
'--------------------------------------------'
|
| 727 |
+
'Mininum Wavenumber: '
|
| 728 |
+
' The smallest wavenumber wave to be '
|
| 729 |
+
' included in the calculation '
|
| 730 |
+
' (in multiples of mtn half-widths). '
|
| 731 |
+
' This is the lower limit on the Fourier '
|
| 732 |
+
' integration for wavenumber. '
|
| 733 |
+
' '
|
| 734 |
+
'Maximum Wavenumber: '
|
| 735 |
+
' The largest wavenumber wave to be '
|
| 736 |
+
' included in the airflow calculation '
|
| 737 |
+
' (in multiples of mtn half-widths). '
|
| 738 |
+
' This is the upper limit on the Fourier '
|
| 739 |
+
' integration for wavenumber. '
|
| 740 |
+
'--------------------------------------------'
|
| 741 |
+
' '];
|
| 742 |
+
helpwin(ttlStr,hlpStr);
|
| 743 |
+
elseif command == 3
|
| 744 |
+
a=1000*get(h_half,'value');
|
| 745 |
+
H=1000*get(h_H,'value');
|
| 746 |
+
ho=1000*get(h_maxht,'value');
|
| 747 |
+
U=get(h_wndspd,'value');
|
| 748 |
+
Lupper=.0001*get(h_Lupper,'value');
|
| 749 |
+
Llower=.0001*get(h_Llower,'value');
|
| 750 |
+
xdom=1000*get(h_xwidth,'value');
|
| 751 |
+
zdom=1000*get(h_vert,'value');
|
| 752 |
+
mink=get(h_mink,'value')/a;
|
| 753 |
+
maxk=get(h_maxk,'value')/a;
|
| 754 |
+
tlwplot(Lupper,Llower,U,H,a,ho,xdom,zdom,mink,maxk);
|
| 755 |
+
elseif command == 4
|
| 756 |
+
maxk=get(h_maxk,'value');
|
| 757 |
+
set(h_mxkval,'string',num2str(maxk));
|
| 758 |
+
elseif command == 5
|
| 759 |
+
mink=get(h_mink,'value');
|
| 760 |
+
set(h_mnkval,'string',num2str(mink));
|
| 761 |
+
elseif command == 6
|
| 762 |
+
H=get(h_H,'value');
|
| 763 |
+
set(h_Hval,'string',num2str(H));
|
| 764 |
+
elseif command == 7
|
| 765 |
+
Llower=get(h_Llower,'value');
|
| 766 |
+
set(h_Lloval,'string',num2str(Llower));
|
| 767 |
+
elseif command == 8
|
| 768 |
+
U=get(h_wndspd,'value');
|
| 769 |
+
set(h_wvalue,'string',num2str(U));
|
| 770 |
+
elseif command == 9
|
| 771 |
+
Lupper=get(h_Lupper,'value');
|
| 772 |
+
set(h_Lupval,'string',num2str(Lupper));
|
| 773 |
+
elseif command == 10
|
| 774 |
+
a=get(h_half,'value');
|
| 775 |
+
set(h_hlfval,'string',num2str(a));
|
| 776 |
+
elseif command == 11
|
| 777 |
+
ho=get(h_maxht,'value');
|
| 778 |
+
set(h_mtnval,'string',num2str(ho));
|
| 779 |
+
elseif command == 12
|
| 780 |
+
xdom=get(h_xwidth,'value');
|
| 781 |
+
set(h_horval,'string',num2str(xdom));
|
| 782 |
+
elseif command == 13
|
| 783 |
+
zdom=get(h_vert,'value');
|
| 784 |
+
set(h_verval,'string',num2str(zdom));
|
| 785 |
+
else
|
| 786 |
+
error('Error: tlwmenu.m called with incorrect command.')
|
| 787 |
+
end
|
| 788 |
+
|
| 789 |
+
if command ~= 1
|
| 790 |
+
H=1000*get(h_H,'value');
|
| 791 |
+
Llower=.0001*get(h_Llower,'value');
|
| 792 |
+
Lupper=.0001*get(h_Lupper,'value');
|
| 793 |
+
scorer=4*H*H*(Llower*Llower-Lupper*Lupper)/(pi*pi);
|
| 794 |
+
set(h_scorer,'string',num2str(scorer));
|
| 795 |
+
|
| 796 |
+
U=get(h_wndspd,'value');
|
| 797 |
+
a=1000*get(h_half,'value');
|
| 798 |
+
rossby=U/(f*a);
|
| 799 |
+
set(h_rossby,'string',num2str(rossby));
|
| 800 |
+
end;
|
tlwplot.m
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
function[]=tlwplot(Lupper,Llower,U,H,a,ho,xdom,zdom,mink,maxk);
|
| 2 |
+
%-------------------------------------------------------------
|
| 3 |
+
% Matlab Subroutine tlwplot.M
|
| 4 |
+
% Called from tlwmenu.M--interactive menu system.
|
| 5 |
+
%
|
| 6 |
+
% By Robert Hart for Meteo 574 / Fall 1995
|
| 7 |
+
% Penn State University Meteorology
|
| 8 |
+
% Updated 1 March 2018 to increase npts given faster cpus
|
| 9 |
+
%
|
| 10 |
+
% Program which analyzes and simulates the flow over
|
| 11 |
+
% an isolated mountain in the presence of a two-
|
| 12 |
+
% layer atmosphere.
|
| 13 |
+
%
|
| 14 |
+
% Parameters received from menu:
|
| 15 |
+
%-------------------------------
|
| 16 |
+
% Lupper - Scorer parameter of upper layer
|
| 17 |
+
% Llower - Scorer parameter of lower layer
|
| 18 |
+
% U - Surface wind speed (in m/s)
|
| 19 |
+
% H - Height above ground of 2-layer interface (in m)
|
| 20 |
+
% a - Half-width of mountain (in m)
|
| 21 |
+
% ho - maximum height of mountain
|
| 22 |
+
% xdom - Horizontal extent of domain (m)
|
| 23 |
+
% zdom - Vertical extent of domain (m)
|
| 24 |
+
% mink - Minimum wavenumber in Fourier analysis of flow
|
| 25 |
+
% maxk - Maximum wavenumber in Fourier analysis of flow
|
| 26 |
+
%
|
| 27 |
+
%--------------------------------------------------------------
|
| 28 |
+
|
| 29 |
+
npts=100; % # cells in each direction
|
| 30 |
+
dk=0.367/a; % wavenumber interval size
|
| 31 |
+
% (smaller the better, but
|
| 32 |
+
% need to watch calc. time!)
|
| 33 |
+
nk=(maxk-mink)/dk; % # loops for wave# integration
|
| 34 |
+
|
| 35 |
+
minx=-.25*xdom; % leftmost limit of domain
|
| 36 |
+
maxx=.75*xdom; % rightmost limit of domain
|
| 37 |
+
minz=0; % lower limit of domain
|
| 38 |
+
maxz=zdom; % upper limit of domain
|
| 39 |
+
|
| 40 |
+
matrix1=zeros(npts+1,npts+1); % temp matrix used in integration
|
| 41 |
+
matrix2=zeros(npts+1,npts+1); % temp matrix used in integration
|
| 42 |
+
matrix3=zeros(npts+1,npts+1); % sum matrix used in integration
|
| 43 |
+
|
| 44 |
+
dx=(maxx-minx)/npts; % grid cell size in horizontal
|
| 45 |
+
dz=(maxz-minz)/npts; % grid cell size in vertical
|
| 46 |
+
|
| 47 |
+
x=[minx:dx:maxx]; % array of x gridpoints
|
| 48 |
+
z=[minz:dz:maxz]; % array of z gridpoints
|
| 49 |
+
k=[mink:dk:maxk]; % array of wavenumbers
|
| 50 |
+
|
| 51 |
+
% mesh arrays into 2-d matrix
|
| 52 |
+
[x,z]=meshgrid(minx:dx:maxx,minz:dz:maxz);
|
| 53 |
+
|
| 54 |
+
ht=0; % initialize weighting for transform
|
| 55 |
+
for kloop=1:nk; % wavenumber loop
|
| 56 |
+
kk=k(kloop); % horiz wavenumber
|
| 57 |
+
m=sqrt(Llower*Llower-kk*kk); % vert wave# in lower layer
|
| 58 |
+
n=sqrt(kk*kk-Lupper*Lupper); % vert wave# in upper layer
|
| 59 |
+
if (m+i*n==0) % check for divide by zero
|
| 60 |
+
r=9e99;
|
| 61 |
+
else
|
| 62 |
+
r=(m-i*n)/(m+i*n); % reflection coefficient
|
| 63 |
+
end;
|
| 64 |
+
R=r*exp(2*i*m*H); % reflection calculation
|
| 65 |
+
A=(1+r)*exp(H*n+i*H*m)/(1+R); % calculate coefficients
|
| 66 |
+
C=1/(1+R);
|
| 67 |
+
D=R.*C;
|
| 68 |
+
hs=pi*a*ho*exp(-a*abs(kk)); % kk-component of Fourier trans.
|
| 69 |
+
ht=ht+pi*dk*a*exp(-a*abs(kk)); % sum Fourier weighting. if
|
| 70 |
+
% integrating over 0<k<oo
|
| 71 |
+
% ht will end up as pi.
|
| 72 |
+
% hence, ht represents the
|
| 73 |
+
% fraction, in radians, of
|
| 74 |
+
% the spectral interval considered.
|
| 75 |
+
|
| 76 |
+
aboveH=A*exp(-z.*n).*(z>H); % calculate w in each layer
|
| 77 |
+
belowH=(C*exp(i*z.*m)+D*exp(-i*z.*m)).*(z<=H);
|
| 78 |
+
|
| 79 |
+
% combine layers, and multiply by
|
| 80 |
+
% transform of kk-component of topo.
|
| 81 |
+
matrix2=((-i*kk*hs*U*(aboveH+belowH)).*exp(-i*x.*kk));
|
| 82 |
+
|
| 83 |
+
if kloop > 1 % trapezoidal integration/summation
|
| 84 |
+
matrix3=matrix3+.5*(matrix1+matrix2).*dk;
|
| 85 |
+
end;
|
| 86 |
+
|
| 87 |
+
matrix1=matrix2; % current w matrix becomes previous
|
| 88 |
+
end; % end of integration loop.
|
| 89 |
+
|
| 90 |
+
w=real(matrix3./ht); % only interested in real comp.
|
| 91 |
+
|
| 92 |
+
figure; % create new figure window
|
| 93 |
+
axis([minx maxx minz maxz]); % define axes
|
| 94 |
+
grid; % graw grid
|
| 95 |
+
xlabel('X (m)'); % X-axis title
|
| 96 |
+
ylabel('Height (m)'); % Z-axis title
|
| 97 |
+
title(['Streamline Analysis']); % figure title
|
| 98 |
+
stream(x,z,U,w,10); % perform streamline analysis
|
| 99 |
+
Hline=H*ones(1,npts+1); % create interface line
|
| 100 |
+
plot(x(1,:),Hline,'m--'); % draw interface line in magenta!
|
| 101 |
+
|
| 102 |
+
figure; % create new figure window
|
| 103 |
+
colormap(jet); % set colormap (red=high;blue=low)
|
| 104 |
+
axis([minx maxx minz maxz]); % define axes
|
| 105 |
+
xlabel('X (m)'); % X-axis title
|
| 106 |
+
ylabel('Height (m)'); % Z-axis title
|
| 107 |
+
title(['Vertical Velocity (m/s)']); % figure title
|
| 108 |
+
hold on; % hold current figure
|
| 109 |
+
plot(x(1,:),Hline,'m--'); % draw interface line
|
| 110 |
+
caxis([-10 10]); % define color contouring extrema
|
| 111 |
+
surface(x,z,w); % 2-d surface contour plot
|
| 112 |
+
shading interp; % interpolate between gridpoints
|
| 113 |
+
colorbar; % add contour legend
|
| 114 |
+
hold off; % remove hold on figure
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
validate.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Validate Rust vs. pure-Python solver, and sanity-check physics.
|
| 3 |
+
|
| 4 |
+
Run with: python validate.py
|
| 5 |
+
|
| 6 |
+
If the Rust extension hasn't been built, only the pure-Python code is
|
| 7 |
+
exercised — useful for catching mistakes in the port of tlwplot.m.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import math
|
| 13 |
+
import sys
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
|
| 18 |
+
ROOT = Path(__file__).resolve().parent
|
| 19 |
+
sys.path.insert(0, str(ROOT / "python"))
|
| 20 |
+
|
| 21 |
+
from mountain_waves import compute_two_layer, compute_from_profile, streamlines, backend_name
|
| 22 |
+
from mountain_waves import reference as ref
|
| 23 |
+
from mountain_waves.profile import (
|
| 24 |
+
default_profile_heights,
|
| 25 |
+
default_theta_profile,
|
| 26 |
+
default_u_profile,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def describe(name, w):
|
| 31 |
+
print(f" {name:<28} min={w.min():+.4f} max={w.max():+.4f} rms={np.sqrt((w**2).mean()):.4f}")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def case_uniform():
|
| 35 |
+
# Example 1: uniform atmosphere. No wave trapping.
|
| 36 |
+
return dict(l_upper=4e-4, l_lower=4e-4, u=20.0, h=3500.0, a=2500.0, ho=500.0,
|
| 37 |
+
xdom=40000.0, zdom=10000.0, mink=0.0, maxk=30.0 / 2500.0, npts=100)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def case_trapped():
|
| 41 |
+
# Example 2: trapped lee waves.
|
| 42 |
+
return dict(l_upper=4e-4, l_lower=10e-4, u=20.0, h=3500.0, a=2500.0, ho=500.0,
|
| 43 |
+
xdom=40000.0, zdom=10000.0, mink=0.0, maxk=30.0 / 2500.0, npts=100)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def main() -> int:
|
| 47 |
+
print(f"Active backend: {backend_name()}")
|
| 48 |
+
|
| 49 |
+
# ---- Python reference sanity: uniform atmosphere, w ≈ odd in x near crest
|
| 50 |
+
print("\n[1] Two-layer, uniform atmosphere (pure-Python reference):")
|
| 51 |
+
p = case_uniform()
|
| 52 |
+
x, z, w, up = ref.compute_two_layer(**p)
|
| 53 |
+
assert w.shape == (101, 101)
|
| 54 |
+
assert up.shape == w.shape
|
| 55 |
+
describe("w (reference)", w)
|
| 56 |
+
describe("u' (reference)", up)
|
| 57 |
+
|
| 58 |
+
print("\n[2] Two-layer, trapped-wave case:")
|
| 59 |
+
pt = case_trapped()
|
| 60 |
+
x, z, w_ref, up_ref = ref.compute_two_layer(**pt)
|
| 61 |
+
describe("w (reference)", w_ref)
|
| 62 |
+
describe("u' (reference)", up_ref)
|
| 63 |
+
|
| 64 |
+
# Expect substantial wave amplitude in the lee (x > 0) for the trapped case.
|
| 65 |
+
# Find amplitude in z ~ 1-2 km and x ~ 5-20 km.
|
| 66 |
+
xi = (x >= 5000) & (x <= 20000)
|
| 67 |
+
zi = (z >= 500) & (z <= 2000)
|
| 68 |
+
lee_rms = np.sqrt((w_ref[np.ix_(zi, xi)] ** 2).mean())
|
| 69 |
+
print(f" lee-wave rms(1-2 km, 5-20 km) = {lee_rms:.3f} m/s")
|
| 70 |
+
assert lee_rms > 0.05, "Trapped-wave case showed suspiciously weak lee waves."
|
| 71 |
+
|
| 72 |
+
# ---- Rust vs Python, if Rust is available
|
| 73 |
+
try:
|
| 74 |
+
from mountain_waves import _core # type: ignore
|
| 75 |
+
has_rust = True
|
| 76 |
+
except ImportError:
|
| 77 |
+
has_rust = False
|
| 78 |
+
|
| 79 |
+
if has_rust:
|
| 80 |
+
print("\n[3] Rust vs. Python two-layer (trapped case):")
|
| 81 |
+
_, _, w_rust, up_rust = _core.compute_two_layer(**pt)
|
| 82 |
+
err_w = np.max(np.abs(w_rust - w_ref))
|
| 83 |
+
err_u = np.max(np.abs(up_rust - up_ref))
|
| 84 |
+
print(f" max|w_rust - w_python| = {err_w:.2e}")
|
| 85 |
+
print(f" max|u'_rust - u'_python| = {err_u:.2e}")
|
| 86 |
+
assert err_w < 1e-6, f"Rust/Python w disagreement too large: {err_w}"
|
| 87 |
+
assert err_u < 1e-6, f"Rust/Python u' disagreement too large: {err_u}"
|
| 88 |
+
else:
|
| 89 |
+
print("\n[3] (skipped — Rust extension not built)")
|
| 90 |
+
|
| 91 |
+
# ---- Profile solver: should approximate two-layer as profile resolution → ∞
|
| 92 |
+
print("\n[4] Profile solver — trapped-wave profile:")
|
| 93 |
+
zs = default_profile_heights(10.0, 17)
|
| 94 |
+
us = np.full_like(zs, 20.0) # constant wind
|
| 95 |
+
# Construct theta so that N^2/u^2 approximates L_lower/L_upper below/above 3.5 km.
|
| 96 |
+
# L_lower^2 = N^2/u^2 - (1/u) d2u/dz2, constant u ⇒ second term 0.
|
| 97 |
+
# Want L^2_lower = 10e-4^2 -> N^2 = L^2 * u^2.
|
| 98 |
+
N2_lower = (10e-4) ** 2 * 20.0 ** 2
|
| 99 |
+
N2_upper = (4e-4) ** 2 * 20.0 ** 2
|
| 100 |
+
thetas = np.empty_like(zs)
|
| 101 |
+
thetas[0] = 290.0
|
| 102 |
+
for i in range(1, zs.size):
|
| 103 |
+
dz = zs[i] - zs[i - 1]
|
| 104 |
+
n2 = N2_lower if zs[i] <= 3500.0 else N2_upper
|
| 105 |
+
thetas[i] = thetas[i - 1] + thetas[i - 1] * n2 / 9.80665 * dz
|
| 106 |
+
|
| 107 |
+
prof_out = ref.compute_from_profile(
|
| 108 |
+
zs, us, thetas,
|
| 109 |
+
a=2500.0, ho=500.0, xdom=40000.0, zdom=10000.0,
|
| 110 |
+
mink=0.0, maxk=30.0 / 2500.0, npts=100,
|
| 111 |
+
)
|
| 112 |
+
# compute_from_profile returns (x, z, w) or (x, z, w, u_prime) depending
|
| 113 |
+
# on the port-era. Accept either to keep this script robust.
|
| 114 |
+
x, z, w_prof = prof_out[0], prof_out[1], prof_out[2]
|
| 115 |
+
describe("w (profile solver)", w_prof)
|
| 116 |
+
# Compare RMS to two-layer case — should be broadly similar.
|
| 117 |
+
rms_prof = np.sqrt(np.mean(w_prof ** 2))
|
| 118 |
+
rms_ref = np.sqrt(np.mean(w_ref ** 2))
|
| 119 |
+
ratio = rms_prof / rms_ref
|
| 120 |
+
print(f" rms(profile) / rms(2-layer) = {ratio:.3f}")
|
| 121 |
+
assert 0.3 < ratio < 3.0, "Profile solver RMS differs unreasonably from two-layer reference."
|
| 122 |
+
|
| 123 |
+
# ---- Critical-level handling: U crosses zero mid-column.
|
| 124 |
+
#
|
| 125 |
+
# Linear Scorer/Taylor-Goldstein is singular at U = 0, so we clamp |U|
|
| 126 |
+
# at U_FLOOR_SCORER (~0.5 m/s) in both Rust and Python scorer helpers
|
| 127 |
+
# and surface the detected zero-crossings separately. This test makes
|
| 128 |
+
# sure (a) the clamp keeps l^2 finite, (b) Rust and Python agree on
|
| 129 |
+
# the clamped profile, and (c) the `critical_levels` helper correctly
|
| 130 |
+
# locates the zero crossing.
|
| 131 |
+
print("\n[5] Critical-level handling (wind reversal at z≈5 km):")
|
| 132 |
+
zs_c = np.linspace(0.0, 10000.0, 41)
|
| 133 |
+
us_c = np.linspace(10.0, -10.0, 41) # zero at index 20, z = 5000 m
|
| 134 |
+
thetas_c = 290.0 + 0.004 * zs_c # mildly stable
|
| 135 |
+
l2_py = ref.scorer_from_profile(zs_c, us_c, thetas_c)
|
| 136 |
+
assert np.all(np.isfinite(l2_py)), "Python Scorer went non-finite across U=0"
|
| 137 |
+
crits = ref.critical_levels(zs_c, us_c)
|
| 138 |
+
print(f" detected critical levels: {[round(h, 1) for h in crits]} m")
|
| 139 |
+
assert len(crits) == 1 and abs(crits[0] - 5000.0) < 1e-6, crits
|
| 140 |
+
# Also make sure the full solver survives a wind-reversal column. Rust
|
| 141 |
+
# and Python both have to clamp |U| internally for scorer_from_profile;
|
| 142 |
+
# running compute_from_profile exercises that path end-to-end.
|
| 143 |
+
out_py = ref.compute_from_profile(
|
| 144 |
+
zs_c, us_c, thetas_c,
|
| 145 |
+
a=2500.0, ho=500.0, xdom=40000.0, zdom=10000.0,
|
| 146 |
+
mink=0.0, maxk=30.0 / 2500.0, npts=80,
|
| 147 |
+
)
|
| 148 |
+
w_py = out_py[2]
|
| 149 |
+
assert np.all(np.isfinite(w_py)), "Python solver went non-finite across U=0"
|
| 150 |
+
if has_rust:
|
| 151 |
+
out_rust = _core.compute_from_profile(
|
| 152 |
+
zs_c, us_c, thetas_c,
|
| 153 |
+
a=2500.0, ho=500.0, xdom=40000.0, zdom=10000.0,
|
| 154 |
+
mink=0.0, maxk=30.0 / 2500.0, npts=80,
|
| 155 |
+
)
|
| 156 |
+
w_rust_c = out_rust[2]
|
| 157 |
+
assert np.all(np.isfinite(w_rust_c)), "Rust solver went non-finite across U=0"
|
| 158 |
+
err = np.max(np.abs(w_rust_c - w_py))
|
| 159 |
+
print(f" wind-reversal max|w_rust - w_python| = {err:.2e}")
|
| 160 |
+
assert err < 1e-4, f"Rust/Python wind-reversal disagreement: {err}"
|
| 161 |
+
|
| 162 |
+
# ---- Streamline tracer: the first line should sweep over the mountain crest.
|
| 163 |
+
print("\n[6] Streamline tracer:")
|
| 164 |
+
lines = streamlines(x, z, 20.0, w_ref, num=10)
|
| 165 |
+
assert len(lines) == 10
|
| 166 |
+
xs0, ys0 = lines[0]
|
| 167 |
+
assert xs0[0] < 0 < xs0[-1]
|
| 168 |
+
print(f" first streamline: x ∈ [{xs0[0]:.0f}, {xs0[-1]:.0f}] m, y ∈ [{ys0.min():.1f}, {ys0.max():.1f}] m")
|
| 169 |
+
|
| 170 |
+
print("\nAll checks passed.")
|
| 171 |
+
return 0
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
if __name__ == "__main__":
|
| 175 |
+
raise SystemExit(main())
|