Repository: tsutterley/gravity-toolkit (language: python)

Issue:
New gravity time series and some other additions
This pull request follows a pull request on the geoid-toolkit repository.

I propose new reading functionality for other centers. I updated the reading of CNES data for releases 4 & 5.  I add reading for GRAZ, COST-G and SWARM data. Information about all those data is available in the comments.
It might be interesting in the future to create a synchronization script for the data in the future.

I implemented C2,0 tide correction to compare the C2,0 series from differents centers with the same tide convention. This has been done following IERS conventions.

Debug /!\ aod1b_geocenter.py hasn't been updated for the new AOD1B releases. The code was still considering 6h interval between data even if this interval has been shortened to 3h now.
Also a small debug for the mean function of spatial.py.

I created a script to read JPL MASCON available on Tellus and convert them to spherical harmonics. For comparison on spherical harmonics.

I did some plotting functions for personal convenience. That includes spherical harmonics coefficient plotting, Fourier transforms plotting and finally, wavelets transform plotting.
For the robustness of those frequency plots, I created a function of gap-fill for the missing months. For now, it's only linear interpolation but I want to add a better gap-fill in the future.


Discussion / hints:

Fix patch:
diff --git a/README.rst b/README.rst
index 0811f278..60c4f68d 100644
--- a/README.rst
+++ b/README.rst
@@ -7,6 +7,7 @@ read-GRACE-harmonics
 |Documentation Status|
 |Binder|
 |Pangeo|
+|zenodo|
 
 .. |Language| image:: https://img.shields.io/badge/python-v3.7-green.svg
    :target: https://www.python.org/
@@ -23,6 +24,9 @@ read-GRACE-harmonics
 .. |Pangeo| image:: https://binder.pangeo.io/badge.svg
    :target: https://binder.pangeo.io/v2/gh/tsutterley/read-GRACE-harmonics/main
 
+.. |zenodo| image:: https://zenodo.org/badge/107323776.svg
+   :target: https://zenodo.org/badge/latestdoi/107323776
+
 Python tools for obtaining and working with Level-2 spherical harmonic coefficients from the NASA/DLR Gravity Recovery and Climate Experiment (GRACE) and the NASA/GFZ Gravity Recovery and Climate Experiment Follow-On (GRACE-FO) missions
 
 Resources
diff --git a/doc/source/index.rst b/doc/source/index.rst
index 11dd4b6f..b6a72f6d 100644
--- a/doc/source/index.rst
+++ b/doc/source/index.rst
@@ -60,6 +60,7 @@ missions
     user_guide/plm_colombo.rst
     user_guide/plm_holmes.rst
     user_guide/plm_mohlenkamp.rst
+    user_guide/read_gfc_harmonics.rst
     user_guide/read_GIA_model.rst
     user_guide/read_GRACE_harmonics.rst
     user_guide/read_gravis_geocenter.rst
@@ -87,8 +88,11 @@ missions
     :caption: Utilities
 
     user_guide/cnes_grace_sync.md
+    user_guide/esa_costg_swarm_sync.md
+    user_guide/gfz_icgem_costg_ftp.md
     user_guide/gfz_isdc_dealiasing_ftp.md
     user_guide/gfz_isdc_grace_ftp.md
+    user_guide/itsg_graz_grace_sync.md
     user_guide/podaac_grace_sync.md
     user_guide/podaac_webdav.md
 
