diff --git a/.github/workflows/build-wheels.yml b/.github/workflows/build-wheels.yml new file mode 100644 index 0000000..09acd71 --- /dev/null +++ b/.github/workflows/build-wheels.yml @@ -0,0 +1,81 @@ +name: Build Wheels + +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + build_wheels: + name: Build wheels on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-13, macos-14] + + steps: + - uses: actions/checkout@v4 + + - name: Set up MSYS2 (Windows only) + if: runner.os == 'Windows' + uses: msys2/setup-msys2@v2 + with: + msystem: MINGW64 + update: true + install: >- + mingw-w64-x86_64-gcc-fortran + mingw-w64-x86_64-meson + mingw-w64-x86_64-ninja + + - name: Install Fortran (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y gfortran + + - name: Install Fortran (macOS) + if: runner.os == 'macOS' + run: | + brew install gcc + + - name: Build wheels + uses: pypa/cibuildwheel@v2.17.0 + env: + CIBW_BUILD: cp39-* cp310-* cp311-* cp312-* cp313-* cp314-* + CIBW_SKIP: "*-musllinux_* pp*" + # Windows: Copy runtime DLLs to libs/ before build + CIBW_BEFORE_BUILD_WINDOWS: | + python -c "from pathlib import Path; (Path('src/lowtran/libs')).mkdir(parents=True, exist_ok=True)" + if exist C:\msys64\mingw64\bin\libgfortran-5.dll ( + copy C:\msys64\mingw64\bin\libgfortran-5.dll src\lowtran\libs\ + copy C:\msys64\mingw64\bin\libgcc_s_seh-1.dll src\lowtran\libs\ + copy C:\msys64\mingw64\bin\libquadmath-0.dll src\lowtran\libs\ + copy C:\msys64\mingw64\bin\libwinpthread-1.dll src\lowtran\libs\ + ) + # Ensure meson and ninja are available + CIBW_BEFORE_BUILD: pip install meson-python ninja + # Test that the wheel works + CIBW_TEST_REQUIRES: pytest numpy + CIBW_TEST_COMMAND: pytest {project}/tests || python -c "import lowtran; lowtran.check()" + + - uses: actions/upload-artifact@v4 + with: + name: wheels-${{ matrix.os }}-${{ strategy.job-index }} + path: ./wheelhouse/*.whl + + build_sdist: + name: Build source distribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build sdist + run: pipx run build --sdist + + - uses: actions/upload-artifact@v4 + with: + name: sdist + path: dist/*.tar.gz diff --git a/.gitignore b/.gitignore index a6dce58..16c6318 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,10 @@ .mypy_cache/ .pytest_cache/ .cache/ +.claude/ +.vscode/ - +build/ dist/ __pycache__/ @@ -11,3 +13,6 @@ __pycache__/ *.so *.dylib *.egg-info/ + +# Runtime DLLs (bundled during wheel build) +src/lowtran/libs/*.dll diff --git a/CMakeLists.txt b/CMakeLists.txt deleted file mode 100644 index 627a319..0000000 --- a/CMakeLists.txt +++ /dev/null @@ -1,46 +0,0 @@ -cmake_minimum_required(VERSION 3.19...3.26) -# 3.17+ for Python_SOABI - -project(lowtran LANGUAGES Fortran) - -enable_testing() - -option(BUILD_TESTING "Build testing tools" ON) -option(matlab "build matlab interface") - -file(GENERATE OUTPUT .gitignore CONTENT "*") - -add_subdirectory(src/lowtran) - - -if(BUILD_TESTING) - -add_executable(lowtran_cli test/lowtran_driver.f90 test/assert.f90) -target_link_libraries(lowtran_cli PRIVATE lowtran) - -add_test(NAME Obs2space COMMAND lowtran_cli obs2space 8333 33333 ${CMAKE_CURRENT_SOURCE_DIR}/test/testfort_trans.asc) -set_property(TEST Obs2space PROPERTY REQUIRED_FILES ${CMAKE_CURRENT_SOURCE_DIR}/test/testfort_trans.asc) - -add_test(NAME SolarRadiance COMMAND lowtran_cli solarrad 749.5 1250 ${CMAKE_CURRENT_SOURCE_DIR}/test/testfort_solarrad.asc) -set_property(TEST SolarRadiance PROPERTY REQUIRED_FILES ${CMAKE_CURRENT_SOURCE_DIR}/test/testfort_solarrad.asc) - -add_test(NAME SolarIrradiance COMMAND lowtran_cli solarirrad 749.5 1250 "") -add_test(NAME UserHoriz COMMAND lowtran_cli userhoriz 714.2857 1250 "") - -endif(BUILD_TESTING) - - -if(matlab) - -find_package(Matlab COMPONENTS MAIN_PROGRAM REQUIRED) - -add_test(NAME MatlabNumpy COMMAND matlab -batch "py.numpy.arange(1);") -set_property(TEST MatlabNumpy PROPERTY FIXTURES_SETUP MatlabNumpy) - -add_test(NAME MatlabLowtran -COMMAND ${Matlab_MAIN_PROGRAM} -batch "r = runtests('lowtran'); assert(~isempty(r)); assertSuccess(r)" -WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} -) -set_property(TEST MatlabLowtran PROPERTY FIXTURES_REQUIRED MatlabNumpy) - -endif() diff --git a/example/LowTran.ipynb b/example/LowTran.ipynb new file mode 100644 index 0000000..146bc3b --- /dev/null +++ b/example/LowTran.ipynb @@ -0,0 +1,114 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "59875f03-1b3d-4e3c-8193-1c88c8d4b013", + "metadata": {}, + "outputs": [], + "source": [ + "import lowtran\n", + "from lowtran.plot import horiz\n", + "import pylab as pb\n", + "m = 1.0\n", + "km = 1000*m\n", + "deg = 1.0\n", + "nm = 1e-9*m\n", + "μm = 1e-6*m\n", + "cm = 1e-2*m" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "bdbf8548-0883-492b-9985-d98849cc656f", + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "outputs": [ + { + "ename": "ImportError", + "evalue": "DLL load failed while importing lowtran7: The specified module could not be found.", + "output_type": "error", + "traceback": [ + "\u001b[31m---------------------------------------------------------------------------\u001b[39m", + "\u001b[31mImportError\u001b[39m Traceback (most recent call last)", + "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[4]\u001b[39m\u001b[32m, line 17\u001b[39m\n\u001b[32m 6\u001b[39m wlstep = \u001b[32m20.0\u001b[39m/cm\n\u001b[32m 8\u001b[39m c1 = {\n\u001b[32m 9\u001b[39m \u001b[33m\"\u001b[39m\u001b[33mzmdl\u001b[39m\u001b[33m\"\u001b[39m: observer_altitude/km,\n\u001b[32m 10\u001b[39m \u001b[33m\"\u001b[39m\u001b[33mh1\u001b[39m\u001b[33m\"\u001b[39m: observer_altitude/km,\n\u001b[32m (...)\u001b[39m\u001b[32m 14\u001b[39m \u001b[33m\"\u001b[39m\u001b[33mwlstep\u001b[39m\u001b[33m\"\u001b[39m: wlstep/(\u001b[32m1.0\u001b[39m/cm),\n\u001b[32m 15\u001b[39m }\n\u001b[32m---> \u001b[39m\u001b[32m17\u001b[39m T_atm = \u001b[43mlowtran\u001b[49m\u001b[43m.\u001b[49m\u001b[43mhoriztrans\u001b[49m\u001b[43m(\u001b[49m\u001b[43mc1\u001b[49m\u001b[43m)\u001b[49m.squeeze()\n\u001b[32m 18\u001b[39m horiz(T_atm,c1)\n\u001b[32m 19\u001b[39m \u001b[38;5;66;03m#pb.show()\u001b[39;00m\n", + "\u001b[36mFile \u001b[39m\u001b[32mc:\\Users\\CAFFOUDA\\.local\\venv\\sandbox\\Lib\\site-packages\\lowtran\\scenarios.py:113\u001b[39m, in \u001b[36mhoriztrans\u001b[39m\u001b[34m(c1)\u001b[39m\n\u001b[32m 101\u001b[39m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34mhoriztrans\u001b[39m(c1: \u001b[38;5;28mdict\u001b[39m[\u001b[38;5;28mstr\u001b[39m, Any]):\n\u001b[32m 103\u001b[39m c1.update(\n\u001b[32m 104\u001b[39m {\n\u001b[32m 105\u001b[39m \u001b[33m\"\u001b[39m\u001b[33mmodel\u001b[39m\u001b[33m\"\u001b[39m: \u001b[32m5\u001b[39m, \u001b[38;5;66;03m# 5: subartic winter\u001b[39;00m\n\u001b[32m (...)\u001b[39m\u001b[32m 110\u001b[39m }\n\u001b[32m 111\u001b[39m )\n\u001b[32m--> \u001b[39m\u001b[32m113\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[43mgolowtran\u001b[49m\u001b[43m(\u001b[49m\u001b[43mc1\u001b[49m\u001b[43m)\u001b[49m\n", + "\u001b[36mFile \u001b[39m\u001b[32mc:\\Users\\CAFFOUDA\\.local\\venv\\sandbox\\Lib\\site-packages\\lowtran\\base.py:147\u001b[39m, in \u001b[36mgolowtran\u001b[39m\u001b[34m(c1)\u001b[39m\n\u001b[32m 141\u001b[39m \u001b[38;5;66;03m# %% invoke lowtran\u001b[39;00m\n\u001b[32m 142\u001b[39m \u001b[38;5;250m\u001b[39m\u001b[33;03m\"\"\"\u001b[39;00m\n\u001b[32m 143\u001b[39m \u001b[33;03mNote we invoke case \"3a\" from table 14, only observer altitude and apparent\u001b[39;00m\n\u001b[32m 144\u001b[39m \u001b[33;03mangle are specified\u001b[39;00m\n\u001b[32m 145\u001b[39m \u001b[33;03m\"\"\"\u001b[39;00m\n\u001b[32m--> \u001b[39m\u001b[32m147\u001b[39m lowtran7 = \u001b[43mcheck\u001b[49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\n\u001b[32m 149\u001b[39m Tx, V, Alam, trace, unif, suma, irrad, sumvv = lowtran7.lwtrn7(\n\u001b[32m 150\u001b[39m \u001b[38;5;28;01mTrue\u001b[39;00m,\n\u001b[32m 151\u001b[39m nwl,\n\u001b[32m (...)\u001b[39m\u001b[32m 168\u001b[39m c1[\u001b[33m\"\u001b[39m\u001b[33mrange_km\u001b[39m\u001b[33m\"\u001b[39m],\n\u001b[32m 169\u001b[39m )\n\u001b[32m 171\u001b[39m dims = (\u001b[33m\"\u001b[39m\u001b[33mtime\u001b[39m\u001b[33m\"\u001b[39m, \u001b[33m\"\u001b[39m\u001b[33mwavelength_nm\u001b[39m\u001b[33m\"\u001b[39m, \u001b[33m\"\u001b[39m\u001b[33mangle_deg\u001b[39m\u001b[33m\"\u001b[39m)\n", + "\u001b[36mFile \u001b[39m\u001b[32mc:\\Users\\CAFFOUDA\\.local\\venv\\sandbox\\Lib\\site-packages\\lowtran\\base.py:18\u001b[39m, in \u001b[36mcheck\u001b[39m\u001b[34m()\u001b[39m\n\u001b[32m 13\u001b[39m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34mcheck\u001b[39m() -> ModuleType:\n\u001b[32m 14\u001b[39m \u001b[38;5;250m \u001b[39m\u001b[33;03m\"\"\"Import the lowtran7 extension module.\u001b[39;00m\n\u001b[32m 15\u001b[39m \n\u001b[32m 16\u001b[39m \u001b[33;03m The extension should be built with Meson and installed alongside this package.\u001b[39;00m\n\u001b[32m 17\u001b[39m \u001b[33;03m \"\"\"\u001b[39;00m\n\u001b[32m---> \u001b[39m\u001b[32m18\u001b[39m lowtran7 = \u001b[43mimport_f2py_mod\u001b[49m\u001b[43m(\u001b[49m\u001b[33;43m\"\u001b[39;49m\u001b[33;43mlowtran7\u001b[39;49m\u001b[33;43m\"\u001b[39;49m\u001b[43m)\u001b[49m\n\u001b[32m 19\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m lowtran7\n", + "\u001b[36mFile \u001b[39m\u001b[32mc:\\Users\\CAFFOUDA\\.local\\venv\\sandbox\\Lib\\site-packages\\lowtran\\base.py:42\u001b[39m, in \u001b[36mimport_f2py_mod\u001b[39m\u001b[34m(name)\u001b[39m\n\u001b[32m 40\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m spec \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[32m 41\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mModuleNotFoundError\u001b[39;00m(\u001b[33mf\u001b[39m\u001b[33m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00mname\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m not found in \u001b[39m\u001b[38;5;132;01m{\u001b[39;00mmod_file\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m\"\u001b[39m)\n\u001b[32m---> \u001b[39m\u001b[32m42\u001b[39m mod = \u001b[43mimportlib\u001b[49m\u001b[43m.\u001b[49m\u001b[43mutil\u001b[49m\u001b[43m.\u001b[49m\u001b[43mmodule_from_spec\u001b[49m\u001b[43m(\u001b[49m\u001b[43mspec\u001b[49m\u001b[43m)\u001b[49m\n\u001b[32m 43\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m mod \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[32m 44\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mImportError\u001b[39;00m(\u001b[33mf\u001b[39m\u001b[33m\"\u001b[39m\u001b[33mcould not import \u001b[39m\u001b[38;5;132;01m{\u001b[39;00mname\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m from \u001b[39m\u001b[38;5;132;01m{\u001b[39;00mmod_file\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m\"\u001b[39m)\n", + "\u001b[36mFile \u001b[39m\u001b[32m:816\u001b[39m, in \u001b[36mmodule_from_spec\u001b[39m\u001b[34m(spec)\u001b[39m\n\u001b[32m 812\u001b[39m module = \u001b[38;5;28;01mNone\u001b[39;00m\n\u001b[32m 813\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mhasattr\u001b[39m(spec.loader, \u001b[33m'\u001b[39m\u001b[33mcreate_module\u001b[39m\u001b[33m'\u001b[39m):\n\u001b[32m 814\u001b[39m \u001b[38;5;66;03m# If create_module() returns `None` then it means default\u001b[39;00m\n\u001b[32m 815\u001b[39m \u001b[38;5;66;03m# module creation should be used.\u001b[39;00m\n\u001b[32m--> \u001b[39m\u001b[32m816\u001b[39m module = \u001b[43mspec\u001b[49m\u001b[43m.\u001b[49m\u001b[43mloader\u001b[49m\u001b[43m.\u001b[49m\u001b[43mcreate_module\u001b[49m\u001b[43m(\u001b[49m\u001b[43mspec\u001b[49m\u001b[43m)\u001b[49m\n\u001b[32m 817\u001b[39m \u001b[38;5;28;01melif\u001b[39;00m \u001b[38;5;28mhasattr\u001b[39m(spec.loader, \u001b[33m'\u001b[39m\u001b[33mexec_module\u001b[39m\u001b[33m'\u001b[39m):\n\u001b[32m 818\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mImportError\u001b[39;00m(\u001b[33m'\u001b[39m\u001b[33mloaders that define exec_module() \u001b[39m\u001b[33m'\u001b[39m\n\u001b[32m 819\u001b[39m \u001b[33m'\u001b[39m\u001b[33mmust also define create_module()\u001b[39m\u001b[33m'\u001b[39m)\n", + "\u001b[36mFile \u001b[39m\u001b[32m:1056\u001b[39m, in \u001b[36mExtensionFileLoader.create_module\u001b[39m\u001b[34m(self, spec)\u001b[39m\n\u001b[32m 1054\u001b[39m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34mcreate_module\u001b[39m(\u001b[38;5;28mself\u001b[39m, spec):\n\u001b[32m 1055\u001b[39m \u001b[38;5;250m \u001b[39m\u001b[33;03m\"\"\"Create an uninitialized extension module\"\"\"\u001b[39;00m\n\u001b[32m-> \u001b[39m\u001b[32m1056\u001b[39m module = \u001b[43m_bootstrap\u001b[49m\u001b[43m.\u001b[49m\u001b[43m_call_with_frames_removed\u001b[49m\u001b[43m(\u001b[49m\n\u001b[32m 1057\u001b[39m \u001b[43m \u001b[49m\u001b[43m_imp\u001b[49m\u001b[43m.\u001b[49m\u001b[43mcreate_dynamic\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mspec\u001b[49m\u001b[43m)\u001b[49m\n\u001b[32m 1058\u001b[39m _bootstrap._verbose_message(\u001b[33m'\u001b[39m\u001b[33mextension module \u001b[39m\u001b[38;5;132;01m{!r}\u001b[39;00m\u001b[33m loaded from \u001b[39m\u001b[38;5;132;01m{!r}\u001b[39;00m\u001b[33m'\u001b[39m,\n\u001b[32m 1059\u001b[39m spec.name, \u001b[38;5;28mself\u001b[39m.path)\n\u001b[32m 1060\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m module\n", + "\u001b[36mFile \u001b[39m\u001b[32m:491\u001b[39m, in \u001b[36m_call_with_frames_removed\u001b[39m\u001b[34m(f, *args, **kwds)\u001b[39m\n\u001b[32m 483\u001b[39m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34m_call_with_frames_removed\u001b[39m(f, *args, **kwds):\n\u001b[32m 484\u001b[39m \u001b[38;5;250m \u001b[39m\u001b[33;03m\"\"\"remove_importlib_frames in import.c will always remove sequences\u001b[39;00m\n\u001b[32m 485\u001b[39m \u001b[33;03m of importlib frames that end with a call to this function\u001b[39;00m\n\u001b[32m 486\u001b[39m \n\u001b[32m (...)\u001b[39m\u001b[32m 489\u001b[39m \u001b[33;03m module code)\u001b[39;00m\n\u001b[32m 490\u001b[39m \u001b[33;03m \"\"\"\u001b[39;00m\n\u001b[32m--> \u001b[39m\u001b[32m491\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[43mf\u001b[49m\u001b[43m(\u001b[49m\u001b[43m*\u001b[49m\u001b[43margs\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43m*\u001b[49m\u001b[43m*\u001b[49m\u001b[43mkwds\u001b[49m\u001b[43m)\u001b[49m\n", + "\u001b[31mImportError\u001b[39m: DLL load failed while importing lowtran7: The specified module could not be found." + ] + } + ], + "source": [ + "observer_altitude = 10*km\n", + "target_range = 100*km\n", + "zenith_angle = 40.0*deg\n", + "wlshort = 100*nm\n", + "wllong = 20*μm\n", + "wlstep = 20.0/cm\n", + "\n", + "c1 = {\n", + " \"zmdl\": observer_altitude/km,\n", + " \"h1\": observer_altitude/km,\n", + " \"range_km\": target_range/km,\n", + " \"wlshort\": wlshort/nm,\n", + " \"wllong\": wllong/nm,\n", + " \"wlstep\": wlstep/(1.0/cm),\n", + " }\n", + "\n", + "T_atm = lowtran.horiztrans(c1).squeeze()\n", + "horiz(T_atm,c1)\n", + "#pb.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "57e96fa4-707a-43b3-ba1b-650bdd542153", + "metadata": {}, + "outputs": [], + "source": [ + "T_atm['wavelength_nm'].to_numpy()[::-1]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fee86c38-0985-4d72-a131-8c30d100904a", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "sandbox", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/meson.build b/meson.build new file mode 100644 index 0000000..831a702 --- /dev/null +++ b/meson.build @@ -0,0 +1,68 @@ +project('lowtran', + ['c', 'fortran'], + version : '0.1', + meson_version: '>= 1.1.0', + default_options : [ + 'warning_level=1', + 'buildtype=release', + #'fortran_args=-Wno-line-truncation -Wno-conversion -Wno-unused-variable -Wno-maybe-uninitialized -Wno-unused-dummy-argument -Wno-compare-reals', + 'fortran_std=legacy', + ], + ) + +py_mod = import('python') +py = py_mod.find_installation() +py_dep = py.dependency() +# Get the system-specific include directory + +py_include_dir = run_command(py, + ['-c', 'import sysconfig; print(sysconfig.get_path("include"))'], + check : true +).stdout().strip() +message('Python include dir', py_include_dir) + +py_install_dir = run_command(py, + ['-c', 'import sysconfig; print(sysconfig.get_path("purelib"))'], + check : true +).stdout().strip() + +message('Meson will install app in', py_install_dir) + +subdir('src/lowtran/fortran') + +py.install_sources( +[ + 'src/lowtran/__init__.py', 'src/lowtran/base.py', 'src/lowtran/plot.py', 'src/lowtran/scenarios.py', + ], + pure : false, + subdir : 'lowtran', +# install_dir: py_install_dir +) + +# Install bundled DLLs for Windows (Fortran runtime dependencies) +# DLLs are copied during wheel build process (see .github/workflows/build-wheels.yml) +# For local development, DLLs are not required if mingw64 is in PATH +if host_machine.system() == 'windows' + dll_files = [ + 'src/lowtran/libs/libgfortran-5.dll', + 'src/lowtran/libs/libgcc_s_seh-1.dll', + 'src/lowtran/libs/libquadmath-0.dll', + 'src/lowtran/libs/libwinpthread-1.dll', + ] + + # Only install DLLs if they exist (e.g., during wheel build) + dll_list = [] + foreach dll : dll_files + if import('fs').exists(dll) + dll_list += [dll] + endif + endforeach + + if dll_list.length() > 0 + py.install_sources( + dll_list, + pure : false, + subdir : 'lowtran/libs' + ) + endif +endif diff --git a/pyproject.toml b/pyproject.toml index d4ad752..9155f51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] -requires = ["setuptools>=61.0.0", "wheel", "numpy"] -build-backend = "setuptools.build_meta" +requires = ["meson-python>=0.16.0", "numpy"] +build-backend = "mesonpy" [project] name = "lowtran" @@ -16,16 +16,19 @@ classifiers = [ "Programming Language :: Fortran", "Topic :: Scientific/Engineering :: Atmospheric Science" ] -dynamic = ["readme"] +readme = {"file" = "README.md", "content-type" = "text/markdown"} + +maintainers = [ + { name = "Chaffra Affouda", email = "caffouda@anduril.com" }, +] + requires-python = ">=3.9" -dependencies = ["numpy", "xarray", "python-dateutil"] +dependencies = ["numpy", "python-dateutil", "pandas"] [project.optional-dependencies] -tests = ["pytest"] +test = ["pytest"] lint = ["flake8", "flake8-bugbear", "flake8-builtins", "flake8-blind-except", "mypy", "types-python-dateutil"] -[tool.setuptools.dynamic] -readme = {file = ["README.md"], content-type = "text/markdown"} [tool.black] line-length = 100 @@ -33,4 +36,6 @@ line-length = 100 [tool.mypy] files = ["src", "example"] allow_redefinition = true +show_error_context = false +show_column_numbers = true ignore_missing_imports = true diff --git a/pyproject.toml.off b/pyproject.toml.off new file mode 100644 index 0000000..d4ad752 --- /dev/null +++ b/pyproject.toml.off @@ -0,0 +1,36 @@ +[build-system] +requires = ["setuptools>=61.0.0", "wheel", "numpy"] +build-backend = "setuptools.build_meta" + +[project] +name = "lowtran" +version = "3.1.0" +description = " Model of Earth atmosphere absorption and transmission vs. wavelength and location." +keywords = ["mesosphere", "stratosphere", "thermosphere", "atmosphere"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Intended Audience :: Science/Research", + "Programming Language :: Fortran", + "Topic :: Scientific/Engineering :: Atmospheric Science" +] +dynamic = ["readme"] +requires-python = ">=3.9" +dependencies = ["numpy", "xarray", "python-dateutil"] + +[project.optional-dependencies] +tests = ["pytest"] +lint = ["flake8", "flake8-bugbear", "flake8-builtins", "flake8-blind-except", "mypy", "types-python-dateutil"] + +[tool.setuptools.dynamic] +readme = {file = ["README.md"], content-type = "text/markdown"} + +[tool.black] +line-length = 100 + +[tool.mypy] +files = ["src", "example"] +allow_redefinition = true +ignore_missing_imports = true diff --git a/src/lowtran/CMakeLists.txt b/src/lowtran/CMakeLists.txt deleted file mode 100644 index c519745..0000000 --- a/src/lowtran/CMakeLists.txt +++ /dev/null @@ -1,17 +0,0 @@ -cmake_minimum_required(VERSION 3.19...3.26) -# 3.17+ for Python_SOABI - -project(lowtran LANGUAGES Fortran) - -enable_testing() - -include(cmake/options.cmake) -include(cmake/compilers.cmake) - -add_library(lowtran fortran/lowtran7.f) - -install(TARGETS lowtran) - -include(cmake/f2pyTarget.cmake) - -f2py_target(lowtran7 ${CMAKE_CURRENT_SOURCE_DIR}/fortran/lowtran7.f ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/src/lowtran/__init__.py b/src/lowtran/__init__.py index fdd66dc..a74f304 100644 --- a/src/lowtran/__init__.py +++ b/src/lowtran/__init__.py @@ -14,7 +14,7 @@ www.dtic.mil/dtic/tr/fulltext/u2/a206773.pdf """ -from .base import check, golowtran, nm2lt7 +from .base import check, golowtran, nm2lt7, LowtranResult from .scenarios import ( scatter, irradiance, @@ -24,3 +24,17 @@ horiztrans, userhoriztrans, ) + +__all__ = [ + "check", + "golowtran", + "nm2lt7", + "LowtranResult", + "scatter", + "irradiance", + "radiance", + "transmittance", + "horizrad", + "horiztrans", + "userhoriztrans", +] diff --git a/src/lowtran/base.py b/src/lowtran/base.py index 08658e6..79a5978 100644 --- a/src/lowtran/base.py +++ b/src/lowtran/base.py @@ -1,25 +1,67 @@ from __future__ import annotations import logging -import xarray import numpy as np from typing import Any from pathlib import Path import importlib.util -import distutils.sysconfig +import sysconfig import os from types import ModuleType -from .cmake import build + +class LowtranResult: + """Structured container for Lowtran atmospheric model results. + + Attributes: + transmission: (time, wavelength, angle) transmission values + radiance: (time, wavelength, angle) radiance values + irradiance: (time, wavelength, angle) irradiance values + pathscatter: (time, wavelength, angle) path scatter values + time: time coordinates + wavelength_nm: wavelength coordinates in nm + angle_deg: angle coordinates in degrees + """ + __slots__ = ('transmission', 'radiance', 'irradiance', 'pathscatter', + 'time', 'wavelength_nm', 'angle_deg') + + def __init__(self, transmission: np.ndarray, radiance: np.ndarray, + irradiance: np.ndarray, pathscatter: np.ndarray, + time: np.ndarray, wavelength_nm: np.ndarray, angle_deg: np.ndarray): + self.transmission = transmission + self.radiance = radiance + self.irradiance = irradiance + self.pathscatter = pathscatter + self.time = time + self.wavelength_nm = wavelength_nm + self.angle_deg = angle_deg + + def __getitem__(self, key: str) -> np.ndarray: + """Dictionary-style access for backward compatibility.""" + return getattr(self, key) + + def __repr__(self) -> str: + return (f"LowtranResult(shape={self.transmission.shape}, " + f"wavelength=[{self.wavelength_nm[0]:.1f}, {self.wavelength_nm[-1]:.1f}] nm)") + + def squeeze(self): + """Return a new result with singleton dimensions removed.""" + return LowtranResult( + transmission=self.transmission.squeeze(), + radiance=self.radiance.squeeze(), + irradiance=self.irradiance.squeeze(), + pathscatter=self.pathscatter.squeeze(), + time=self.time.squeeze() if self.time.ndim > 0 else self.time, + wavelength_nm=self.wavelength_nm, + angle_deg=self.angle_deg.squeeze() if self.angle_deg.ndim > 0 else self.angle_deg + ) def check() -> ModuleType: - try: - lowtran7 = import_f2py_mod("lowtran7") - except ImportError: - src = Path(__file__).parent - build(source_dir=src, build_dir=src / "build") - lowtran7 = import_f2py_mod("lowtran7") + """Import the lowtran7 extension module. + The extension should be built with Meson and installed alongside this package. + """ + lowtran7 = import_f2py_mod("lowtran7") return lowtran7 @@ -27,19 +69,40 @@ def import_f2py_mod(name: str) -> ModuleType: if os.name == "nt": # https://github.com/space-physics/lowtran/issues/19 - # code inspired by scipy._distributor_init.py for loading DLLs on Window - dll_path = (Path(__file__) / "../build/lowtran7/.libs").resolve() - if dll_path.is_dir(): - # add the folder for Python 3.8 and above - logging.info(f"Adding {dll_path} to DLL search path") - os.add_dll_directory(dll_path) # type: ignore - else: - logging.info(f"Could not find {dll_path} to add to DLL search path") + # code inspired by scipy._distributor_init.py for loading DLLs on Windows - mod_name = name + distutils.sysconfig.get_config_var("EXT_SUFFIX") # type: ignore + # First, try to use bundled DLLs (installed alongside the package) + bundled_libs = Path(__file__).parent / "libs" + if bundled_libs.is_dir(): + logging.info(f"Adding {bundled_libs} to DLL search path (bundled runtime)") + os.add_dll_directory(str(bundled_libs)) # type: ignore + else: + # Fallback: look for mingw64/bin for gfortran runtime DLLs + mingw_paths = [ + Path("C:/mingw64/bin"), + Path("C:/msys64/mingw64/bin"), + Path("C:/msys64/ucrt64/bin"), + ] + for mingw_path in mingw_paths: + if mingw_path.is_dir(): + logging.info(f"Adding {mingw_path} to DLL search path for Fortran runtime") + os.add_dll_directory(str(mingw_path)) # type: ignore + break + + mod_name = name + sysconfig.get_config_var("EXT_SUFFIX") # type: ignore mod_file = Path(__file__).parent / mod_name if not mod_file.is_file(): - raise ModuleNotFoundError(mod_file) + # Editable installs with meson-python keep the built .pyd in + # the build tree rather than next to __init__.py. Walk up to + # the repo root and search ``build/*/src/lowtran/fortran/`` + # before giving up. + for candidate in Path(__file__).resolve().parents: + hit = sorted(candidate.glob(f'build/*/src/lowtran/fortran/{mod_name}')) + if hit: + mod_file = hit[-1] + break + if not mod_file.is_file(): + raise ModuleNotFoundError(mod_file) spec = importlib.util.spec_from_file_location(name, mod_file) if spec is None: raise ModuleNotFoundError(f"{name} not found in {mod_file}") @@ -88,8 +151,8 @@ def loopuserdef(c1: dict[str, Any]): ), "WMOL, P, T,time must be vectors of equal length" N = len(P) - # %% 3-D array indexed by metadata - TR = xarray.Dataset(coords={"time": time, "wavelength_nm": None, "angle_deg": None}) + # %% accumulate results + results = [] for i in range(N): c = c1.copy() @@ -98,11 +161,18 @@ def loopuserdef(c1: dict[str, Any]): c["t"] = T[i] c["time"] = time[i] - TR = TR.merge(golowtran(c)) - - # TR = TR.sort_index(axis=0) # put times in order, sometimes CSV is not monotonic in time. - - return TR + results.append(golowtran(c)) + + # Concatenate along time axis + return LowtranResult( + transmission=np.concatenate([r.transmission for r in results], axis=0), + radiance=np.concatenate([r.radiance for r in results], axis=0), + irradiance=np.concatenate([r.irradiance for r in results], axis=0), + pathscatter=np.concatenate([r.pathscatter for r in results], axis=0), + time=time, + wavelength_nm=results[0].wavelength_nm, + angle_deg=results[0].angle_deg, + ) def loopangle(c1: dict[str, Any]): @@ -110,14 +180,23 @@ def loopangle(c1: dict[str, Any]): loop over "ANGLE" """ angles = np.atleast_1d(c1["angle"]) - TR = xarray.Dataset(coords={"wavelength_nm": None, "angle_deg": angles}) + results = [] for a in angles: c = c1.copy() c["angle"] = a - TR = TR.merge(golowtran(c)) - - return TR + results.append(golowtran(c)) + + # Concatenate along angle axis + return LowtranResult( + transmission=np.concatenate([r.transmission for r in results], axis=2), + radiance=np.concatenate([r.radiance for r in results], axis=2), + irradiance=np.concatenate([r.irradiance for r in results], axis=2), + pathscatter=np.concatenate([r.pathscatter for r in results], axis=2), + time=results[0].time, + wavelength_nm=results[0].wavelength_nm, + angle_deg=angles, + ) def golowtran(c1: dict[str, Any]): @@ -172,19 +251,12 @@ def golowtran(c1: dict[str, Any]): c1["range_km"], ) - dims = ("time", "wavelength_nm", "angle_deg") - TR = xarray.Dataset( - { - "transmission": (dims, Tx[:, 9][None, :, None]), - "radiance": (dims, sumvv[None, :, None]), - "irradiance": (dims, irrad[:, 0][None, :, None]), - "pathscatter": (dims, irrad[:, 2][None, :, None]), - }, - coords={ - "time": [c1["time"]], - "wavelength_nm": Alam * 1e3, - "angle_deg": [c1["angle"]], - }, + return LowtranResult( + transmission=Tx[:, 9][None, :, None], + radiance=sumvv[None, :, None], + irradiance=irrad[:, 0][None, :, None], + pathscatter=irrad[:, 2][None, :, None], + time=np.array([c1["time"]]), + wavelength_nm=Alam * 1e3, + angle_deg=np.array([c1["angle"]]), ) - - return TR diff --git a/src/lowtran/cmake.py b/src/lowtran/cmake.py deleted file mode 100644 index fc63de7..0000000 --- a/src/lowtran/cmake.py +++ /dev/null @@ -1,32 +0,0 @@ -import subprocess -import shutil -from pathlib import Path -import os -import logging - -__all__ = ["build"] - - -def build(source_dir: Path, build_dir: Path) -> None: - """build with CMake""" - cmake = shutil.which("cmake") - if not cmake: - raise FileNotFoundError("CMake not found. Try:\n pip install cmake") - - gen = os.environ.get("CMAKE_GENERATOR", "") - if not gen or "Visual Studio" in gen: - if shutil.which("ninja") or shutil.which("samu") or shutil.which("ninja-build"): - gen = "Ninja" - elif os.name == "nt" and shutil.which("mingw32-make"): - gen = "MinGW Makefiles" - else: - gen = "Unix Makefiles" - - # %% Configure - cmd = [cmake, f"-B{build_dir}", f"-S{source_dir}", f"-G{gen}"] - logging.info(" ".join(cmd)) - subprocess.check_call(cmd) - # %% Build - cmd = [cmake, "--build", str(build_dir), "--parallel"] - logging.info(" ".join(cmd)) - subprocess.check_call(cmd) diff --git a/src/lowtran/cmake/compilers.cmake b/src/lowtran/cmake/compilers.cmake deleted file mode 100644 index 45399fd..0000000 --- a/src/lowtran/cmake/compilers.cmake +++ /dev/null @@ -1,5 +0,0 @@ -if(CMAKE_Fortran_COMPILER_ID STREQUAL "GNU") - add_compile_options("$<$:-std=legacy;-w>") -elseif(CMAKE_Fortran_COMPILER_ID MATCHES "^Intel") - add_compile_options("$<$:-w>") -endif() diff --git a/src/lowtran/cmake/f2py.cmake b/src/lowtran/cmake/f2py.cmake deleted file mode 100644 index 05c12ec..0000000 --- a/src/lowtran/cmake/f2py.cmake +++ /dev/null @@ -1,43 +0,0 @@ -# f2py - -find_package(Python COMPONENTS Interpreter NumPy REQUIRED) - -if(CMAKE_Fortran_COMPILER_ID STREQUAL "GNU" AND - CMAKE_Fortran_COMPILER_VERSION VERSION_GREATER_EQUAL 10 AND - Python_NumPy_VERSION VERSION_LESS 1.19) - message(FATAL_ERROR "Numpy >= 1.19 required for GCC >= 10") -endif() - -find_program(f2py NAMES f2py REQUIRED) - -if(f2py_suffix) - return() -endif() - -execute_process( -COMMAND ${Python_EXECUTABLE} -c "import sysconfig; x=sysconfig.get_config_var('EXT_SUFFIX'); assert x is not None; print(x)" -OUTPUT_STRIP_TRAILING_WHITESPACE -RESULT_VARIABLE ret -OUTPUT_VARIABLE out -ERROR_VARIABLE err -) - -if(NOT ret EQUAL 0) - -message(VERBOSE "${ret}: ${out}: ${err}") - -execute_process( -COMMAND ${Python_EXECUTABLE} -c "import distutils.sysconfig; x=distutils.sysconfig.get_config_var('EXT_SUFFIX'); assert x is not None; print(x)" -OUTPUT_STRIP_TRAILING_WHITESPACE -RESULT_VARIABLE ret -OUTPUT_VARIABLE out -ERROR_VARIABLE err -) - -endif() - -if(NOT ret EQUAL 0) - message(FATAL_ERROR "${ret}: ${out}: ${err}: could not determine f2py output file suffix") -endif() - -set(f2py_suffix ${out} CACHE STRING "f2py file suffix") diff --git a/src/lowtran/cmake/f2pyTarget.cmake b/src/lowtran/cmake/f2pyTarget.cmake deleted file mode 100644 index f7bfad5..0000000 --- a/src/lowtran/cmake/f2pyTarget.cmake +++ /dev/null @@ -1,29 +0,0 @@ -include(${CMAKE_CURRENT_LIST_DIR}/f2py.cmake) - - -function(f2py_target module_name module_src out_dir) - -set(f2py_bin ${CMAKE_CURRENT_BINARY_DIR}/${module_name}${f2py_suffix}) - -set(f2py_arg -m ${module_name} -c ${module_src}) -if(CMAKE_Fortran_COMPILER_ID MATCHES "^Intel") - if(WIN32) - list(APPEND f2py_arg --fcompiler=intelvem) - else() - list(APPEND f2py_arg --fcompiler=intelem) - endif() -endif() - -add_custom_command( -OUTPUT ${f2py_bin} -COMMAND ${f2py} ${f2py_arg} -) - -add_custom_target(${module_name} ALL DEPENDS ${f2py_bin}) - -add_custom_command( -TARGET ${module_name} POST_BUILD -COMMAND ${CMAKE_COMMAND} -E copy ${f2py_bin} ${out_dir}/ -) - -endfunction(f2py_target) diff --git a/src/lowtran/cmake/options.cmake b/src/lowtran/cmake/options.cmake deleted file mode 100644 index f9ff8e7..0000000 --- a/src/lowtran/cmake/options.cmake +++ /dev/null @@ -1,10 +0,0 @@ -include(GNUInstallDirs) - -# Necessary for shared library with Visual Studio / Windows oneAPI -set(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS true) - -if(PROJECT_IS_TOP_LEVEL AND CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set_property(CACHE CMAKE_INSTALL_PREFIX PROPERTY VALUE "${PROJECT_BINARY_DIR}") -endif() - -file(GENERATE OUTPUT .gitignore CONTENT "*") diff --git a/src/lowtran/fortran/meson.build b/src/lowtran/fortran/meson.build new file mode 100644 index 0000000..82f1152 --- /dev/null +++ b/src/lowtran/fortran/meson.build @@ -0,0 +1,39 @@ +fc = meson.get_compiler('fortran') + +incdir_numpy = run_command(py, + ['-c', 'import numpy; print(numpy.get_include());'], + check : true +).stdout().strip() +message('incdir_numpy', incdir_numpy) + +incdir_f2py = run_command(py, + ['-c', 'import numpy.f2py; print(numpy.f2py.get_include())'], + check : true +).stdout().strip() +message('incdir_f2py', incdir_f2py) + +lowtran7_source = custom_target('lowtran7module.c', + input: ['lowtran7.f'], + output: ['lowtran7module.c', 'lowtran7-f2pywrappers.f'], + command: [py, '-m', 'numpy.f2py', '@INPUT@', '-m', 'lowtran7', '--lower', '--build-dir', '@BUILD_ROOT@/src/lowtran/fortran'] + ) + +#inc_np = include_directories(incdir_numpy, incdir_f2py,) +#np_dep = declare_dependency(include_directories: inc_np) + +#incdir_f2py = incdir_numpy / '..' / '..' / 'f2py' / 'src' +#inc_f2py = include_directories(incdir_f2py) +fortranobject_c = incdir_f2py / 'fortranobject.c' + +inc_np = include_directories(incdir_numpy, incdir_f2py) +quadmath_dep = fc.find_library('quadmath', required: false) +#mkl_dep = fc.find_library('mkl_core', required: false) + +py.extension_module('lowtran7', + sources: ['lowtran7.f', lowtran7_source, fortranobject_c], + include_directories: [ inc_np, py_include_dir], + dependencies : [py_dep, quadmath_dep,], + install : true, +# install_dir : py_install_dir + subdir: 'lowtran' + ) diff --git a/src/lowtran/plot.py b/src/lowtran/plot.py index 1b40882..98b27ff 100644 --- a/src/lowtran/plot.py +++ b/src/lowtran/plot.py @@ -1,8 +1,9 @@ from __future__ import annotations import numpy as np -import xarray from matplotlib.pyplot import figure -from typing import Any +from typing import Any, Union + +from .base import LowtranResult # h = 6.62607004e-34 @@ -11,7 +12,7 @@ plotNp = False -def scatter(irrad: xarray.Dataset, c1: dict[str, Any], log: bool = False) -> None: +def scatter(irrad: Union[LowtranResult, dict[str, Any]], c1: dict[str, Any], log: bool = False) -> None: fg = figure() axs = fg.subplots(2, 1, sharex=True) @@ -19,19 +20,19 @@ def scatter(irrad: xarray.Dataset, c1: dict[str, Any], log: bool = False) -> Non transtxt = "Transmittance" ax = axs[0] - ax.plot(irrad.wavelength_nm, irrad["transmission"].squeeze()) + ax.plot(irrad["wavelength_nm"], irrad["transmission"].squeeze()) ax.set_title(transtxt) ax.set_ylabel("Transmission (unitless)") ax.grid(True) - ax.legend(irrad.angle_deg.values) + ax.legend(irrad["angle_deg"]) ax = axs[1] if plotNp: - Np = (irrad["pathscatter"] * 10000) * (irrad.wavelength_nm * 1e9) / (h * c) - ax.plot(irrad.wavelength_nm, Np) + Np = (irrad["pathscatter"] * 10000) * (irrad["wavelength_nm"] * 1e9) / (h * c) + ax.plot(irrad["wavelength_nm"], Np) ax.set_ylabel("Photons [s$^{-1}$ " + UNITS) else: - ax.plot(irrad.wavelength_nm, irrad["pathscatter"].squeeze()) + ax.plot(irrad["wavelength_nm"], irrad["pathscatter"].squeeze()) ax.set_ylabel("Radiance [W " + UNITS) ax.set_xlabel("wavelength [nm]") @@ -46,30 +47,29 @@ def scatter(irrad: xarray.Dataset, c1: dict[str, Any], log: bool = False) -> Non try: fg.suptitle(f'Obs. to Space: zenith angle: {c1["angle"]} deg., ') - # {datetime.utcfromtimestamp(irrad.time.item()/1e9)} - except (AttributeError, TypeError): + except (AttributeError, TypeError, KeyError): pass -def radiance(irrad: xarray.Dataset, c1: dict[str, Any], log: bool = False) -> None: +def radiance(irrad: Union[LowtranResult, dict[str, Any]], c1: dict[str, Any], log: bool = False) -> None: fg = figure() axs = fg.subplots(2, 1, sharex=True) transtxt = "Transmittance Observer to Space" ax = axs[0] - ax.plot(irrad.wavelength_nm, irrad["transmission"].squeeze()) + ax.plot(irrad["wavelength_nm"], irrad["transmission"].squeeze()) ax.set_title(transtxt) ax.set_ylabel("Transmission (unitless)") ax.grid(True) ax = axs[1] if plotNp: - Np = (irrad["radiance"] * 10000) * (irrad.wavelength_nm * 1e9) / (h * c) - ax.plot(irrad.wavelength_nm, Np) + Np = (irrad["radiance"] * 10000) * (irrad["wavelength_nm"] * 1e9) / (h * c) + ax.plot(irrad["wavelength_nm"], Np) ax.set_ylabel("Photons [s$^{-1}$ " + UNITS) else: - ax.plot(irrad.wavelength_nm, irrad["radiance"].squeeze()) + ax.plot(irrad["wavelength_nm"], irrad["radiance"].squeeze()) ax.set_ylabel("Radiance [W " + UNITS) ax.set_xlabel("wavelength [nm]") @@ -84,12 +84,11 @@ def radiance(irrad: xarray.Dataset, c1: dict[str, Any], log: bool = False) -> No try: fg.suptitle(f'Obs. zenith angle: {c1["angle"]} deg., ') - # {datetime.utcfromtimestamp(irrad.time.item()/1e9)} - except (AttributeError, TypeError): + except (AttributeError, TypeError, KeyError): pass -def radtime(TR: xarray.Dataset, c1: dict[str, Any], log: bool = False) -> None: +def radtime(TR: Union[LowtranResult, dict[str, Any]], c1: dict[str, Any], log: bool = False) -> None: """ make one plot per time for now. @@ -98,14 +97,35 @@ def radtime(TR: xarray.Dataset, c1: dict[str, Any], log: bool = False) -> None: radiance is currently single-scatter solar """ - for t in TR.time: # for each time - irradiance(TR.sel(time=t), c1, log) - - -def transmission(T: xarray.Dataset, c1: dict[str, Any], log: bool = False) -> None: + for i, t in enumerate(TR["time"]): # for each time + # Extract slice for this time + if isinstance(TR, LowtranResult): + tr_slice = LowtranResult( + transmission=TR.transmission[i:i+1, :, :], + radiance=TR.radiance[i:i+1, :, :], + irradiance=TR.irradiance[i:i+1, :, :], + pathscatter=TR.pathscatter[i:i+1, :, :], + time=TR.time[i:i+1], + wavelength_nm=TR.wavelength_nm, + angle_deg=TR.angle_deg, + ) + else: + tr_slice = { + "transmission": TR["transmission"][i:i+1, :, :], + "radiance": TR["radiance"][i:i+1, :, :], + "irradiance": TR["irradiance"][i:i+1, :, :], + "pathscatter": TR["pathscatter"][i:i+1, :, :], + "time": TR["time"][i:i+1], + "wavelength_nm": TR["wavelength_nm"], + "angle_deg": TR["angle_deg"], + } + irradiance(tr_slice, c1, log) + + +def transmission(T: Union[LowtranResult, dict[str, Any]], c1: dict[str, Any], log: bool = False) -> None: ax = figure().gca() - h = ax.plot(T.wavelength_nm, T["transmission"].squeeze()) + h = ax.plot(T["wavelength_nm"], T["transmission"].squeeze()) ax.set_xlabel("wavelength [nm]") ax.set_ylabel("transmission (unitless)") @@ -119,24 +139,19 @@ def transmission(T: xarray.Dataset, c1: dict[str, Any], log: bool = False) -> No ax.set_ylim(0, 1) ax.invert_xaxis() ax.autoscale(True, axis="x", tight=True) - ax.legend(h, T.angle_deg.values) + ax.legend(h, T["angle_deg"]) -def irradiance(irrad: xarray.Dataset, c1: dict[str, Any], log: bool = False) -> None: +def irradiance(irrad: Union[LowtranResult, dict[str, Any]], c1: dict[str, Any], log: bool = False) -> None: fg = figure() axs = fg.subplots(2, 1, sharex=True) - # if c1['isourc'] == 0: stxt = "Sun's" - # elif c1['isourc'] == 1: - # stxt = "Moon's" - # else: - # raise ValueError(f'ISOURC={c1["isourc"]} not defined case') - stxt += f' zenith angle {irrad.angle_deg.values} deg., Obs. height {c1["h1"]} km. ' + stxt += f' zenith angle {irrad["angle_deg"]} deg., Obs. height {c1["h1"]} km. ' try: - stxt += np.datetime_as_string(irrad.time)[:-10] - except (AttributeError, TypeError): + stxt += np.datetime_as_string(irrad["time"])[:-10] + except (AttributeError, TypeError, KeyError): pass fg.suptitle(stxt) @@ -148,20 +163,18 @@ def irradiance(irrad: xarray.Dataset, c1: dict[str, Any], log: bool = False) -> key = "radiance" transtxt = "Transmittance Observer to Observer" - # irrad.['transmission'].plot() - ax = axs[0] - h = ax.plot(irrad.wavelength_nm, irrad["transmission"].squeeze()) + h = ax.plot(irrad["wavelength_nm"], irrad["transmission"].squeeze()) ax.set_title(transtxt) ax.set_ylabel("Transmission (unitless)") ax.grid(True) try: - ax.legend(h, irrad.angle_deg.values) - except AttributeError: + ax.legend(h, irrad["angle_deg"]) + except (AttributeError, KeyError): pass ax = axs[1] - ax.plot(irrad.wavelength_nm, irrad[key].squeeze()) + ax.plot(irrad["wavelength_nm"], irrad[key].squeeze()) ax.set_xlabel("wavelength [nm]") ax.invert_xaxis() ax.grid(True) @@ -182,7 +195,7 @@ def irradiance(irrad: xarray.Dataset, c1: dict[str, Any], log: bool = False) -> ax.autoscale(True, axis="x", tight=True) -def horiz(trans: xarray.Dataset, c1: dict[str, Any], log: bool = False) -> None: +def horiz(trans: Union[LowtranResult, dict[str, Any]], c1: dict[str, Any], log: bool = False) -> None: ttxt = f'Transmittance Horizontal \n {c1["range_km"]} km path @ {c1["h1"]} km altitude\n' @@ -193,7 +206,7 @@ def horiz(trans: xarray.Dataset, c1: dict[str, Any], log: bool = False) -> None: ax = figure().gca() - ax.plot(trans.wavelength_nm, trans["transmission"].squeeze()) + ax.plot(trans["wavelength_nm"], trans["transmission"].squeeze()) ax.set_xlabel("wavelength [nm]") ax.set_ylabel("transmission (unitless)") diff --git a/src/lowtran/scenarios.py b/src/lowtran/scenarios.py index 9b15798..08df5c4 100644 --- a/src/lowtran/scenarios.py +++ b/src/lowtran/scenarios.py @@ -2,7 +2,6 @@ from pathlib import Path from pandas import read_csv from dateutil.parser import parse -import xarray from typing import Any import numpy as np @@ -64,8 +63,7 @@ def horizrad(infn: Path, outfn: Path, c1: dict[str, Any]): infn = Path(infn).expanduser() if infn.suffix == ".h5": - TR = xarray.open_dataset(infn) - return TR + raise NotImplementedError("HDF5 loading is no longer supported. Use numpy .npz format instead.") c1.update( {