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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,3 @@
1
+ [console_scripts]
2
+ multigrid = multigrid.cli:main
3
+
@@ -0,0 +1,8 @@
1
+ numpy>=1.16
2
+ scipy>=1.2
3
+
4
+ [dev]
5
+ pytest
6
+
7
+ [plot]
8
+ matplotlib>=3.0