diff --git a/doc/source/user_guide/esa_costg_swarm_sync.md b/doc/source/user_guide/esa_costg_swarm_sync.md
new file mode 100644
index 00000000..21e98d92
--- /dev/null
+++ b/doc/source/user_guide/esa_costg_swarm_sync.md
@@ -0,0 +1,20 @@
+esa_costg_swarm_sync.py
+=======================
+
+- Syncs Swarm gravity field products from the [ESA Swarm Science Server](https://earth.esa.int/eogateway/missions/swarm/data)
+- Creates an index file for each data product
+
+#### Calling Sequence
+```bash
+python esa_costg_swarm_sync.py --directory <path_to_grace_directory>
+```
+[Source code](https://github.com/tsutterley/read-GRACE-harmonics/blob/main/scripts/esa_costg_swarm_sync.py)
+
+#### Command Line Options
+- `-D X`, `--directory X`: Working Data Directory
+- `-t X`, `--timeout X`: Timeout in seconds for blocking operations
+- `-l`, `--log`: Output log file
+- `-L`, `--list`: Only print files that are to be transferred
+- `-C`, `--clobber`: Overwrite existing data in transfer
+- `--checksum`: Compare hashes to check if overwriting existing data
+- `-M X`, `--mode X`: Permission mode of directories and files synced
diff --git a/doc/source/user_guide/gfz_icgem_costg_ftp.md b/doc/source/user_guide/gfz_icgem_costg_ftp.md
new file mode 100644
index 00000000..14ddeccf
--- /dev/null
+++ b/doc/source/user_guide/gfz_icgem_costg_ftp.md
@@ -0,0 +1,25 @@
+gfz_icgem_costg_ftp.py
+======================
+
+- Syncs GRACE/GRACE-FO/Swarm COST-G data from the [GFZ International Centre for Global Earth Models (ICGEM)](http://icgem.gfz-potsdam.de/)
+- Creates an index file for each data product
+
+#### Calling Sequence
+```bash
+python gfz_icgem_costg_ftp.py --directory <path_to_grace_directory> \
+    --mission Grace Grace-FO Swarm
+```
+[Source code](https://github.com/tsutterley/read-GRACE-harmonics/blob/main/scripts/gfz_icgem_costg_ftp.py)
+
+#### Command Line Options
+- `-D X`, `--directory X`: Working Data Directory
+- `-m X`, `--mission X`: Mission to sync between GRACE, GRACE-FO and Swarm
+    * `'Grace'`
+    * `'Grace-FO'`
+    * `'Swarm'`
+- `-t X`, `--timeout X`: Timeout in seconds for blocking operations
+- `-l`, `--log`: Output log file
+- `-L`, `--list`: Only print files that are to be transferred
+- `-C`, `--clobber`: Overwrite existing data in transfer
+- `--checksum`: Compare hashes to check if overwriting existing data
+- `-M X`, `--mode X`: Permission mode of directories and files synced
diff --git a/doc/source/user_guide/grace_date.md b/doc/source/user_guide/grace_date.md
index 29d4f8f4..df9410a9 100644
--- a/doc/source/user_guide/grace_date.md
+++ b/doc/source/user_guide/grace_date.md
@@ -16,13 +16,16 @@ grace_files = grace_date(base_dir, PROC=PROC, DREL=DREL, DSET=DSET)
 1. Working data directory for GRACE/GRACE-FO data
 
 #### Keyword arguments
-- `PROC`: GRACE/GRACE-FO data processing center (CSR/CNES/JPL/GFZ)
+- `PROC`: Data processing center or satellite mission
    * `'CSR'`: University of Texas Center for Space Research
    * `'GFZ'`: German Research Centre for Geosciences (GeoForschungsZentrum)
    * `'JPL'`: Jet Propulsion Laboratory
    * `'CNES'`: French Centre National D'Etudes Spatiales
-- `DREL`: GRACE/GRACE-FO data release (RL04/RL05/RL06)
-- `DSET`: GRACE/GRACE-FO dataset (GAA/GAB/GAC/GAD/GSM)
+   * `'GRAZ'`: Institute of Geodesy from GRAZ University of Technology
+   * `'COSTG'`: Combination Service for Time-variable Gravity Fields
+   * `'Swarm'`: Time-variable gravity data from Swarm satellites
+- `DREL`: GRACE/GRACE-FO/Swarm data release
+- `DSET`: GRACE/GRACE-FO/Swarm dataset
    * `'GAA'`: non-tidal atmospheric correction
    * `'GAB'`: non-tidal oceanic correction
    * `'GAC'`: combined non-tidal atmospheric and oceanic correction
diff --git a/doc/source/user_guide/grace_find_months.md b/doc/source/user_guide/grace_find_months.md
index 4128e858..65902432 100644
--- a/doc/source/user_guide/grace_find_months.md
+++ b/doc/source/user_guide/grace_find_months.md
@@ -1,8 +1,8 @@
 grace_find_months.py
 ====================
 
-- Parses date index file from grace_date.py
-- Finds the months available for a GRACE/GRACE-FO product
+- Parses date index file from `grace_date` program
+- Finds the months available for a GRACE/GRACE-FO/Swarm product
 - Finds the all months missing from the product
 
 #### Calling Sequence
@@ -14,20 +14,23 @@ grace_months = grace_find_months(base_dir, PROC, DREL, DSET=DSET)
 
 #### Arguments
 1. `base_dir`: Working data directory for GRACE/GRACE-FO data
-2. `PROC`: GRACE/GRACE-FO data processing center (CSR, CNES, JPL, GFZ)
+2. `PROC`: Data processing center or satellite mission
     * `'CSR'`: University of Texas Center for Space Research
     * `'GFZ'`: German Research Centre for Geosciences (GeoForschungsZentrum)
     * `'JPL'`: Jet Propulsion Laboratory
     * `'CNES'`: French Centre National D'Etudes Spatiales
-3. `DREL`: GRACE/GRACE-FO Data Release (RL04, RL05, RL06)
+    * `'GRAZ'`: Institute of Geodesy from GRAZ University of Technology
+    * `'COSTG'`: Combination Service for Time-variable Gravity Fields
+    * `'Swarm'`: Time-variable gravity data from Swarm satellites
+3. `DREL`: GRACE/GRACE-FO/Swarm data release (RL04, RL05, RL06)
 
 #### Keyword arguments
-- `DSET`: GRACE dataset (GSM, GAC, GAD, GAB, GAA)
+- `DSET`: GRACE/GRACE-FO/Swarm dataset
     * `'GAA'`: non-tidal atmospheric correction
     * `'GAB'`: non-tidal oceanic correction
     * `'GAC'`: combined non-tidal atmospheric and oceanic correction
-    * `'GAD'`: GRACE/GRACE-FO ocean bottom pressure product
-    * `'GSM'`: corrected monthly GRACE/GRACE-FO static field product
+    * `'GAD'`: ocean bottom pressure product
+    * `'GSM'`: corrected monthly static field product
 
 #### Returns
 - `start`: First month in a GRACE/GRACE-FO dataset
diff --git a/doc/source/user_guide/grace_input_months.rst b/doc/source/user_guide/grace_input_months.rst
index fb2334df..5abc7d0c 100644
--- a/doc/source/user_guide/grace_input_months.rst
+++ b/doc/source/user_guide/grace_input_months.rst
@@ -26,14 +26,17 @@ Arguments
 #########
 
 1. ``base_dir``: Working data directory for GRACE/GRACE-FO data
-2. ``PROC``: GRACE/GRACE-FO data processing center (CSR, CNES, JPL, GFZ)
+2. ``PROC``: Data processing center or satellite mission
 
       * ``'CSR'``: University of Texas Center for Space Research
       * ``'GFZ'``: German Research Centre for Geosciences (GeoForschungsZentrum)
       * ``'JPL'``: Jet Propulsion Laboratory
       * ``'CNES'``: French Centre National D'Etudes Spatiales
-3. ``DREL``: GRACE/GRACE-FO data release (RL04, RL05, RL06)
-4. ``DSET``: GRACE/GRACE-FO data product (GAA, GAB, GAC, GAD, GSM)
+      * ``'GRAZ'``: Institute of Geodesy from GRAZ University of Technology
+      * ``'COSTG'``: Combination Service for Time-variable Gravity Fields
+      * ``'Swarm'``: Time-variable gravity data from Swarm satellites
+3. ``DREL``: GRACE/GRACE-FO/Swarm data release (RL04, RL05, RL06)
+4. ``DSET``: GRACE/GRACE-FO/Swarm data product
 
       * ``'GAA'``: non-tidal atmospheric correction
       * ``'GAB'``: non-tidal oceanic correction
diff --git a/doc/source/user_guide/itsg_graz_grace_sync.md b/doc/source/user_guide/itsg_graz_grace_sync.md
new file mode 100644
index 00000000..500c9a08
--- /dev/null
+++ b/doc/source/user_guide/itsg_graz_grace_sync.md
@@ -0,0 +1,30 @@
+itsg_graz_grace_sync.py
+=======================
+
+- Syncs GRACE/GRACE-FO and auxiliary data from the [ITSG GRAZ server](https://www.tugraz.at/institute/ifg/downloads/gravity-field-models)
+- Creates an index file for each data product
+
+#### Calling Sequence
+```bash
+python itsg_graz_grace_sync.py --directory <path_to_grace_directory> \
+    --release Grace2018 Grace_operational --lmax 60
+```
+[Source code](https://github.com/tsutterley/read-GRACE-harmonics/blob/main/scripts/itsg_graz_grace_sync.py)
+
+#### Command Line Options
+- `-D X`, `--directory X`: Working Data Directory
+- `-r X`, `--release X`: GRAZ Data Releases to sync
+    * `'Grace2014'`
+    * `'Grace2016'`
+    * `'Grace2018'`
+    * `'Grace_operational'`
+- `--lmax X`: Maximum degree and order of GRAZ products
+    * `60`
+    * `96`
+    * `120`
+- `-t X`, `--timeout X`: Timeout in seconds for blocking operations
+- `-l`, `--log`: Output log file
+- `-L`, `--list`: Only print files that are to be transferred
+- `-C`, `--clobber`: Overwrite existing data in transfer
+- `--checksum`: Compare hashes to check if overwriting existing data
+- `-M X`, `--mode X`: Permission mode of directories and files synced
diff --git a/doc/source/user_guide/read_GRACE_harmonics.rst b/doc/source/user_guide/read_GRACE_harmonics.rst
index 25085696..b0731d19 100644
--- a/doc/source/user_guide/read_GRACE_harmonics.rst
+++ b/doc/source/user_guide/read_GRACE_harmonics.rst
@@ -40,6 +40,8 @@ Returns
 - ``time``: mid-month date of GRACE file in year-decimal
 - ``start``: start date of range as Julian day
 - ``end``: end date of range as Julian day
+- ``l``: spherical harmonic degree to LMAX
+- ``m``: spherical harmonic order to MMAX
 - ``clm``: cosine spherical harmonics of input data
 - ``slm``: sine spherical harmonics of input data
 - ``eclm``: cosine spherical harmonic uncalibrated standard deviations
diff --git a/doc/source/user_guide/read_gfc_harmonics.rst b/doc/source/user_guide/read_gfc_harmonics.rst
new file mode 100644
index 00000000..4fc2b422
--- /dev/null
+++ b/doc/source/user_guide/read_gfc_harmonics.rst
@@ -0,0 +1,58 @@
+=====================
+read_gfc_harmonics.py
+=====================
+
+- Reads gfc files and extracts spherical harmonics for Swarm and GRAZ GRACE/GRACE-FO data
+- Parses date of GRACE/GRACE-FO data from filename
+
+Calling Sequence
+################
+
+.. code-block:: python
+
+    from gravity_toolkit.read_gfc_harmonics import read_gfc_harmonics
+    Ylms = read_gfc_harmonics(input_file, TIDE='tide_free')
+
+`Source code`__
+
+.. __: https://github.com/tsutterley/read-GRACE-harmonics/blob/main/gravity_toolkit/read_gfc_harmonics.py
+
+Arguments
+#########
+
+1. ``input_file``: full path to gfc spherical harmonic data file
+
+Keyword arguments
+#################
+
+- ``TIDE``: `tide system of output geoid <http://mitgcm.org/~mlosch/geoidcookbook/node9.html>`_ [Losch2003]_
+
+    * ``'tide_free'``: no permanent direct and indirect tidal potentials
+    * ``'mean_tide'``: permanent tidal potentials (direct and indirect)
+    * ``'zero_tide'``: permanent direct tidal potential
+- ``FLAG``: string denoting data lines
+
+Returns
+#######
+
+- ``time``: mid-month date in decimal form
+- ``start``: Julian dates of the start date
+- ``end``: Julian dates of the start date
+- ``l``: spherical harmonic degree to maximum degree of data
+- ``m``: spherical harmonic order to maximum degree of data
+- ``clm``: cosine spherical harmonics of input data
+- ``slm``: sine spherical harmonics of input data
+- ``eclm``: cosine spherical harmonic standard deviations
+- ``eslm``: sine spherical harmonic standard deviations
+- ``modelname``: name of the gravity model
+- ``earth_gravity_constant``: GM constant of the Earth for the gravity model
+- ``radius``: semi-major axis of the Earth for the gravity model
+- ``max_degree``: maximum degree and order for the gravity model
+- ``errors``: error type of the gravity model
+- ``norm``: normalization of the spherical harmonics
+- ``tide_system``: tide system of gravity model (mean_tide, zero_tide, tide_free)
+
+References
+##########
+
+.. [Losch2003] M. Losch and V. Seufer, "How to Compute Geoid Undulations (Geoid Height Relative to a Given Reference Ellipsoid) from Spherical Harmonic Coefficients for Satellite Altimetry Applications", (2003). `eprint ID: 11802 <http://mitgcm.org/~mlosch/geoidcookbook.pdf>`_
diff --git a/doc/source/user_guide/utilities.rst b/doc/source/user_guide/utilities.rst
index 5f5edc43..ad06092f 100644
--- a/doc/source/user_guide/utilities.rst
+++ b/doc/source/user_guide/utilities.rst
@@ -196,6 +196,35 @@ General Methods
         ``HOST``: remote http host
 
 
+.. method:: gravity_toolkit.utilities.http_list(HOST,timeout=None,context=ssl.SSLContext(),parser=lxml.etree.HTMLParser(),format='%Y-%m-%d %H:%M',pattern='',sort=False)
+
+    List a directory on an Apache http Server
+
+    Arguments:
+
+        ``HOST``: remote http host path split as list
+
+    Keyword arguments:
+
+        ``timeout``: timeout in seconds for blocking operations
+
+        ``context``: SSL context for url opener object
+
+        ``parser``: HTML parser for lxml
+
+        ``format``: format for input time string
+
+        ``pattern``: regular expression pattern for reducing list
+
+        ``sort``: sort output list
+
+    Returns:
+
+        ``output``: list of items in a directory
+
+        ``mtimes``: list of last modification times for items in the directory
+
+
 .. method:: gravity_toolkit.utilities.from_http(HOST,timeout=None,context=ssl.SSLContext(),local=None,hash='',chunk=16384,verbose=False,fid=sys.stdout,mode=0o775)
 
     Download a file from a http host
diff --git a/gravity_toolkit/__init__.py b/gravity_toolkit/__init__.py
index a4872e30..bc6fb1b7 100644
--- a/gravity_toolkit/__init__.py
+++ b/gravity_toolkit/__init__.py
@@ -49,6 +49,7 @@
 from gravity_toolkit.plm_colombo import plm_colombo
 from gravity_toolkit.plm_holmes import plm_holmes
 from gravity_toolkit.plm_mohlenkamp import plm_mohlenkamp
+from gravity_toolkit.read_gfc_harmonics import read_gfc_harmonics
 from gravity_toolkit.read_GIA_model import read_GIA_model
 from gravity_toolkit.read_GRACE_harmonics import read_GRACE_harmonics
 from gravity_toolkit.read_gravis_geocenter import read_gravis_geocenter
diff --git a/gravity_toolkit/grace_date.py b/gravity_toolkit/grace_date.py
index 74ec5c56..3e7e5482 100644
--- a/gravity_toolkit/grace_date.py
+++ b/gravity_toolkit/grace_date.py
@@ -1,7 +1,8 @@
 #!/usr/bin/env python
 u"""
 grace_date.py
-Written by Tyler Sutterley (07/2021)
+Written by Tyler Sutterley (09/2021)
+Contributions by Hugo Lecomte and Yara Mohajerani
 
 Reads index file from podaac_grace_sync.py or gfz_isdc_grace_ftp.py
 Parses dates of each GRACE/GRACE-FO file and assigns the month number
@@ -11,14 +12,21 @@
     base_dir: Working data directory for GRACE/GRACE-FO data
 
 OPTIONS:
-    PROC: GRACE data processing center (CSR/CNES/JPL/GFZ)
-    DREL: GRACE/GRACE-FO Data Release (RL03 for CNES) (RL06 for CSR/GFZ/JPL)
-    DSET: GRACE dataset (GAA/GAB/GAC/GAD/GSM)
-        GAA is the non-tidal atmospheric correction
-        GAB is the non-tidal oceanic correction
-        GAC is the combined non-tidal atmospheric and oceanic correction
-        GAD is the GRACE ocean bottom pressure product
-        GSM is corrected monthly GRACE/GRACE-FO static field product
+    PROC: Data processing center or satellite mission
+        CSR: University of Texas Center for Space Research
+        GFZ: German Research Centre for Geosciences (GeoForschungsZentrum)
+        JPL: Jet Propulsion Laboratory
+        CNES: French Centre National D'Etudes Spatiales
+        GRAZ: Institute of Geodesy from GRAZ University of Technology
+        COSTG: Combination Service for Time-variable Gravity Fields
+        Swarm: Time-variable gravity data from Swarm satellites
+    DREL: GRACE/GRACE-FO/Swarm data release
+    DSET: GRACE/GRACE-FO/Swarm dataset
+        GAA: non-tidal atmospheric correction
+        GAB: non-tidal oceanic correction
+        GAC: combined non-tidal atmospheric and oceanic correction
+        GAD: ocean bottom pressure product
+        GSM: monthly static field product
     OUTPUT: create index of dates for GRACE/GRACE-FO data
     MODE: permissions mode of output files
 
@@ -38,10 +46,13 @@
     time.py: utilities for calculating time operations
 
 UPDATE HISTORY:
+    Updated 09/2021: adjust regular expression operators for Swarm and GRAZ
     Updated 07/2021: remove choices for argparse processing centers
     Updated 05/2021: define int/float precision to prevent deprecation warning
     Updated 02/2021: use adjust_months function to fix special months cases
     Updated 12/2020: using utilities from time module
+        Add Swarm data time-variable gravity fields
+    Updated 11/2020: added CNES RL04 & RL05 and GRAZ 2018 (monthly fields)
     Updated 10/2020: use argparse to set command line parameters
     Updated 07/2020: added function docstrings
     Updated 03/2020: for public release
@@ -88,6 +99,103 @@
 import numpy as np
 import gravity_toolkit.time
 
+#-- PURPOSE: extract parameters from filename
+def parse_file(input_file):
+    """
+    Extract parameters from filename
+
+    Arguments
+    ---------
+    input_file: GRACE/GRACE-FO Level-2 spherical harmonic data file
+    """
+    #-- reduce to basename of file
+    file_basename = os.path.basename(input_file)
+    #-- compile numerical expression operator for parameters from files
+    #-- UTCSR: The University of Texas at Austin Center for Space Research
+    #-- EIGEN: GFZ German Research Center for Geosciences (RL01-RL05)
+    #-- GFZOP: GFZ German Research Center for Geosciences (RL06+GRACE-FO)
+    #-- JPLEM: NASA Jet Propulsion Laboratory (harmonic solutions)
+    #-- JPLMSC: NASA Jet Propulsion Laboratory (mascon solutions)
+    #-- GRGS: French Centre National D'Etudes Spatiales (CNES)
+    #-- COSTG: International Combined Time-variable Gravity Fields
+    args = r'UTCSR|EIGEN|GFZOP|JPLEM|JPLMSC|GRGS|COSTG'
+    regex_pattern = (r'(.*?)-2_(\d{{4}})(\d{{3}})-(\d{{4}})(\d{{3}})_'
+        r'(.*?)_({0})_(.*?)_(\d+)(.*?)(\.gz|\.gfc)?$').format(args)
+    rx = re.compile(regex_pattern, re.VERBOSE)
+    #-- extract parameters from input filename
+    PFX,SY,SD,EY,ED,AUX,PRC,F1,DRL,F2,SFX = rx.findall(file_basename).pop()
+    #-- return the start and end date lists
+    return ([SY,SD],[EY,ED])
+
+#-- PURPOSE: extract dates from GRAZ or Swarm files with regular expressions
+def parse_date(input_file, PROC, DSET):
+    """
+    Extract dates from GRAZ and Swarm filenames
+
+    Arguments
+    ---------
+    input_file: GRAZ or Swarm spherical harmonic data file
+    PROC: GRACE/GRACE-FO Processing Center or Satellite mission
+        GRAZ: Institute of Geodesy from GRAZ University of Technology
+        Swarm: Time-variable gravity data from Swarm satellites
+    DSET: GRACE/GRACE-FO/Swarm dataset
+        GAA: non-tidal atmospheric correction
+        GAB: non-tidal oceanic correction
+        GAC: combined non-tidal atmospheric and oceanic correction
+        GAD: ocean bottom pressure product
+        GSM: monthly static field product
+    """
+    #-- reduce to basename of file
+    file_basename = os.path.basename(input_file)
+    #-- extract parameters from input filename
+    if (PROC == 'GRAZ'):
+        #-- regular expression operators for ITSG data and models
+        itsg_products = []
+        itsg_products.append(r'atmosphere')
+        itsg_products.append(r'dealiasing')
+        itsg_products.append(r'oceanBottomPressure')
+        itsg_products.append(r'ocean')
+        itsg_products.append(r'Grace2014')
+        itsg_products.append(r'Grace2016')
+        itsg_products.append(r'Grace2018')
+        itsg_products.append(r'Grace_operational')
+        regex_pattern=(r'(AOD1B_RL\d+|model|ITSG)[-_]({0})(_n\d+)?_'
+            r'(\d+)-(\d+)(\.gfc)').format(r'|'.join(itsg_products))
+        #-- compile regular expression operator for parameters from files
+        rx = re.compile(regex_pattern, re.VERBOSE | re.IGNORECASE)
+        #-- extract parameters from input filename
+        PFX,PRD,trunc,year,month,SFX = rx.findall(file_basename).pop()
+        #-- number of days in each month for the calendar year
+        dpm = gravity_toolkit.time.calendar_days(int(year))
+        #-- create start and end date lists
+        start_date = [int(year),int(month),1,0,0,0]
+        end_date = [int(year),int(month),dpm[int(month)-1],23,59,59]
+    elif (PROC == 'Swarm') and (DSET == 'GSM'):
+        #-- regular expression operators for Swarm data
+        regex_pattern=r'(SW)_(.*?)_(EGF_SHA_2)__(.*?)_(.*?)_(.*?)(\.gfc|\.ZIP)'
+        #-- compile regular expression operator for parameters from files
+        rx = re.compile(regex_pattern, re.VERBOSE | re.IGNORECASE)
+        #-- extract parameters from input filename
+        SAT,tmp,PROD,starttime,endtime,RL,SFX = rx.findall(file_basename).pop()
+        start_date,_ = gravity_toolkit.time.parse_date_string(starttime)
+        end_date,_ = gravity_toolkit.time.parse_date_string(endtime)
+    elif (PROC == 'Swarm') and (DSET != 'GSM'):
+        #-- regular expression operators for Swarm models
+        regex_pattern=(r'(GAA|GAB|GAC|GAD)_Swarm_(\d+)_(\d{2})_(\d{4})'
+            r'(\.gfc|\.ZIP)')
+        #-- compile regular expression operator for parameters from files
+        rx = re.compile(regex_pattern, re.VERBOSE | re.IGNORECASE)
+        #-- extract parameters from input filename
+        PROD,trunc,month,year,SFX = rx.findall(file_basename).pop()
+        #-- number of days in each month for the calendar year
+        dpm = gravity_toolkit.time.calendar_days(int(year))
+        #-- create start and end date lists
+        start_date = [int(year),int(month),1,0,0,0]
+        end_date = [int(year),int(month),dpm[int(month)-1],23,59,59]
+    #-- return the start and end date lists
+    return (start_date, end_date)
+
+#-- PURPOSE: parses GRACE/GRACE-FO data files and assigns month numbers
 def grace_date(base_dir, PROC='', DREL='', DSET='', OUTPUT=True, MODE=0o775):
     """
     Reads index file from podaac_grace_sync.py or gfz_isdc_grace_ftp.py
@@ -100,13 +208,16 @@ def grace_date(base_dir, PROC='', DREL='', DSET='', OUTPUT=True, MODE=0o775):
 
     Keyword arguments
     -----------------
-    PROC: GRACE data processing center
+    PROC: Data processing center or satellite mission
         CSR: University of Texas Center for Space Research
         GFZ: German Research Centre for Geosciences (GeoForschungsZentrum)
         JPL: Jet Propulsion Laboratory
         CNES: French Centre National D'Etudes Spatiales
-    DREL: GRACE/GRACE-FO data release
-    DSET: GRACE/GRACE-FO dataset
+        GRAZ: Institute of Geodesy from GRAZ University of Technology
+        COSTG: Combination Service for Time-variable Gravity Fields
+        Swarm: Time-variable gravity data from Swarm satellites
+    DREL: GRACE/GRACE-FO/Swarm data release
+    DSET: GRACE/GRACE-FO/Swarm dataset
         GAA: non-tidal atmospheric correction
         GAB: non-tidal oceanic correction
         GAC: combined non-tidal atmospheric and oceanic correction
@@ -139,26 +250,30 @@ def grace_date(base_dir, PROC='', DREL='', DSET='', OUTPUT=True, MODE=0o775):
     tdec = np.zeros((n_files))#-- tdec is the date in decimal form
     mon = np.zeros((n_files,),dtype=np.int64)#-- GRACE/GRACE-FO month number
 
-    #-- compile numerical expression operator for parameters from files
-    #-- will work with previous releases and releases for GRACE-FO
-    #-- UTCSR: The University of Texas at Austin Center for Space Research
-    #-- EIGEN: GFZ German Research Center for Geosciences (RL01-RL05)
-    #-- GFZOP: GFZ German Research Center for Geosciences (RL06+GRACE-FO)
-    #-- JPLEM: NASA Jet Propulsion Laboratory (harmonic solutions)
-    #-- JPLMSC: NASA Jet Propulsion Laboratory (mascon solutions)
-    regex_pattern = (r'(.*?)-2_(\d+)-(\d+)_(.*?)_({0})_(.*?)_(\d+)(.*?)'
-       r'(\.gz|\.gfc)?$').format(r'UTCSR|EIGEN|GFZOP|JPLEM|JPLMSC')
-    rx = re.compile(regex_pattern, re.VERBOSE)
-
     #-- for each data file
-    for t, infile in enumerate(input_files):
-        #-- extract parameters from input filename
-        PFX,start_date,end_date,AUX,PRC,F1,DRL,F2,SFX = rx.findall(infile).pop()
-        #-- find start date, end date and number of days
-        start_yr[t] = np.float64(start_date[:4])
-        end_yr[t] = np.float64(end_date[:4])
-        start_day[t] = np.float64(start_date[4:])
-        end_day[t] = np.float64(end_date[4:])
+    for t,infile in enumerate(input_files):
+        if PROC in ('GRAZ','Swarm',):
+            #-- get date lists for the start and end of fields
+            start_date,end_date = parse_date(infile, PROC, DSET)
+            #-- start and end year
+            start_yr[t] = np.float64(start_date[0])
+            end_yr[t] = np.float64(end_date[0])
+            #-- number of days in each month for the calendar year
+            dpm = gravity_toolkit.time.calendar_days(start_yr[t])
+            #-- start and end day of the year
+            start_day[t] = np.sum(dpm[:start_date[1]-1]) + start_date[2] + \
+                start_date[3]/24. + start_date[4]/1440. + start_date[5]/86400.
+            end_day[t] = np.sum(dpm[:end_date[1]-1]) + end_date[2] + \
+                end_date[3]/24. + end_date[4]/1440. + end_date[5]/86400.
+        else:
+            #-- get date lists for the start and end of fields
+            start_date,end_date = parse_file(infile)
+            #-- start and end year
+            start_yr[t] = np.float64(start_date[0])
+            end_yr[t] = np.float64(end_date[0])
+            #-- start and end day of the year
+            start_day[t] = np.float64(start_date[1])
+            end_day[t] = np.float64(end_date[1])
 
         #-- number of days in the starting year for leap and standard years
         dpy = gravity_toolkit.time.calendar_days(start_yr[t]).sum()
@@ -251,7 +366,7 @@ def main():
         type=lambda p: os.path.abspath(os.path.expanduser(p)),
         default=os.getcwd(),
         help='Working data directory')
-    #-- GRACE/GRACE-FO data processing center
+    #-- Data processing center or satellite mission
     parser.add_argument('--center','-c',
         metavar='PROC', type=str, nargs='+',
         default=['CSR','GFZ','JPL'],
diff --git a/gravity_toolkit/grace_find_months.py b/gravity_toolkit/grace_find_months.py
index fa4c4067..d0a94366 100644
--- a/gravity_toolkit/grace_find_months.py
+++ b/gravity_toolkit/grace_find_months.py
@@ -3,17 +3,24 @@
 grace_find_months.py
 Written by Tyler Sutterley (05/2021)
 
-Parses date index file from grace_date.py
-Finds the months available for a GRACE/GRACE-FO product
+Parses date index file from grace_date program
+Finds the months available for a GRACE/GRACE-FO/Swarm product
 Finds the all months missing from the product
 
 INPUTS:
     base_dir: Working data directory for GRACE/GRACE-FO data
-    PROC: GRACE/GRACE-FO data processing center (CSR, CNES, JPL, GFZ)
-    DREL: GRACE/GRACE-FO data release (RL04, RL05, RL06)
+    PROC: Data processing center or satellite mission
+        CSR: University of Texas Center for Space Research
+        GFZ: German Research Centre for Geosciences (GeoForschungsZentrum)
+        JPL: Jet Propulsion Laboratory
+        CNES: French Centre National D'Etudes Spatiales
+        GRAZ: Institute of Geodesy from GRAZ University of Technology
+        COSTG: Combination Service for Time-variable Gravity Fields
+        Swarm: Time-variable gravity data from Swarm satellites
+    DREL: GRACE/GRACE-FO/Swarm data release
 
 OPTIONS:
-    DSET: GRACE dataset (GSM, GAC, GAD, GAB, GAA)
+    DSET: GRACE/GRACE-FO/Swarm dataset (GSM, GAC, GAD, GAB, GAA)
 
 OUTPUTS:
     start: First month in a GRACE/GRACE-FO dataset
@@ -60,11 +67,11 @@ def grace_find_months(base_dir, PROC, DREL, DSET='GSM'):
         GFZ: German Research Centre for Geosciences (GeoForschungsZentrum)
         JPL: Jet Propulsion Laboratory
         CNES: French Centre National D'Etudes Spatiales
-    DREL: GRACE/GRACE-FO data release
+    DREL: GRACE/GRACE-FO/Swarm data release
 
     Keyword arguments
     -----------------
-    DSET: GRACE/GRACE-FO dataset
+    DSET: GRACE/GRACE-FO/Swarm dataset
         GAA: non-tidal atmospheric correction
         GAB: non-tidal oceanic correction
         GAC: combined non-tidal atmospheric and oceanic correction
diff --git a/gravity_toolkit/grace_input_months.py b/gravity_toolkit/grace_input_months.py
index eade4f6e..c6bf01ac 100644
--- a/gravity_toolkit/grace_input_months.py
+++ b/gravity_toolkit/grace_input_months.py
@@ -1,7 +1,7 @@
 #!/usr/bin/env python
 u"""
 grace_input_months.py
-Written by Tyler Sutterley (08/2021)
+Written by Tyler Sutterley (09/2021)
 Contributions by Hugo Lecomte and Yara Mohajerani
 
 Reads GRACE/GRACE-FO files for a specified spherical harmonic degree and order
@@ -14,9 +14,21 @@
 
 INPUTS:
     base_dir: Working data directory for GRACE/GRACE-FO data
-    PROC: (CSR/CNES/JPL/GFZ) data processing center
-    DREL: (RL01/RL02/RL03/RL04/RL05/RL06) data release
-    DSET: (GAA/GAB/GAC/GAD/GSM) data product
+    PROC: Data processing center or satellite mission
+        CSR: University of Texas Center for Space Research
+        GFZ: German Research Centre for Geosciences (GeoForschungsZentrum)
+        JPL: Jet Propulsion Laboratory
+        CNES: French Centre National D'Etudes Spatiales
+        GRAZ: Institute of Geodesy from GRAZ University of Technology
+        COSTG: Combination Service for Time-variable Gravity Fields
+        Swarm: Time-variable gravity data from Swarm satellites
+    DREL: GRACE/GRACE-FO/Swarm data release
+    DSET: GRACE/GRACE-FO/Swarm data product
+        GAA: non-tidal atmospheric correction
+        GAB: non-tidal oceanic correction
+        GAC: combined non-tidal atmospheric and oceanic correction
+        GAD: ocean bottom pressure product
+        GSM: monthly static field product
     LMAX: Upper bound of Spherical Harmonic Degrees (e.g. 60)
     start_mon: starting month to consider in analysis
     end_mon: ending month to consider in analysis
@@ -92,9 +104,11 @@
     read_swenson_geocenter.py: reads degree 1 files from Sean Swenson
     read_SLR_geocenter.py: reads degree 1 files from Satellite Laser Ranging
     read_GRACE_geocenter.py: reads degree 1 files from Sutterley et al. (2019)
-    read_GRACE_harmonics.py: reads an input GRACE data file and calculates date
+    read_GRACE_harmonics.py: read spherical harmonic data from SHM files
+    read_gfc_harmonics.py: reads spherical harmonic data from gfc files
 
 UPDATE HISTORY:
+    Updated 09/2021: added time-variable gravity data from GRAZ and Swarm
     Updated 08/2021: fix spherical harmonic errors for SLR C21,S21,C22,S22
     Updated 07/2021: fix inputs to AOD-corrected SLR geocenter coefficients
         output uncalibrated spherical harmonic errors (eclm and eslm)
@@ -158,6 +172,7 @@
 from gravity_toolkit.read_gravis_geocenter import read_gravis_geocenter
 from read_GRACE_geocenter.read_GRACE_geocenter import read_GRACE_geocenter
 from gravity_toolkit.read_GRACE_harmonics import read_GRACE_harmonics
+from gravity_toolkit.read_gfc_harmonics import read_gfc_harmonics
 
 def grace_input_months(base_dir, PROC, DREL, DSET, LMAX, start_mon, end_mon,
     missing, SLR_C20, DEG1, MMAX=None, SLR_21='', SLR_22='',  SLR_C30='',
@@ -267,9 +282,15 @@ def grace_input_months(base_dir, PROC, DREL, DSET, LMAX, start_mon, end_mon,
 
     #-- importing data from GRACE/GRACE-FO files
     for i,grace_month in enumerate(months):
-        #-- Effects of Pole tide drift will be compensated if specified
+        #-- read spherical harmonic data products
         infile = grace_files[grace_month]
-        Ylms = read_GRACE_harmonics(infile,LMAX,MMAX=MMAX,POLE_TIDE=POLE_TIDE)
+        if PROC in ('GRAZ','Swarm'):
+            #-- Degree 2 zonals will be converted to a tide free state
+            Ylms = read_gfc_harmonics(infile, TIDE='tide_free')
+        else:
+            #-- Effects of Pole tide drift will be compensated if specified
+            Ylms = read_GRACE_harmonics(infile, LMAX, MMAX=MMAX,
+                POLE_TIDE=POLE_TIDE)
         #-- truncate harmonics to degree and order
         grace_Ylms['clm'][:,:,i] = Ylms['clm'][0:LMAX+1,0:MMAX+1]
         grace_Ylms['slm'][:,:,i] = Ylms['slm'][0:LMAX+1,0:MMAX+1]
diff --git a/gravity_toolkit/grace_months_index.py b/gravity_toolkit/grace_months_index.py
index ca693678..0c87579d 100644
--- a/gravity_toolkit/grace_months_index.py
+++ b/gravity_toolkit/grace_months_index.py
@@ -11,7 +11,7 @@
     base_dir: Working data directory for GRACE/GRACE-FO data
 
 OPTIONS:
-    DREL: GRACE/GRACE-FO data release (RL04, RL05, RL06)
+    DREL: GRACE/GRACE-FO/Swarm data release (RL04, RL05, RL06)
     MODE: Permissions mode of output index file
 
 OUTPUTS:
@@ -69,7 +69,7 @@ def grace_months_index(base_dir, DREL=['RL06','v02.4'], MODE=None):
 
     Keyword arguments
     -----------------
-    DREL: GRACE/GRACE-FO data release (RL04, RL05, RL06)
+    DREL: GRACE/GRACE-FO/Swarm data release (RL04, RL05, RL06)
     MODE: Permissions mode of output index file
     """
     #-- Output GRACE months file
diff --git a/gravity_toolkit/harmonics.py b/gravity_toolkit/harmonics.py
index 23f860f8..b0afaf96 100644
--- a/gravity_toolkit/harmonics.py
+++ b/gravity_toolkit/harmonics.py
@@ -1,7 +1,8 @@
 #!/usr/bin/env python
 u"""
 harmonics.py
-Written by Tyler Sutterley (08/2021)
+Written by Tyler Sutterley (09/2021)
+Contributions by Hugo Lecomte
 
 Spherical harmonic data class for processing GRACE/GRACE-FO Level-2 data
 
@@ -23,10 +24,12 @@
     ncdf_read_stokes.py: reads spherical harmonic data from netcdf
     hdf5_read_stokes.py: reads spherical harmonic data from HDF5
     read_GRACE_harmonics.py: read spherical harmonic data from SHM files
+    read_gfc_harmonics.py: reads spherical harmonic data from gfc files
     read_ICGEM_harmonics.py: reads gravity model coefficients from GFZ ICGEM
     destripe_harmonics.py: filters spherical harmonics for correlated errors
 
 UPDATE HISTORY:
+    Updated 09/2021: added time-variable gravity data from gfc format
     Updated 08/2021: added from spherical harmonic model (SHM) format
     Updated 07/2021: fixed gfc format in from file wrapper
     Updated 05/2021: define int/float precision to prevent deprecation warning
@@ -67,6 +70,7 @@
 from gravity_toolkit.ncdf_read_stokes import ncdf_read_stokes
 from gravity_toolkit.hdf5_read_stokes import hdf5_read_stokes
 from gravity_toolkit.destripe_harmonics import destripe_harmonics
+from gravity_toolkit.read_gfc_harmonics import read_gfc_harmonics
 from geoid_toolkit.read_ICGEM_harmonics import read_ICGEM_harmonics
 from gravity_toolkit.read_GRACE_harmonics import read_GRACE_harmonics
 
@@ -110,7 +114,7 @@ def case_insensitive_filename(self,filename):
                 self.filename = os.path.join(directory,f.pop())
         return self
 
-    def from_ascii(self, filename, date=True, **kwargs):
+    def from_ascii(self, filename, **kwargs):
         """
         Read a harmonics object from an ascii file
         Inputs: full path of input ascii file
@@ -121,6 +125,7 @@ def from_ascii(self, filename, date=True, **kwargs):
         #-- set filename
         self.case_insensitive_filename(filename)
         #-- set default parameters
+        kwargs.setdefault('date',True)
         kwargs.setdefault('verbose',False)
         kwargs.setdefault('compression',None)
         #-- open the ascii file and extract contents
@@ -150,7 +155,7 @@ def from_ascii(self, filename, date=True, **kwargs):
         self.mmax = 0
         #-- for each line in the file
         for line in file_contents:
-            if date:
+            if kwargs['date']:
                 l1,m1,clm1,slm1,time = rx.findall(line)
             else:
                 l1,m1,clm1,slm1 = rx.findall(line)
@@ -165,7 +170,7 @@ def from_ascii(self, filename, date=True, **kwargs):
         self.clm = np.zeros((self.lmax+1,self.mmax+1))
         self.slm = np.zeros((self.lmax+1,self.mmax+1))
         #-- if the ascii file contains date variables
-        if date:
+        if kwargs['date']:
             self.time = np.float64(time)
             self.month = np.int64(12.0*(self.time - 2002.0)) + 1
             #-- adjust months to fix special cases if necessary
@@ -173,7 +178,7 @@ def from_ascii(self, filename, date=True, **kwargs):
         #-- extract harmonics and convert to matrix
         #-- for each line in the file
         for line in file_contents:
-            if date:
+            if kwargs['date']:
                 l1,m1,clm1,slm1,time = rx.findall(line)
             else:
                 l1,m1,clm1,slm1 = rx.findall(line)
@@ -186,7 +191,7 @@ def from_ascii(self, filename, date=True, **kwargs):
         self.update_dimensions()
         return self
 
-    def from_netCDF4(self, filename, date=True, **kwargs):
+    def from_netCDF4(self, filename, **kwargs):
         """
         Read a harmonics object from a netCDF4 file
         Inputs: full path of input netCDF4 file
@@ -197,10 +202,12 @@ def from_netCDF4(self, filename, date=True, **kwargs):
         #-- set filename
         self.case_insensitive_filename(filename)
         #-- set default parameters
+        kwargs.setdefault('date',True)
         kwargs.setdefault('verbose',False)
         kwargs.setdefault('compression',None)
         #-- read data from netCDF4 file
-        Ylms = ncdf_read_stokes(self.filename, DATE=date,
+        Ylms = ncdf_read_stokes(self.filename,
+            DATE=kwargs['date'],
             COMPRESSION=kwargs['compression'],
             VERBOSE=kwargs['verbose'])
         #-- copy variables to harmonics object
@@ -210,7 +217,7 @@ def from_netCDF4(self, filename, date=True, **kwargs):
         self.m = Ylms['m'].copy()
         self.lmax = np.max(Ylms['l'])
         self.mmax = np.max(Ylms['m'])
-        if date:
+        if kwargs['date']:
             self.time = Ylms['time'].copy()
             #-- adjust months to fix special cases if necessary
             self.month = adjust_months(Ylms['month'])
@@ -218,7 +225,7 @@ def from_netCDF4(self, filename, date=True, **kwargs):
         self.update_dimensions()
         return self
 
-    def from_HDF5(self, filename, date=True, **kwargs):
+    def from_HDF5(self, filename, **kwargs):
         """
         Read a harmonics object from a HDF5 file
         Inputs: full path of input HDF5 file
@@ -229,10 +236,12 @@ def from_HDF5(self, filename, date=True, **kwargs):
         #-- set filename
         self.case_insensitive_filename(filename)
         #-- set default parameters
+        kwargs.setdefault('date',True)
         kwargs.setdefault('verbose',False)
         kwargs.setdefault('compression',None)
         #-- read data from HDF5 file
-        Ylms = hdf5_read_stokes(self.filename, DATE=date,
+        Ylms = hdf5_read_stokes(self.filename,
+            DATE=kwargs['date'],
             COMPRESSION=kwargs['compression'],
             VERBOSE=kwargs['verbose'])
         #-- copy variables to harmonics object
@@ -242,7 +251,7 @@ def from_HDF5(self, filename, date=True, **kwargs):
         self.m = Ylms['m'].copy()
         self.lmax = np.max(Ylms['l'])
         self.mmax = np.max(Ylms['m'])
-        if date:
+        if kwargs['date']:
             self.time = Ylms['time'].copy()
             #-- adjust months to fix special cases if necessary
             self.month = adjust_months(Ylms['month'])
@@ -252,28 +261,40 @@ def from_HDF5(self, filename, date=True, **kwargs):
 
     def from_gfc(self, filename, **kwargs):
         """
-        Read a harmonics object from a gfc gravity model file from the GFZ ICGEM
+        Read a harmonics object from a gfc gravity model file
         Inputs: full path of input gfc file
         Options:
             keyword arguments for gfc input
         """
         #-- set filename
         self.case_insensitive_filename(filename)
-        #-- set default verbosity
+        #-- set default parameters
+        kwargs.setdefault('date',False)
+        kwargs.setdefault('tide',None)
         kwargs.setdefault('verbose',False)
         #-- read data from gfc file
-        Ylms = read_ICGEM_harmonics(self.filename)
+        if kwargs['date']:
+            Ylms = read_gfc_harmonics(self.filename,
+                TIDE=kwargs['tide'])
+        else:
+            Ylms = read_ICGEM_harmonics(self.filename,
+                TIDE=kwargs['tide'])
         #-- Output file information
         if kwargs['verbose']:
             print(self.filename)
             print(list(Ylms.keys()))
-        #-- copy variables for static gravity model
+        #-- copy variables for gravity model
         self.clm = Ylms['clm'].copy()
         self.slm = Ylms['slm'].copy()
         self.lmax = np.int64(Ylms['max_degree'])
         self.mmax = np.int64(Ylms['max_degree'])
         self.l = np.arange(self.lmax+1)
         self.m = np.arange(self.mmax+1)
+        #-- copy date variables
+        if kwargs['date']:
+            self.time = Ylms['time'].copy()
+            #-- adjust months to fix special cases if necessary
+            self.month = adjust_months(Ylms['month'])
         #-- geophysical parameters of gravity model
         self.GM = np.float64(Ylms['earth_gravity_constant'])
         self.R = np.float64(Ylms['radius'])
diff --git a/gravity_toolkit/read_GRACE_harmonics.py b/gravity_toolkit/read_GRACE_harmonics.py
index f4ee230e..e1e87be0 100644
--- a/gravity_toolkit/read_GRACE_harmonics.py
+++ b/gravity_toolkit/read_GRACE_harmonics.py
@@ -1,7 +1,8 @@
 #!/usr/bin/env python
 u"""
 read_GRACE_harmonics.py
-Written by Tyler Sutterley (05/2021)
+Written by Tyler Sutterley (09/2021)
+Contributions by Hugo Lecomte
 
 Reads GRACE files and extracts spherical harmonic data and drift rates (RL04)
 Adds drift rates to clm and slm for release 4 harmonics
@@ -21,10 +22,12 @@
     time: mid-month date in year-decimal
     start: start date of range as Julian day
     end: end date of range as Julian day
-    clm: cosine spherical harmonics of input data (LMAX,MMAX)
-    slm: sine spherical harmonics of input data (LMAX,MMAX)
-    eclm: cosine spherical harmonic uncalibrated standard deviations (LMAX,MMAX)
-    eslm: sine spherical harmonic uncalibrated standard deviations (LMAX,MMAX)
+    l: spherical harmonic degree to LMAX
+    m: spherical harmonic order to MMAX
+    clm: cosine spherical harmonics of input data
+    slm: sine spherical harmonics of input data
+    eclm: cosine spherical harmonic uncalibrated standard deviations
+    eslm: sine spherical harmonic uncalibrated standard deviations
 
 PYTHON DEPENDENCIES:
     numpy: Scientific Computing Tools For Python
@@ -39,8 +42,11 @@
     time.py: utilities for calculating time operations
 
 UPDATE HISTORY:
+    Updated 09/2021: added COST-G combined solutions from the GFZ ICGEM
+        output spherical harmonic degree and order in dict
     Updated 05/2021: define int/float precision to prevent deprecation warning
     Updated 12/2020: using utilities from time module
+    Updated 10/2020: Change parse function to work with GRGS data
     Updated 08/2020: flake8 compatible regular expression strings
         input file can be "diskless" bytesIO object
     Updated 07/2020: added function docstrings
@@ -82,6 +88,8 @@ def read_GRACE_harmonics(input_file, LMAX, MMAX=None, POLE_TIDE=False):
     time: mid-month date in year-decimal
     start: start date of range as Julian day
     end: end date of range as Julian day
+    l: spherical harmonic degree to LMAX
+    m: spherical harmonic order to MMAX
     clm: cosine spherical harmonics coefficients
     slm: sine spherical harmonics coefficients
     eclm: cosine spherical harmonic uncalibrated standard deviations
@@ -89,19 +97,26 @@ def read_GRACE_harmonics(input_file, LMAX, MMAX=None, POLE_TIDE=False):
     """
 
     #-- parse filename
-    PFX,SD,ED,N,PRC,F1,DRL,F2,SFX = parse_file(input_file)
+    PFX,SY,SD,EY,ED,N,PRC,F1,DRL,F2,SFX = parse_file(input_file)
     file_contents = extract_file(input_file, (SFX=='.gz'))
 
     #-- JPL Mascon solutions
-    if PRC in ('JPLMSC'):
+    if PRC in ('JPLMSC',):
         DSET = 'GSM'
         DREL = np.int64(DRL)
         FLAG = r'GRCOF2'
-    #-- Kusche et al. (2009) DDK filtered solutions 10.1007/s00190-009-0308-3
+    #-- Kusche et al. (2009) DDK filtered solutions
+    #-- https://doi.org/10.1007/s00190-009-0308-3
     elif PFX.startswith('kfilter_DDK'):
         DSET = 'GSM'
         DREL = np.int64(DRL)
         FLAG = r'gfc'
+    #-- COST-G unfiltered combination solutions
+    #-- https://doi.org/10.5880/ICGEM.COST-G.001
+    elif PRC in ('COSTG',):
+        DSET, = re.findall('GSM|GAC',PFX)
+        DREL = np.int64(DRL)
+        FLAG = r'gfc'
     #-- Standard GRACE solutions
     else:
         DSET = PFX
@@ -111,10 +126,10 @@ def read_GRACE_harmonics(input_file, LMAX, MMAX=None, POLE_TIDE=False):
     #-- output python dictionary with GRACE data and date information
     grace_L2_input = {}
     #-- extract GRACE date information from input file name
-    start_yr = np.float64(SD[:4])
-    end_yr = np.float64(ED[:4])
-    start_day = np.float64(SD[4:])
-    end_day = np.float64(ED[4:])
+    start_yr = np.float64(SY)
+    end_yr = np.float64(EY)
+    start_day = np.float64(SD)
+    end_day = np.float64(ED)
     #-- calculate mid-month date taking into account if measurements are
     #-- on different years
     dpy = gravity_toolkit.time.calendar_days(start_yr).sum()
@@ -135,6 +150,9 @@ def read_GRACE_harmonics(input_file, LMAX, MMAX=None, POLE_TIDE=False):
 
     #-- set maximum spherical harmonic order
     MMAX = np.copy(LMAX) if (MMAX is None) else MMAX
+    #-- output dimensions
+    grace_L2_input['l'] = np.arange(LMAX+1)
+    grace_L2_input['m'] = np.arange(MMAX+1)
     #-- Spherical harmonic coefficient matrices to be filled from data file
     grace_L2_input['clm'] = np.zeros((LMAX+1,MMAX+1))
     grace_L2_input['slm'] = np.zeros((LMAX+1,MMAX+1))
@@ -150,7 +168,13 @@ def read_GRACE_harmonics(input_file, LMAX, MMAX=None, POLE_TIDE=False):
     #-- replace colons in header if within quotations
     head = [re.sub(r'\"(.*?)\:\s(.*?)\"',r'"\1, \2"',l) for l in file_contents
         if not re.match(r'{0}|GRDOTA'.format(FLAG),l)]
-    if ((N == 'GRAC') and (DREL >= 6)) or (N == 'GRFO'):
+    if SFX in ('.gfc',):
+        #-- extract parameters from header
+        header_parameters = ['modelname','earth_gravity_constant','radius',
+            'max_degree','errors','norm','tide_system']
+        header_regex = re.compile(r'(' + r'|'.join(header_parameters) + r')')
+        grace_L2_input['header'] = [l for l in head if header_regex.match(l)]
+    elif ((N == 'GRAC') and (DREL >= 6)) or (N == 'GRFO'):
         #-- parse the YAML header for RL06 or GRACE-FO (specifying yaml loader)
         grace_L2_input.update(yaml.load('\n'.join(head),Loader=yaml.BaseLoader))
     else:
@@ -251,8 +275,11 @@ def parse_file(input_file):
     #-- GFZOP: GFZ German Research Center for Geosciences (RL06+GRACE-FO)
     #-- JPLEM: NASA Jet Propulsion Laboratory (harmonic solutions)
     #-- JPLMSC: NASA Jet Propulsion Laboratory (mascon solutions)
-    regex_pattern = (r'(.*?)-2_(\d+)-(\d+)_(.*?)_({0})_(.*?)_(\d+)(.*?)'
-        r'(\.gz|\.gfc)?$').format('UTCSR|EIGEN|GFZOP|JPLEM|JPLMSC')
+    #-- GRGS: French Centre National D'Etudes Spatiales (CNES)
+    #-- COSTG: International Combined Time-variable Gravity Fields
+    args = r'UTCSR|EIGEN|GFZOP|JPLEM|JPLMSC|GRGS|COSTG'
+    regex_pattern = (r'(.*?)-2_(\d{{4}})(\d{{3}})-(\d{{4}})(\d{{3}})_'
+        r'(.*?)_({0})_(.*?)_(\d+)(.*?)(\.gz|\.gfc)?$').format(args)
     rx = re.compile(regex_pattern, re.VERBOSE)
     #-- extract parameters from input filename
     if isinstance(input_file, io.IOBase):
diff --git a/gravity_toolkit/read_SLR_geocenter.py b/gravity_toolkit/read_SLR_geocenter.py
index 23b9469a..992c1e92 100644
--- a/gravity_toolkit/read_SLR_geocenter.py
+++ b/gravity_toolkit/read_SLR_geocenter.py
@@ -206,7 +206,7 @@ def aod_corrected_SLR_geocenter(geocenter_file, DREL, RADIUS=None, HEADER=0,
     Arguments
     ---------
     geocenter_file: Satellite Laser Ranging file
-    DREL: GRACE/GRACE-FO data release
+    DREL: GRACE/GRACE-FO/Swarm data release
 
     Keyword arguments
     -----------------
diff --git a/gravity_toolkit/read_gfc_harmonics.py b/gravity_toolkit/read_gfc_harmonics.py
new file mode 100644
index 00000000..ae1f9bf8
--- /dev/null
+++ b/gravity_toolkit/read_gfc_harmonics.py
@@ -0,0 +1,185 @@
+#!/usr/bin/env python
+u"""
+read_gfc_harmonics.py
+Written by Tyler Sutterley (09/2021)
+Contributions by Hugo Lecomte
+
+Reads gfc files and extracts spherical harmonics for Swarm and
+    GRAZ GRACE/GRACE-FO data
+Parses date of GRACE/GRACE-FO/Swarm data from filename
+
+GRAZ: https://www.tugraz.at/institute/ifg/downloads/gravity-field-models
+Swarm: https://earth.esa.int/eogateway/missions/swarm
+
+INPUTS:
+    input_file: full path to gfc spherical harmonic data file
+
+OPTIONS:
+    TIDE: tide system of output gravity fields
+        tide_free: no permanent direct and indirect tidal potentials
+        mean_tide: permanent tidal potentials (direct and indirect)
+        zero_tide: permanent direct tidal potential
+    FLAG: string denoting data lines
+
+OUTPUTS:
+    time: mid-month date in decimal form
+    start: Julian dates of the start date
+    end: Julian dates of the start date
+    l: spherical harmonic degree to maximum degree of data
+    m: spherical harmonic order to maximum degree of data
+    clm: cosine spherical harmonics of input data
+    slm: sine spherical harmonics of input data
+    eclm: cosine spherical harmonic standard deviations
+    eslm: sine spherical harmonic standard deviations
+    modelname: name of the gravity model
+    earth_gravity_constant: GM constant of the Earth for the gravity model
+    radius: semi-major axis of the Earth for the gravity model
+    max_degree: maximum degree and order for the gravity model
+    errors: error type of the gravity model
+    norm: normalization of the spherical harmonics
+    tide_system: tide system of gravity model
+        tide_free: no permanent direct and indirect tidal potentials
+        mean_tide: permanent tidal potentials (direct and indirect)
+        zero_tide: permanent direct tidal potential
+
+PYTHON DEPENDENCIES:
+    numpy: Scientific Computing Tools For Python
+        https://numpy.org
+        https://numpy.org/doc/stable/user/numpy-for-matlab-users.html
+    dateutil: powerful extensions to datetime
+        https://dateutil.readthedocs.io/en/stable/
+
+PROGRAM DEPENDENCIES:
+    time.py: utilities for calculating time operations
+    read_ICGEM_harmonics.py: reads gravity model coefficients from GFZ ICGEM
+    calculate_tidal_offset.py: calculates the C20 offset for a tidal system
+
+UPDATE HISTORY:
+    Updated 09/2021: forked from read_ICGEM_harmonics in geoid toolkit
+        use gravity toolkit time modules and reorganize structure
+    Updated 05/2021: Add GRAZ/Swarm/COST-G ICGEM file
+    Updated 03/2021: made degree of truncation LMAX a keyword argument
+    Updated 07/2020: added function docstrings
+    Updated 07/2019: split read and wrapper funciton into separate files
+    Updated 07/2017: include parameters to change the tide system
+    Written 12/2015
+"""
+import os
+import re
+import numpy as np
+import gravity_toolkit.time
+from geoid_toolkit.read_ICGEM_harmonics import read_ICGEM_harmonics
+
+#-- PURPOSE: read spherical harmonic coefficients of a gravity model
+def read_gfc_harmonics(input_file, TIDE=None, FLAG='gfc'):
+    """
+    Extract gravity model spherical harmonics from gfc files
+
+    Arguments
+    ---------
+    input_file: full path to gfc spherical harmonic data file
+
+    Keyword arguments
+    -----------------
+    TIDE: tide system of output gravity fields
+        tide_free: no permanent direct and indirect tidal potentials
+        mean_tide: permanent tidal potentials (direct and indirect)
+        zero_tide: permanent direct tidal potential
+    FLAG: string denoting data lines
+
+    Returns
+    -------
+    time: mid-month date in decimal form
+    start: Julian dates of the start date
+    end: Julian dates of the start date
+    l: spherical harmonic degree to maximum degree of data
+    m: spherical harmonic order to maximum degree of data
+    clm: cosine spherical harmonics of input data
+    slm: sine spherical harmonics of input data
+    eclm: cosine spherical harmonic standard deviations of type errors
+    eslm: sine spherical harmonic standard deviations of type errors
+    modelname: name of the gravity model
+    earth_gravity_constant: GM constant of the Earth for gravity model
+    radius: semi-major axis of the Earth for gravity model
+    max_degree: maximum degree and order for gravity model
+    errors: error type of the gravity model
+    norm: normalization of the spherical harmonics
+    tide_system: tide system of gravity model
+    """
+    #-- regular expression operators for ITSG data and models
+    itsg_products = []
+    itsg_products.append(r'atmosphere')
+    itsg_products.append(r'dealiasing')
+    itsg_products.append(r'oceanBottomPressure')
+    itsg_products.append(r'ocean')
+    itsg_products.append(r'Grace2014')
+    itsg_products.append(r'Grace2016')
+    itsg_products.append(r'Grace2018')
+    itsg_products.append(r'Grace_operational')
+    itsg_pattern = (r'(AOD1B_RL\d+|model|ITSG)[-_]({0})(_n\d+)?_'
+        r'(\d+)-(\d+)(\.gfc)').format(r'|'.join(itsg_products))
+    #-- regular expression operators for Swarm data and models
+    swarm_data = r'(SW)_(.*?)_(EGF_SHA_2)__(.*?)_(.*?)_(.*?)(\.gfc|\.ZIP)'
+    swarm_model = r'(GAA|GAB|GAC|GAD)_Swarm_(\d+)_(\d{2})_(\d{4})(\.gfc|\.ZIP)'
+    #-- extract parameters for each data center and product
+    if re.match(itsg_pattern, os.path.basename(input_file)):
+        #-- compile numerical expression operator for parameters from files
+        #-- GRAZ: Institute of Geodesy from GRAZ University of Technology
+        rx = re.compile(itsg_pattern, re.VERBOSE | re.IGNORECASE)
+        #-- extract parameters from input filename
+        PFX,PRD,trunc,year,month,SFX = rx.findall(input_file).pop()
+        #-- number of days in each month for the calendar year
+        dpm = gravity_toolkit.time.calendar_days(int(year))
+        #-- create start and end date lists
+        start_date = [int(year),int(month),1,0,0,0]
+        end_date = [int(year),int(month),dpm[int(month)-1],23,59,59]
+    elif re.match(swarm_data, os.path.basename(input_file)):
+        #-- compile numerical expression operator for parameters from files
+        #-- Swarm: data from Swarm satellite
+        rx = re.compile(swarm_data, re.VERBOSE | re.IGNORECASE)
+        #-- extract parameters from input filename
+        SAT,tmp,PROD,starttime,endtime,RL,SFX = rx.findall(input_file).pop()
+        start_date,_ = gravity_toolkit.time.parse_date_string(starttime)
+        end_date,_ = gravity_toolkit.time.parse_date_string(endtime)
+        #-- number of days in each month for the calendar year
+        dpm = gravity_toolkit.time.calendar_days(start_date[0])
+    elif re.match(swarm_model, os.path.basename(input_file)):
+        #-- compile numerical expression operator for parameters from files
+        #-- Swarm: dealiasing products for Swarm data
+        rx = re.compile(swarm_data, re.VERBOSE | re.IGNORECASE)
+        #-- extract parameters from input filename
+        PROD,trunc,month,year,SFX = rx.findall(input_file).pop()
+        #-- number of days in each month for the calendar year
+        dpm = gravity_toolkit.time.calendar_days(int(year))
+        #-- create start and end date lists
+        start_date = [int(year),int(month),1,0,0,0]
+        end_date = [int(year),int(month),dpm[int(month)-1],23,59,59]
+
+    #-- python dictionary with model input and headers
+    ZIP = bool(re.search('ZIP',SFX,re.IGNORECASE))
+    model_input = read_ICGEM_harmonics(input_file, TIDE=TIDE,
+        FLAG=FLAG, ZIP=ZIP)
+
+    #-- start and end day of the year
+    start_day = np.sum(dpm[:start_date[1]-1]) + start_date[2] + \
+        start_date[3]/24.0 + start_date[4]/1440.0 + start_date[5]/86400.0
+    end_day = np.sum(dpm[:end_date[1]-1]) + end_date[2] + \
+        end_date[3]/24.0 + end_date[4]/1440.0 + end_date[5]/86400.0
+    #-- end date taking into account measurements taken on different years
+    end_cyclic = (end_date[0]-start_date[0])*np.sum(dpm) + end_day
+    #-- calculate mid-month value
+    mid_day = np.mean([start_day, end_cyclic])
+    #-- Calculating the mid-month date in decimal form
+    model_input['time'] = start_date[0] + mid_day/np.sum(dpm)
+    #-- Calculating the Julian dates of the start and end date
+    model_input['start'] = 2400000.5 + \
+        gravity_toolkit.time.convert_calendar_dates(start_date[0],
+        start_date[1],start_date[2],hour=start_date[3],minute=start_date[4],
+        second=start_date[5],epoch=(1858,11,17,0,0,0))
+    model_input['end'] = 2400000.5 + \
+        gravity_toolkit.time.convert_calendar_dates(end_date[0],
+        end_date[1],end_date[2],hour=end_date[3],minute=end_date[4],
+        second=end_date[5],epoch=(1858,11,17,0,0,0))
+
+    #-- return the spherical harmonics and parameters
+    return model_input
diff --git a/gravity_toolkit/utilities.py b/gravity_toolkit/utilities.py
index 7058cbdb..3ec438bc 100644
--- a/gravity_toolkit/utilities.py
+++ b/gravity_toolkit/utilities.py
@@ -1,13 +1,14 @@
 #!/usr/bin/env python
 u"""
 utilities.py
-Written by Tyler Sutterley (07/2021)
+Written by Tyler Sutterley (09/2021)
 Download and management utilities for syncing time and auxiliary files
 
 PYTHON DEPENDENCIES:
     lxml: processing XML and HTML in Python (https://pypi.python.org/pypi/lxml)
 
 UPDATE HISTORY:
+    Updated 09/2021: added generic list from Apache http server
     Updated 07/2021: added unique filename opener for log files
     Updated 06/2021: add parser for converting file files to arguments
     Updated 05/2021: download GFZ satellite laser ranging and GravIS files
@@ -394,6 +395,60 @@ def check_connection(HOST):
     else:
         return True
 
+#-- PURPOSE: list a directory on an Apache http Server
+def http_list(HOST,timeout=None,context=ssl.SSLContext(),
+    parser=lxml.etree.HTMLParser(),format='%Y-%m-%d %H:%M',
+    pattern='',sort=False):
+    """
+    List a directory on an Apache http Server
+
+    Arguments
+    ---------
+    HOST: remote http host path split as list
+
+    Keyword arguments
+    -----------------
+    timeout: timeout in seconds for blocking operations
+    context: SSL context for url opener object
+    parser: HTML parser for lxml
+    format: format for input time string
+    pattern: regular expression pattern for reducing list
+    sort: sort output list
+
+    Returns
+    -------
+    output: list of items in a directory
+    mtimes: list of last modification times for items in the directory
+    """
+    #-- try listing from http
+    try:
+        #-- Create and submit request.
+        request=urllib2.Request(posixpath.join(*HOST))
+        response=urllib2.urlopen(request,timeout=timeout,context=context)
+    except (urllib2.HTTPError, urllib2.URLError):
+        raise Exception('List error from {0}'.format(posixpath.join(*HOST)))
+    else:
+        #-- read and parse request for files (column names and modified times)
+        tree = lxml.etree.parse(response,parser)
+        colnames = tree.xpath('//tr/td[not(@*)]//a/@href')
+        #-- get the Unix timestamp value for a modification time
+        lastmod = [get_unix_time(i,format=format)
+            for i in tree.xpath('//tr/td[@align="right"][1]/text()')]
+        #-- reduce using regular expression pattern
+        if pattern:
+            i = [i for i,f in enumerate(colnames) if re.search(pattern,f)]
+            #-- reduce list of column names and last modified times
+            colnames = [colnames[indice] for indice in i]
+            lastmod = [lastmod[indice] for indice in i]
+        #-- sort the list
+        if sort:
+            i = [i for i,j in sorted(enumerate(colnames), key=lambda i: i[1])]
+            #-- sort list of column names and last modified times
+            colnames = [colnames[indice] for indice in i]
+            lastmod = [lastmod[indice] for indice in i]
+        #-- return the list of column names and last modified times
+        return (colnames,lastmod)
+
 #-- PURPOSE: download a file from a http host
 def from_http(HOST,timeout=None,context=ssl.SSLContext(),local=None,hash='',
     chunk=16384,verbose=False,fid=sys.stdout,mode=0o775):
diff --git a/scripts/calc_mascon.py b/scripts/calc_mascon.py
index f3b65406..331bd00c 100644
--- a/scripts/calc_mascon.py
+++ b/scripts/calc_mascon.py
@@ -780,7 +780,7 @@ def main():
         type=lambda p: os.path.abspath(os.path.expanduser(p)),
         default=os.getcwd(),
         help='Output directory for mascon files')
-    #-- GRACE/GRACE-FO data processing center
+    #-- Data processing center or satellite mission
     parser.add_argument('--center','-c',
         metavar='PROC', type=str, required=True,
         help='GRACE/GRACE-FO Processing Center')
diff --git a/scripts/cnes_grace_sync.py b/scripts/cnes_grace_sync.py
index f2bb3ce5..3f5dade3 100755
--- a/scripts/cnes_grace_sync.py
+++ b/scripts/cnes_grace_sync.py
@@ -4,7 +4,7 @@
 Written by Tyler Sutterley (05/2021)
 
 CNES/GRGS GRACE data download program for gravity field products
-    http://grgs.obs-mip.fr/grace
+    https://grace.obs-mip.fr/
 
 Downloads the tar file containing the CNES/GRGS GRACE data for a given release
 Iterates through files to determine if any are not in the local file system
diff --git a/scripts/dealiasing_monthly_mean.py b/scripts/dealiasing_monthly_mean.py
index 92b8df8c..11ca1d51 100755
--- a/scripts/dealiasing_monthly_mean.py
+++ b/scripts/dealiasing_monthly_mean.py
@@ -671,7 +671,7 @@ def main():
         type=lambda p: os.path.abspath(os.path.expanduser(p)),
         default=os.getcwd(),
         help='Working data directory')
-    #-- GRACE/GRACE-FO data processing center
+    #-- Data processing center or satellite mission
     parser.add_argument('--center','-c',
         metavar='PROC', type=str, required=True,
         help='GRACE/GRACE-FO Processing Center')
diff --git a/scripts/esa_costg_swarm_sync.py b/scripts/esa_costg_swarm_sync.py
new file mode 100644
index 00000000..2f614ea3
--- /dev/null
+++ b/scripts/esa_costg_swarm_sync.py
@@ -0,0 +1,252 @@
+#!/usr/bin/env python
+u"""
+esa_costg_swarm_sync.py
+Written by Tyler Sutterley (09/2021)
+Syncs Swarm gravity field products from the ESA Swarm Science Server
+    https://earth.esa.int/eogateway/missions/swarm/data
+    https://www.esa.int/Applications/Observing_the_Earth/Swarm
+
+CALLING SEQUENCE:
+    python esa_costg_swarm_sync.py
+
+COMMAND LINE OPTIONS:
+    --help: list the command line options
+    -D X, --directory X: working data directory
+    -t X, --timeout X: Timeout in seconds for blocking operations
+    -l, --log: output log of files downloaded
+    -L, --list: print files to be transferred, but do not execute transfer
+    -C, --clobber: Overwrite existing data in transfer
+    --checksum: compare hashes to check if overwriting existing data
+    -M X, --mode=X: Local permissions mode of the directories and files synced
+
+PYTHON DEPENDENCIES:
+    lxml: Pythonic XML and HTML processing library using libxml2/libxslt
+        https://lxml.de/
+        https://github.com/lxml/lxml
+    numpy: Scientific Computing Tools For Python
+        https://numpy.org
+        https://numpy.org/doc/stable/user/numpy-for-matlab-users.html
+
+UPDATE HISTORY:
+    Written 09/2021
+"""
+from __future__ import print_function
+
+import sys
+import re
+import os
+import io
+import json
+import shutil
+import argparse
+import posixpath
+import lxml.etree
+import time
+import gravity_toolkit.utilities
+
+#-- PURPOSE: sync local Swarm files with ESA server
+def esa_costg_swarm_sync(DIRECTORY, TIMEOUT=None, LOG=False,
+    LIST=False, CLOBBER=False, CHECKSUM=False, MODE=0o775):
+
+    #-- local directory for exact data product
+    local_dir = os.path.join(DIRECTORY,'Swarm','RL01','GSM')
+    #-- check if directory exists and recursively create if not
+    os.makedirs(local_dir,MODE) if not os.path.exists(local_dir) else None
+
+    #-- create log file with list of synchronized files (or print to terminal)
+    if LOG:
+        #-- output to log file
+        #-- format: ESA_Swarm_sync_2002-04-01.log
+        today = time.strftime('%Y-%m-%d',time.localtime())
+        LOGFILE = 'ESA_Swarm_sync_{0}.log'.format(today)
+        fid1 = open(os.path.join(DIRECTORY,LOGFILE),'w')
+        print('ESA Swarm Sync Log ({0})'.format(today), file=fid1)
+    else:
+        #-- standard output (terminal output)
+        fid1 = sys.stdout
+
+    #-- Swarm Science Server url
+    #-- using the JSON api protocols to retrieve files
+    #-- static site is no longer available
+    HOST = 'https://swarm-diss.eo.esa.int'
+    #-- compile xml parsers for lxml
+    XMLparser = lxml.etree.XMLParser()
+    #-- create "opener" (OpenerDirector instance)
+    gravity_toolkit.utilities.build_opener(None, None,
+        authorization_header=False, urs=HOST)
+    #-- All calls to urllib2.urlopen will now use handler
+    #-- Make sure not to include the protocol in with the URL, or
+    #-- HTTPPasswordMgrWithDefaultRealm will be confused.
+
+    #-- compile regular expression operator for files
+    swarm_data = r'(SW)_(.*?)_(EGF_SHA_2)__(.*?)_(.*?)_(.*?)(\.gfc|\.ZIP)'
+    R1 = re.compile(swarm_data, re.VERBOSE)
+
+    #-- create combined list of filenames and last modified times
+    colnames = []
+    collastmod = []
+    #-- position, maximum number of files to list, flag to check if done
+    pos,maxfiles,prevmax = (0,500,500)
+    #-- iterate to get a compiled list of files
+    #-- will iterate until there are no more files to add to the lists
+    while (maxfiles == prevmax):
+        #-- set previous flag to maximum
+        prevmax = maxfiles
+        #-- open connection with Swarm science server at remote directory
+        #-- to list maxfiles number of files at position
+        parameters = gravity_toolkit.utilities.urlencode({'maxfiles':prevmax,
+            'pos':pos,'file':posixpath.join('swarm','Level2longterm','EGF')})
+        url=posixpath.join(HOST,'?do=list&{0}'.format(parameters))
+        request = gravity_toolkit.utilities.urllib2.Request(url=url)
+        response = gravity_toolkit.utilities.urllib2.urlopen(request,
+            timeout=TIMEOUT)
+        table = json.loads(response.read().decode())
+        #-- extend lists with new files
+        colnames.extend([t['name'] for t in table['results']])
+        collastmod.extend([t['mtime'] for t in table['results']])
+        #-- update maximum number of files
+        maxfiles = len(table['results'])
+        #-- update position
+        pos += maxfiles
+
+    #-- find lines of valid files
+    valid_lines = [i for i,f in enumerate(colnames) if R1.match(f)]
+    #-- write each file to an index
+    fid = open(os.path.join(local_dir,'index.txt'),'w')
+    #-- for each data and header file
+    for i in valid_lines:
+        #-- remote and local versions of the file
+        parameters = gravity_toolkit.utilities.urlencode({'file':
+            posixpath.join('swarm','Level2longterm','EGF',colnames[i])})
+        remote_file = posixpath.join(HOST,
+            '?do=download&{0}'.format(parameters))
+        local_file = os.path.join(local_dir,colnames[i])
+        #-- check that file is not in file system unless overwriting
+        http_pull_file(fid1, remote_file, collastmod[i], local_file,
+            TIMEOUT=TIMEOUT, LIST=LIST, CLOBBER=CLOBBER,
+            CHECKSUM=CHECKSUM, MODE=MODE)
+        #-- output Swarm filenames to index
+        print('{0}'.format(colnames[i]), file=fid)
+    #-- change permissions of index file
+    os.chmod(os.path.join(local_dir,'index.txt'), MODE)
+
+    #-- close log file and set permissions level to MODE
+    if LOG:
+        fid1.close()
+        os.chmod(os.path.join(DIRECTORY,LOGFILE), MODE)
+
+#-- PURPOSE: pull file from a remote host checking if file exists locally
+#-- and if the remote file is newer than the local file
+def http_pull_file(fid, remote_file, remote_mtime, local_file, TIMEOUT=120,
+    LIST=False, CLOBBER=False, CHECKSUM=False, MODE=0o775):
+    #-- if file exists in file system: check if remote file is newer
+    TEST = False
+    OVERWRITE = ' (clobber)'
+    #-- check if local version of file exists
+    if CHECKSUM and os.access(local_file, os.F_OK):
+        #-- generate checksum hash for local file
+        #-- open the local_file in binary read mode
+        local_hash = gravity_toolkit.utilities.get_hash(local_file)
+        #-- Create and submit request.
+        #-- There are a wide range of exceptions that can be thrown here
+        #-- including HTTPError and URLError.
+        req=gravity_toolkit.utilities.urllib2.Request(remote_file)
+        resp=gravity_toolkit.utilities.urllib2.urlopen(req,timeout=TIMEOUT)
+        #-- copy remote file contents to bytesIO object
+        remote_buffer = io.BytesIO(resp.read())
+        remote_buffer.seek(0)
+        #-- generate checksum hash for remote file
+        remote_hash = gravity_toolkit.utilities.get_hash(remote_buffer)
+        #-- compare checksums
+        if (local_hash != remote_hash):
+            TEST = True
+            OVERWRITE = ' (checksums: {0} {1})'.format(local_hash,remote_hash)
+    elif os.access(local_file, os.F_OK):
+        #-- check last modification time of local file
+        local_mtime = os.stat(local_file).st_mtime
+        #-- if remote file is newer: overwrite the local file
+        if (gravity_toolkit.utilities.even(remote_mtime) >
+            gravity_toolkit.utilities.even(local_mtime)):
+            TEST = True
+            OVERWRITE = ' (overwrite)'
+    else:
+        TEST = True
+        OVERWRITE = ' (new)'
+    #-- if file does not exist locally, is to be overwritten, or CLOBBER is set
+    if TEST or CLOBBER:
+        #-- Printing files transferred
+        print('{0} --> '.format(remote_file), file=fid)
+        print('\t{0}{1}\n'.format(local_file,OVERWRITE), file=fid)
+        #-- if executing copy command (not only printing the files)
+        if not LIST:
+            #-- chunked transfer encoding size
+            CHUNK = 16 * 1024
+            #-- copy bytes or transfer file
+            if CHECKSUM and os.access(local_file, os.F_OK):
+                #-- store bytes to file using chunked transfer encoding
+                remote_buffer.seek(0)
+                with open(local_file, 'wb') as f:
+                    shutil.copyfileobj(remote_buffer, f, CHUNK)
+            else:
+                #-- Create and submit request.
+                #-- There are a range of exceptions that can be thrown here
+                #-- including HTTPError and URLError.
+                request = gravity_toolkit.utilities.urllib2.Request(remote_file)
+                response = gravity_toolkit.utilities.urllib2.urlopen(request,
+                    timeout=TIMEOUT)
+                #-- copy remote file contents to local file
+                with open(local_file, 'wb') as f:
+                    shutil.copyfileobj(response, f, CHUNK)
+            #-- keep remote modification time of file and local access time
+            os.utime(local_file, (os.stat(local_file).st_atime, remote_mtime))
+            os.chmod(local_file, MODE)
+
+#-- Main program that calls esa_costg_swarm_sync()
+def main():
+    #-- Read the system arguments listed after the program
+    parser = argparse.ArgumentParser(
+        description="""Syncs Swarm gravity field products from the
+            ESA Swarm Science Server
+            """
+    )
+    #-- command line parameters
+    #-- working data directory
+    parser.add_argument('--directory','-D',
+        type=lambda p: os.path.abspath(os.path.expanduser(p)),
+        default=os.getcwd(),
+        help='Working data directory')
+    #-- connection timeout
+    parser.add_argument('--timeout','-t',
+        type=int, default=360,
+        help='Timeout in seconds for blocking operations')
+    #-- Output log file in form
+    #-- ESA_Swarm_sync_2002-04-01.log
+    parser.add_argument('--log','-l',
+        default=False, action='store_true',
+        help='Output log file')
+    #-- sync options
+    parser.add_argument('--list','-L',
+        default=False, action='store_true',
+        help='Only print files that could be transferred')
+    parser.add_argument('--clobber','-C',
+        default=False, action='store_true',
+        help='Overwrite existing data in transfer')
+    parser.add_argument('--checksum',
+        default=False, action='store_true',
+        help='Compare hashes to check for overwriting existing data')
+    #-- permissions mode of the directories and files synced (number in octal)
+    parser.add_argument('--mode','-M',
+        type=lambda x: int(x,base=8), default=0o775,
+        help='Permission mode of directories and files synced')
+    args = parser.parse_args()
+
+    #-- check internet connection before attempting to run program
+    HOST = 'https://swarm-diss.eo.esa.int'
+    if gravity_toolkit.utilities.check_connection(HOST):
+        esa_costg_swarm_sync(args.directory, TIMEOUT=args.timeout,
+            LOG=args.log, LIST=args.list, CLOBBER=args.clobber,
+            CHECKSUM=args.checksum, MODE=args.mode)
+
+#-- run main program
+if __name__ == '__main__':
+    main()
diff --git a/scripts/gfz_icgem_costg_ftp.py b/scripts/gfz_icgem_costg_ftp.py
new file mode 100644
index 00000000..4e90d115
--- /dev/null
+++ b/scripts/gfz_icgem_costg_ftp.py
@@ -0,0 +1,270 @@
+#!/usr/bin/env python
+u"""
+gfz_icgem_costg_ftp.py
+Written by Tyler Sutterley (09/2021)
+Syncs GRACE/GRACE-FO/Swarm COST-G data from the GFZ International
+    Centre for Global Earth Models (ICGEM)
+
+CALLING SEQUENCE:
+    python gfz_icgem_costg_ftp.py --mission Grace Grace-FO Swarm
+
+OUTPUTS:
+    Grace: COST-G GRACE spherical harmonic fields
+    Grace-FO: COST-G GRACE-FO spherical harmonic fields
+    Swarm: COST-G Swarm spherical harmonic fields
+
+COMMAND LINE OPTIONS:
+    --help: list the command line options
+    -D X, --directory X: working data directory
+    -m X, --mission X: Sync GRACE, GRACE Follow-On (GRACE-FO) and Swarm data
+         Grace
+         Grace-FO
+         Swarm
+    -t X, --timeout X: Timeout in seconds for blocking operations
+    -L, --list: print files to be transferred, but do not execute transfer
+    -l, --log: output log of files downloaded
+    -C, --clobber: Overwrite existing data in transfer
+    --checksum: compare hashes to check if overwriting existing data
+    -M X, --mode X: Local permissions mode of the directories and files synced
+
+PYTHON DEPENDENCIES:
+    future: Compatibility layer between Python 2 and Python 3
+        http://python-future.org/
+    lxml: processing XML and HTML in Python
+        https://pypi.python.org/pypi/lxml
+
+PROGRAM DEPENDENCIES:
+    utilities.py: download and management utilities for syncing files
+
+UPDATE HISTORY:
+    Written 09/2021
+"""
+from __future__ import print_function
+
+import sys
+import os
+import re
+import time
+import ftplib
+import shutil
+import hashlib
+import argparse
+import posixpath
+import gravity_toolkit.utilities
+
+#-- PURPOSE: create and compile regular expression operator to find files
+def compile_regex_pattern(MISSION, DSET):
+    if ((DSET == 'GSM') and (MISSION == 'Swarm')):
+        #-- regular expression operators for Swarm data
+        regex=r'(SW)_(.*?)_(EGF_SHA_2)__(.*?)_(.*?)_(.*?)(\.gfc|\.ZIP)'
+    elif ((DSET != 'GSM') and (MISSION == 'Swarm')):
+        regex=r'(GAA|GAB|GAC|GAD)_Swarm_(\d+)_(\d{2})_(\d{4})(\.gfc|\.ZIP)'
+    else:
+        regex=r'{0}-2_(.*?)\.gfc$'.format(DSET)
+    #-- return the compiled regular expression operator used to find files
+    return re.compile(regex, re.VERBOSE)
+
+#-- PURPOSE: sync local GRACE/GRACE-FO/Swarm files with GFZ ICGEM server
+def gfz_icgem_costg_ftp(DIRECTORY, MISSION=[], TIMEOUT=None, LOG=False,
+    LIST=False, CLOBBER=False, CHECKSUM=False, MODE=None):
+
+    #-- connect and login to GFZ ICGEM ftp server
+    ftp = ftplib.FTP('icgem.gfz-potsdam.de', timeout=TIMEOUT)
+    ftp.login()
+
+    #-- check if directory exists and recursively create if not
+    os.makedirs(DIRECTORY,MODE) if not os.path.exists(DIRECTORY) else None
+    #-- dealiasing datasets for each mission
+    DSET = {}
+    DSET['Grace'] = ['GAC','GSM']
+    DSET['Grace-FO'] = ['GSM']
+    DSET['Swarm'] = ['GAA','GAB','GAC','GAD','GSM']
+    #-- local subdirectory for data
+    LOCAL = {}
+    LOCAL['Grace'] = 'COSTG'
+    LOCAL['Grace-FO'] = 'COSTG'
+    LOCAL['Swarm'] = 'Swarm'
+
+    #-- create log file with list of synchronized files (or print to terminal)
+    if LOG:
+        #-- output to log file
+        #-- format: GFZ_ICGEM_COST-G_sync_2002-04-01.log
+        today = time.strftime('%Y-%m-%d',time.localtime())
+        LOGFILE = 'GFZ_ICGEM_COST-G_sync_{0}.log'.format(today)
+        fid1 = open(os.path.join(DIRECTORY,LOGFILE),'w')
+        print('GFZ ICGEM COST-G Sync Log ({0})'.format(today), file=fid1)
+    else:
+        #-- standard output (terminal output)
+        fid1 = sys.stdout
+
+    #-- find files for a particular mission
+    print('{0} Spherical Harmonics:'.format(MISSION),file=fid1)
+
+    #-- Sync gravity field dealiasing products
+    for ds in DSET[MISSION]:
+        #-- print string of exact data product
+        print('{0}/{1}/{2}'.format(MISSION,'RL01',ds), file=fid1)
+        #-- local directory for exact data product
+        local_dir = os.path.join(DIRECTORY,LOCAL[MISSION],'RL01',ds)
+        #-- check if directory exists and recursively create if not
+        if not os.path.exists(local_dir):
+            os.makedirs(local_dir,MODE)
+        #-- compile the regular expression operator to find files
+        R1 = compile_regex_pattern(MISSION, ds)
+        #-- set the remote path to download files
+        if ds in ('GAA','GAB','GAC','GAD') and (MISSION == 'Swarm'):
+            remote_path = [ftp.host,'02_COST-G',MISSION,'GAX_products',ds]
+        elif ds in ('GAA','GAB','GAC','GAD') and (MISSION != 'Swarm'):
+            remote_path = [ftp.host,'02_COST-G',MISSION,'GAX_products']
+        elif (MISSION == 'Swarm'):
+            remote_path = [ftp.host,'02_COST-G',MISSION,'40x40']
+        elif (MISSION == 'Grace'):
+            remote_path = [ftp.host,'02_COST-G',MISSION,'unfiltered']
+        elif (MISSION == 'Grace-FO'):
+            remote_path = [ftp.host,'02_COST-G',MISSION]
+        #-- get filenames from remote directory
+        remote_files,remote_mtimes = gravity_toolkit.utilities.ftp_list(
+            remote_path, timeout=TIMEOUT, basename=True, pattern=R1,
+            sort=True)
+        #-- download the file from the ftp server
+        for fi,remote_mtime in zip(remote_files,remote_mtimes):
+            #-- remote and local versions of the file
+            remote_path.append(fi)
+            local_file = os.path.join(local_dir,fi)
+            ftp_mirror_file(fid1, ftp, remote_path, remote_mtime,
+                local_file, TIMEOUT=TIMEOUT, LIST=LIST,
+                CLOBBER=CLOBBER, CHECKSUM=CHECKSUM, MODE=MODE)
+            #-- remove the file from the remote path list
+            remote_path.remove(fi)
+        #-- find local GRACE/GRACE-FO/Swarm files to create index
+        grace_files=[fi for fi in os.listdir(local_dir) if R1.match(fi)]
+        #-- write each file to an index
+        with open(os.path.join(local_dir,'index.txt'),'w') as fid:
+            #-- output GRACE/GRACE-FO/Swarm filenames to index
+            for fi in sorted(grace_files):
+                print('{0}'.format(fi), file=fid)
+        #-- change permissions of index file
+        os.chmod(os.path.join(local_dir,'index.txt'), MODE)
+
+    #-- close the ftp connection
+    ftp.quit()
+    #-- close log file and set permissions level to MODE
+    if LOG:
+        fid1.close()
+        os.chmod(os.path.join(DIRECTORY,LOGFILE), MODE)
+
+#-- PURPOSE: pull file from a remote host checking if file exists locally
+#-- and if the remote file is newer than the local file
+def ftp_mirror_file(fid,ftp,remote_path,remote_mtime,local_file,
+    TIMEOUT=None,LIST=False,CLOBBER=False,CHECKSUM=False,MODE=0o775):
+    #-- if file exists in file system: check if remote file is newer
+    TEST = False
+    OVERWRITE = ' (clobber)'
+    #-- check if local version of file exists
+    if CHECKSUM and os.access(local_file, os.F_OK):
+        #-- generate checksum hash for local file
+        #-- open the local_file in binary read mode
+        with open(local_file, 'rb') as local_buffer:
+            local_hash = hashlib.md5(local_buffer.read()).hexdigest()
+        #-- copy remote file contents to bytesIO object
+        remote_buffer = gravity_toolkit.utilities.from_ftp(remote_path,
+            timeout=TIMEOUT)
+        #-- generate checksum hash for remote file
+        remote_hash = hashlib.md5(remote_buffer.getvalue()).hexdigest()
+        #-- compare checksums
+        if (local_hash != remote_hash):
+            TEST = True
+            OVERWRITE = ' (checksums: {0} {1})'.format(local_hash,remote_hash)
+    elif os.access(local_file, os.F_OK):
+        #-- check last modification time of local file
+        local_mtime = os.stat(local_file).st_mtime
+        #-- if remote file is newer: overwrite the local file
+        if (gravity_toolkit.utilities.even(remote_mtime) >
+            gravity_toolkit.utilities.even(local_mtime)):
+            TEST = True
+            OVERWRITE = ' (overwrite)'
+    else:
+        TEST = True
+        OVERWRITE = ' (new)'
+    #-- if file does not exist locally, is to be overwritten, or CLOBBER is set
+    if TEST or CLOBBER:
+        #-- Printing files transferred
+        arg=(posixpath.join('ftp://',*remote_path),local_file,OVERWRITE)
+        fid.write('{0} -->\n\t{1}{2}\n\n'.format(*arg))
+        #-- if executing copy command (not only printing the files)
+        if not LIST:
+            #-- copy file from ftp server or from bytesIO object
+            if CHECKSUM and os.access(local_file, os.F_OK):
+                #-- store bytes to file using chunked transfer encoding
+                remote_buffer.seek(0)
+                with open(local_file, 'wb') as f:
+                    shutil.copyfileobj(remote_buffer, f, 16 * 1024)
+            else:
+                #-- path to remote file
+                remote_file = posixpath.join(*remote_path[1:])
+                #-- copy remote file contents to local file
+                with open(local_file, 'wb') as f:
+                    ftp.retrbinary('RETR {0}'.format(remote_file), f.write)
+            #-- keep remote modification time of file and local access time
+            os.utime(local_file, (os.stat(local_file).st_atime, remote_mtime))
+            os.chmod(local_file, MODE)
+
+#-- Main program that calls gfc_icgem_costg_ftp()
+def main():
+    #-- Read the system arguments listed after the program
+    parser = argparse.ArgumentParser(
+        description="""Syncs GRACE/GRACE-FO/Swarm COST-G data from the
+            GFZ International Centre for Global Earth Models (ICGEM)
+            """
+    )
+    #-- command line parameters
+    #-- working data directory
+    parser.add_argument('--directory','-D',
+        type=lambda p: os.path.abspath(os.path.expanduser(p)),
+        default=os.getcwd(),
+        help='Working data directory')
+    #-- mission (GRACE, GRACE Follow-On or Swarm)
+    choices = ['Grace','Grace-FO','Swarm']
+    parser.add_argument('--mission','-m',
+        type=str, nargs='+',
+        default=['Grace','Grace-FO','Swarm'], choices=choices,
+        help='Mission to sync between GRACE, GRACE-FO and Swarm')
+    #-- connection timeout
+    parser.add_argument('--timeout','-t',
+        type=int, default=360,
+        help='Timeout in seconds for blocking operations')
+    #-- Output log file in form
+    #-- GFZ_ICGEM_COST-G_sync_2002-04-01.log
+    parser.add_argument('--log','-l',
+        default=False, action='store_true',
+        help='Output log file')
+    #-- sync options
+    parser.add_argument('--list','-L',
+        default=False, action='store_true',
+        help='Only print files that could be transferred')
+    parser.add_argument('--checksum',
+        default=False, action='store_true',
+        help='Compare hashes to check for overwriting existing data')
+    parser.add_argument('--clobber','-C',
+        default=False, action='store_true',
+        help='Overwrite existing data in transfer')
+    #-- permissions mode of the directories and files synced (number in octal)
+    parser.add_argument('--mode','-M',
+        type=lambda x: int(x,base=8), default=0o775,
+        help='Permission mode of directories and files synced')
+    args,_ = parser.parse_known_args()
+
+    #-- check internet connection before attempting to run program
+    HOST = 'icgem.gfz-potsdam.de'
+    if gravity_toolkit.utilities.check_ftp_connection(HOST):
+        for m in args.mission:
+            gfz_icgem_costg_ftp(args.directory, MISSION=m,
+                TIMEOUT=args.timeout, LIST=args.list, LOG=args.log,
+                CLOBBER=args.clobber, CHECKSUM=args.checksum,
+                MODE=args.mode)
+    else:
+        raise RuntimeError('Check internet connection')
+
+#-- run main program
+if __name__ == '__main__':
+    main()
diff --git a/scripts/grace_mean_harmonics.py b/scripts/grace_mean_harmonics.py
index 7733e379..eed43dda 100644
--- a/scripts/grace_mean_harmonics.py
+++ b/scripts/grace_mean_harmonics.py
@@ -323,7 +323,7 @@ def main():
         type=lambda p: os.path.abspath(os.path.expanduser(p)),
         default=os.getcwd(),
         help='Working data directory')
-    #-- GRACE/GRACE-FO data processing center
+    #-- Data processing center or satellite mission
     parser.add_argument('--center','-c',
         metavar='PROC', type=str, required=True,
         help='GRACE/GRACE-FO Processing Center')
diff --git a/scripts/grace_spatial_error.py b/scripts/grace_spatial_error.py
index 3e6d43f3..17f0532e 100755
--- a/scripts/grace_spatial_error.py
+++ b/scripts/grace_spatial_error.py
@@ -553,7 +553,7 @@ def main():
     parser.add_argument('--file-prefix','-P',
         type=str,
         help='Prefix string for input and output files')
-    #-- GRACE/GRACE-FO data processing center
+    #-- Data processing center or satellite mission
     parser.add_argument('--center','-c',
         metavar='PROC', type=str, required=True,
         help='GRACE/GRACE-FO Processing Center')
diff --git a/scripts/grace_spatial_maps.py b/scripts/grace_spatial_maps.py
index 8eb65308..3ef6fd09 100755
--- a/scripts/grace_spatial_maps.py
+++ b/scripts/grace_spatial_maps.py
@@ -565,7 +565,7 @@ def main():
     parser.add_argument('--file-prefix','-P',
         type=str,
         help='Prefix string for input and output files')
-    #-- GRACE/GRACE-FO data processing center
+    #-- Data processing center or satellite mission
     parser.add_argument('--center','-c',
         metavar='PROC', type=str, required=True,
         help='GRACE/GRACE-FO Processing Center')
diff --git a/scripts/itsg_graz_grace_sync.py b/scripts/itsg_graz_grace_sync.py
new file mode 100755
index 00000000..1f22d036
--- /dev/null
+++ b/scripts/itsg_graz_grace_sync.py
@@ -0,0 +1,261 @@
+#!/usr/bin/env python
+u"""
+itsg_graz_grace_sync.py
+Written by Tyler Sutterley (09/2021)
+Syncs GRACE/GRACE-FO and auxiliary data from the ITSG GRAZ server
+
+CALLING SEQUENCE:
+    python itsg_graz_grace_sync.py --release Grace2018 --lmax 60
+
+COMMAND LINE OPTIONS:
+    --help: list the command line options
+    -D X, --directory X: working data directory
+    -r X, --release X: GRAZ Data Releases to sync
+        Grace2014
+        Grace2016
+        Grace2018
+        Grace_operational
+    --lmax X: Maximum degree and order of GRAZ products
+        60
+        96
+        120
+    -t X, --timeout X: Timeout in seconds for blocking operations
+    --log: output log of files downloaded
+    --list: print files to be transferred, but do not execute transfer
+    --clobber: Overwrite existing data in transfer
+    -M X, --mode X: permissions mode of the directories and files synced
+
+PYTHON DEPENDENCIES:
+    numpy: Scientific Computing Tools For Python
+        https://numpy.org
+        https://numpy.org/doc/stable/user/numpy-for-matlab-users.html
+    dateutil: powerful extensions to datetime
+        https://dateutil.readthedocs.io/en/stable/
+    lxml: Pythonic XML and HTML processing library using libxml2/libxslt
+        https://lxml.de/
+        https://github.com/lxml/lxml
+
+PROGRAM DEPENDENCIES:
+    utilities.py: download and management utilities for syncing files
+
+UPDATE HISTORY:
+    Written 09/2021
+"""
+from __future__ import print_function
+
+import sys
+import os
+import re
+import time
+import shutil
+import argparse
+import posixpath
+import gravity_toolkit.utilities
+
+#-- PURPOSE: sync local GRACE/GRACE-FO files with ITSG GRAZ server
+def itsg_graz_grace_sync(DIRECTORY, RELEASE=None, LMAX=None, TIMEOUT=0,
+    LOG=False, LIST=False, MODE=0o775, CLOBBER=False):
+
+    #-- check if directory exists and recursively create if not
+    os.makedirs(DIRECTORY,MODE) if not os.path.exists(DIRECTORY) else None
+    #-- create log file with list of synchronized files (or print to terminal)
+    if LOG:
+        #-- output to log file
+        #-- format: ITSG_GRAZ_GRACE_sync_2002-04-01.log
+        today = time.strftime('%Y-%m-%d',time.localtime())
+        LOGFILE = 'ITSG_GRAZ_GRACE_sync_{0}.log'.format(today)
+        fid = open(os.path.join(DIRECTORY,LOGFILE),'w')
+        print('ITSG GRAZ GRACE Sync Log ({0})'.format(today), file=fid)
+        print('Release: {0}'.format(RELEASE))
+        print('LMAX: {0:d}'.format(LMAX))
+    else:
+        #-- standard output (terminal output)
+        fid = sys.stdout
+
+    #-- ITSG GRAZ server
+    HOST = ['http://ftp.tugraz.at','outgoing','ITSG','GRACE']
+    #-- open connection with ITSG GRAZ server at remote directory
+    release_directory = 'ITSG-{0}'.format(RELEASE)
+    #-- regular expression operators for ITSG data and models
+    itsg_products = []
+    itsg_products.append(r'atmosphere')
+    itsg_products.append(r'dealiasing')
+    itsg_products.append(r'oceanBottomPressure')
+    itsg_products.append(r'ocean')
+    itsg_products.append(r'Grace2014')
+    itsg_products.append(r'Grace2016')
+    itsg_products.append(r'Grace2018')
+    itsg_products.append(r'Grace_operational')
+    itsg_pattern = (r'(AOD1B_RL\d+|model|ITSG)[-_]({0})(_n\d+)?_'
+        r'(\d+)-(\d+)(\.gfc)').format(r'|'.join(itsg_products))
+    R1 = re.compile(itsg_pattern, re.VERBOSE | re.IGNORECASE)
+    #-- local directory for release
+    DREL = {}
+    DREL['Grace2014'] = '2014'
+    DREL['Grace2016'] = '2016'
+    DREL['Grace2018'] = '2018'
+    DREL['Grace_operational'] = '2018'
+    #-- local dealiasing directories for each product
+    DEALIASING = {}
+    DEALIASING['atmosphere'] = 'GAA'
+    DEALIASING['ocean'] = 'GAB'
+    DEALIASING['dealiasing'] = 'GAC'
+    DEALIASING['oceanBottomPressure'] = 'GAD'
+
+    #-- sync ITSG GRAZ dealiasing products
+    subdir = 'background' if (RELEASE == 'Grace2014') else 'monthly_background'
+    REMOTE = [*HOST,release_directory,'monthly',subdir]
+    files,mtimes = gravity_toolkit.utilities.http_list(REMOTE,
+        timeout=TIMEOUT,pattern=R1,sort=True)
+    #-- for each file on the remote directory
+    for colname,remote_mtime in zip(files,mtimes):
+        #-- extract parameters from input filename
+        PFX,PRD,trunc,year,month,SFX = R1.findall(colname).pop()
+        #-- local directory for output GRAZ data
+        local_dir=os.path.join(DIRECTORY,'GRAZ',DREL[RELEASE],DEALIASING[PRD])
+        #-- check if local directory exists and recursively create if not
+        os.makedirs(local_dir,MODE) if not os.path.exists(local_dir) else None
+        #-- local and remote versions of the file
+        local_file = os.path.join(local_dir,colname)
+        remote_file = posixpath.join(*REMOTE,colname)
+        #-- copy file from remote directory comparing modified dates
+        http_pull_file(fid, remote_file, remote_mtime, local_file,
+            TIMEOUT=TIMEOUT, LIST=LIST, CLOBBER=CLOBBER, MODE=MODE)
+
+    #-- sync ITSG GRAZ data for truncation
+    subdir = 'monthly_n{0:d}'.format(LMAX)
+    REMOTE = [*HOST,release_directory,'monthly',subdir]
+    files,mtimes = gravity_toolkit.utilities.http_list(REMOTE,
+        timeout=TIMEOUT,pattern=R1,sort=True)
+    #-- local directory for output GRAZ data
+    local_dir = os.path.join(DIRECTORY,'GRAZ',DREL[RELEASE],'GSM')
+    #-- check if local directory exists and recursively create if not
+    os.makedirs(local_dir,MODE) if not os.path.exists(local_dir) else None
+    #-- for each file on the remote directory
+    for colname,remote_mtime in zip(files,mtimes):
+        #-- local and remote versions of the file
+        local_file = os.path.join(local_dir,colname)
+        remote_file = posixpath.join(*REMOTE,colname)
+        #-- copy file from remote directory comparing modified dates
+        http_pull_file(fid, remote_file, remote_mtime, local_file,
+            TIMEOUT=TIMEOUT, LIST=LIST, CLOBBER=CLOBBER, MODE=MODE)
+
+    #-- create index file for GRACE/GRACE-FO L2 Spherical Harmonic Data
+    #-- DATA PRODUCTS (GAC, GAD, GSM, GAA, GAB)
+    for ds in ['GAA','GAB','GAC','GAD','GSM']:
+        #-- local directory for exact data product
+        local_dir = os.path.join(DIRECTORY,'GRAZ',DREL[RELEASE],ds)
+        if not os.access(local_dir,os.F_OK):
+            continue
+        #-- find local GRACE files to create index
+        grace_files=[fi for fi in os.listdir(local_dir) if R1.match(fi)]
+        #-- outputting GRACE filenames to index
+        with open(os.path.join(local_dir,'index.txt'),'w') as fid:
+            for fi in sorted(grace_files):
+                print('{0}'.format(fi), file=fid)
+        #-- change permissions of index file
+        os.chmod(os.path.join(local_dir,'index.txt'), MODE)
+
+    #-- close log file and set permissions level to MODE
+    if LOG:
+        fid.close()
+        os.chmod(os.path.join(DIRECTORY,LOGFILE), MODE)
+
+#-- PURPOSE: pull file from a remote host checking if file exists locally
+#-- and if the remote file is newer than the local file
+def http_pull_file(fid,remote_file,remote_mtime,local_file,
+    TIMEOUT=0,LIST=False,CLOBBER=False,MODE=0o775):
+    #-- if file exists in file system: check if remote file is newer
+    TEST = False
+    OVERWRITE = ' (clobber)'
+    #-- check if local version of file exists
+    if os.access(local_file, os.F_OK):
+        #-- check last modification time of local file
+        local_mtime = os.stat(local_file).st_mtime
+        #-- if remote file is newer: overwrite the local file
+        if (gravity_toolkit.utilities.even(remote_mtime) >
+            gravity_toolkit.utilities.even(local_mtime)):
+            TEST = True
+            OVERWRITE = ' (overwrite)'
+    else:
+        TEST = True
+        OVERWRITE = ' (new)'
+    #-- if file does not exist locally, is to be overwritten, or CLOBBER is set
+    if TEST or CLOBBER:
+        #-- Printing files transferred
+        print('{0} --> '.format(remote_file), file=fid)
+        print('\t{0}{1}\n'.format(local_file,OVERWRITE), file=fid)
+        #-- if executing copy command (not only printing the files)
+        if not LIST:
+            #-- Create and submit request. There are a wide range of exceptions
+            #-- that can be thrown here, including HTTPError and URLError.
+            request = gravity_toolkit.utilities.urllib2.Request(remote_file)
+            response = gravity_toolkit.utilities.urllib2.urlopen(request,
+                timeout=TIMEOUT)
+            #-- chunked transfer encoding size
+            CHUNK = 16 * 1024
+            #-- copy contents to local file using chunked transfer encoding
+            #-- transfer should work properly with ascii and binary data formats
+            with open(local_file, 'wb') as f:
+                shutil.copyfileobj(response, f, CHUNK)
+            #-- keep remote modification time of file and local access time
+            os.utime(local_file, (os.stat(local_file).st_atime, remote_mtime))
+            os.chmod(local_file, MODE)
+
+#-- Main program that calls itsg_graz_grace_sync()
+def main():
+    #-- Read the system arguments listed after the program
+    parser = argparse.ArgumentParser(
+        description="""Syncs GRACE/GRACE-FO and auxiliary data from the
+            ITSG GRAZ server
+            """
+    )
+    #-- command line parameters
+    #-- working data directory
+    parser.add_argument('--directory','-D',
+        type=lambda p: os.path.abspath(os.path.expanduser(p)),
+        default=os.getcwd(),
+        help='Working data directory')
+    #-- ITSG GRAZ releases
+    choices = ['Grace2014','Grace2016','Grace2018','Grace_operational']
+    parser.add_argument('--release','-r',
+        type=str, nargs='+', metavar='DREL',
+        default=['Grace2018','Grace_operational'],choices=choices,
+        help='GRAZ Data Releases to sync')
+    parser.add_argument('--lmax',
+        type=int, default=60, choices=[60,96,120],
+        help='Maximum degree and order of GRAZ products')
+    #-- connection timeout
+    parser.add_argument('--timeout','-t',
+        type=int, default=360,
+        help='Timeout in seconds for blocking operations')
+    #-- Output log file in form
+    #-- ITSG_GRAZ_GRACE_sync_2002-04-01.log
+    parser.add_argument('--log','-l',
+        default=False, action='store_true',
+        help='Output log file')
+    #-- sync options
+    parser.add_argument('--list','-L',
+        default=False, action='store_true',
+        help='Only print files that could be transferred')
+    parser.add_argument('--clobber','-C',
+        default=False, action='store_true',
+        help='Overwrite existing data in transfer')
+    #-- permissions mode of the directories and files synced (number in octal)
+    parser.add_argument('--mode','-M',
+        type=lambda x: int(x,base=8), default=0o775,
+        help='Permission mode of directories and files synced')
+    args,_ = parser.parse_known_args()
+
+    #-- check internet connection before attempting to run program
+    HOST = posixpath.join('http://ftp.tugraz.at')
+    if gravity_toolkit.utilities.check_connection(HOST):
+        #-- for each ITSG GRAZ release
+        for RELEASE in args.release:
+            itsg_graz_grace_sync(args.directory, RELEASE=RELEASE,
+                LMAX=args.lmax, TIMEOUT=args.timeout, LOG=args.log,
+                LIST=args.list, CLOBBER=args.clobber, MODE=args.mode)
+
+#-- run main program
+if __name__ == '__main__':
+    main()
diff --git a/scripts/run_grace_date.py b/scripts/run_grace_date.py
index efa7602b..23c84508 100755
--- a/scripts/run_grace_date.py
+++ b/scripts/run_grace_date.py
@@ -1,7 +1,7 @@
 #!/usr/bin/env python
 u"""
 run_grace_date.py
-Written by Tyler Sutterley (05/2021)
+Written by Tyler Sutterley (09/2021)
 
 Wrapper program for running GRACE date and months programs
 
@@ -15,8 +15,11 @@
 
 INPUTS:
     base_dir: working GRACE/GRACE-FO data directory
-    PROC: GRACE processing centers to run
-    DREL: GRACE/GRACE-FO Data Releases to run
+    PROC: GRACE/GRACE-FO data processing center
+        CSR: University of Texas Center for Space Research
+        GFZ: German Research Centre for Geosciences (GeoForschungsZentrum)
+        JPL: Jet Propulsion Laboratory
+    DREL: GRACE/GRACE-FO data release to run
 
 OPTIONS:
     VERBOSE: Track program progress
@@ -44,6 +47,7 @@
     grace_months_index.py: creates a single file showing the GRACE dates
 
 UPDATE HISTORY:
+    Updated 09/2021: using verbose option to track program progress
     Updated 05/2021: define int/float precision to prevent deprecation warning
     Updated 10/2020: use argparse to set command line parameters
     Updated 05/2020: updated for public release
@@ -94,12 +98,13 @@ def run_grace_date(base_dir, PROC, DREL, VERBOSE=False, MODE=0o775):
         for r in drel:
             #-- for number of data products
             for d in DSET[p][r]:
-                print('GRACE Date Program: {0} {1} {2}'.format(p,r,d))
+                if VERBOSE:
+                    print('GRACE Date Program: {0} {1} {2}'.format(p,r,d))
                 #-- run program for processing center, data release and product
                 grace_date(base_dir,PROC=p,DREL=r,DSET=d,OUTPUT=True,MODE=MODE)
 
     #-- run grace months program for data releases
-    print('GRACE Months Program')
+    print('GRACE Months Program') if VERBOSE else None
     grace_months_index(base_dir, DREL=DREL, MODE=MODE)
 
 
@@ -117,7 +122,7 @@ def main():
         type=lambda p: os.path.abspath(os.path.expanduser(p)),
         default=os.getcwd(),
         help='Working data directory')
-    #-- GRACE/GRACE-FO data processing center
+    #-- Data processing center or satellite mission
     parser.add_argument('--center','-c',
         metavar='PROC', type=str, nargs='+',
         default=['CSR','GFZ','JPL'],
diff --git a/scripts/scale_grace_maps.py b/scripts/scale_grace_maps.py
index 913e7f77..61a71657 100644
--- a/scripts/scale_grace_maps.py
+++ b/scripts/scale_grace_maps.py
@@ -716,7 +716,7 @@ def main():
     parser.add_argument('--file-prefix','-P',
         type=str,
         help='Prefix string for input and output files')
-    #-- GRACE/GRACE-FO data processing center
+    #-- Data processing center or satellite mission
     parser.add_argument('--center','-c',
         metavar='PROC', type=str, required=True,
         help='GRACE/GRACE-FO Processing Center')


Test patch:
diff --git a/test/test_download_and_read.py b/test/test_download_and_read.py
index 5a2af9c8..ffefe3ca 100644
--- a/test/test_download_and_read.py
+++ b/test/test_download_and_read.py
@@ -1,13 +1,16 @@
 #!/usr/bin/env python
 u"""
-test_download_and_read.py (08/2020)
+test_download_and_read.py (09/2021)
 Tests the read program to verify that coefficients are being extracted
 """
 import os
 import pytest
+import shutil
 import inspect
 import warnings
+import posixpath
 import gravity_toolkit.utilities
+from gravity_toolkit.read_gfc_harmonics import read_gfc_harmonics
 from gravity_toolkit.read_GRACE_harmonics import read_GRACE_harmonics
 from read_GRACE_geocenter.read_GRACE_geocenter import read_GRACE_geocenter
 
@@ -26,7 +29,7 @@ def test_podaac_download_and_read(username,webdav):
     assert (Ylms['clm'][2,0] == -0.484169355584e-03)
 
 #-- PURPOSE: Download a GRACE file from GFZ and check that read program runs
-def test_gfz_ftp_download_and_read(username,webdav):
+def test_gfz_ftp_download_and_read():
     HOST=['isdcftp.gfz-potsdam.de','grace','Level-2','CSR','RL06',
         'GSM-2_2002095-2002120_GRAC_UTCSR_BA01_0600.gz']
     #-- download and read as virtual file object
@@ -38,13 +41,73 @@ def test_gfz_ftp_download_and_read(username,webdav):
     assert all((Ylms[key] == val) for key,val in test.items())
     assert (Ylms['clm'][2,0] == -0.484169355584e-03)
 
+#-- PURPOSE: Download a GRACE-FO COST-G file from the GFZ ICGEM
+def test_gfz_icgem_costg_download_and_read():
+    HOST=['icgem.gfz-potsdam.de','02_COST-G','Grace-FO',
+        'GSM-2_2018152-2018181_GRFO_COSTG_BF01_0100.gfc']
+    #-- download and read as virtual file object
+    FILE = gravity_toolkit.utilities.from_ftp(HOST,verbose=True)
+    Ylms = read_GRACE_harmonics(FILE, 60)
+    keys = ['time', 'start', 'end', 'clm', 'slm', 'eclm', 'eslm', 'header']
+    test = dict(start=2458270.5, end=2458299.5)
+    assert all((key in Ylms.keys()) for key in keys)
+    assert all((Ylms[key] == val) for key,val in test.items())
+    assert (Ylms['clm'][2,0] == -0.484165436067e-03)
+
+#-- PURPOSE: Download a Swarm file from ESA and check that read program runs
+def test_esa_swarm_download_and_read():
+    #-- build url for Swarm file
+    HOST='https://swarm-diss.eo.esa.int'
+    swarm_file='SW_OPER_EGF_SHA_2__20131201T000000_20131231T235959_0101.ZIP'
+    parameters = gravity_toolkit.utilities.urlencode({'file':
+        posixpath.join('swarm','Level2longterm','EGF',swarm_file)})
+    remote_file = [HOST,'?do=download&{0}'.format(parameters)]
+    #-- download and read as virtual file object
+    gravity_toolkit.utilities.from_http(remote_file,local=swarm_file,verbose=True)
+    Ylms = read_gfc_harmonics(swarm_file)
+    keys = ['time', 'start', 'end', 'clm', 'slm', 'eclm', 'eslm']
+    test = dict(start=2456627.5, end=2456658.499988426)
+    assert all((key in Ylms.keys()) for key in keys)
+    assert all((Ylms[key] == val) for key,val in test.items())
+    assert (Ylms['clm'][2,0] == -0.48416530506600003e-03)
+    #-- clean up
+    os.remove(swarm_file)
+
+#-- PURPOSE: Download a GRACE ITSG GRAZ file and check that read program runs
+def test_itsg_graz_download_and_read():
+    HOST=['http://ftp.tugraz.at','outgoing','ITSG','GRACE',
+        'ITSG-Grace_operational','monthly','monthly_n60',
+        'ITSG-Grace_operational_n60_2018-06.gfc']
+    #-- download and read as virtual file object
+    gravity_toolkit.utilities.from_http(HOST,local=HOST[-1],verbose=True)
+    Ylms = read_gfc_harmonics(HOST[-1])
+    keys = ['time', 'start', 'end', 'clm', 'slm', 'eclm', 'eslm']
+    test = dict(start=2458270.5, end=2458300.499988426)
+    assert all((key in Ylms.keys()) for key in keys)
+    assert all((Ylms[key] == val) for key,val in test.items())
+    assert (Ylms['clm'][2,0] == -0.4841694727612e-03)
+    #-- clean up
+    os.remove(HOST[-1])
+
+#-- PURPOSE: Download Sutterley and Velicogna (2019) geocenter files
+@pytest.fixture(scope="module", autouse=True)
+def download_geocenter():
+    #-- download geocenter files to filepath
+    filename = inspect.getframeinfo(inspect.currentframe()).filename
+    filepath = os.path.dirname(os.path.abspath(filename))
+    gravity_toolkit.utilities.from_figshare(filepath,verbose=True)
+    #-- run tests
+    yield
+    #-- clean up
+    shutil.rmtree(os.path.join(filepath,'geocenter'))
+
 #-- parameterize processing center and data release
 @pytest.mark.parametrize("PROC", ['CSR','GFZ','JPL'])
 @pytest.mark.parametrize("DREL", ['RL06'])
-def test_geocenter_download_and_read(PROC, DREL):
+#-- PURPOSE: read Sutterley and Velicogna (2019) geocenter files
+def test_geocenter_read(PROC, DREL):
     filename = inspect.getframeinfo(inspect.currentframe()).filename
     filepath = os.path.dirname(os.path.abspath(filename))
-    gravity_toolkit.utilities.from_figshare(filepath,verbose=True)
     MODEL = dict(RL04='OMCT', RL05='OMCT', RL06='MPIOM')
     args = (PROC,DREL,MODEL[DREL],'SLF_iter')
     FILE = '{0}_{1}_{2}_{3}.txt'.format(*args)