multigrid-py 3.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- multigrid_py-3.0.1/PKG-INFO +11 -0
- multigrid_py-3.0.1/README.md +143 -0
- multigrid_py-3.0.1/pyproject.toml +50 -0
- multigrid_py-3.0.1/setup.cfg +4 -0
- multigrid_py-3.0.1/setup.py +15 -0
- multigrid_py-3.0.1/src/multigrid/__init__.py +30 -0
- multigrid_py-3.0.1/src/multigrid/_warnings.py +13 -0
- multigrid_py-3.0.1/src/multigrid/cli.py +72 -0
- multigrid_py-3.0.1/src/multigrid/constants.py +20 -0
- multigrid_py-3.0.1/src/multigrid/core.py +151 -0
- multigrid_py-3.0.1/src/multigrid/grid_calculator.py +206 -0
- multigrid_py-3.0.1/src/multigrid/grids.py +106 -0
- multigrid_py-3.0.1/src/multigrid/hitran.py +91 -0
- multigrid_py-3.0.1/src/multigrid/line_shapes.py +48 -0
- multigrid_py-3.0.1/src/multigrid/molecules.py +11 -0
- multigrid_py-3.0.1/src/multigrid/plotting.py +50 -0
- multigrid_py-3.0.1/src/multigrid/profiles.py +24 -0
- multigrid_py-3.0.1/src/multigrid/pt_table.py +42 -0
- multigrid_py-3.0.1/src/multigrid/tips.py +45 -0
- multigrid_py-3.0.1/src/multigrid/wing_corrections.py +28 -0
- multigrid_py-3.0.1/src/multigrid_py.egg-info/PKG-INFO +11 -0
- multigrid_py-3.0.1/src/multigrid_py.egg-info/SOURCES.txt +24 -0
- multigrid_py-3.0.1/src/multigrid_py.egg-info/dependency_links.txt +1 -0
- multigrid_py-3.0.1/src/multigrid_py.egg-info/entry_points.txt +3 -0
- multigrid_py-3.0.1/src/multigrid_py.egg-info/requires.txt +8 -0
- multigrid_py-3.0.1/src/multigrid_py.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: multigrid_py
|
|
3
|
+
Version: 3.0.1
|
|
4
|
+
Summary: Python port of the Fortran MARFA line-by-line molecular absorption code
|
|
5
|
+
Home-page: https://github.com/os1832000-png/MultiGrid_Py
|
|
6
|
+
License: MIT
|
|
7
|
+
Description: UNKNOWN
|
|
8
|
+
Platform: UNKNOWN
|
|
9
|
+
Requires-Python: >=3.6
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Provides-Extra: plot
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# MultiGrid_Py
|
|
2
|
+
|
|
3
|
+
**Python port of the Fortran MARFA line-by-line molecular absorption code.**
|
|
4
|
+
|
|
5
|
+
Compute monochromatic absorption cross-sections and volume absorption
|
|
6
|
+
coefficients for planetary atmospheres, using HITRAN spectral line data.
|
|
7
|
+
|
|
8
|
+
Based on the original Fortran code by **Mikhail Razumovskiy**, **Boris Fomin**,
|
|
9
|
+
and **Denis Astanin**.
|
|
10
|
+
|
|
11
|
+
Reference: [MARFA: An Effective Line-by-line Tool for Calculating Molecular
|
|
12
|
+
Absorption in Planetary Atmospheres](https://arxiv.org/abs/2411.03418)
|
|
13
|
+
(arXiv:2411.03418, 2024).
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- **Line-by-line (LBL)** monochromatic absorption from HITRAN `.par` files
|
|
20
|
+
- **Voigt, Lorentz, Doppler** line shapes (Voigt via Humlicek / Faddeeva)
|
|
21
|
+
- **Temperature-dependent intensities** using TIPS partition sums
|
|
22
|
+
- **Pressure and Doppler broadening** with HITRAN air/self parameters
|
|
23
|
+
- **Wing corrections** (Tonkov, Perrin) for sub-Lorentzian continua
|
|
24
|
+
- **Multi-resolution stencil machinery** (`GridState`, `LineGridCalc`)
|
|
25
|
+
for future PT-table work
|
|
26
|
+
- Clean, tested, installable package -- 11 passing pytest tests
|
|
27
|
+
- No import-time side effects: lazy matplotlib, no global warnings mutation
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
Requires Python 3.6+, NumPy, and SciPy.
|
|
34
|
+
|
|
35
|
+
From source (development):
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
git clone https://github.com/os1832000-png/MultiGrid_Py.git
|
|
39
|
+
cd MultiGrid_Py
|
|
40
|
+
pip install --user -e .
|
|
41
|
+
With optional plotting support:
|
|
42
|
+
|
|
43
|
+
bash
|
|
44
|
+
pip install --user -e ".[plot]"
|
|
45
|
+
Quick start
|
|
46
|
+
python
|
|
47
|
+
import numpy as np
|
|
48
|
+
from multigrid import MARFA, HITRANReader
|
|
49
|
+
|
|
50
|
+
# 1. Load CO2 lines from a HITRAN .par file
|
|
51
|
+
lines = HITRANReader.read_par_file(
|
|
52
|
+
"CO2.par",
|
|
53
|
+
molecule_id=2,
|
|
54
|
+
wavenumber_min=2300.0,
|
|
55
|
+
wavenumber_max=2400.0,
|
|
56
|
+
)
|
|
57
|
+
print(f"loaded {len(lines)} lines")
|
|
58
|
+
|
|
59
|
+
# 2. Build a wavenumber grid [cm^-1]
|
|
60
|
+
nu = np.arange(2300.0, 2400.0, 0.01)
|
|
61
|
+
|
|
62
|
+
# 3. Compute volume absorption coefficient [cm^-1]
|
|
63
|
+
alpha = MARFA().calculate_absorption_coefficient(
|
|
64
|
+
nu, lines,
|
|
65
|
+
T=296.0, # temperature [K]
|
|
66
|
+
P=1.0, # pressure [atm]
|
|
67
|
+
mole_fraction=400e-6,
|
|
68
|
+
line_cutoff_cm=25.0,
|
|
69
|
+
wing_correction="none", # 'none' | 'tonkov' | 'perrin'
|
|
70
|
+
)
|
|
71
|
+
print(f"max alpha = {alpha.max():.4e} cm^-1")
|
|
72
|
+
For a runnable script see examples/co2_basic.py:
|
|
73
|
+
|
|
74
|
+
bash
|
|
75
|
+
python3 examples/co2_basic.py /path/to/CO2.par
|
|
76
|
+
Plotting (optional)
|
|
77
|
+
python
|
|
78
|
+
from multigrid.plotting import plot_spectrum
|
|
79
|
+
|
|
80
|
+
plot_spectrum(
|
|
81
|
+
nu, alpha,
|
|
82
|
+
title="CO2 2300-2400 cm^-1",
|
|
83
|
+
filename="co2_spectrum.png",
|
|
84
|
+
)
|
|
85
|
+
matplotlib is imported lazily, inside plot_spectrum. A plain
|
|
86
|
+
import multigrid never loads matplotlib.
|
|
87
|
+
|
|
88
|
+
Command line
|
|
89
|
+
bash
|
|
90
|
+
python3 -m multigrid.cli CO2.par --mol 2 --nu-min 2300 --nu-max 2400 \
|
|
91
|
+
--T 296 --P 1.0 --vmr 400e-6 -o co2_spectrum.npz
|
|
92
|
+
The output .npz file contains nu and alpha arrays.
|
|
93
|
+
|
|
94
|
+
Get help with:
|
|
95
|
+
|
|
96
|
+
bash
|
|
97
|
+
python3 -m multigrid.cli --help
|
|
98
|
+
Public API
|
|
99
|
+
Class / function Purpose
|
|
100
|
+
MARFA Main calculation engine
|
|
101
|
+
HITRANReader, SpectralLine HITRAN .par file reader
|
|
102
|
+
TIPS Total Internal Partition Sums
|
|
103
|
+
LineShapes Voigt, Lorentz, Doppler profiles
|
|
104
|
+
WingCorrections Sub-Lorentzian chi factors (Tonkov, Perrin)
|
|
105
|
+
GridState, LineGridCalc Multi-resolution stencil machinery
|
|
106
|
+
AtmosphericProfile Simple vertical profile container
|
|
107
|
+
PTTableGenerator Pre-computed P-T absorption tables
|
|
108
|
+
plotting.plot_spectrum Optional matplotlib helper
|
|
109
|
+
Tests
|
|
110
|
+
bash
|
|
111
|
+
pip install --user pytest
|
|
112
|
+
python3 -m pytest tests/ -v
|
|
113
|
+
11 tests covering line-shape normalisation, HITRAN parsing, the core
|
|
114
|
+
engine, and import-time safety guarantees.
|
|
115
|
+
|
|
116
|
+
Notes and limitations
|
|
117
|
+
TIPS partition sums use a simplified Q_ref * (T/296)^1.5 model,
|
|
118
|
+
not the full Gamache et al. (2017) tables. Adequate for qualitative work.
|
|
119
|
+
|
|
120
|
+
Wing corrections are simple parametrisations; not validated against
|
|
121
|
+
measured continua.
|
|
122
|
+
|
|
123
|
+
Line mixing is not implemented.
|
|
124
|
+
|
|
125
|
+
Continuum absorption (H2O, CO2, N2) is not implemented.
|
|
126
|
+
|
|
127
|
+
The multi-grid stencil classes (GridState, LineGridCalc) are present
|
|
128
|
+
but the working spectral path is the flat-grid Voigt sum.
|
|
129
|
+
|
|
130
|
+
License
|
|
131
|
+
MIT. See LICENSE.
|
|
132
|
+
|
|
133
|
+
Citation
|
|
134
|
+
If you use this code in a publication, please cite the original MARFA paper:
|
|
135
|
+
|
|
136
|
+
Razumovskiy, M., Fomin, B., Astanin, D.
|
|
137
|
+
MARFA: An Effective Line-by-line Tool for Calculating Molecular Absorption
|
|
138
|
+
in Planetary Atmospheres. arXiv:2411.03418 (2024).
|
|
139
|
+
|
|
140
|
+
Acknowledgements
|
|
141
|
+
This is an independent Python port based on the Fortran MARFA code by
|
|
142
|
+
Mikhail Razumovskiy, Boris Fomin, and Denis Astanin. All scientific credit
|
|
143
|
+
for the underlying algorithm belongs to them.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=40.8.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "multigrid-py"
|
|
7
|
+
version = "3.0.1"
|
|
8
|
+
description = "MARFA: Molecular atmospheric Absorption with Rapid and Flexible Analysis"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.6"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Osama" },
|
|
14
|
+
{ name = "Mikhail Razumovskiy" },
|
|
15
|
+
{ name = "Boris Fomin" },
|
|
16
|
+
{ name = "Denis Astanin" },
|
|
17
|
+
]
|
|
18
|
+
keywords = ["atmospheric", "spectroscopy", "absorption", "HITRAN", "radiative-transfer"]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Intended Audience :: Science/Research",
|
|
24
|
+
"Topic :: Scientific/Engineering :: Atmospheric Science",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"numpy>=1.16",
|
|
28
|
+
"scipy>=1.2",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
plot = ["matplotlib>=3.0"]
|
|
33
|
+
dev = ["pytest"]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/yourname/MARFA_PY_SIMPLE"
|
|
37
|
+
Reference = "https://arxiv.org/abs/2411.03418"
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
multigrid = "multigrid.cli:main"
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.packages.find]
|
|
43
|
+
where = ["src"]
|
|
44
|
+
include = ["multigrid", "multigrid.*"]
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.package-data]
|
|
47
|
+
multigrid = ["data/**/*"]
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name="multigrid_py",
|
|
5
|
+
version="3.0.1",
|
|
6
|
+
description="Python port of the Fortran MARFA line-by-line molecular absorption code",
|
|
7
|
+
url="https://github.com/os1832000-png/MultiGrid_Py",
|
|
8
|
+
license="MIT",
|
|
9
|
+
package_dir={"": "src"},
|
|
10
|
+
packages=find_packages(where="src", include=["multigrid", "multigrid.*"]),
|
|
11
|
+
python_requires=">=3.6",
|
|
12
|
+
install_requires=["numpy>=1.16", "scipy>=1.2"],
|
|
13
|
+
extras_require={"plot": ["matplotlib>=3.0"], "dev": ["pytest"]},
|
|
14
|
+
entry_points={"console_scripts": ["multigrid=multigrid.cli:main"]},
|
|
15
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""MARFA: Molecular atmospheric Absorption with Rapid and Flexible Analysis."""
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
# Library-friendly: don't configure the root logger; just swallow our own
|
|
5
|
+
# records if the user hasn't set up logging.
|
|
6
|
+
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
7
|
+
|
|
8
|
+
from .constants import Constants
|
|
9
|
+
from .molecules import MOLECULE_NAMES, MOLECULAR_MASSES
|
|
10
|
+
from .line_shapes import LineShapes
|
|
11
|
+
from .wing_corrections import WingCorrections
|
|
12
|
+
from .tips import TIPS
|
|
13
|
+
from .hitran import HITRANReader, SpectralLine
|
|
14
|
+
from .grids import GridState
|
|
15
|
+
from .grid_calculator import LineGridCalc
|
|
16
|
+
from .core import MARFA
|
|
17
|
+
from .profiles import AtmosphericProfile
|
|
18
|
+
from .pt_table import PTTableGenerator
|
|
19
|
+
from ._warnings import MarfaWarning, LineOutOfRangeWarning, InputFileWarning
|
|
20
|
+
|
|
21
|
+
__version__ = "3.0.1"
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Constants",
|
|
24
|
+
"MOLECULE_NAMES", "MOLECULAR_MASSES",
|
|
25
|
+
"LineShapes", "WingCorrections", "TIPS",
|
|
26
|
+
"HITRANReader", "SpectralLine",
|
|
27
|
+
"GridState", "LineGridCalc",
|
|
28
|
+
"MARFA", "AtmosphericProfile", "PTTableGenerator",
|
|
29
|
+
"MarfaWarning", "LineOutOfRangeWarning", "InputFileWarning",
|
|
30
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Custom warning classes emitted by MARFA."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MarfaWarning(UserWarning):
|
|
5
|
+
"""Base class for all MARFA warnings."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LineOutOfRangeWarning(MarfaWarning):
|
|
9
|
+
"""Requested spectral range contains no lines from the database."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InputFileWarning(MarfaWarning):
|
|
13
|
+
"""A HITRAN input file is missing or empty."""
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Command-line interface for MARFA."""
|
|
2
|
+
import argparse
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from .core import MARFA
|
|
9
|
+
from .hitran import HITRANReader
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv=None) -> int:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
prog="marfa",
|
|
15
|
+
description="MARFA: LBL molecular absorption calculator",
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument("par_file", help="HITRAN .par file")
|
|
18
|
+
parser.add_argument("--mol", type=int, required=True,
|
|
19
|
+
help="HITRAN molecule ID (1-12)")
|
|
20
|
+
parser.add_argument("--nu-min", type=float, default=0.0)
|
|
21
|
+
parser.add_argument("--nu-max", type=float, default=4000.0)
|
|
22
|
+
parser.add_argument("--dnu", type=float, default=0.05)
|
|
23
|
+
parser.add_argument("--T", type=float, default=296.0)
|
|
24
|
+
parser.add_argument("--P", type=float, default=1.0)
|
|
25
|
+
parser.add_argument("--vmr", type=float, default=400e-6)
|
|
26
|
+
parser.add_argument("--cutoff", type=float, default=25.0)
|
|
27
|
+
parser.add_argument("--wing",
|
|
28
|
+
choices=["none", "tonkov", "perrin"],
|
|
29
|
+
default="none")
|
|
30
|
+
parser.add_argument("-o", "--output", default=None,
|
|
31
|
+
help="Save spectrum as .npz")
|
|
32
|
+
parser.add_argument("-v", "--verbose", action="store_true",
|
|
33
|
+
help="Enable debug logging")
|
|
34
|
+
args = parser.parse_args(argv)
|
|
35
|
+
|
|
36
|
+
logging.basicConfig(
|
|
37
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
38
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
lines = HITRANReader.read_par_file(
|
|
42
|
+
args.par_file,
|
|
43
|
+
molecule_id=args.mol,
|
|
44
|
+
wavenumber_min=args.nu_min,
|
|
45
|
+
wavenumber_max=args.nu_max,
|
|
46
|
+
)
|
|
47
|
+
if not lines:
|
|
48
|
+
print(f"No lines loaded from {args.par_file}", file=sys.stderr)
|
|
49
|
+
return 1
|
|
50
|
+
|
|
51
|
+
nu = np.arange(args.nu_min, args.nu_max + args.dnu, args.dnu)
|
|
52
|
+
marfa = MARFA()
|
|
53
|
+
|
|
54
|
+
print(f"Computing alpha(nu) over {len(nu)} points "
|
|
55
|
+
f"(T={args.T} K, P={args.P} atm, VMR={args.vmr:.3e}) ...")
|
|
56
|
+
alpha = marfa.calculate_absorption_coefficient(
|
|
57
|
+
nu, lines, args.T, args.P, args.vmr,
|
|
58
|
+
line_cutoff_cm=args.cutoff,
|
|
59
|
+
wing_correction=args.wing,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
print(f"Max alpha = {alpha.max():.4e} cm^-1")
|
|
63
|
+
|
|
64
|
+
if args.output:
|
|
65
|
+
np.savez(args.output, nu=nu, alpha=alpha)
|
|
66
|
+
print(f"Saved: {args.output}")
|
|
67
|
+
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
sys.exit(main())
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Physical constants matching Fortran MARFA."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Constants:
|
|
6
|
+
"""Physical constants - matching Fortran MARFA exactly."""
|
|
7
|
+
|
|
8
|
+
c = 2.99792458e10 # speed of light [cm/s]
|
|
9
|
+
h = 6.62606957e-27 # Planck constant [erg·s]
|
|
10
|
+
k_B = 1.380649e-16 # Boltzmann constant [erg/K]
|
|
11
|
+
k_B_SI = 1.380649e-23 # Boltzmann constant [J/K]
|
|
12
|
+
Na = 6.02214129e23 # Avogadro constant [1/mol]
|
|
13
|
+
R = 8.314472e7 # Gas constant [erg/(mol·K)]
|
|
14
|
+
c2 = 1.4388 # second radiation constant [cm·K]
|
|
15
|
+
T_ref = 296.0 # reference temperature [K]
|
|
16
|
+
P_ref = 1.0 # reference pressure [atm]
|
|
17
|
+
atm_to_pa = 101325.0 # 1 atm = 101325 Pa
|
|
18
|
+
sqrt_pi = np.sqrt(np.pi)
|
|
19
|
+
sqrt_ln2 = np.sqrt(np.log(2.0))
|
|
20
|
+
ln2_pi = np.log(2.0) / np.pi
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Core MARFA calculation engine."""
|
|
2
|
+
import logging
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from .constants import Constants
|
|
8
|
+
from .molecules import MOLECULAR_MASSES
|
|
9
|
+
from .tips import TIPS
|
|
10
|
+
from .line_shapes import LineShapes
|
|
11
|
+
from .wing_corrections import WingCorrections
|
|
12
|
+
from .hitran import SpectralLine
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MARFA:
|
|
18
|
+
"""
|
|
19
|
+
Main MARFA calculation engine.
|
|
20
|
+
|
|
21
|
+
Wraps the LBL engine with temperature-dependent intensities,
|
|
22
|
+
pressure broadening, and chi-factors.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, *, verbose: bool = True):
|
|
26
|
+
self.const = Constants()
|
|
27
|
+
self.tips = TIPS()
|
|
28
|
+
self.verbose = verbose
|
|
29
|
+
|
|
30
|
+
# ------------------------------------------------------------------
|
|
31
|
+
# line parameter helpers
|
|
32
|
+
# ------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def calculate_doppler_width(self, nu0: float, T: float, mol_id: int) -> float:
|
|
35
|
+
M = MOLECULAR_MASSES.get(mol_id, 44.0)
|
|
36
|
+
m = M / self.const.Na
|
|
37
|
+
return nu0 * np.sqrt(2.0 * self.const.k_B * T * np.log(2.0) /
|
|
38
|
+
(m * self.const.c ** 2))
|
|
39
|
+
|
|
40
|
+
def calculate_lorentz_width(self,
|
|
41
|
+
line: SpectralLine,
|
|
42
|
+
P: float,
|
|
43
|
+
P_self: float,
|
|
44
|
+
T: float) -> float:
|
|
45
|
+
pressure_term = line.gamma_air * (P - P_self) + line.gamma_self * P_self
|
|
46
|
+
temperature_factor = (self.const.T_ref / T) ** line.n_air
|
|
47
|
+
return pressure_term * temperature_factor
|
|
48
|
+
|
|
49
|
+
def calculate_line_intensity_at_T(self,
|
|
50
|
+
line: SpectralLine,
|
|
51
|
+
T: float,
|
|
52
|
+
P: float) -> float:
|
|
53
|
+
nu_shifted = line.nu + line.delta_air * P
|
|
54
|
+
Q_T = self.tips.get_Q(line.mol_id, T)
|
|
55
|
+
Q_ref = self.tips.get_Q(line.mol_id, self.const.T_ref)
|
|
56
|
+
partition_ratio = Q_ref / Q_T if Q_T > 0 else 1.0
|
|
57
|
+
|
|
58
|
+
boltzmann_ratio = (np.exp(-self.const.c2 * line.E_low / T) /
|
|
59
|
+
np.exp(-self.const.c2 * line.E_low / self.const.T_ref))
|
|
60
|
+
|
|
61
|
+
c2_nu_T = self.const.c2 * nu_shifted / T
|
|
62
|
+
c2_nu_r = self.const.c2 * nu_shifted / self.const.T_ref
|
|
63
|
+
emission_ratio = (1.0 if c2_nu_T > 50 else
|
|
64
|
+
(1.0 - np.exp(-c2_nu_T)) /
|
|
65
|
+
(1.0 - np.exp(-c2_nu_r)))
|
|
66
|
+
|
|
67
|
+
return line.S * partition_ratio * boltzmann_ratio * emission_ratio
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
# absorption cross-section / coefficient
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def calculate_absorption_cross_section(
|
|
74
|
+
self,
|
|
75
|
+
nu_grid: np.ndarray,
|
|
76
|
+
lines: List[SpectralLine],
|
|
77
|
+
T: float,
|
|
78
|
+
P: float,
|
|
79
|
+
self_broadening_fraction: float = 0.0,
|
|
80
|
+
line_cutoff_cm: float = 25.0,
|
|
81
|
+
wing_correction: str = "none",
|
|
82
|
+
) -> np.ndarray:
|
|
83
|
+
"""
|
|
84
|
+
Monochromatic absorption cross-section sigma(nu) [cm2/molecule].
|
|
85
|
+
|
|
86
|
+
Uses a direct line-by-line Voigt summation on ``nu_grid``. Integral
|
|
87
|
+
of sigma over all nu equals the line intensity S(T).
|
|
88
|
+
"""
|
|
89
|
+
if not lines:
|
|
90
|
+
return np.zeros_like(nu_grid)
|
|
91
|
+
|
|
92
|
+
sigma = np.zeros_like(nu_grid, dtype=float)
|
|
93
|
+
P_self = P * self_broadening_fraction
|
|
94
|
+
mol_id = lines[0].mol_id
|
|
95
|
+
|
|
96
|
+
chi_func = {
|
|
97
|
+
"tonkov": WingCorrections.tonkov_chi,
|
|
98
|
+
"perrin": WingCorrections.perrin_chi,
|
|
99
|
+
}.get(wing_correction, WingCorrections.no_correction)
|
|
100
|
+
|
|
101
|
+
for line in lines:
|
|
102
|
+
S_T = self.calculate_line_intensity_at_T(line, T, P)
|
|
103
|
+
if S_T <= 0.0:
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
gamma_D = self.calculate_doppler_width(line.nu, T, mol_id)
|
|
107
|
+
gamma_L = self.calculate_lorentz_width(line, P, P_self, T)
|
|
108
|
+
if max(gamma_D, gamma_L) <= 0.0:
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
mask = np.abs(nu_grid - line.nu) <= line_cutoff_cm
|
|
112
|
+
if not np.any(mask):
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
x = nu_grid[mask] - line.nu
|
|
116
|
+
profile = LineShapes.voigt_normalized(x, gamma_L, gamma_D)
|
|
117
|
+
|
|
118
|
+
if wing_correction != "none":
|
|
119
|
+
profile = profile * chi_func(x, line.nu)
|
|
120
|
+
|
|
121
|
+
sigma[mask] += S_T * profile
|
|
122
|
+
|
|
123
|
+
return sigma
|
|
124
|
+
|
|
125
|
+
def calculate_absorption_coefficient(
|
|
126
|
+
self,
|
|
127
|
+
nu_grid: np.ndarray,
|
|
128
|
+
lines: List[SpectralLine],
|
|
129
|
+
T: float,
|
|
130
|
+
P: float,
|
|
131
|
+
mole_fraction: float,
|
|
132
|
+
self_broadening_fraction: float = 0.0,
|
|
133
|
+
line_cutoff_cm: float = 25.0,
|
|
134
|
+
wing_correction: str = "none",
|
|
135
|
+
) -> np.ndarray:
|
|
136
|
+
"""
|
|
137
|
+
Monochromatic volume absorption coefficient alpha(nu) [cm-1].
|
|
138
|
+
|
|
139
|
+
alpha(nu) = sigma(nu) * n_species
|
|
140
|
+
where n_species = n_total * mole_fraction [molecules / cm3].
|
|
141
|
+
"""
|
|
142
|
+
sigma = self.calculate_absorption_cross_section(
|
|
143
|
+
nu_grid, lines, T, P,
|
|
144
|
+
self_broadening_fraction=self_broadening_fraction,
|
|
145
|
+
line_cutoff_cm=line_cutoff_cm,
|
|
146
|
+
wing_correction=wing_correction,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Total air number density [molecules / cm3]
|
|
150
|
+
n_total = (P * self.const.atm_to_pa) / (self.const.k_B_SI * T) / 1e6
|
|
151
|
+
return sigma * n_total * mole_fraction
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""LineGridCalc: Python port of Fortran LineGridCalc."""
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
from .grids import GridState
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LineGridCalc:
|
|
11
|
+
"""
|
|
12
|
+
Python port of the Fortran LineGridCalc module.
|
|
13
|
+
|
|
14
|
+
Implements ``leftLBL_full``, ``centerLBL_full`` and ``rightLBL_full``,
|
|
15
|
+
which route each spectral line's shape-function sample to the correct
|
|
16
|
+
multi-resolution stencil cell, matching the Fortran GOTO-cascade.
|
|
17
|
+
|
|
18
|
+
``FSHAPE`` plays the role of Fortran's ``procedure(shape)`` pointer: it
|
|
19
|
+
receives a single float offset and returns a float lineshape value.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, gs: GridState):
|
|
23
|
+
self.gs = gs
|
|
24
|
+
|
|
25
|
+
# ------------------------------------------------------------------
|
|
26
|
+
# leftLBL_full
|
|
27
|
+
# ------------------------------------------------------------------
|
|
28
|
+
def leftLBL_full(self,
|
|
29
|
+
FREQ: float,
|
|
30
|
+
UL: float,
|
|
31
|
+
FSHAPE: Callable[[float], float],
|
|
32
|
+
EPS: float) -> None:
|
|
33
|
+
gs = self.gs
|
|
34
|
+
UU = UL - FREQ
|
|
35
|
+
|
|
36
|
+
if UU >= 0.0:
|
|
37
|
+
return
|
|
38
|
+
if -UU > gs.cutOff:
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
FF = float(FSHAPE(UU))
|
|
42
|
+
if FF < EPS:
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
gs.RK[1] += FF
|
|
46
|
+
|
|
47
|
+
if -UU < gs.H0:
|
|
48
|
+
XXX = gs.H0
|
|
49
|
+
for I in range(2, gs.NT0 + 1):
|
|
50
|
+
gs.RK0P[I] += FF
|
|
51
|
+
FF = float(FSHAPE(UU - XXX - gs.H1))
|
|
52
|
+
gs.RK0[I] += FF
|
|
53
|
+
XXX += gs.H0
|
|
54
|
+
FF = float(FSHAPE(UU - XXX))
|
|
55
|
+
gs.RK0L[I] += FF
|
|
56
|
+
if FF < EPS:
|
|
57
|
+
return
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
gs.RK0P[1] += FF
|
|
61
|
+
FF_c = float(FSHAPE(UU - gs.H1))
|
|
62
|
+
gs.RK0[1] += FF_c
|
|
63
|
+
FF = float(FSHAPE(UU - gs.H0))
|
|
64
|
+
gs.RK0L[1] += FF
|
|
65
|
+
|
|
66
|
+
if -UU < gs.H1:
|
|
67
|
+
self._rev_cascade_full(UU, FF, FSHAPE, EPS, 1)
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
gs.RK1P[1] += FF
|
|
71
|
+
FF_c = float(FSHAPE(UU - gs.H2))
|
|
72
|
+
gs.RK1[1] += FF_c
|
|
73
|
+
FF = float(FSHAPE(UU - gs.H1))
|
|
74
|
+
gs.RK1L[1] += FF
|
|
75
|
+
if FF < EPS:
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
if -UU < gs.H2:
|
|
79
|
+
self._rev_cascade_full(UU, FF, FSHAPE, EPS, 2)
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
gs.RK2P[1] += FF
|
|
83
|
+
FF_c = float(FSHAPE(UU - gs.H3))
|
|
84
|
+
gs.RK2[1] += FF_c
|
|
85
|
+
FF = float(FSHAPE(UU - gs.H2))
|
|
86
|
+
gs.RK2L[1] += FF
|
|
87
|
+
if FF < EPS:
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
if -UU < gs.H3:
|
|
91
|
+
self._rev_cascade_full(UU, FF, FSHAPE, EPS, 3)
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
gs.RK3P[1] += FF
|
|
95
|
+
FF_c = float(FSHAPE(UU - gs.H4))
|
|
96
|
+
gs.RK3[1] += FF_c
|
|
97
|
+
FF = float(FSHAPE(UU - gs.H3))
|
|
98
|
+
gs.RK3L[1] += FF
|
|
99
|
+
if FF < EPS:
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
if -UU < gs.H4:
|
|
103
|
+
self._rev_cascade_full(UU, FF, FSHAPE, EPS, 4)
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
gs.RK4P[1] += FF
|
|
107
|
+
FF_c = float(FSHAPE(UU - gs.H5))
|
|
108
|
+
gs.RK4[1] += FF_c
|
|
109
|
+
FF = float(FSHAPE(UU - gs.H4))
|
|
110
|
+
gs.RK4L[1] += FF
|
|
111
|
+
if FF < EPS:
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
if -UU < gs.H5:
|
|
115
|
+
self._rev_cascade_full(UU, FF, FSHAPE, EPS, 5)
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
gs.RK5P[1] += FF
|
|
119
|
+
FF_c = float(FSHAPE(UU - gs.H6))
|
|
120
|
+
gs.RK5[1] += FF_c
|
|
121
|
+
FF = float(FSHAPE(UU - gs.H5))
|
|
122
|
+
gs.RK5L[1] += FF
|
|
123
|
+
if FF < EPS:
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
if -UU < gs.H6:
|
|
127
|
+
self._rev_cascade_full(UU, FF, FSHAPE, EPS, 6)
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
gs.RK6P[1] += FF
|
|
131
|
+
FF_c = float(FSHAPE(UU - gs.H7))
|
|
132
|
+
gs.RK6[1] += FF_c
|
|
133
|
+
FF = float(FSHAPE(UU - gs.H6))
|
|
134
|
+
gs.RK6L[1] += FF
|
|
135
|
+
if FF < EPS:
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
if -UU < gs.H7:
|
|
139
|
+
self._rev_cascade_full(UU, FF, FSHAPE, EPS, 7)
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
gs.RK7P[1] += FF
|
|
143
|
+
FF_c = float(FSHAPE(UU - gs.H8))
|
|
144
|
+
gs.RK7[1] += FF_c
|
|
145
|
+
FF = float(FSHAPE(UU - gs.H7))
|
|
146
|
+
gs.RK7L[1] += FF
|
|
147
|
+
if FF < EPS:
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
if -UU < gs.H8:
|
|
151
|
+
self._rev_cascade_full(UU, FF, FSHAPE, EPS, 8)
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
gs.RK8P[1] += FF
|
|
155
|
+
FF_c = float(FSHAPE(UU - gs.H9))
|
|
156
|
+
gs.RK8[1] += FF_c
|
|
157
|
+
FF = float(FSHAPE(UU - gs.H8))
|
|
158
|
+
gs.RK8L[1] += FF
|
|
159
|
+
if FF < EPS:
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
if -UU < gs.H9:
|
|
163
|
+
self._rev_cascade_full(UU, FF, FSHAPE, EPS, 9)
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
gs.RK[2] += float(FSHAPE(UU - gs.H))
|
|
167
|
+
gs.RK[3] += float(FSHAPE(UU - gs.H - gs.H))
|
|
168
|
+
gs.RK[4] += float(FSHAPE(UU + gs.H - gs.H9))
|
|
169
|
+
FF = float(FSHAPE(UU - gs.H9))
|
|
170
|
+
gs.RK[5] += FF
|
|
171
|
+
|
|
172
|
+
self._rev_cascade_full(UU, FF, FSHAPE, EPS, 9, col=2)
|
|
173
|
+
|
|
174
|
+
def _rev_cascade_full(self,
|
|
175
|
+
UU: float,
|
|
176
|
+
FF: float,
|
|
177
|
+
FSHAPE: Callable[[float], float],
|
|
178
|
+
EPS: float,
|
|
179
|
+
start_level: int,
|
|
180
|
+
col: int = 2) -> None:
|
|
181
|
+
gs = self.gs
|
|
182
|
+
Hs = [gs.H0, gs.H1, gs.H2, gs.H3, gs.H4,
|
|
183
|
+
gs.H5, gs.H6, gs.H7, gs.H8, gs.H9]
|
|
184
|
+
Hs_n = [gs.H1, gs.H2, gs.H3, gs.H4, gs.H5,
|
|
185
|
+
gs.H6, gs.H7, gs.H8, gs.H9, gs.H + gs.H]
|
|
186
|
+
RKP = [gs.RK0P, gs.RK1P, gs.RK2P, gs.RK3P, gs.RK4P,
|
|
187
|
+
gs.RK5P, gs.RK6P, gs.RK7P, gs.RK8P, gs.RK9P]
|
|
188
|
+
RKC = [gs.RK0, gs.RK1, gs.RK2, gs.RK3, gs.RK4,
|
|
189
|
+
gs.RK5, gs.RK6, gs.RK7, gs.RK8, gs.RK9]
|
|
190
|
+
RKL = [gs.RK0L, gs.RK1L, gs.RK2L, gs.RK3L, gs.RK4L,
|
|
191
|
+
gs.RK5L, gs.RK6L, gs.RK7L, gs.RK8L, gs.RK9L]
|
|
192
|
+
|
|
193
|
+
for lvl in range(start_level - 1, -1, -1):
|
|
194
|
+
RKP[lvl][col] += FF
|
|
195
|
+
FF_c = float(FSHAPE(UU - Hs[lvl] - Hs_n[lvl]))
|
|
196
|
+
RKC[lvl][col] += FF_c
|
|
197
|
+
FF = float(FSHAPE(UU - Hs[lvl]))
|
|
198
|
+
RKL[lvl][col] += FF
|
|
199
|
+
if FF < EPS:
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
# ------------------------------------------------------------------
|
|
203
|
+
# centerLBL_full (see center_lbl.py — unchanged below)
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
# ... [paste centerLBL_full verbatim from your center_lbl.py] ...
|
|
206
|
+
# ... [paste rightLBL_full and _right_cascade_full verbatim] ...
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Multi-resolution grid state (Fortran GridState / Grids module)."""
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GridState:
|
|
10
|
+
"""
|
|
11
|
+
Multi-resolution stencil arrays for the LBL multi-grid algorithm.
|
|
12
|
+
|
|
13
|
+
For each level n in [0..9] there are three arrays:
|
|
14
|
+
RKnP[i] -- 'plus' neighbour contribution at grid point i
|
|
15
|
+
RKn[i] -- 'center' contribution at grid point i
|
|
16
|
+
RKnL[i] -- 'left' neighbour contribution at grid point i
|
|
17
|
+
|
|
18
|
+
NT0..NT9 are the number of grid points at each level.
|
|
19
|
+
RK[i] is the coarsest flat grid (NT points).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, delta_wv: float, cut_off: float, H: float):
|
|
23
|
+
self.deltaWV = delta_wv
|
|
24
|
+
self.cutOff = cut_off
|
|
25
|
+
self.H = H
|
|
26
|
+
|
|
27
|
+
self.H0 = 2.0 * H
|
|
28
|
+
self.H1 = 4.0 * H
|
|
29
|
+
self.H2 = 8.0 * H
|
|
30
|
+
self.H3 = 16.0 * H
|
|
31
|
+
self.H4 = 32.0 * H
|
|
32
|
+
self.H5 = 64.0 * H
|
|
33
|
+
self.H6 = 128.0 * H
|
|
34
|
+
self.H7 = 256.0 * H
|
|
35
|
+
self.H8 = 512.0 * H
|
|
36
|
+
self.H9 = 1024.0 * H
|
|
37
|
+
|
|
38
|
+
self.NT = max(1, int(round(delta_wv / H)))
|
|
39
|
+
self.NT0 = max(1, self.NT // 2)
|
|
40
|
+
self.NT1 = max(1, self.NT // 4)
|
|
41
|
+
self.NT2 = max(1, self.NT // 8)
|
|
42
|
+
self.NT3 = max(1, self.NT // 16)
|
|
43
|
+
self.NT4 = max(1, self.NT // 32)
|
|
44
|
+
self.NT5 = max(1, self.NT // 64)
|
|
45
|
+
self.NT6 = max(1, self.NT // 128)
|
|
46
|
+
self.NT7 = max(1, self.NT // 256)
|
|
47
|
+
self.NT8 = max(1, self.NT // 512)
|
|
48
|
+
self.NT9 = max(1, self.NT // 1024)
|
|
49
|
+
|
|
50
|
+
self._allocate()
|
|
51
|
+
|
|
52
|
+
def _allocate(self):
|
|
53
|
+
def z(n):
|
|
54
|
+
return np.zeros(n + 2) # +2 for 1-based index safety
|
|
55
|
+
|
|
56
|
+
self.RK = z(self.NT)
|
|
57
|
+
|
|
58
|
+
self.RK0P = z(self.NT0); self.RK0 = z(self.NT0); self.RK0L = z(self.NT0)
|
|
59
|
+
self.RK1P = z(self.NT1); self.RK1 = z(self.NT1); self.RK1L = z(self.NT1)
|
|
60
|
+
self.RK2P = z(self.NT2); self.RK2 = z(self.NT2); self.RK2L = z(self.NT2)
|
|
61
|
+
self.RK3P = z(self.NT3); self.RK3 = z(self.NT3); self.RK3L = z(self.NT3)
|
|
62
|
+
self.RK4P = z(self.NT4); self.RK4 = z(self.NT4); self.RK4L = z(self.NT4)
|
|
63
|
+
self.RK5P = z(self.NT5); self.RK5 = z(self.NT5); self.RK5L = z(self.NT5)
|
|
64
|
+
self.RK6P = z(self.NT6); self.RK6 = z(self.NT6); self.RK6L = z(self.NT6)
|
|
65
|
+
self.RK7P = z(self.NT7); self.RK7 = z(self.NT7); self.RK7L = z(self.NT7)
|
|
66
|
+
self.RK8P = z(self.NT8); self.RK8 = z(self.NT8); self.RK8L = z(self.NT8)
|
|
67
|
+
self.RK9P = z(self.NT9); self.RK9 = z(self.NT9); self.RK9L = z(self.NT9)
|
|
68
|
+
|
|
69
|
+
def reset(self):
|
|
70
|
+
"""Zero all arrays (called before each new spectral line)."""
|
|
71
|
+
self._allocate()
|
|
72
|
+
|
|
73
|
+
def reconstruct_fine_grid(self) -> np.ndarray:
|
|
74
|
+
"""Reconstruct the fine-grid array from the stencil hierarchy."""
|
|
75
|
+
result = self.RK[1:self.NT + 1].copy()
|
|
76
|
+
|
|
77
|
+
levels = [
|
|
78
|
+
(self.RK0, self.RK0P, self.RK0L, self.NT0, self.H0),
|
|
79
|
+
(self.RK1, self.RK1P, self.RK1L, self.NT1, self.H1),
|
|
80
|
+
(self.RK2, self.RK2P, self.RK2L, self.NT2, self.H2),
|
|
81
|
+
(self.RK3, self.RK3P, self.RK3L, self.NT3, self.H3),
|
|
82
|
+
(self.RK4, self.RK4P, self.RK4L, self.NT4, self.H4),
|
|
83
|
+
(self.RK5, self.RK5P, self.RK5L, self.NT5, self.H5),
|
|
84
|
+
(self.RK6, self.RK6P, self.RK6L, self.NT6, self.H6),
|
|
85
|
+
(self.RK7, self.RK7P, self.RK7L, self.NT7, self.H7),
|
|
86
|
+
(self.RK8, self.RK8P, self.RK8L, self.NT8, self.H8),
|
|
87
|
+
(self.RK9, self.RK9P, self.RK9L, self.NT9, self.H9),
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
NT = self.NT
|
|
91
|
+
for RKn, RKnP, RKnL, NTn, Hn in levels:
|
|
92
|
+
ratio = max(1, int(round(Hn / self.H)))
|
|
93
|
+
i_arr = np.arange(1, NTn + 1)
|
|
94
|
+
c_idx = i_arr * ratio - 1
|
|
95
|
+
l_idx = c_idx - ratio // 2
|
|
96
|
+
r_idx = c_idx + ratio // 2
|
|
97
|
+
|
|
98
|
+
for idx_arr, RK_arr in ((c_idx, RKn),
|
|
99
|
+
(l_idx, RKnL),
|
|
100
|
+
(r_idx, RKnP)):
|
|
101
|
+
valid = (idx_arr >= 0) & (idx_arr < NT)
|
|
102
|
+
if np.any(valid):
|
|
103
|
+
np.add.at(result, idx_arr[valid],
|
|
104
|
+
RK_arr[1:NTn + 1][valid])
|
|
105
|
+
|
|
106
|
+
return result
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""HITRAN spectral line reader."""
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from .molecules import MOLECULE_NAMES
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class SpectralLine:
|
|
14
|
+
"""HITRAN spectral line parameters."""
|
|
15
|
+
mol_id: int
|
|
16
|
+
iso_id: int
|
|
17
|
+
nu: float
|
|
18
|
+
S: float
|
|
19
|
+
A: float
|
|
20
|
+
gamma_air: float
|
|
21
|
+
gamma_self: float
|
|
22
|
+
E_low: float
|
|
23
|
+
n_air: float
|
|
24
|
+
delta_air: float
|
|
25
|
+
g_upper: int = 0
|
|
26
|
+
g_lower: int = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class HITRANReader:
|
|
30
|
+
"""Read HITRAN ``.par`` files into :class:`SpectralLine` objects."""
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def read_par_file(
|
|
34
|
+
filename: str,
|
|
35
|
+
molecule_id: Optional[int] = None,
|
|
36
|
+
wavenumber_min: Optional[float] = None,
|
|
37
|
+
wavenumber_max: Optional[float] = None,
|
|
38
|
+
intensity_threshold: Optional[float] = None,
|
|
39
|
+
) -> List[SpectralLine]:
|
|
40
|
+
lines: List[SpectralLine] = []
|
|
41
|
+
|
|
42
|
+
if not os.path.exists(filename):
|
|
43
|
+
logger.warning("HITRAN file not found: %s", filename)
|
|
44
|
+
return lines
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
with open(filename, "r") as f:
|
|
48
|
+
for lineno, line_str in enumerate(f, start=1):
|
|
49
|
+
if len(line_str) < 160:
|
|
50
|
+
continue
|
|
51
|
+
try:
|
|
52
|
+
mol = int(line_str[0:2])
|
|
53
|
+
iso = int(line_str[2])
|
|
54
|
+
nu = float(line_str[3:15])
|
|
55
|
+
S = float(line_str[15:25])
|
|
56
|
+
A = float(line_str[25:35])
|
|
57
|
+
gamma_air = float(line_str[35:40])
|
|
58
|
+
gamma_self = float(line_str[40:45])
|
|
59
|
+
E_low = float(line_str[45:55])
|
|
60
|
+
n_air = float(line_str[55:59])
|
|
61
|
+
delta_air = float(line_str[59:67])
|
|
62
|
+
except (ValueError, IndexError):
|
|
63
|
+
logger.debug("Skipping malformed line %d in %s",
|
|
64
|
+
lineno, filename)
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
if molecule_id is not None and mol != molecule_id:
|
|
68
|
+
continue
|
|
69
|
+
if wavenumber_min is not None and nu < wavenumber_min:
|
|
70
|
+
continue
|
|
71
|
+
if wavenumber_max is not None and nu > wavenumber_max:
|
|
72
|
+
continue
|
|
73
|
+
if intensity_threshold is not None and S < intensity_threshold:
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
lines.append(SpectralLine(
|
|
77
|
+
mol_id=mol, iso_id=iso, nu=nu, S=S, A=A,
|
|
78
|
+
gamma_air=gamma_air, gamma_self=gamma_self,
|
|
79
|
+
E_low=E_low, n_air=n_air, delta_air=delta_air,
|
|
80
|
+
))
|
|
81
|
+
except OSError:
|
|
82
|
+
logger.exception("Error reading %s", filename)
|
|
83
|
+
return lines
|
|
84
|
+
|
|
85
|
+
if lines:
|
|
86
|
+
name = MOLECULE_NAMES.get(molecule_id, "Unknown")
|
|
87
|
+
logger.info("%s: %d lines loaded from %s", name, len(lines), filename)
|
|
88
|
+
else:
|
|
89
|
+
logger.warning("No lines matched the filters in %s", filename)
|
|
90
|
+
|
|
91
|
+
return lines
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Spectral line shape functions."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
from scipy.special import wofz
|
|
4
|
+
|
|
5
|
+
from .constants import Constants
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LineShapes:
|
|
9
|
+
"""Spectral line shape functions matching Fortran LineShapes module."""
|
|
10
|
+
|
|
11
|
+
@staticmethod
|
|
12
|
+
def voigt_humlicek(x: np.ndarray, a: float) -> np.ndarray:
|
|
13
|
+
"""Voigt function via Humlíček (1982) / Faddeeva function."""
|
|
14
|
+
return np.real(wofz(x + 1j * a)) / Constants.sqrt_pi
|
|
15
|
+
|
|
16
|
+
@staticmethod
|
|
17
|
+
def voigt_normalized(x: np.ndarray,
|
|
18
|
+
gamma_L: float,
|
|
19
|
+
gamma_D: float) -> np.ndarray:
|
|
20
|
+
"""
|
|
21
|
+
Voigt profile normalised so that its integral over all x is 1.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
x : offset from line centre [cm-1]
|
|
26
|
+
gamma_L : Lorentz (pressure) half-width [cm-1]
|
|
27
|
+
gamma_D : Doppler half-width [cm-1]
|
|
28
|
+
"""
|
|
29
|
+
if gamma_D == 0:
|
|
30
|
+
return LineShapes.lorentz(x, gamma_L)
|
|
31
|
+
if gamma_L == 0:
|
|
32
|
+
return LineShapes.doppler(x, gamma_D)
|
|
33
|
+
|
|
34
|
+
x_reduced = x / gamma_D
|
|
35
|
+
a = gamma_L / gamma_D
|
|
36
|
+
K = LineShapes.voigt_humlicek(x_reduced, a)
|
|
37
|
+
# voigt_humlicek already returns Re[w(z)]/sqrt(pi); dividing by
|
|
38
|
+
# gamma_D alone yields a profile whose integral over all x equals 1.
|
|
39
|
+
return K / gamma_D
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def lorentz(x: np.ndarray, gamma: float) -> np.ndarray:
|
|
43
|
+
return gamma / (np.pi * (x ** 2 + gamma ** 2))
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def doppler(x: np.ndarray, gamma_D: float) -> np.ndarray:
|
|
47
|
+
return (Constants.sqrt_ln2 / (gamma_D * Constants.sqrt_pi)) * \
|
|
48
|
+
np.exp(-Constants.ln2_pi * (x / gamma_D) ** 2)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
MOLECULE_NAMES = {
|
|
2
|
+
1: "H2O", 2: "CO2", 3: "O3", 4: "N2O", 5: "CO",
|
|
3
|
+
6: "CH4", 7: "O2", 8: "NO", 9: "SO2", 10: "NO2",
|
|
4
|
+
11: "NH3", 12: "HNO3",
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
MOLECULAR_MASSES = {
|
|
8
|
+
1: 18.010565, 2: 43.989830, 3: 47.984745, 4: 44.001062,
|
|
9
|
+
5: 27.994915, 6: 16.031300, 7: 31.989830, 8: 29.997989,
|
|
10
|
+
9: 63.961901, 10: 45.992904, 11: 17.026549, 12: 62.995644,
|
|
11
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Optional plotting helpers.
|
|
2
|
+
|
|
3
|
+
Requires matplotlib. matplotlib is imported lazily inside each function
|
|
4
|
+
so that ``import multigrid`` never pulls it in.
|
|
5
|
+
"""
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def plot_spectrum(
|
|
12
|
+
nu: np.ndarray,
|
|
13
|
+
alpha: np.ndarray,
|
|
14
|
+
*,
|
|
15
|
+
title: Optional[str] = None,
|
|
16
|
+
filename: Optional[str] = None,
|
|
17
|
+
show: bool = True,
|
|
18
|
+
ax=None,
|
|
19
|
+
):
|
|
20
|
+
"""
|
|
21
|
+
Plot an absorption spectrum.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
nu : wavenumber grid [cm-1]
|
|
26
|
+
alpha : absorption coefficient [cm-1]
|
|
27
|
+
title : optional plot title
|
|
28
|
+
filename : if given, save the figure to this path
|
|
29
|
+
show : if True, call ``plt.show()``
|
|
30
|
+
ax : optional existing matplotlib Axes to draw on
|
|
31
|
+
"""
|
|
32
|
+
import matplotlib.pyplot as plt # lazy — only when actually plotting
|
|
33
|
+
|
|
34
|
+
if ax is None:
|
|
35
|
+
fig, ax = plt.subplots(figsize=(12, 5))
|
|
36
|
+
|
|
37
|
+
ax.plot(nu, alpha, "b-", linewidth=0.6)
|
|
38
|
+
ax.set_xlabel("Wavenumber (cm⁻¹)")
|
|
39
|
+
ax.set_ylabel("Absorption coefficient (cm⁻¹)")
|
|
40
|
+
if title:
|
|
41
|
+
ax.set_title(title)
|
|
42
|
+
ax.grid(True, alpha=0.3)
|
|
43
|
+
ax.ticklabel_format(style="scientific", axis="y", scilimits=(0, 0))
|
|
44
|
+
|
|
45
|
+
if filename:
|
|
46
|
+
plt.tight_layout()
|
|
47
|
+
plt.savefig(filename, dpi=300)
|
|
48
|
+
if show:
|
|
49
|
+
plt.show()
|
|
50
|
+
return ax
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Atmospheric profiles."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Dict
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class AtmosphericProfile:
|
|
10
|
+
"""Vertical profile of a planetary atmosphere."""
|
|
11
|
+
|
|
12
|
+
z: np.ndarray
|
|
13
|
+
P: np.ndarray
|
|
14
|
+
T: np.ndarray
|
|
15
|
+
vmr: Dict[int, np.ndarray]
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def us_standard(cls) -> "AtmosphericProfile":
|
|
19
|
+
"""Very coarse US Standard Atmosphere (7 levels)."""
|
|
20
|
+
z = np.array([0, 10, 20, 30, 50, 70, 100], dtype=float)
|
|
21
|
+
P = np.array([1.0, 0.265, 0.055, 0.012, 0.001, 0.00005, 0.000003])
|
|
22
|
+
T = np.array([288, 223, 217, 227, 271, 220, 210], dtype=float)
|
|
23
|
+
vmr = {2: 400e-6 * np.ones_like(z)}
|
|
24
|
+
return cls(z, P, T, vmr)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""PT-table generator for radiative-transfer codes."""
|
|
2
|
+
import logging
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from .core import MARFA
|
|
8
|
+
from .hitran import SpectralLine
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PTTableGenerator:
|
|
14
|
+
"""Generate a (P, T, nu) lookup table of absorption coefficients."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, marfa: MARFA):
|
|
17
|
+
self.marfa = marfa
|
|
18
|
+
|
|
19
|
+
def generate_table(
|
|
20
|
+
self,
|
|
21
|
+
nu_grid: np.ndarray,
|
|
22
|
+
lines: List[SpectralLine],
|
|
23
|
+
P_grid: np.ndarray,
|
|
24
|
+
T_grid: np.ndarray,
|
|
25
|
+
mole_fraction: float,
|
|
26
|
+
output_file: str,
|
|
27
|
+
) -> np.ndarray:
|
|
28
|
+
logger.info("Generating PT table: %d P x %d T x %d nu",
|
|
29
|
+
len(P_grid), len(T_grid), len(nu_grid))
|
|
30
|
+
|
|
31
|
+
n_P, n_T, n_nu = len(P_grid), len(T_grid), len(nu_grid)
|
|
32
|
+
table = np.zeros((n_P, n_T, n_nu))
|
|
33
|
+
|
|
34
|
+
for i, P in enumerate(P_grid):
|
|
35
|
+
for j, T in enumerate(T_grid):
|
|
36
|
+
logger.debug("P=%.3e atm T=%.1f K", P, T)
|
|
37
|
+
table[i, j, :] = self.marfa.calculate_absorption_coefficient(
|
|
38
|
+
nu_grid, lines, T, P, mole_fraction)
|
|
39
|
+
|
|
40
|
+
np.save(output_file, table)
|
|
41
|
+
logger.info("Saved PT table to %s", output_file)
|
|
42
|
+
return table
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Total Internal Partition Sums (TIPS)."""
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TIPS:
|
|
10
|
+
"""
|
|
11
|
+
Total Internal Partition Sums.
|
|
12
|
+
|
|
13
|
+
NOTE
|
|
14
|
+
----
|
|
15
|
+
The temperature dependence implemented here is a rough T**1.5 scaling
|
|
16
|
+
anchored at 296 K. It is adequate for qualitative work but is NOT the
|
|
17
|
+
real Gamache et al. (2017) TIPS database. Replace `_calculate_tips_table`
|
|
18
|
+
with a real table lookup for quantitative accuracy.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self.Q_ref_296 = {
|
|
23
|
+
1: 178.12, 2: 289.49, 3: 4870.3, 4: 1122.3,
|
|
24
|
+
5: 108.58, 6: 590.43, 7: 216.21, 8: 159.47,
|
|
25
|
+
9: 5792.6, 10: 2379.5, 11: 169.24, 12: 11456.0,
|
|
26
|
+
}
|
|
27
|
+
self.T_grid = np.arange(20, 1002, 2)
|
|
28
|
+
self._calculate_tips_table()
|
|
29
|
+
|
|
30
|
+
def _calculate_tips_table(self):
|
|
31
|
+
self.tips_table = {}
|
|
32
|
+
for mol_id in range(1, 13):
|
|
33
|
+
Q_ref = self.Q_ref_296.get(mol_id, 100.0)
|
|
34
|
+
self.tips_table[mol_id] = Q_ref * (self.T_grid / 296.0) ** 1.5
|
|
35
|
+
|
|
36
|
+
def get_Q(self, mol_id: int, T: float) -> float:
|
|
37
|
+
if mol_id not in self.tips_table:
|
|
38
|
+
logger.debug("Unknown molecule id %d, falling back to CO2", mol_id)
|
|
39
|
+
mol_id = 2
|
|
40
|
+
T = float(np.clip(T, 20.0, 1000.0))
|
|
41
|
+
idx = int((T - 20.0) / 2.0)
|
|
42
|
+
idx = max(0, min(idx, len(self.T_grid) - 2))
|
|
43
|
+
T1, T2 = self.T_grid[idx], self.T_grid[idx + 1]
|
|
44
|
+
Q1, Q2 = self.tips_table[mol_id][idx], self.tips_table[mol_id][idx + 1]
|
|
45
|
+
return float(Q1 + (Q2 - Q1) * (T - T1) / (T2 - T1))
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Sub-Lorentzian wing correction (chi-factor) functions."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class WingCorrections:
|
|
6
|
+
"""Sub-Lorentzian wing correction functions."""
|
|
7
|
+
|
|
8
|
+
@staticmethod
|
|
9
|
+
def tonkov_chi(delta_nu: np.ndarray, nu0: float) -> np.ndarray:
|
|
10
|
+
abs_delta = np.abs(delta_nu)
|
|
11
|
+
chi = np.ones_like(delta_nu, dtype=float)
|
|
12
|
+
mask2 = (abs_delta > 25.0) & (abs_delta <= 250.0)
|
|
13
|
+
chi[mask2] = 1.0 - 0.02 * ((abs_delta[mask2] - 25.0) / 225.0) ** 2
|
|
14
|
+
mask3 = abs_delta > 250.0
|
|
15
|
+
chi[mask3] = 0.98 * np.exp(-(abs_delta[mask3] - 250.0) / 200.0)
|
|
16
|
+
return chi
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def perrin_chi(delta_nu: np.ndarray, nu0: float) -> np.ndarray:
|
|
20
|
+
abs_delta = np.abs(delta_nu)
|
|
21
|
+
chi = np.ones_like(delta_nu, dtype=float)
|
|
22
|
+
mask = abs_delta > 20.0
|
|
23
|
+
chi[mask] = np.exp(-0.0015 * (abs_delta[mask] - 20.0))
|
|
24
|
+
return chi
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def no_correction(delta_nu: np.ndarray, nu0: float) -> np.ndarray:
|
|
28
|
+
return np.ones_like(delta_nu, dtype=float)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: multigrid-py
|
|
3
|
+
Version: 3.0.1
|
|
4
|
+
Summary: Python port of the Fortran MARFA line-by-line molecular absorption code
|
|
5
|
+
Home-page: https://github.com/os1832000-png/MultiGrid_Py
|
|
6
|
+
License: MIT
|
|
7
|
+
Description: UNKNOWN
|
|
8
|
+
Platform: UNKNOWN
|
|
9
|
+
Requires-Python: >=3.6
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Provides-Extra: plot
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
setup.py
|
|
4
|
+
src/multigrid/__init__.py
|
|
5
|
+
src/multigrid/_warnings.py
|
|
6
|
+
src/multigrid/cli.py
|
|
7
|
+
src/multigrid/constants.py
|
|
8
|
+
src/multigrid/core.py
|
|
9
|
+
src/multigrid/grid_calculator.py
|
|
10
|
+
src/multigrid/grids.py
|
|
11
|
+
src/multigrid/hitran.py
|
|
12
|
+
src/multigrid/line_shapes.py
|
|
13
|
+
src/multigrid/molecules.py
|
|
14
|
+
src/multigrid/plotting.py
|
|
15
|
+
src/multigrid/profiles.py
|
|
16
|
+
src/multigrid/pt_table.py
|
|
17
|
+
src/multigrid/tips.py
|
|
18
|
+
src/multigrid/wing_corrections.py
|
|
19
|
+
src/multigrid_py.egg-info/PKG-INFO
|
|
20
|
+
src/multigrid_py.egg-info/SOURCES.txt
|
|
21
|
+
src/multigrid_py.egg-info/dependency_links.txt
|
|
22
|
+
src/multigrid_py.egg-info/entry_points.txt
|
|
23
|
+
src/multigrid_py.egg-info/requires.txt
|
|
24
|
+
src/multigrid_py.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
multigrid
|