snesbitt commited on
Commit
7c3bfa9
·
0 Parent(s):

Mountain Waves — deploy to Hugging Face Space

Browse files

Rust + Python/Dash port of Hart (1995) 2-D mountain-wave model.
Full development history at github.com/swnesbitt/mountainwaves-bannonhart.

.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())