From 89fdca8c2608144da928a8bc4cef1126b92e7c20 Mon Sep 17 00:00:00 2001 From: Craig Miller <5473482+craigmillernz@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:25:03 +1300 Subject: [PATCH 01/21] update files with new build from ESNZ --- CHANGELOG | 2 + README.md | 86 ++++--- meson.build | 71 ++++++ pygtide/__init__.py | 21 +- pygtide/core.py | 233 ++++++++++------- pygtide/update_etpred_data.py | 467 ++++++++++++++++++++++------------ pyproject.toml | 46 ++++ setup.cfg | 4 - setup.py | 33 --- 9 files changed, 640 insertions(+), 323 deletions(-) create mode 100644 meson.build create mode 100644 pyproject.toml delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/CHANGELOG b/CHANGELOG index db42ce8..af23eb7 100755 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,5 @@ +v0.8.0: + * modernisation of update script and test script and build system by Craig Miller, ESNZ. v0.7.1: * record length was corrected * length of day (LOD) tide is now properly interpolated diff --git a/README.md b/README.md index 3b764c9..c6227a6 100644 --- a/README.md +++ b/README.md @@ -14,49 +14,67 @@ There are two options: ## How to install and run -Instructions: +### Prerequisites + * Download and install [*Anaconda*](https://www.anaconda.com/products/distribution) or [*Miniconda*](https://docs.conda.io/en/latest/miniconda.html) +* Install required packages: + ``` + conda install numpy pandas requests git + ``` -* Make sure the following packages are installed:
- `conda install numpy pandas requests git` - -* Download and install pygtide: - * *Linux* or *MacOS*:
- **NOTE**: Make sure suitable C++ and Fortran compilers are available. - ``` - pip install pygtide - ``` - - * *Windows*:
- Download the correct wheel for your Python version (available for 3.8 to 3.11) from the subfolder "windows" onto your system. - Then navigate your Anaconda explorer to the download location and execute: - ``` - pip install [wheel_name_depending_on_python_version] - ``` - -* Run tests:
- ``` - python -c "import pygtide; pygtide.test(msg=True)" - ``` -* The internal database files can be updated as follows:
- ``` - python -c "import pygtide; pygtide.update()" - ``` -* See `pygtide/tests.py` for example usage: +### Installation options +#### Option 1: Pre-built wheels (Windows, Python 3.8–3.11) +Download the correct wheel for your Python version from the `windows/` subfolder and install: +```powershell +pip install [wheel_name_depending_on_python_version] ``` -from pygtide import predict_series -args = (-20.82071, -70.15288, 830.0, '2020-01-01', 6, 600) -series = predict_series(*args, statazimut=90, tidalcompo=8) + +#### Option 2: Build from source (Linux, macOS, Windows; Python 3.8–3.14) + +**Requirements for building:** +- A Fortran compiler (e.g., `gfortran` via MinGW on Windows; included in Linux/macOS gcc toolchains) +- Meson build system: automatically installed via `pip` (see below) +- Ninja (optional but recommended): `conda install ninja` or `pip install ninja` + +**Install from GitHub:** +```bash +pip install git+https://github.com/hydrogeoscience/pygtide.git +``` + +**Install from local repository:** +```bash +cd /path/to/pygtide +pip install -e . +``` + +If Meson or Ninja are missing, pip will attempt to install them automatically. For faster builds, pre-install them: +```bash +pip install meson-python meson ninja ``` -* Development version: This can be installed by downloading the Github repository and running: - `pip install download_path`.
- Alternatively, in one step as:
+### After installation + +* Run tests to verify installation: + ``` + python -c "import pygtide; pygtide.test(msg=True)" + ``` + +* Update internal database files (downloads latest leap seconds and pole data): ``` - pip install git+https://github.com/hydrogeoscience/pygtide.git + python -c "import pygtide; pygtide.update()" ``` +### Example usage + +See `pygtide/tests.py` for complete examples. Quick start: + +```python +from pygtide import predict_series +args = (-20.82071, -70.15288, 830.0, '2020-01-01', 6, 600) +series = predict_series(*args, statazimut=90, tidalcompo=8) +``` + ## How to use An updated user guide is currently in progress ... diff --git a/meson.build b/meson.build new file mode 100644 index 0000000..4d55acf --- /dev/null +++ b/meson.build @@ -0,0 +1,71 @@ +project('pygtide', 'fortran', + version : '0.81', + license : 'MPL-2.0', + meson_version : '>=1.1.0', +) + +# Get Python installation +py = import('python').find_installation() + +# Determine file extension based on OS +host_os = host_machine.system() +if host_os == 'windows' + ext = 'pyd' +else + ext = 'so' +endif + +# Source file +src = 'src/etpred.f90' + +# Build the Fortran extension module using f2py +# f2py generates a version-specific filename like etpred.cp314-win_amd64.pyd +etpred_module = custom_target( + 'etpred_build', + input : src, + output : 'etpred.' + ext, + command : [ + py, + '-m', 'numpy.f2py', + '-c', + '--f90flags=-fPIC -O3', + '-m', 'etpred', + '@INPUT@', + ], + build_by_default : true, +) + +# Final copy: after the module is built, copy the versioned file into the +# package directory with a stable name `pygtide/etpred.{ext}` so imports work. +copy_code = 'import glob,shutil,os; os.chdir(os.environ.get("MESON_SOURCE_ROOT","../..")); files=glob.glob(os.path.join("build","**","etpred*.' + ext + '"),recursive=True); files and shutil.copy(files[0],os.path.join("pygtide","etpred.' + ext + '")) and print("Copied to pygtide/etpred.' + ext + '")' + +final_copy = custom_target( + 'copy_etpred_final', + input : etpred_module, + output : 'copy_etpred.stamp', + command : [py, '-c', copy_code], + build_by_default : true, + depends : etpred_module, +) + +# Install package data (commdat files) +install_subdir( + 'pygtide/commdat', + install_dir : py.get_install_dir() / 'pygtide', + exclude_files : ['.gitkeep'] +) + +# Install the .pyd/.so file that was copied to pygtide/ by the copy_etpred_final target +install_data( + 'pygtide/etpred.' + ext, + install_dir : py.get_install_dir() / 'pygtide', +) + +# Install Python source files +install_data( + 'pygtide/__init__.py', + 'pygtide/core.py', + 'pygtide/tests.py', + 'pygtide/update_etpred_data.py', + install_dir : py.get_install_dir() / 'pygtide', +) diff --git a/pygtide/__init__.py b/pygtide/__init__.py index ebe224a..a6c5f16 100755 --- a/pygtide/__init__.py +++ b/pygtide/__init__.py @@ -2,5 +2,22 @@ from pygtide.core import predict_series, predict_spectrum, predict_table from pygtide.core import plot_series, plot_spectrum from pygtide.tests import test -# from pygtide.update_etpred_data import update -__version__ = '0.8' +from pygtide.update_etpred_data import update + +from importlib.metadata import version + +try: + __version__ = version("pygtide") +except Exception: + __version__ = "0.8.0" + +__all__ = [ + "pygtide", + "predict_series", + "predict_spectrum", + "predict_table", + "plot_series", + "plot_spectrum", + "test", + "update", +] diff --git a/pygtide/core.py b/pygtide/core.py index 4e069ed..11fec21 100755 --- a/pygtide/core.py +++ b/pygtide/core.py @@ -90,83 +90,110 @@ sampling rate was lower than 60 seconds. This was successfully fixed. =============================================================================== """ + import pygtide.etpred as etpred from datetime import datetime, timedelta, date from warnings import warn import numpy as np import pandas as pd -from pkg_resources import resource_filename +from importlib import resources import os + class pygtide(object): """ The PyGTide class will initialise internal variables """ + def __init__(self, msg=True): """ pygtide.init() initialises the etpred (Fortran) module and sets global variables """ self.msg = msg self.fortran_version = etpred.inout.vers.astype(str) - self.data_dir = resource_filename('pygtide', 'commdat/') - etpred.params.comdir = self.data_dir + ' ' * (256 - len(self.data_dir)) - # set OS dependent module output - etpred.params.nullfile = os.devnull + ' ' * (10 - len(os.devnull)) + # Resolve package data directory directly from filesystem. + # Avoid importlib.resources.as_file() which creates temp dirs that get cleaned up. + pkg_dir = os.path.dirname(__file__) + self.data_dir = os.path.join(pkg_dir, "commdat") + if not self.data_dir.endswith(os.sep): + self.data_dir += os.sep + + # Fortran expects a fixed-length string (256 chars) + etpred.params.comdir = self.data_dir + " " * (256 - len(self.data_dir)) + + # OS-dependent null file + etpred.params.nullfile = os.devnull + " " * (10 - len(os.devnull)) + self.args = [] - # set some common variables for external access + + # Initialise Fortran module etpred.init() + # capture end date of file "etddt.dat" from module year = int(etpred.inout.etd_start) self.etddt_start = datetime(year, 1, 1) year = etpred.inout.etd_end - self.etddt_end = (datetime(int(year), 1, 1) + timedelta(days=(year - int(year)) * 365)) + self.etddt_end = datetime(int(year), 1, 1) + timedelta( + days=(year - int(year)) * 365 + ) + # capture end date of file "etpolut1.dat" from module self.etpolut1_start = datetime.strptime(str(etpred.inout.etpol_start), "%Y%m%d") self.etpolut1_end = datetime.strptime(str(etpred.inout.etpol_end), "%Y%m%d") - self.headers = np.char.strip(etpred.inout.header.astype('str')) + self.headers = np.char.strip(etpred.inout.header.astype("str")) # self.units = ['(m/s)**2','nm/s**2','mas','mm','mm','nstr','nstr','nstr','nstr','nstr','mm'] self.exec = False - self.wavegroup_def = np.asarray([[0, 10, 1., 0.]]) + self.wavegroup_def = np.asarray([[0, 10, 1, 0.0]]) self.set_wavegroup(self.wavegroup_def) - #%% sync the Python object + # %% sync the Python object def update(self): """ self.update() refreshes the variables of PyGTide based on the Fortran module etpred """ - self.headers = np.char.strip(etpred.inout.header.astype('str')) + self.headers = np.char.strip(etpred.inout.header.astype("str")) self.args = etpred.inout.argsin - self.unit = etpred.inout.etpunit.astype('str') + self.unit = etpred.inout.etpunit.astype("str") - #%% set wave group parameters + # %% set wave group parameters def set_wavegroup(self, wavedata=None): - if (wavedata is None): + if wavedata is None: wavedata = self.wavegroup_def # require at least 4 columns - if (wavedata.shape[1] != 4): + if wavedata.shape[1] != 4: raise ValueError("The wave group input must have 4 columns!") return False # require frequency ranges to increase and not overlap freq_diffs = np.diff(wavedata[:, 0:1].flatten()) - if ((freq_diffs < 0).any()): - raise ValueError("Wave group frequency ranges must be increasing and not overlapping!") + if (freq_diffs < 0).any(): + raise ValueError( + "Wave group frequency ranges must be increasing and not overlapping!" + ) return False - if ((wavedata[:, 2] < 0).any()): + if (wavedata[:, 2] < 0).any(): raise ValueError("Amplitude factors must be positive!") return False # set the wave group parameters - etpred.waves(wavedata[:, 0], wavedata[:, 1], wavedata[:, 2], wavedata[:, 3], int(wavedata.shape[0])) + etpred.waves( + wavedata[:, 0], + wavedata[:, 1], + wavedata[:, 2], + wavedata[:, 3], + int(wavedata.shape[0]), + ) return True - #%% reset the wave group + # %% reset the wave group def reset_wavegroup(self): self.set_wavegroup(self.wavegroup_def) return True # run module etpred and return numbers - def predict(self, latitude, longitude, height, startdate, duration, samprate, **control): + def predict( + self, latitude, longitude, height, startdate, duration, samprate, **control + ): """ self.predict(latitude, longitude, height, startdate, duration, samprate, **control): ------------------------------------------------------------------------------- @@ -256,77 +283,77 @@ def predict(self, latitude, longitude, height, startdate, duration, samprate, ** # tidal catalog argsin[10] = 8 # amplitude truncation - argsin[12] = 1.0E-10 + argsin[12] = 1.0e-10 # values from: https://dx.doi.org/10.1016/j.jog.2005.08.035 argsin[13] = 1.16 argsin[14] = 1.16 # iterate through optional arguments passed - if 'statgravit' in control: - if not (0 <= control['statgravit'] <= 20): - raise ValueError('Station gravity exceeds permissible range!') + if "statgravit" in control: + if not (0 <= control["statgravit"] <= 20): + raise ValueError("Station gravity exceeds permissible range!") else: - argsin[8] = control['statgravit'] - if 'statazimut' in control: - if not (0 <= control['statazimut'] <= 180): - raise ValueError('Statazimut exceeds permissible range!') + argsin[8] = control["statgravit"] + if "statazimut" in control: + if not (0 <= control["statazimut"] <= 180): + raise ValueError("Statazimut exceeds permissible range!") else: - argsin[9] = control['statazimut'] - if 'tidalpoten' in control: - if control['tidalpoten'] not in range(1,9): - raise ValueError('Tidalpoten must be an integer between 1 and 8!') + argsin[9] = control["statazimut"] + if "tidalpoten" in control: + if control["tidalpoten"] not in range(1, 9): + raise ValueError("Tidalpoten must be an integer between 1 and 8!") else: - argsin[10] = control['tidalpoten'] - if 'tidalcompo' in control: - if control['tidalcompo'] not in range(-1,10): - raise ValueError('Tidalcompo must be an integer between -1 and 9!') + argsin[10] = control["tidalpoten"] + if "tidalcompo" in control: + if control["tidalcompo"] not in range(-1, 10): + raise ValueError("Tidalcompo must be an integer between -1 and 9!") else: - argsin[11] = control['tidalcompo'] - if 'amtruncate' in control: - if not (0 <= control['amtruncate']): - raise ValueError('Amtruncate must be greater than 0!') + argsin[11] = control["tidalcompo"] + if "amtruncate" in control: + if not (0 <= control["amtruncate"]): + raise ValueError("Amtruncate must be greater than 0!") else: - argsin[12] = control['amtruncate'] - if 'poltidecor' in control: - if not (control['poltidecor'] >= 0): - raise ValueError('Poltidecor must be >= 0!') + argsin[12] = control["amtruncate"] + if "poltidecor" in control: + if not (control["poltidecor"] >= 0): + raise ValueError("Poltidecor must be >= 0!") else: - argsin[13] = control['poltidecor'] - if 'lodtidecor' in control: - if not (control['lodtidecor'] >= 0): - raise ValueError('Lodtidecor must be >= 0!') + argsin[13] = control["poltidecor"] + if "lodtidecor" in control: + if not (control["lodtidecor"] >= 0): + raise ValueError("Lodtidecor must be >= 0!") else: - argsin[14] = control['lodtidecor'] + argsin[14] = control["lodtidecor"] # additional control parameters - if 'fileprd' in control: - if control['fileprd'] not in range(0,2): - raise ValueError('Fileprd flag must be 0 or 1!') + if "fileprd" in control: + if control["fileprd"] not in range(0, 2): + raise ValueError("Fileprd flag must be 0 or 1!") else: - argsin[15] = control['fileprd'] - if 'fileprn' in control: - if control['fileprn'] not in range(0,2): - raise ValueError('Fileprn flag must be 0 or 1!') + argsin[15] = control["fileprd"] + if "fileprn" in control: + if control["fileprn"] not in range(0, 2): + raise ValueError("Fileprn flag must be 0 or 1!") else: - argsin[16] = control['fileprn'] - if 'screenout' in control: - if control['screenout'] not in range(0,2): - raise ValueError('Screenout flag must be 0 or 1!') + argsin[16] = control["fileprn"] + if "screenout" in control: + if control["screenout"] not in range(0, 2): + raise ValueError("Screenout flag must be 0 or 1!") else: - argsin[17] = control['screenout'] + argsin[17] = control["screenout"] # process required parameters here if not (-90 <= latitude <= 90): - raise ValueError('Latitude exceeds permissible range!') + raise ValueError("Latitude exceeds permissible range!") else: argsin[0] = latitude if not (-180 <= longitude <= 180): - raise ValueError('Longitude exceeds permissible range!') + raise ValueError("Longitude exceeds permissible range!") else: argsin[1] = longitude if not (-500 <= height <= 5000): - raise ValueError('Height exceeds permissible range!') + raise ValueError("Height exceeds permissible range!") else: argsin[2] = height - if not (0 < duration <= 10*24*365): + if not (0 < duration <= 10 * 24 * 365): raise ValueError("Duration exceeds permissible range!") else: argsin[6] = int(duration) @@ -336,37 +363,51 @@ def predict(self, latitude, longitude, height, startdate, duration, samprate, ** try: startdate = datetime.strptime(startdate, "%Y-%m-%d") except ValueError: - raise ValueError("Startdate has incorrect format (YYYY-MM-DD)!" ) + raise ValueError("Startdate has incorrect format (YYYY-MM-DD)!") enddate = startdate + timedelta(hours=duration) # check if requested prediction series exceeds permissible time - if (startdate < self.etddt_start): + if startdate < self.etddt_start: fname = str(etpred.params.etddtdat) - warn("Prediction timeframe is earlier than the available time database (%s). " - "For details refer to the file '%s'." % (self.etddt_start, fname)) - if (enddate > (self.etddt_end + timedelta(days=365))): + warn( + "Prediction timeframe is earlier than the available time database (%s). " + "For details refer to the file '%s'." % (self.etddt_start, fname) + ) + if enddate > (self.etddt_end + timedelta(days=365)): fname = str(etpred.params.etddtdat) - warn("Please consider updating the leap second database '%s' (last value is from %s)." % (fname, self.etddt_end)) + warn( + "Please consider updating the leap second database '%s' (last value is from %s)." + % (fname, self.etddt_end) + ) # if not (-50*365 < (startdate - dt.datetime.now()).days < 365): - if ( ((argsin[13] > 0) or (argsin[14] > 0)) and ((startdate < self.etpolut1_start) or (enddate > self.etpolut1_end)) ): + if ((argsin[13] > 0) or (argsin[14] > 0)) and ( + (startdate < self.etpolut1_start) or (enddate > self.etpolut1_end) + ): fname = str(etpred.params.etddtdat) - warn("Dates exceed permissible range for pole/LOD tide correction (interval %s to %s). Consider update file '%s'." % (self.etpolut1_start, self.etpolut1_end, fname)) - if ( ((argsin[13] > 0) or (argsin[14] > 0)) and (startdate < datetime.strptime('1600-01-01', "%Y-%m-%d")) ): - raise ValueError("PyGTide should not be used for dates before the year 1600.") + warn( + "Dates exceed permissible range for pole/LOD tide correction (interval %s to %s). Consider update file '%s'." + % (self.etpolut1_start, self.etpolut1_end, fname) + ) + if ((argsin[13] > 0) or (argsin[14] > 0)) and ( + startdate < datetime.strptime("1600-01-01", "%Y-%m-%d") + ): + raise ValueError( + "PyGTide should not be used for dates before the year 1600." + ) # set the start date and time - argsin[3:6] = [startdate.year,startdate.month,startdate.day] + argsin[3:6] = [startdate.year, startdate.month, startdate.day] # test sammprate validity - if not (0 < samprate <= 24*3600): + if not (0 < samprate <= 24 * 3600): raise ValueError("samprate exceeds permissible range!") else: argsin[7] = int(samprate) # test that samprate is not larger than duration - if (samprate/3600 > duration): + if samprate / 3600 > duration: raise ValueError("samprate exceeds duration!") # print(argsin) self.args = argsin if self.msg: - print('%s is calculating, please wait ...' % (self.fortran_version)) - + print("%s is calculating, please wait ..." % (self.fortran_version)) + # hand over variables etpred.predict(argsin) @@ -385,20 +426,20 @@ def results(self, digits=6): """ if self.exec: # get the headers from Fortran - cols = np.char.strip(etpred.inout.header.astype('str')) - allcols = np.insert(cols[2:], 0, 'UTC') + cols = np.char.strip(etpred.inout.header.astype("str")) + allcols = np.insert(cols[2:], 0, "UTC") etdata = pd.DataFrame(columns=allcols) # format date and time into padded number strings etpred_data = np.array(etpred.inout.etpdata) # print(etpred.inout.etpdata[:, 0]) # catch non-complete container fills from odd duration/sampling pairs - etpred_data = etpred_data[etpred_data[:,0] > 0, :] - # convert - date = np.char.mod("%08.0f ", etpred_data[:,0]) - time = np.char.mod("%06.0f", etpred_data[:,1]) + etpred_data = etpred_data[etpred_data[:, 0] > 0, :] + # convert + date = np.char.mod("%08.0f ", etpred_data[:, 0]) + time = np.char.mod("%06.0f", etpred_data[:, 1]) # merge date and time arrays datetime = np.core.defchararray.add(date, time) - etdata['UTC'] = pd.to_datetime(datetime, format="%Y%m%d %H%M%S", utc=True) + etdata["UTC"] = pd.to_datetime(datetime, format="%Y%m%d %H%M%S", utc=True) # obtain header strings from routine and convert etdata[cols[2:]] = np.around(etpred_data[:, 2:], digits) return etdata @@ -443,22 +484,22 @@ def datetime(self): """ if self.exec: # reformat the date and time values obtained from ETERNA - date = np.char.mod("%08.0f", etpred.inout.etpdata[:,0]) - time = np.char.mod("%06.0f", etpred.inout.etpdata[:,1]) + date = np.char.mod("%08.0f", etpred.inout.etpdata[:, 0]) + time = np.char.mod("%06.0f", etpred.inout.etpdata[:, 1]) return np.stack((date, time), axis=1) else: return None def predict_table(*args, msg=False, **kwargs): - kwargs.setdefault('screenout', int(msg)) + kwargs.setdefault("screenout", int(msg)) pt = pygtide(msg=msg) pt.predict(*args, **kwargs) return pt.results() def predict_series(*args, msg=False, index=0, **kwargs): - kwargs.setdefault('screenout', int(msg)) + kwargs.setdefault("screenout", int(msg)) pt = pygtide(msg=msg) pt.predict(*args, **kwargs) return pt.data()[:, index] @@ -466,6 +507,7 @@ def predict_series(*args, msg=False, index=0, **kwargs): def predict_spectrum(*args, nfft=None, **kwargs): from numpy.fft import rfft, rfftfreq + sr = args[-1] data = predict_series(*args, **kwargs) if nfft is None: @@ -480,15 +522,18 @@ def plot_series(*args, indices=(0, 1), show=True, **kwargs): table.plot(*indices) if show: import matplotlib.pyplot as plt + plt.show() + def plot_spectrum(*args, ax=None, show=True, **kwargs): import matplotlib.pyplot as plt + freq, spec = predict_spectrum(*args, **kwargs) if ax is None: ax = plt.subplot(111) ax.plot(freq * 24 * 3600, np.abs(spec)) - ax.set_xlabel('freq (cycles per day)') - ax.set_ylabel('amplitude') + ax.set_xlabel("freq (cycles per day)") + ax.set_ylabel("amplitude") if show: plt.show() diff --git a/pygtide/update_etpred_data.py b/pygtide/update_etpred_data.py index 1192141..774c6ac 100755 --- a/pygtide/update_etpred_data.py +++ b/pygtide/update_etpred_data.py @@ -4,108 +4,148 @@ Author: Gabriel C. Rau (gabriel@hydrogeo.science) Website: https://hydrogeo.science """ + import pygtide.etpred as etpred -from pkg_resources import resource_filename + +from importlib import resources from pathlib import Path import numpy as np import time as tt import pandas as pd import datetime as dt -import urllib, urllib.request, re, os +import urllib +import urllib.request +import re +import os + def timestampToDecyear(ts): - year=ts.year - jan1=pd.Timestamp(year,1,1) - jan1next=pd.Timestamp(year+1,1,1) - yrlen=(jan1next-jan1).total_seconds() - return year+(ts-jan1).total_seconds()/yrlen + year = ts.year + jan1 = pd.Timestamp(year, 1, 1) + jan1next = pd.Timestamp(year + 1, 1, 1) + yrlen = (jan1next - jan1).total_seconds() + return year + (ts - jan1).total_seconds() / yrlen + class update_etpred_db(object): """ The pygtide_update class will initialise internal variables """ + def __init__(self, msg=True): self.msg = msg - self.data_dir = resource_filename('pygtide', 'commdat/') - etpred.params.comdir = self.data_dir + ' ' * (256 - len(self.data_dir)) + # Resolve package data directory directly from filesystem. + # Avoid importlib.resources.as_file() which creates temp dirs that get cleaned up. + pkg_dir = os.path.dirname(__file__) + self.data_dir = os.path.join(pkg_dir, "commdat") + if not self.data_dir.endswith(os.sep): + self.data_dir += os.sep + + # Fortran expects a fixed-length string (256 chars) + etpred.params.comdir = self.data_dir + " " * (256 - len(self.data_dir)) + + # OS-dependent null file + etpred.params.nullfile = os.devnull + " " * (10 - len(os.devnull)) + # set OS dependent module output - etpred.params.nullfile = os.devnull + ' ' * (10 - len(os.devnull)) - self.etddt_file = str(etpred.params.etddtdat, 'UTF-8').strip() - self.etpolut1_dat_file = str(etpred.params.etpolutdat, 'UTF-8').strip() - self.etpolut1_bin_file = str(etpred.params.etpolutbin, 'UTF-8').strip() + etpred.params.nullfile = os.devnull + " " * (10 - len(os.devnull)) + self.etddt_file = str(etpred.params.etddtdat, "UTF-8").strip() + self.etpolut1_dat_file = str(etpred.params.etpolutdat, "UTF-8").strip() + self.etpolut1_bin_file = str(etpred.params.etpolutbin, "UTF-8").strip() - #%% remote data files + # %% remote data files # IERS leap seconds history file - self.etddt_tmpl = 'etddt_tmpl.dat' - self.leapsec_rfile = 'https://hpiers.obspm.fr/iers/bul/bulc/Leap_Second_History.dat' + self.etddt_tmpl = "etddt_tmpl.dat" + self.leapsec_rfile = ( + "https://hpiers.obspm.fr/iers/bul/bulc/Leap_Second_History.dat" + ) # IERS pole coordinate observations # self.iauhist_rfile = 'http://hpiers.obspm.fr/iers/eop/eopc04/eopc04_IAU2000.62-now' - self.iauhist_rfile = 'ftp://hpiers.obspm.fr/iers/eop/eopc04/eopc04_IAU2000.62-now' + self.iauhist_rfile = ( + "ftp://hpiers.obspm.fr/iers/eop/eopc04/eopc04_IAU2000.62-now" + ) # US navy pole coordinate predictions - self.iaucurr_rfile = 'https://datacenter.iers.org/data/9/finals2000A.all' + self.iaucurr_rfile = "https://datacenter.iers.org/data/9/finals2000A.all" # self.iaucurr_rfile = 'ftp://cddis.gsfc.nasa.gov/pub/products/iers/finals2000A.all' - - #%% update the pole coordinates and UT1 to TAI times + # %% update the pole coordinates and UT1 to TAI times def update_etpolut1(self): global etpolut status = True - etpolut1_file = Path(self.data_dir + '/' + self.etpolut1_dat_file) - leapsec_file = Path(self.data_dir + '/' + '[raw]_Leap_Second_History.dat') - iauhist_file = Path(self.data_dir + '/' + '[raw]_eopc04_IAU2000.dat') - iaucurr_file = Path(self.data_dir + '/' + '[raw]_finals2000A.dat') + etpolut1_file = Path(self.data_dir + "/" + self.etpolut1_dat_file) + leapsec_file = Path(self.data_dir + "/" + "[raw]_Leap_Second_History.dat") + iauhist_file = Path(self.data_dir + "/" + "[raw]_eopc04_IAU2000.dat") + iaucurr_file = Path(self.data_dir + "/" + "[raw]_finals2000A.dat") print("--------------------------------------") - print("-->> Updating the Earth orientation database '{:s}':".format(etpolut1_file.as_posix())) + print( + "-->> Updating the Earth orientation database '{:s}':".format( + etpolut1_file.as_posix() + ) + ) start = tt.time() if status: try: urllib.request.urlopen(self.leapsec_rfile) except OSError as error: - print("ERROR: Could not connect to remote server: {:s}".format(self.leapsec_rfile)) + print( + "ERROR: Could not connect to remote server: {:s}".format( + self.leapsec_rfile + ) + ) print("MESSAGE: {0}.".format(error)) - status=False + status = False pass else: - print('Start downloading: {:s} ...'.format(self.leapsec_rfile)) + print("Start downloading: {:s} ...".format(self.leapsec_rfile)) urllib.request.urlretrieve(self.leapsec_rfile, leapsec_file) end = tt.time() - print('Finished downloading ({:.1f} s).'.format((end - start))) + print("Finished downloading ({:.1f} s).".format((end - start))) if status: try: urllib.request.urlopen(self.iauhist_rfile) except OSError as error: - print("ERROR: Could not connect to remote server: {:s}".format(self.iauhist_rfile)) + print( + "ERROR: Could not connect to remote server: {:s}".format( + self.iauhist_rfile + ) + ) print("MESSAGE: {0}.".format(error)) - status=False + status = False pass else: - print('Start downloading: {:s} ...'.format(self.iauhist_rfile)) + print("Start downloading: {:s} ...".format(self.iauhist_rfile)) urllib.request.urlretrieve(self.iauhist_rfile, iauhist_file) end = tt.time() - print('Finished downloading ({:.1f} s).'.format((end - start))) + print("Finished downloading ({:.1f} s).".format((end - start))) if status: try: urllib.request.urlopen(self.iaucurr_rfile) except OSError as error: - print("ERROR: Could not connect to remote server: {:s}".format(self.iaucurr_rfile)) + print( + "ERROR: Could not connect to remote server: {:s}".format( + self.iaucurr_rfile + ) + ) print("MESSAGE: {0}.".format(error)) - status=False + status = False pass else: - print('Start downloading: {:s} ...'.format(self.iaucurr_rfile)) + print("Start downloading: {:s} ...".format(self.iaucurr_rfile)) urllib.request.urlretrieve(self.iaucurr_rfile, iaucurr_file) end = tt.time() - print('Finished downloading ({:.1f} s).'.format((end - start))) + print("Finished downloading ({:.1f} s).".format((end - start))) - #%% + # %% if status: try: open(leapsec_file, "r") except OSError as error: - print("ERROR: Could not open file: {:s}".format(leapsec_file.as_posix())) + print( + "ERROR: Could not open file: {:s}".format(leapsec_file.as_posix()) + ) print("MESSAGE: {0}.".format(error)) status = False pass @@ -114,7 +154,9 @@ def update_etpolut1(self): try: open(iauhist_file, "r") except OSError as error: - print("ERROR: Could not open file: {:s}".format(iauhist_file.as_posix())) + print( + "ERROR: Could not open file: {:s}".format(iauhist_file.as_posix()) + ) print("MESSAGE: {0}.".format(error)) status = False pass @@ -123,73 +165,117 @@ def update_etpolut1(self): try: open(iaucurr_file, "r") except OSError as error: - print("ERROR: Could not open file: {:s}".format(iaucurr_file.as_posix())) + print( + "ERROR: Could not open file: {:s}".format(iaucurr_file.as_posix()) + ) print("MESSAGE: {0}.".format(error)) status = False pass if status: - #%% read leap second history - leapsdf = pd.read_csv(leapsec_file, comment='#', header=None, parse_dates= {'date':[1,2,3]}, \ - date_format='%d %m %Y', delimiter=r"\s+") - leapsdf.columns = ['date', 'MJD', 'leaps'] - leapsdf.drop(['MJD'], axis=1, inplace=True) + # %% read leap second history + leapsdf = pd.read_csv( + leapsec_file, + comment="#", + header=None, + delimiter=r"\s+", + names=["MJD", "day", "month", "year", "leaps"], + usecols=[0, 1, 2, 3, 4], + ) + + # Create datetime column from day, month, year + leapsdf["date"] = pd.to_datetime(leapsdf[["year", "month", "day"]]) + + # Keep only 'date' and 'leaps' + leapsdf = leapsdf[["date", "leaps"]] + + # Reindex starting at 1 leapsdf.index += 1 - # insert row at the beginning - leapsdf.loc[0] = [dt.datetime(1962,1,1), 10] + + # Insert initial row at the beginning + leapsdf.loc[0] = [dt.datetime(1962, 1, 1), 10] + + # Sort index to restore correct order leapsdf.sort_index(inplace=True) - - #%% read historic pole coordinates + + # %% read historic pole coordinates # convert = {3: lambda x: np.around(np.float64(x), 3)} - iauhist = pd.read_csv(iauhist_file, skiprows=13, header=None, parse_dates= {'date':[0,1,2]}, \ - date_format='%Y %m %d', delimiter=r"\s+", usecols=[0,1,2,3,4,5,6]) - iauhist.columns = ['date', 'MJD', 'x', 'y', 'UT1-UTC'] - #iauhist = iauhist.set_index('date') - - #%% read current pole coordinates - # dateparse = lambda x,y,z: pd.to_datetime(x.zfill(2)+y.zfill(2)+z.zfill(2), format='%y%m%d') - # convert = {'date': lambda x,y,z: pd.to_datetime(x.zfill(2)+y.zfill(2)+z.zfill(2), format='%y%m%d')} - fw = [2,2,2,9,3,9,9,10,9,3,10,10] - iaucurr = pd.read_fwf(iaucurr_file, header=None, widths=fw, usecols=[0,1,2,3,5,7,10]) - iaucurr.iloc[:, 0] = pd.to_datetime(iaucurr.iloc[:, 0].astype(str).str.zfill(2) + '-' + iaucurr.iloc[:, 1].astype(str).str.zfill(2) + '-' + iaucurr.iloc[:, 2].astype(str).str.zfill(2), format='%y-%m-%d') - iaucurr.drop(iaucurr.columns[[1, 2]], axis=1, inplace=True) - iaucurr.columns = ['date', 'MJD', 'x', 'y', 'UT1-UTC'] - #iaucurr = iaucurr.set_index('date') - mask = (iaucurr['date'] > iauhist['date'].values[-1]) - # etpolut = iauhist.append(iaucurr[mask]) - etpolut = pd.concat([iauhist, iaucurr[mask]]) - etpolut = etpolut[np.isfinite(etpolut['x'])] - #iauhist.combine_first(iaucurr) - - #%% - etpolut['Date'] = etpolut['date'].dt.strftime('%Y%m%d') - etpolut['Time'] = etpolut['date'].dt.strftime('%H%M%S') - etpolut['MJD'] = etpolut['MJD'].map('{:8.3f}'.format) - etpolut['x'] = etpolut['x'].map('{:9.5f}'.format) - etpolut['y'] = etpolut['y'].map('{:9.5f}'.format) - etpolut['TAI-UT1'] = etpolut['UT1-UTC'] - etpolut['UT1-UTC'] = etpolut['UT1-UTC'].map('{:9.6f}'.format) - - #%% + iauhist = pd.read_csv( + iauhist_file, + skiprows=13, + header=None, + delimiter=r"\s+", + usecols=[0, 1, 2, 3, 4, 5, 6], + names=["year", "month", "day", "MJD", "x", "y", "UT1-UTC"], + ) + + # Combine year, month, day into a single datetime column + iauhist["date"] = pd.to_datetime(iauhist[["year", "month", "day"]]) + + # Keep only the desired columns + iauhist = iauhist[["date", "MJD", "x", "y", "UT1-UTC"]] + + # %% read current pole coordinates + fw = [2, 2, 2, 9, 3, 9, 9, 10, 9, 3, 10, 10] + iaucurr = pd.read_fwf( + iaucurr_file, header=None, widths=fw, usecols=[0, 1, 2, 3, 5, 7, 10] + ) + + iaucurr_dates = ( + iaucurr.iloc[:, 0].astype(str).str.zfill(2) + + "-" + + iaucurr.iloc[:, 1].astype(str).str.zfill(2) + + "-" + + iaucurr.iloc[:, 2].astype(str).str.zfill(2) + ) + + iaucurr["date"] = pd.to_datetime(iaucurr_dates, format="%y-%m-%d") + + # Drop old columns used for date + iaucurr = iaucurr.drop(columns=iaucurr.columns[:3]) + # Rename remaining columns + iaucurr.columns = ["MJD", "x", "y", "UT1-UTC", "date"] + # Move 'date' to first position if needed + iaucurr = iaucurr[["date", "MJD", "x", "y", "UT1-UTC"]] + + mask = iaucurr["date"] > iauhist["date"].iloc[-1] + etpolut = pd.concat([iauhist, iaucurr.loc[mask]]) + etpolut = etpolut[np.isfinite(etpolut["x"])] + + # %% + etpolut["Date"] = etpolut["date"].dt.strftime("%Y%m%d") + etpolut["Time"] = etpolut["date"].dt.strftime("%H%M%S") + etpolut["MJD"] = etpolut["MJD"].map("{:8.3f}".format) + etpolut["x"] = etpolut["x"].map("{:9.5f}".format) + etpolut["y"] = etpolut["y"].map("{:9.5f}".format) + etpolut["TAI-UT1"] = etpolut["UT1-UTC"] + etpolut["UT1-UTC"] = etpolut["UT1-UTC"].map("{:9.6f}".format) + + # %% # prepare the last column for idx, val in leapsdf.iterrows(): # print(idx, val[1]) # find mask for leap seconds - if idx+1 in leapsdf.index: - mask = ((etpolut['date'] >= leapsdf['date'].loc[idx]) & (etpolut['date'] < leapsdf['date'].loc[idx+1])) - #print(mask) + if idx + 1 in leapsdf.index: + mask = (etpolut["date"] >= leapsdf["date"].loc[idx]) & ( + etpolut["date"] < leapsdf["date"].loc[idx + 1] + ) + # print(mask) # subtract leap seconds from UTC - etpolut.loc[mask, 'TAI-UT1'] = leapsdf['leaps'].loc[idx] - etpolut.loc[mask, 'TAI-UT1'] + etpolut.loc[mask, "TAI-UT1"] = ( + leapsdf["leaps"].loc[idx] - etpolut.loc[mask, "TAI-UT1"] + ) else: - mask = (etpolut['date'] >= leapsdf['date'].loc[idx]) - etpolut.loc[mask, 'TAI-UT1'] = leapsdf['leaps'].loc[idx] - etpolut.loc[mask, 'TAI-UT1'] + mask = etpolut["date"] >= leapsdf["date"].loc[idx] + etpolut.loc[mask, "TAI-UT1"] = ( + leapsdf["leaps"].loc[idx] - etpolut.loc[mask, "TAI-UT1"] + ) - etpolut['TAI-UT1'] = etpolut['TAI-UT1'].map('{:9.6f}'.format) + etpolut["TAI-UT1"] = etpolut["TAI-UT1"].map("{:9.6f}".format) - #%% - #etpolut[0] = etpolut[0].map('${:,.2f}'.format) - header = \ -"""File : etpolut1.dat + # %% + # etpolut[0] = etpolut[0].map('${:,.2f}'.format) + header = """File : etpolut1.dat Updated : $1$ Contents : Pole coordinates and earth rotation one day sampling interval, given at 0 hours UTC. Historic data is combined with predictions. @@ -202,9 +288,13 @@ def update_etpolut1(self): Date Time MJD x y UT1-UTC TAI-UT1 ["] ["] [sec] [sec] C****************************************************************\n""" - header = header.replace("$1$", dt.datetime.utcnow().strftime('%d/%m/%Y')) - header = header.replace("$2$", etpolut['date'].iloc[0].strftime('%d/%m/%Y') \ - + ' to ' + etpolut['date'].iloc[-1].strftime('%d/%m/%Y')) + header = header.replace("$1$", dt.datetime.now(dt.UTC).strftime("%d/%m/%Y")) + header = header.replace( + "$2$", + etpolut["date"].iloc[0].strftime("%d/%m/%Y") + + " to " + + etpolut["date"].iloc[-1].strftime("%d/%m/%Y"), + ) header = header.replace("$3$", self.iauhist_rfile) header = header.replace("$4$", self.iaucurr_rfile) header = header.replace("$5$", self.leapsec_rfile) @@ -213,55 +303,73 @@ def update_etpolut1(self): # IMPORTANT: newline needs to comply with windows platform! # https://pythonconquerstheuniverse.wordpress.com/2011/05/08/newline-conversion-in-python-3/ - with open(etpolut1_file, "w", newline='') as myfile: + with open(etpolut1_file, "w", newline="") as myfile: myfile.write(header) # myfile.write(etpolut['combined'].to_string(index=False, header=False).replace('\n ', '\n')) # etpolut['combined'].to_string(myfile, index=False, header=False) # WEIRD PANDAS BUG: to_string() puts white space at beginning of each line for index, row in etpolut.iterrows(): - string = "{:s} {:s} {:s} {:s} {:s} {:s} {:s}".format(row['Date'], row['Time'], row['MJD'],\ - row['x'], row['y'], row['UT1-UTC'], row['TAI-UT1']) - myfile.write(string + '\r\n') + string = "{:s} {:s} {:s} {:s} {:s} {:s} {:s}".format( + row["Date"], + row["Time"], + row["MJD"], + row["x"], + row["y"], + row["UT1-UTC"], + row["TAI-UT1"], + ) + myfile.write(string + "\r\n") myfile.write("99999999") myfile.close() end = tt.time() # update also bin file self.etpolut1_dat2bin() - print('Finished updating {:s} ({:.1f} s).'.format(etpolut1_file.as_posix(), (end - start))) + print( + "Finished updating {:s} ({:.1f} s).".format( + etpolut1_file.as_posix(), (end - start) + ) + ) else: - print('Update failed!') + print("Update failed!") pass - - #%% remove temporary files + + # %% remove temporary files os.remove(leapsec_file) os.remove(iauhist_file) os.remove(iaucurr_file) return - #%% update the etpolut1 binary file from the text file + # %% update the etpolut1 binary file from the text file def etpolut1_dat2bin(self): - etpolut1_dat = Path(self.data_dir + '/' + self.etpolut1_dat_file) - etpolut1_bin = Path(self.data_dir + '/' + self.etpolut1_bin_file) + etpolut1_dat = Path(self.data_dir + "/" + self.etpolut1_dat_file) + etpolut1_bin = Path(self.data_dir + "/" + self.etpolut1_bin_file) header = [] # find the end of the header with open(etpolut1_dat, "r") as f: for num, line in enumerate(f, 1): header.append(line) - if "C*******" in header[-1]: break + if "C*******" in header[-1]: + break # read into dataframe - cols = ['Date', 'Time', 'MJD', 'x', 'y', 'UT1-UTC', 'TAI-UT1'] - etpolut = pd.read_csv(etpolut1_dat, names=cols, skiprows=num, header=None, delimiter=r"\s+") + cols = ["Date", "Time", "MJD", "x", "y", "UT1-UTC", "TAI-UT1"] + etpolut = pd.read_csv( + etpolut1_dat, names=cols, skiprows=num, header=None, delimiter=r"\s+" + ) # drop the last row with EOL ('99999999') etpolut = etpolut[:-1] - print("File '{:s}' has {:d} rows.".format(etpolut1_dat.as_posix(), etpolut.shape[0])) - #%% + print( + "File '{:s}' has {:d} rows.".format( + etpolut1_dat.as_posix(), etpolut.shape[0] + ) + ) + # %% # write as binary for use in fortran: each record has 4*8 bytes = 32 bytes # header contains start date in MJD and number of rows + 1 - head = np.array([np.int32(etpolut.iloc[0, 2]), np.int32(etpolut.shape[0]+1)]) + head = np.array([np.int32(etpolut.iloc[0, 2]), np.int32(etpolut.shape[0] + 1)]) data = np.float64(etpolut.values[:, 3:]) - #print(data) - with open(etpolut1_bin,'wb+') as f: + # print(data) + with open(etpolut1_bin, "wb+") as f: # write header integers f.write(head.tobytes()) # advance to next record (32 bytes) @@ -269,20 +377,27 @@ def etpolut1_dat2bin(self): # write the flattened matrix (this may have 64 bytes) f.write(data.flatten().tobytes()) f.close() - print("File '{:s}' has been updated (Header: {:.0f}, {:d}).".format(etpolut1_bin.as_posix(), etpolut.iloc[0, 2], etpolut.shape[0]+1)) - + print( + "File '{:s}' has been updated (Header: {:.0f}, {:d}).".format( + etpolut1_bin.as_posix(), etpolut.iloc[0, 2], etpolut.shape[0] + 1 + ) + ) - #%% update the time conversion database (leap seconds) + # %% update the time conversion database (leap seconds) def update_etddt(self): global etddt, leapsdf, tmp - leapsec_file = Path(self.data_dir + '/' + '[raw]_Leap_Second_History.dat') - old_etddt_file = Path(self.data_dir + '/' + self.etddt_tmpl) - etddt_file = Path(self.data_dir + '/' + self.etddt_file) + leapsec_file = Path(self.data_dir + "/" + "[raw]_Leap_Second_History.dat") + old_etddt_file = Path(self.data_dir + "/" + self.etddt_tmpl) + etddt_file = Path(self.data_dir + "/" + self.etddt_file) - #%% + # %% print("--------------------------------------") - print("-->> Updating time conversion database '{:s}':".format(leapsec_file.as_posix())) - #%% download leap second history + print( + "-->> Updating time conversion database '{:s}':".format( + leapsec_file.as_posix() + ) + ) + # %% download leap second history start = tt.time() try: urllib.request.urlopen(self.leapsec_rfile) @@ -291,12 +406,12 @@ def update_etddt(self): print("MESSAGE: {0}.".format(error)) pass else: - print('Start downloading: {:s} ...'.format(leapsec_file.as_posix())) + print("Start downloading: {:s} ...".format(leapsec_file.as_posix())) urllib.request.urlretrieve(self.leapsec_rfile, leapsec_file) end = tt.time() - print('Finished downloading ({:.1f} s).'.format((end - start))) + print("Finished downloading ({:.1f} s).".format((end - start))) - #%% READ THE EXISTING FILE + # %% READ THE EXISTING FILE # print(etddt_file) # find the end of the header with open(old_etddt_file, "r") as f: @@ -304,52 +419,90 @@ def update_etddt(self): header = [] regex = re.compile(r"^\s*updated\s*\:.*$", re.IGNORECASE) for num, line in enumerate(f, 1): - line = regex.sub("Updated : %s" % dt.datetime.utcnow().strftime('%d/%m/%Y'), line) + line = regex.sub( + "Updated : %s" % dt.datetime.now(dt.UTC).strftime("%d/%m/%Y"), + line, + ) header.append(line) - if "C*******" in header[-1]: break - - cols = ['year','JD','DDT'] - etddt = pd.read_csv(old_etddt_file, names=cols, skiprows=num, header=None, delimiter=r"\s+") - - #%% read leap second history - leapsdf = pd.read_csv(leapsec_file, comment='#', header=None, parse_dates= {'date':[1,2,3]}, date_format='%d %m %Y', delimiter=r"\s+") - leapsdf.columns = ['date', 'MJD', 'leaps'] - # leapsdf = leapsdf.set_index('date') - # DDT = delta-T + delta-UT = leaps + 32.184 s offset - leapsdf['DDT'] = leapsdf['leaps'] + 32.184 - - #%% - leapsdf['JD'] = [dt.to_julian_date() for dt in leapsdf['date']] - leapsdf['year'] = [timestampToDecyear(dt) for dt in leapsdf['date']] - - #%% - mask = (leapsdf['year'] > etddt['year'].values[-1]) + if "C*******" in header[-1]: + break + + cols = ["year", "JD", "DDT"] + etddt = pd.read_csv( + old_etddt_file, names=cols, skiprows=num, header=None, delimiter=r"\s+" + ) + + # %% read leap second history + leapsdf = pd.read_csv( + leapsec_file, + comment="#", + header=None, + delimiter=r"\s+", + names=[ + "MJD", + "day", + "month", + "year", + "leaps", + ], # assign proper column names + usecols=[0, 1, 2, 3, 4], + ) + # Combine day, month, year into a single datetime column + leapsdf["date"] = pd.to_datetime(leapsdf[["year", "month", "day"]]) + + # Keep only the relevant columns + leapsdf = leapsdf[["date", "MJD", "leaps"]] + + # Compute DDT = leaps + 32.184 s + leapsdf["DDT"] = leapsdf["leaps"] + 32.184 + + # %% + leapsdf["JD"] = [dt.to_julian_date() for dt in leapsdf["date"]] + leapsdf["year"] = [timestampToDecyear(dt) for dt in leapsdf["date"]] + + # %% + mask = leapsdf["year"] > etddt["year"].values[-1] indices = leapsdf.index[mask] # print(indices) # tmp = [] for i, val in enumerate(indices): # for each record create a new row - etddt.loc[len(etddt) + 1] = {'year': leapsdf.loc[val, 'year'], 'JD': leapsdf.loc[val, 'JD'], 'DDT': leapsdf.loc[val, 'DDT']} + etddt.loc[len(etddt) + 1] = { + "year": leapsdf.loc[val, "year"], + "JD": leapsdf.loc[val, "JD"], + "DDT": leapsdf.loc[val, "DDT"], + } # number of new records records = sum(mask) - if (records > 0): + if records > 0: # write header - with open(self.data_dir + '/' + self.etddt_file, "w+", newline='\r\n') as f: + with open(self.data_dir + "/" + self.etddt_file, "w+", newline="\r\n") as f: f.write("".join(header)) - #etddt['combined'].to_string(f, index=False, header=False) - #f.write("\n") + # etddt['combined'].to_string(f, index=False, header=False) + # f.write("\n") # WEIRD PANDAS BUG: to_string() puts white space at beginning of each line for index, row in etddt.iterrows(): - string = "{:.5f} {:.5f} {:8.3f}".format(row['year'], row['JD'], row['DDT']) + string = "{:.5f} {:.5f} {:8.3f}".format( + row["year"], row["JD"], row["DDT"] + ) # print(string) - f.write(string + '\n') + f.write(string + "\n") f.close() end = tt.time() - print('{:d} records were added to the template ({:.1f} s).'.format(records, end - start)) - print("The leap second File ('{:s}') is now up to date ({:.1f} s).".format(self.etddt_file, end - start)) - -#%% run the update + print( + "{:d} records were added to the template ({:.1f} s).".format( + records, end - start + ) + ) + print( + "The leap second File ('{:s}') is now up to date ({:.1f} s).".format( + self.etddt_file, end - start + ) + ) + + +# %% run the update def update(msg=True): pt = update_etpred_db(msg) pt.update_etddt() @@ -357,4 +510,6 @@ def update(msg=True): pt.update_etpolut1() print("---------------------") -update() \ No newline at end of file + +if __name__ == "__main__": + update() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a9bc764 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,46 @@ +[build-system] +requires = [ + "meson-python", + "meson>=1.1.0", + "ninja", + "numpy>=1.21.0", +] +build-backend = "mesonpy" + +[project] +name = "pygtide" +version = "0.81" +description = "A Python module and wrapper for ETERNA PREDICT to compute gravitational tides on Earth" +readme = "README.md" +license = {text = "MPL-2.0"} +authors = [ + {name = "Gabriel C. Rau", email = "gabriel@hydrogeo.science"}, + {name = "Tom Eulenfeld"}, +] +requires-python = ">=3.8" +dependencies = [ + "numpy>=1.21.0", + "pandas", + "requests", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Scientific/Engineering :: Physics", +] + +[project.urls] +Homepage = "https://github.com/hydrogeoscience/pygtide" +Documentation = "https://hydrogeo.science" +Repository = "https://github.com/hydrogeoscience/pygtide.git" +Issues = "https://github.com/hydrogeoscience/pygtide/issues" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index c59cb2a..0000000 --- a/setup.cfg +++ /dev/null @@ -1,4 +0,0 @@ -[metadata] -long_description = file: README.md -long_description_content_type = text/markdown -license_files=LICENSE \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 35cd46f..0000000 --- a/setup.py +++ /dev/null @@ -1,33 +0,0 @@ -import os.path -import re - -from numpy.distutils.core import setup, Extension - -def find_version(*paths): - fname = os.path.join(os.path.dirname(__file__), *paths) - with open(fname) as fp: - code = fp.read() - match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", code, re.M) - if match: - return match.group(1) - raise RuntimeError("Unable to find version string.") - - -VERSION = find_version('pygtide', '__init__.py') - -ext = [Extension(name='pygtide.etpred', - sources=['src/etpred.f90'])] - -setup( - name='pygtide', - version=VERSION, - packages=['pygtide'], - package_data={'pygtide': ['commdat/*']}, - ext_modules=ext, - install_requires=['numpy', 'pandas','requests'], - author='Gabriel C. Rau, Tom Eulenfeld', - author_email='gabriel@hydrogeo.science', - url='https://github.com/hydrogeoscience/pygtide', - description=('A Python module and wrapper for ETERNA PREDICT to compute ' - 'gravitational tides on Earth'), - ) From 52ffc69ae3e05580583fea7a16404eaf80f6432e Mon Sep 17 00:00:00 2001 From: Craig Miller <5473482+craigmillernz@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:31:11 +1300 Subject: [PATCH 02/21] update pyproject version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a9bc764..6d74239 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "mesonpy" [project] name = "pygtide" -version = "0.81" +version = "0.8.0 description = "A Python module and wrapper for ETERNA PREDICT to compute gravitational tides on Earth" readme = "README.md" license = {text = "MPL-2.0"} From 1d98d5264e654a6e761acdbfdb59d0a02c7f7d2c Mon Sep 17 00:00:00 2001 From: Craig Miller <5473482+craigmillernz@users.noreply.github.com> Date: Thu, 29 Jan 2026 17:27:49 +1300 Subject: [PATCH 03/21] new build system for multiple os --- README.md | 11 +++++--- meson.build | 79 +++++++++++++++++++++++++++-------------------------- setup.py | 19 +++++++++++++ 3 files changed, 67 insertions(+), 42 deletions(-) create mode 100644 setup.py diff --git a/README.md b/README.md index c6227a6..e1570b9 100644 --- a/README.md +++ b/README.md @@ -33,19 +33,19 @@ pip install [wheel_name_depending_on_python_version] #### Option 2: Build from source (Linux, macOS, Windows; Python 3.8–3.14) **Requirements for building:** -- A Fortran compiler (e.g., `gfortran` via MinGW on Windows; included in Linux/macOS gcc toolchains) +- A Fortran compiler (e.g., `gfortran` via MinGW on Windows; included in Linux/macOS gcc toolchains) `conda install gfortran` - Meson build system: automatically installed via `pip` (see below) - Ninja (optional but recommended): `conda install ninja` or `pip install ninja` -**Install from GitHub:** +**Clone repo from git:** ```bash -pip install git+https://github.com/hydrogeoscience/pygtide.git +git clone https://github.com/hydrogeoscience/pygtide.git ``` **Install from local repository:** ```bash cd /path/to/pygtide -pip install -e . +python setup.py ``` If Meson or Ninja are missing, pip will attempt to install them automatically. For faster builds, pre-install them: @@ -53,6 +53,9 @@ If Meson or Ninja are missing, pip will attempt to install them automatically. F pip install meson-python meson ninja ``` +Add the repo path to your pythonpath. + + ### After installation * Run tests to verify installation: diff --git a/meson.build b/meson.build index 4d55acf..5b225e9 100644 --- a/meson.build +++ b/meson.build @@ -1,71 +1,74 @@ project('pygtide', 'fortran', - version : '0.81', - license : 'MPL-2.0', + version : '0.8.0', meson_version : '>=1.1.0', ) -# Get Python installation py = import('python').find_installation() -# Determine file extension based on OS host_os = host_machine.system() +fc = meson.get_compiler('fortran') + if host_os == 'windows' ext = 'pyd' + if fc.get_id() == 'msvc' + f90flags = ['/O2'] + else + f90flags = ['-O3'] + endif else ext = 'so' + f90flags = ['-fPIC', '-O3'] endif -# Source file src = 'src/etpred.f90' +f90_flag_args = ['--f90flags=' + ' '.join(f90flags)] -# Build the Fortran extension module using f2py -# f2py generates a version-specific filename like etpred.cp314-win_amd64.pyd -etpred_module = custom_target( +# -------------------------------------------------- +# 1. Build f2py (ABI-tagged output) +# -------------------------------------------------- +etpred_build = custom_target( 'etpred_build', input : src, - output : 'etpred.' + ext, + output : 'etpred.build.stamp', command : [ py, '-m', 'numpy.f2py', '-c', - '--f90flags=-fPIC -O3', + ] + f90_flag_args + [ '-m', 'etpred', '@INPUT@', ], build_by_default : true, ) -# Final copy: after the module is built, copy the versioned file into the -# package directory with a stable name `pygtide/etpred.{ext}` so imports work. -copy_code = 'import glob,shutil,os; os.chdir(os.environ.get("MESON_SOURCE_ROOT","../..")); files=glob.glob(os.path.join("build","**","etpred*.' + ext + '"),recursive=True); files and shutil.copy(files[0],os.path.join("pygtide","etpred.' + ext + '")) and print("Copied to pygtide/etpred.' + ext + '")' +# -------------------------------------------------- +# 2. Copy ABI module → pygtide/etpred.pyd +# -------------------------------------------------- +copy_code = ''' +import glob, os, shutil, sys -final_copy = custom_target( - 'copy_etpred_final', - input : etpred_module, - output : 'copy_etpred.stamp', - command : [py, '-c', copy_code], - build_by_default : true, - depends : etpred_module, -) +build_dir = os.getcwd() +src_root = r"@0@" -# Install package data (commdat files) -install_subdir( - 'pygtide/commdat', - install_dir : py.get_install_dir() / 'pygtide', - exclude_files : ['.gitkeep'] -) +pattern = os.path.join(build_dir, '**', 'etpred*.@1@') +matches = glob.glob(pattern, recursive=True) + +if not matches: + print("ERROR: etpred ABI module not found") + sys.exit(1) -# Install the .pyd/.so file that was copied to pygtide/ by the copy_etpred_final target -install_data( - 'pygtide/etpred.' + ext, - install_dir : py.get_install_dir() / 'pygtide', +dst = os.path.join(src_root, 'pygtide', 'etpred.@1@') +shutil.copy(matches[0], dst) +print(f"Copied {matches[0]} -> {dst}") +'''.format( + meson.project_source_root(), + ext ) -# Install Python source files -install_data( - 'pygtide/__init__.py', - 'pygtide/core.py', - 'pygtide/tests.py', - 'pygtide/update_etpred_data.py', - install_dir : py.get_install_dir() / 'pygtide', +custom_target( + 'copy_etpred', + input : etpred_build, + output : 'etpred.copy.stamp', + command : [py, '-c', copy_code], + build_by_default : true, ) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..7432550 --- /dev/null +++ b/setup.py @@ -0,0 +1,19 @@ +import subprocess +import shutil +import sys +import os + +# --- Step 1: Setup build dir --- +build_dir = "build" + +if not os.path.exists(build_dir): + subprocess.check_call(["meson", "setup", build_dir]) +else: + print(f"Build directory '{build_dir}' already exists, skipping setup.") + +# --- Step 2: Compile --- +subprocess.check_call(["meson", "compile", "-C", build_dir]) + +# --- Step 3: Clean up build dir --- +shutil.rmtree(build_dir) +print(f"Deleted build directory '{build_dir}'") From ccbc47aa3799c883e1ef913d7c425b8bcfbb8e72 Mon Sep 17 00:00:00 2001 From: Craig Miller <5473482+craigmillernz@users.noreply.github.com> Date: Thu, 29 Jan 2026 21:31:36 +1300 Subject: [PATCH 04/21] another build system update --- MANIFEST.in | 2 ++ README.md | 8 ++++---- build.py | 41 +++++++++++++++++++++++++++++++++++++ pyproject.toml | 32 ++++++++++++++++++----------- setup.py | 55 ++++++++++++++++++++++++++++++++++++++------------ 5 files changed, 109 insertions(+), 29 deletions(-) create mode 100644 build.py diff --git a/MANIFEST.in b/MANIFEST.in index bb3ec5f..418dfc8 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,3 @@ include README.md +include pygtide/etpred.* + diff --git a/README.md b/README.md index e1570b9..aabdd22 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,10 @@ git clone https://github.com/hydrogeoscience/pygtide.git **Install from local repository:** ```bash cd /path/to/pygtide -python setup.py + +python build.py + +pip install . --no-build-isolation ``` If Meson or Ninja are missing, pip will attempt to install them automatically. For faster builds, pre-install them: @@ -53,9 +56,6 @@ If Meson or Ninja are missing, pip will attempt to install them automatically. F pip install meson-python meson ninja ``` -Add the repo path to your pythonpath. - - ### After installation * Run tests to verify installation: diff --git a/build.py b/build.py new file mode 100644 index 0000000..27688fe --- /dev/null +++ b/build.py @@ -0,0 +1,41 @@ +import subprocess +import shutil +import os +import glob +import sys + +# --- Configuration --- +build_dir = 'build' +ext = 'pyd' if os.name == 'nt' else 'so' + +# --- Step 1: Setup Meson build directory --- +if not os.path.exists(build_dir): + print(f"Setting up Meson build directory '{build_dir}'...") + subprocess.check_call(['meson', 'setup', build_dir]) +else: + print(f"Build directory '{build_dir}' already exists, skipping setup.") + +# --- Step 2: Compile Meson targets --- +print("Compiling Meson targets...") +subprocess.check_call(['meson', 'compile', '-C', build_dir]) + +# --- Step 3: Copy ABI module to pygtide/ --- +print("Locating ABI module in build directory...") +build_path = os.path.abspath(build_dir) +pattern = os.path.join(build_path, '**', f'etpred*.{ext}') +matches = glob.glob(pattern, recursive=True) + +if not matches: + print("ERROR: ABI module not found! Did Meson compile succeed?") + sys.exit(1) + +dst = os.path.join(os.path.abspath('.'), 'pygtide', f'etpred.{ext}') +shutil.copy(matches[0], dst) +print(f"Copied ABI module {matches[0]} -> {dst}") + +# --- Step 4: Clean up build directory --- +print(f"Deleting build directory '{build_dir}'...") +shutil.rmtree(build_dir) +print("Build complete!") + +print("\nNext step: run 'pip install .' or 'pip install -e .' to install the package.") diff --git a/pyproject.toml b/pyproject.toml index 6d74239..84ccd7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,27 +1,25 @@ [build-system] requires = [ - "meson-python", - "meson>=1.1.0", - "ninja", - "numpy>=1.21.0", + "setuptools>=61", + "wheel", + "numpy>=1.21.0" ] -build-backend = "mesonpy" +build-backend = "setuptools.build_meta" [project] name = "pygtide" -version = "0.8.0 +version = "0.8.0" description = "A Python module and wrapper for ETERNA PREDICT to compute gravitational tides on Earth" readme = "README.md" -license = {text = "MPL-2.0"} +requires-python = ">=3.8" authors = [ - {name = "Gabriel C. Rau", email = "gabriel@hydrogeo.science"}, - {name = "Tom Eulenfeld"}, + { name = "Gabriel C. Rau", email = "gabriel@hydrogeo.science" }, + { name = "Tom Eulenfeld" } ] -requires-python = ">=3.8" dependencies = [ "numpy>=1.21.0", "pandas", - "requests", + "requests" ] classifiers = [ "Development Status :: 4 - Beta", @@ -36,7 +34,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", - "Topic :: Scientific/Engineering :: Physics", + "Topic :: Scientific/Engineering :: Physics" ] [project.urls] @@ -44,3 +42,13 @@ Homepage = "https://github.com/hydrogeoscience/pygtide" Documentation = "https://hydrogeo.science" Repository = "https://github.com/hydrogeoscience/pygtide.git" Issues = "https://github.com/hydrogeoscience/pygtide/issues" + +# ---------------------------- +# Setuptools-specific configuration +# ---------------------------- +[tool.setuptools] +packages = ["pygtide"] # <-- move packages here +include-package-data = true # include package data + +[tool.setuptools.package-data] +pygtide = ["etpred.*", "commdat/*"] diff --git a/setup.py b/setup.py index 7432550..997ed49 100644 --- a/setup.py +++ b/setup.py @@ -1,19 +1,48 @@ -import subprocess -import shutil +# setup.py import sys import os +from setuptools import setup, Extension, find_packages -# --- Step 1: Setup build dir --- -build_dir = "build" +# Detect platform-specific ABI extension +ext = '.pyd' if sys.platform.startswith('win') else '.so' +etpred_path = os.path.join('pygtide', f'etpred{ext}') -if not os.path.exists(build_dir): - subprocess.check_call(["meson", "setup", build_dir]) -else: - print(f"Build directory '{build_dir}' already exists, skipping setup.") +if not os.path.exists(etpred_path): + raise FileNotFoundError(f"Prebuilt ABI module not found: {etpred_path}\n" + "Run build.py first to generate it.") -# --- Step 2: Compile --- -subprocess.check_call(["meson", "compile", "-C", build_dir]) +# Define the prebuilt extension +etpred_module = Extension( + name='pygtide.etpred', + sources=[], # No sources; using prebuilt ABI + extra_objects=[etpred_path], +) -# --- Step 3: Clean up build dir --- -shutil.rmtree(build_dir) -print(f"Deleted build directory '{build_dir}'") +# Setup configuration +setup( + name='pygtide', + version='0.8.0', + description='A Python module and wrapper for ETERNA PREDICT to compute gravitational tides on Earth', + author='Gabriel C. Rau', + author_email='gabriel@hydrogeo.science', + packages=find_packages(), # finds 'pygtide' + #ext_modules=[etpred_module], + include_package_data=True, # ensures any package data is included + package_data={ + 'pygtide': ['etpred.*', 'commdat/*'], + }, + python_requires='>=3.8', + install_requires=[ + 'numpy>=1.21.0', + 'pandas', + 'requests', + ], + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Topic :: Scientific/Engineering :: Physics", + ], +) From 7486e0d8e2a6efa6c0890ebfc238c8992e8cdf9d Mon Sep 17 00:00:00 2001 From: Tom Eulenfeld Date: Thu, 29 Jan 2026 13:51:12 +0100 Subject: [PATCH 05/21] automatically invoke meson build script from setup.py --- build.py | 41 ------------------------------- build_pygtide_abi.py | 47 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 4 +++- setup.py | 57 +++++++++++++++++--------------------------- 4 files changed, 72 insertions(+), 77 deletions(-) delete mode 100644 build.py create mode 100644 build_pygtide_abi.py diff --git a/build.py b/build.py deleted file mode 100644 index 27688fe..0000000 --- a/build.py +++ /dev/null @@ -1,41 +0,0 @@ -import subprocess -import shutil -import os -import glob -import sys - -# --- Configuration --- -build_dir = 'build' -ext = 'pyd' if os.name == 'nt' else 'so' - -# --- Step 1: Setup Meson build directory --- -if not os.path.exists(build_dir): - print(f"Setting up Meson build directory '{build_dir}'...") - subprocess.check_call(['meson', 'setup', build_dir]) -else: - print(f"Build directory '{build_dir}' already exists, skipping setup.") - -# --- Step 2: Compile Meson targets --- -print("Compiling Meson targets...") -subprocess.check_call(['meson', 'compile', '-C', build_dir]) - -# --- Step 3: Copy ABI module to pygtide/ --- -print("Locating ABI module in build directory...") -build_path = os.path.abspath(build_dir) -pattern = os.path.join(build_path, '**', f'etpred*.{ext}') -matches = glob.glob(pattern, recursive=True) - -if not matches: - print("ERROR: ABI module not found! Did Meson compile succeed?") - sys.exit(1) - -dst = os.path.join(os.path.abspath('.'), 'pygtide', f'etpred.{ext}') -shutil.copy(matches[0], dst) -print(f"Copied ABI module {matches[0]} -> {dst}") - -# --- Step 4: Clean up build directory --- -print(f"Deleting build directory '{build_dir}'...") -shutil.rmtree(build_dir) -print("Build complete!") - -print("\nNext step: run 'pip install .' or 'pip install -e .' to install the package.") diff --git a/build_pygtide_abi.py b/build_pygtide_abi.py new file mode 100644 index 0000000..7ea53f6 --- /dev/null +++ b/build_pygtide_abi.py @@ -0,0 +1,47 @@ +import subprocess +import shutil +import os +import glob +import sys + + +def build(): + # --- Configuration --- + print('Start Meson build') + build_dir = 'build_abi' + ext = 'pyd' if os.name == 'nt' else 'so' + + # --- Step 1: Setup Meson build directory --- + if os.path.exists(build_dir): + print(f"Build directory '{build_dir}' already exists, skipping setup.") + else: + print(f"Setting up Meson build directory '{build_dir}'...") + subprocess.check_call(['meson', 'setup', build_dir]) + # --- Step 2: Compile Meson targets --- + print("Compiling Meson targets...") + subprocess.check_call(['meson', 'compile', '-C', build_dir]) + + # --- Step 3: Copy ABI module to pygtide/ --- + print("Locating ABI module in build directory...") + build_path = os.path.abspath(build_dir) + pattern = os.path.join(build_path, '**', f'etpred*.{ext}') + matches = glob.glob(pattern, recursive=True) + + if not matches: + print("ERROR: ABI module not found! Did Meson compile succeed?") + sys.exit(1) + + dst = os.path.join(os.path.abspath('.'), 'pygtide', f'etpred.{ext}') + shutil.copy(matches[0], dst) + print(f"Copied ABI module {matches[0]} -> {dst}") + + # --- Step 4: Clean up build directory --- + print(f"Deleting build directory '{build_dir}'...") + shutil.rmtree(build_dir) + print("Meson build complete!") + + #print("\nNext step: run 'pip install .' or 'pip install -e .' to install the package.") + + +if __name__ == '__main__': + build() diff --git a/pyproject.toml b/pyproject.toml index 84ccd7a..ab9a4e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,9 @@ requires = [ "setuptools>=61", "wheel", - "numpy>=1.21.0" + "numpy>=1.21.0", + "meson", + "ninja" ] build-backend = "setuptools.build_meta" diff --git a/setup.py b/setup.py index 997ed49..e37cc6c 100644 --- a/setup.py +++ b/setup.py @@ -1,48 +1,35 @@ # setup.py +from pathlib import Path import sys -import os -from setuptools import setup, Extension, find_packages + +from setuptools import setup, Extension + # Detect platform-specific ABI extension +HERE = Path(__file__).resolve().parent + ext = '.pyd' if sys.platform.startswith('win') else '.so' -etpred_path = os.path.join('pygtide', f'etpred{ext}') +etpred_path = HERE / 'pygtide' / f'etpred{ext}' + +if etpred_path.exists(): + print(f"Use ABI module {etpred_path}") +else: + print(f"Prebuilt ABI module not found: {etpred_path}\n" + "Run build_pygtide_abi.py") + sys.path.insert(0, str(HERE)) + import build_pygtide_abi + build_pygtide_abi.build() + if not etpred_path.exists(): + raise FileNotFoundError(f"No ABI module, error in Meson build") -if not os.path.exists(etpred_path): - raise FileNotFoundError(f"Prebuilt ABI module not found: {etpred_path}\n" - "Run build.py first to generate it.") # Define the prebuilt extension etpred_module = Extension( name='pygtide.etpred', sources=[], # No sources; using prebuilt ABI - extra_objects=[etpred_path], + extra_objects=[str(etpred_path)], ) -# Setup configuration -setup( - name='pygtide', - version='0.8.0', - description='A Python module and wrapper for ETERNA PREDICT to compute gravitational tides on Earth', - author='Gabriel C. Rau', - author_email='gabriel@hydrogeo.science', - packages=find_packages(), # finds 'pygtide' - #ext_modules=[etpred_module], - include_package_data=True, # ensures any package data is included - package_data={ - 'pygtide': ['etpred.*', 'commdat/*'], - }, - python_requires='>=3.8', - install_requires=[ - 'numpy>=1.21.0', - 'pandas', - 'requests', - ], - classifiers=[ - "Development Status :: 4 - Beta", - "Intended Audience :: Science/Research", - "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3", - "Topic :: Scientific/Engineering :: Physics", - ], -) + +# Metadata pulled from pyproject.toml +setup() From cc2e0bc9d7ec3b94117d7a34e03b5583368b414e Mon Sep 17 00:00:00 2001 From: Tom Eulenfeld Date: Thu, 29 Jan 2026 13:53:19 +0100 Subject: [PATCH 06/21] update test conda env --- .github/test_conda_env.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/test_conda_env.yml b/.github/test_conda_env.yml index 7aaba20..6497b7b 100644 --- a/.github/test_conda_env.yml +++ b/.github/test_conda_env.yml @@ -4,4 +4,4 @@ channels: dependencies: - numpy - pandas - - fortran-compiler + - requests From c84c8af7378b5514c1abf236c30b7f5f9fee3d23 Mon Sep 17 00:00:00 2001 From: Tom Eulenfeld Date: Thu, 29 Jan 2026 13:58:08 +0100 Subject: [PATCH 07/21] use SPDX license string --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ab9a4e5..0910bab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ name = "pygtide" version = "0.8.0" description = "A Python module and wrapper for ETERNA PREDICT to compute gravitational tides on Earth" readme = "README.md" +license = "MPL-2.0" requires-python = ">=3.8" authors = [ { name = "Gabriel C. Rau", email = "gabriel@hydrogeo.science" }, @@ -26,7 +27,6 @@ dependencies = [ classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Science/Research", - "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", From 00779c2ea878d722c4455fae072a0b37db6e24de Mon Sep 17 00:00:00 2001 From: Tom Eulenfeld Date: Thu, 29 Jan 2026 14:13:46 +0100 Subject: [PATCH 08/21] fix datetime.UTC not found in new python versions --- pygtide/update_etpred_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygtide/update_etpred_data.py b/pygtide/update_etpred_data.py index 774c6ac..a7930ac 100755 --- a/pygtide/update_etpred_data.py +++ b/pygtide/update_etpred_data.py @@ -288,7 +288,7 @@ def update_etpolut1(self): Date Time MJD x y UT1-UTC TAI-UT1 ["] ["] [sec] [sec] C****************************************************************\n""" - header = header.replace("$1$", dt.datetime.now(dt.UTC).strftime("%d/%m/%Y")) + header = header.replace("$1$", dt.datetime.now(dt.timezone.utc).strftime("%d/%m/%Y")) header = header.replace( "$2$", etpolut["date"].iloc[0].strftime("%d/%m/%Y") From 3863703d261dfb5ce430838d301c3b396862a173 Mon Sep 17 00:00:00 2001 From: Tom Eulenfeld Date: Thu, 29 Jan 2026 14:19:50 +0100 Subject: [PATCH 09/21] add windows and py 3.14 to test matrix, use newest conda activate action --- .github/workflows/tests.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 50c2548..cb60f05 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,9 +8,10 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] include: - python: "3.10" + - python: "3.14" runs-on: ${{ matrix.os }} defaults: run: @@ -18,9 +19,10 @@ jobs: steps: - uses: actions/checkout@v3 - name: setup conda - uses: conda-incubator/setup-miniconda@v2 + uses: conda-incubator/setup-miniconda@v3 with: python-version: ${{ matrix.python }} + auto-update-conda: true environment-file: .github/test_conda_env.yml - name: print conda environment info run: | From 1350c356cd7ca01422e354adc273baaf4b6b66e2 Mon Sep 17 00:00:00 2001 From: Tom Eulenfeld Date: Thu, 29 Jan 2026 14:27:44 +0100 Subject: [PATCH 10/21] try to setup fortran with action --- .github/workflows/tests.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cb60f05..9dcfbe1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,6 +18,10 @@ jobs: shell: bash -l {0} steps: - uses: actions/checkout@v3 + - uses: fortran-lang/setup-fortran@v1 + id: setup-fortran + with: + compiler: gcc - name: setup conda uses: conda-incubator/setup-miniconda@v3 with: From 73843034ac196eed69787db952f25e7e59a35347 Mon Sep 17 00:00:00 2001 From: Tom Eulenfeld Date: Thu, 29 Jan 2026 14:32:20 +0100 Subject: [PATCH 11/21] test py 3.10 and 3.14 --- .github/workflows/tests.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9dcfbe1..c101975 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,9 +9,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - include: - - python: "3.10" - - python: "3.14" + python: ["3.10", "3.14"] runs-on: ${{ matrix.os }} defaults: run: From 1a1300ef8bce9339c91cb3f35581fd5ab8c25c79 Mon Sep 17 00:00:00 2001 From: Tom Eulenfeld Date: Thu, 29 Jan 2026 14:42:53 +0100 Subject: [PATCH 12/21] move start download prints to correct place --- pygtide/update_etpred_data.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pygtide/update_etpred_data.py b/pygtide/update_etpred_data.py index a7930ac..c00dae6 100755 --- a/pygtide/update_etpred_data.py +++ b/pygtide/update_etpred_data.py @@ -86,6 +86,7 @@ def update_etpolut1(self): start = tt.time() if status: try: + print("Start downloading: {:s} ...".format(self.leapsec_rfile)) urllib.request.urlopen(self.leapsec_rfile) except OSError as error: print( @@ -97,13 +98,13 @@ def update_etpolut1(self): status = False pass else: - print("Start downloading: {:s} ...".format(self.leapsec_rfile)) urllib.request.urlretrieve(self.leapsec_rfile, leapsec_file) end = tt.time() print("Finished downloading ({:.1f} s).".format((end - start))) if status: try: + print("Start downloading: {:s} ...".format(self.iauhist_rfile)) urllib.request.urlopen(self.iauhist_rfile) except OSError as error: print( @@ -115,13 +116,13 @@ def update_etpolut1(self): status = False pass else: - print("Start downloading: {:s} ...".format(self.iauhist_rfile)) urllib.request.urlretrieve(self.iauhist_rfile, iauhist_file) end = tt.time() print("Finished downloading ({:.1f} s).".format((end - start))) if status: try: + print("Start downloading: {:s} ...".format(self.iaucurr_rfile)) urllib.request.urlopen(self.iaucurr_rfile) except OSError as error: print( @@ -133,7 +134,6 @@ def update_etpolut1(self): status = False pass else: - print("Start downloading: {:s} ...".format(self.iaucurr_rfile)) urllib.request.urlretrieve(self.iaucurr_rfile, iaucurr_file) end = tt.time() print("Finished downloading ({:.1f} s).".format((end - start))) @@ -400,13 +400,13 @@ def update_etddt(self): # %% download leap second history start = tt.time() try: + print("Start downloading: {:s} ...".format(leapsec_file.as_posix())) urllib.request.urlopen(self.leapsec_rfile) except OSError as error: print("ERROR: Could not connect to remote server!") print("MESSAGE: {0}.".format(error)) pass else: - print("Start downloading: {:s} ...".format(leapsec_file.as_posix())) urllib.request.urlretrieve(self.leapsec_rfile, leapsec_file) end = tt.time() print("Finished downloading ({:.1f} s).".format((end - start))) From 8ec3b8a6d8bf04ba95f45724923ed745ef6617de Mon Sep 17 00:00:00 2001 From: Tom Eulenfeld Date: Thu, 29 Jan 2026 14:47:02 +0100 Subject: [PATCH 13/21] add timeout to blocking ftp open call --- pygtide/update_etpred_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygtide/update_etpred_data.py b/pygtide/update_etpred_data.py index c00dae6..7e916a5 100755 --- a/pygtide/update_etpred_data.py +++ b/pygtide/update_etpred_data.py @@ -105,7 +105,7 @@ def update_etpolut1(self): if status: try: print("Start downloading: {:s} ...".format(self.iauhist_rfile)) - urllib.request.urlopen(self.iauhist_rfile) + urllib.request.urlopen(self.iauhist_rfile, timeout=1) except OSError as error: print( "ERROR: Could not connect to remote server: {:s}".format( From eef0f9c16be1e30b86c60e566590a14837a4d780 Mon Sep 17 00:00:00 2001 From: Tom Eulenfeld Date: Thu, 29 Jan 2026 15:04:40 +0100 Subject: [PATCH 14/21] fix another occurence of dt.UTC --- pygtide/update_etpred_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygtide/update_etpred_data.py b/pygtide/update_etpred_data.py index 7e916a5..5ed09a3 100755 --- a/pygtide/update_etpred_data.py +++ b/pygtide/update_etpred_data.py @@ -420,7 +420,7 @@ def update_etddt(self): regex = re.compile(r"^\s*updated\s*\:.*$", re.IGNORECASE) for num, line in enumerate(f, 1): line = regex.sub( - "Updated : %s" % dt.datetime.now(dt.UTC).strftime("%d/%m/%Y"), + "Updated : %s" % dt.datetime.now(dt.timezone.utc).strftime("%d/%m/%Y"), line, ) header.append(line) From 8ec709431d13167069797e48aebac390a169c5f1 Mon Sep 17 00:00:00 2001 From: Tom Eulenfeld Date: Thu, 29 Jan 2026 15:04:47 +0100 Subject: [PATCH 15/21] update readme --- README.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index aabdd22..63b31d4 100644 --- a/README.md +++ b/README.md @@ -19,23 +19,22 @@ There are two options: * Download and install [*Anaconda*](https://www.anaconda.com/products/distribution) or [*Miniconda*](https://docs.conda.io/en/latest/miniconda.html) * Install required packages: ``` - conda install numpy pandas requests git + conda install numpy pandas requests ``` ### Installation options -#### Option 1: Pre-built wheels (Windows, Python 3.8–3.11) -Download the correct wheel for your Python version from the `windows/` subfolder and install: -```powershell -pip install [wheel_name_depending_on_python_version] +#### Option 1: Install and compile source distribution from PyPi (Linux, macOS, Windows; Python 3.8–3.14) + +```bash +pip install pygtide ``` -#### Option 2: Build from source (Linux, macOS, Windows; Python 3.8–3.14) +#### Option 2: Build from source locally (Linux, macOS, Windows; Python 3.8–3.14) **Requirements for building:** - A Fortran compiler (e.g., `gfortran` via MinGW on Windows; included in Linux/macOS gcc toolchains) `conda install gfortran` -- Meson build system: automatically installed via `pip` (see below) -- Ninja (optional but recommended): `conda install ninja` or `pip install ninja` +- Meson build system with ninja: automatically installed via `pip` **Clone repo from git:** ```bash @@ -46,9 +45,7 @@ git clone https://github.com/hydrogeoscience/pygtide.git ```bash cd /path/to/pygtide -python build.py - -pip install . --no-build-isolation +pip install . ``` If Meson or Ninja are missing, pip will attempt to install them automatically. For faster builds, pre-install them: @@ -56,6 +53,12 @@ If Meson or Ninja are missing, pip will attempt to install them automatically. F pip install meson-python meson ninja ``` +#### Option 3: Pre-built wheels (Windows, Python 3.8–3.11) +Download the correct wheel for your Python version from the `windows/` subfolder and install: +```powershell +pip install [wheel_name_depending_on_python_version] +``` + ### After installation * Run tests to verify installation: From 5a52cee2d10a84235b68ea3b5114a4b051a0d993 Mon Sep 17 00:00:00 2001 From: Tom Eulenfeld Date: Thu, 29 Jan 2026 17:22:01 +0100 Subject: [PATCH 16/21] check if CI tests in windows work with ifx compiler --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c101975..399ad44 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,7 +19,7 @@ jobs: - uses: fortran-lang/setup-fortran@v1 id: setup-fortran with: - compiler: gcc + compiler: ${{ startsWith(matrix.os, 'windows') && 'ifx' || 'gcc' }} - name: setup conda uses: conda-incubator/setup-miniconda@v3 with: From 70aa376ba177528ecbd41607364c7669ac53575b Mon Sep 17 00:00:00 2001 From: Tom Eulenfeld Date: Thu, 29 Jan 2026 17:25:54 +0100 Subject: [PATCH 17/21] remove requests dep, not used --- .github/test_conda_env.yml | 1 - pyproject.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/test_conda_env.yml b/.github/test_conda_env.yml index 6497b7b..0af1d61 100644 --- a/.github/test_conda_env.yml +++ b/.github/test_conda_env.yml @@ -4,4 +4,3 @@ channels: dependencies: - numpy - pandas - - requests diff --git a/pyproject.toml b/pyproject.toml index 0910bab..5fe53b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,6 @@ authors = [ dependencies = [ "numpy>=1.21.0", "pandas", - "requests" ] classifiers = [ "Development Status :: 4 - Beta", From 048e6b808d760155a13ab454bc7e139fffcf6811 Mon Sep 17 00:00:00 2001 From: Tom Eulenfeld Date: Thu, 29 Jan 2026 17:30:49 +0100 Subject: [PATCH 18/21] install gfortran runtime on windows --- .github/workflows/tests.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 399ad44..8e29ec6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,13 +19,16 @@ jobs: - uses: fortran-lang/setup-fortran@v1 id: setup-fortran with: - compiler: ${{ startsWith(matrix.os, 'windows') && 'ifx' || 'gcc' }} + compiler: gcc - name: setup conda uses: conda-incubator/setup-miniconda@v3 with: python-version: ${{ matrix.python }} auto-update-conda: true environment-file: .github/test_conda_env.yml + - name: install gfortran runtime (Windows) + if: runner.os == 'Windows' + run: conda install -y -c conda-forge gfortran - name: print conda environment info run: | conda info -a From c935c8cfdc04493f7ca8ac3d24712de5524ebdb4 Mon Sep 17 00:00:00 2001 From: Tom Eulenfeld Date: Thu, 29 Jan 2026 17:49:01 +0100 Subject: [PATCH 19/21] approach appears to work for windows py3.14, but not py3.10, check other versions --- .github/workflows/tests.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8e29ec6..db17a61 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,6 +10,16 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] python: ["3.10", "3.14"] + exclude: + - os: windows-latest + python: "3.10" + include: + - os: windows-latest + python: "3.11" + - os: windows-latest + python: "3.12" + - os: windows-latest + python: "3.13" runs-on: ${{ matrix.os }} defaults: run: From 8f0a7e2ab904d2e955141baa80a812f6728b6a26 Mon Sep 17 00:00:00 2001 From: Tom Eulenfeld Date: Fri, 30 Jan 2026 14:26:27 +0100 Subject: [PATCH 20/21] remove win/py11+13 from CI matrix --- .github/workflows/tests.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index db17a61..c445197 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,12 +14,8 @@ jobs: - os: windows-latest python: "3.10" include: - - os: windows-latest - python: "3.11" - os: windows-latest python: "3.12" - - os: windows-latest - python: "3.13" runs-on: ${{ matrix.os }} defaults: run: From 7882889545e2fc184ce8d7cb3b02f150aadb5e4f Mon Sep 17 00:00:00 2001 From: Tom Eulenfeld Date: Fri, 30 Jan 2026 14:54:51 +0100 Subject: [PATCH 21/21] tested mesonpy build backend --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 5fe53b5..2d4ca8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,10 @@ requires = [ "wheel", "numpy>=1.21.0", "meson", +# "meson-python", "ninja" ] +#build-backend = 'mesonpy' build-backend = "setuptools.build_meta" [project]