Repository files navigation

GeoMetrics

A multi-resolution environmental data store backed by Google Earth Engine (GEE), with a built-in map viewer for exploration.

GeoMetrics solves a common research problem: you have a list of field sites and dates, and you need environmental covariates — vegetation indices, land cover, climate, water proximity — for each one. Pulling that data manually from different sources is slow and produces inconsistent spatial representations. GeoMetrics automates the full pipeline: it snaps your locations to a consistent spatial grid, checks what you already have in the database, submits missing extractions to GEE as batch jobs, ingests the results, and serves them back as a clean DataFrame.


How it works

Your locations CSV
│
▼
[snap to grid] ← HierGP rectangular grid, resolution-aware
│
▼
[check store] ← which (cell, variable, year) are already cached?
│
├─── available ─── gm.fetch() ──► DataFrame
│
└─── missing ──── gm.gee_submit() ──► GEE batch export jobs
│
(jobs run in GEE)
│
gm.ingest() ──► PostgreSQL
│
gm.fetch() ──► DataFrame

Spatial grid. All sources share a common HierGP rectangular grid. Each location is snapped to the nearest cell at the source's native resolution, so repeated queries for nearby points are automatically deduplicated and every dataset lines up spatially.

Temporal snapping. Timestamps are resolved to each dataset's temporal granularity — any date in 2023 maps to 2023-01-01 for annual datasets, to the nearest hour for ERA5-Land, and so on.

Backend-agnostic. The grid layer is pluggable. HierGP (rectangular) is the default; H3 (hexagonal) is also supported. The rest of the system — schema, ingest, query — works identically regardless of backend.


Supported datasets

SourceVariable(s)Native resolutionTemporalGEE collection
Landsat_NDVINDVI30 mAnnual medianLandsat 5/7/8/9 (USGS SR)
MODIS_NDVINDVI250 mAnnualMOD13Q1
MODIS_Treecoverpercent_tree_cover, percent_nontree_vegetation, percent_nonvegetated, quality, percent_tree_cover_sd, percent_nonvegetated_sd, cloud250 mAnnualMOD44B
ERA5_Landtemperature_2m, dewpoint_temperature_2m, surface_pressure, u_component_of_wind_10m, v_component_of_wind_10m, surface_thermal_radiation_downwards, surface_net_solar_radiation, total_precipitation~9 kmHourlyERA5-Land (ECMWF)
JRC_Waterwater_distance30 mAnnualJRC Global Surface Water
YALE_UHIyearly_daytime, yearly_nighttime, winter_daytime, winter_nighttime, summer_daytime, summer_nighttime1 kmAnnualYale Urban Heat Island
NLCDlandcover, impervious, impervious_descriptor30 mAnnualNLCD (USGS)

Prerequisites

  • Python 3.10+
  • PostgreSQL 14+ database
  • Google Earth Engine account with project access (ee.Initialize())
  • Google Drive mounted locally (for ingest step only)

Installation

conda env create -f environment.yml
conda activate geometrics
pip install -e .

Setup

1. Configure

Run once. Saves connection settings to ~/.geometrics/config.json.

fromgeometricsimportGeoMetricsgm=GeoMetrics.configure(
db_url="postgresql://user:password@localhost:5432/geometrics",
gdrive_base="/path/to/My Drive",
backend="hiergp",
)

All subsequent calls to GeoMetrics() load the saved config automatically.

2. Initialize the database

gm=GeoMetrics()
gm.init_db()
# Database initialized.# Registered 7 new source(s): ['Landsat_NDVI', 'MODIS_NDVI', ...]

Safe to call on an existing database — creates tables only if they don't exist, and skips sources that are already registered.


Core workflow

Prepare your locations CSV

At minimum, three columns are required. Column names are configurable.

site_id,latitude,longitude,timestamp
WA001,47.6062,-122.3321,2023-07-15T14:30:00
WA002,46.8523,-121.7603,2023-07-15T11:00:00
BR001,-2.4297,-54.7083,2023-08-01T10:00:00

Check availability

importeeee.Initialize()
gm=GeoMetrics()
report=gm.check(
"locations.csv",
variables=["Landsat_NDVI:NDVI", "MODIS_Treecover:percent_tree_cover"],
)
# report["available"] — already in the store# report["missing"] — need to be extracted from GEE

Each row in both lists includes the original coordinates, the resolved grid cell, the requested timestamp, and the resolved timestamp.

Submit missing items to GEE

job_ids=gm.gee_submit(report["missing"], gdrive_folder="extract-01")
# Submitted 2 job(s). Track with: gm.jobs()

GEE runs the export tasks asynchronously. Results are written as CSVs to the specified Drive folder.

Track job status

gm.jobs() # DataFrame of all submitted jobsgm.jobs("RUNNING") # filter by statusgm.check_status() # poll GEE and update local DB

Status values: PENDINGRUNNINGCOMPLETED / FAILED / CANCELLED / EXPIREDINGESTED

Ingest completed results

Once GEE jobs are COMPLETED and the Drive folder has synced locally:

gm.ingest("extract-01")
# Ingesting Landsat_NDVI_batch_001.csv ... inserted 7 row(s), skipped 0 duplicate(s).# Ingesting MODIS_Treecover_batch_001.csv ... inserted 7 row(s), skipped 0 duplicate(s).

Re-running ingest is safe — duplicates are detected and skipped.

Fetch stored data

df=gm.fetch(
"locations.csv",
variables=["Landsat_NDVI:NDVI", "MODIS_Treecover:percent_tree_cover"],
)

Returns a DataFrame joined back to your original input, with one column per variable. Rows with no data are NaN by default.

Output format options:

# Long format (one row per location × variable)df=gm.fetch("locations.csv", variables=[...], output_format="long")
# Drop rows with no data at alldf=gm.fetch("locations.csv", variables=[...], preserve_rows=False)
# Strip extra input columns from outputdf=gm.fetch("locations.csv", variables=[...], preserve_cols=False)
# Long format with grid metadatadf=gm.fetch("locations.csv", variables=[...],
output_format="long", include_metadata=True)
# Extra columns: cell_id, level, aggregated, resolved_timestamp

Map viewer

GeoMetrics ships a browser-based map viewer for exploring what's in the database.

python viewer/server.py
# or with live reload:
uvicorn viewer.server:app --reload --port 8765

Open http://localhost:8765 in your browser.

Features:

  • Browse all sources, variables, and available years from a sidebar
  • Load up to 200,000 cells for the full dataset or just the current viewport ("Load focus area")
  • Circle radius scales with the dataset's spatial resolution
  • 12 colormaps (Viridis, Greens, NDVI Red→Green, Blues, Plasma, Inferno, Magma, Yellow→Red, Spectral, Cividis, Hot, Greys)
  • Adjustable opacity
  • Hover tooltip showing value and coordinates
  • Auto-updating legend with data min/max

The viewer API is also accessible directly:

  • GET /api/sources — all sources with variables and available timestamps
  • GET /api/data?source=&variable=&timestamp=&bbox= — cell data for a selection

API reference

Configuration and setup

GeoMetrics.configure(db_url, gdrive_base, backend) # save config, return instancegm.show_config() # print current config as dictgm.init_db() # create tables + register sourcesgm.register_sources() # register/update catalog in DB

Discovery

GeoMetrics.list_sources() # list[dict] — all catalog entriesGeoMetrics.list_variables(source) # list[dict] — variables for one sourcegm.jobs(status=None) # DataFrame of submitted jobs

Data pipeline

gm.check(locations, variables, lat_col, lon_col, timestamp_col)
# → {"available": [...], "missing": [...]}gm.gee_submit(missing_items, gdrive_folder, batch_size=1000)
# → list[int] of job_idsgm.check_status()
# → {status: count} summary dictgm.ingest(gdrive_folder)
# → {filename: rows_inserted}gm.fetch(locations, variables, lat_col, lon_col, timestamp_col,
output_format="wide", preserve_rows=True, preserve_cols=True,
include_metadata=False)
# → pd.DataFrame

Maintenance

gm.clear(source) # drop all observations for a source (keeps schema)gm.reset_db() # drop all observation tables and reinitialize

Architecture

Spatial grid

GeoMetrics uses HierGP, a recursive rectangular grid with a base cell size of 25 m. The grid has 15 levels:

Standard levelCell sizeTypical use
1525 mFinest — high-res imagery
1450 m
13100 m
12200 m
11400 m
10800 m
91.6 km
......
1~410 kmCoarsest

Each source is registered with a native_level that matches its pixel footprint. Locations are snapped to that level, so two sites that fall in the same 100 m cell share a single database row for that source.

Database schema

sources — one row per dataset (name, native_level, pixel_resolution_m, ...)
variables — one row per band within a source (name, unit)
cells — one row per unique grid cell (cell_id, backend, level)
hiergp_cells — HierGP-specific: x/y integer coordinates for each cell
spatiotemporal_units — one row per (cell × timestamp) pair; RANGE-partitioned by year
obs_{source_name} — wide observation table: unit_pk + one column per variable
jobs — GEE export task registry (status, file paths, row counts)

The observation tables are intentionally denormalized (wide format) so that fetching multiple variables for the same location requires only one join. The spatiotemporal_units table is partitioned by year in PostgreSQL for fast range scans.

Adding a new source

  1. Create geometrics/extraction/my_source.py and define a SOURCE_SPEC dict and a build_ee_image() function following the pattern in geometrics/extraction/ndvi.py.
  2. Import SOURCE_SPEC in geometrics/catalog.py and add it to CATALOG.
  3. Run gm.register_sources() to add the source and its variables to the database.

License

See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

GeoMetrics

A multi-resolution environmental data store backed by Google Earth Engine (GEE), with a built-in map viewer for exploration.

GeoMetrics solves a common research problem: you have a list of field sites and dates, and you need environmental covariates — vegetation indices, land cover, climate, water proximity — for each one. Pulling that data manually from different sources is slow and produces inconsistent spatial representations. GeoMetrics automates the full pipeline: it snaps your locations to a consistent spatial grid, checks what you already have in the database, submits missing extractions to GEE as batch jobs, ingests the results, and serves them back as a clean DataFrame.


How it works

Your locations CSV
│
▼
[snap to grid] ← HierGP rectangular grid, resolution-aware
│
▼
[check store] ← which (cell, variable, year) are already cached?
│
├─── available ─── gm.fetch() ──► DataFrame
│
└─── missing ──── gm.gee_submit() ──► GEE batch export jobs
│
(jobs run in GEE)
│
gm.ingest() ──► PostgreSQL
│
gm.fetch() ──► DataFrame

Spatial grid. All sources share a common HierGP rectangular grid. Each location is snapped to the nearest cell at the source's native resolution, so repeated queries for nearby points are automatically deduplicated and every dataset lines up spatially.

Temporal snapping. Timestamps are resolved to each dataset's temporal granularity — any date in 2023 maps to 2023-01-01 for annual datasets, to the nearest hour for ERA5-Land, and so on.

Backend-agnostic. The grid layer is pluggable. HierGP (rectangular) is the default; H3 (hexagonal) is also supported. The rest of the system — schema, ingest, query — works identically regardless of backend.


Supported datasets

SourceVariable(s)Native resolutionTemporalGEE collection
Landsat_NDVINDVI30 mAnnual medianLandsat 5/7/8/9 (USGS SR)
MODIS_NDVINDVI250 mAnnualMOD13Q1
MODIS_Treecoverpercent_tree_cover, percent_nontree_vegetation, percent_nonvegetated, quality, percent_tree_cover_sd, percent_nonvegetated_sd, cloud250 mAnnualMOD44B
ERA5_Landtemperature_2m, dewpoint_temperature_2m, surface_pressure, u_component_of_wind_10m, v_component_of_wind_10m, surface_thermal_radiation_downwards, surface_net_solar_radiation, total_precipitation~9 kmHourlyERA5-Land (ECMWF)
JRC_Waterwater_distance30 mAnnualJRC Global Surface Water
YALE_UHIyearly_daytime, yearly_nighttime, winter_daytime, winter_nighttime, summer_daytime, summer_nighttime1 kmAnnualYale Urban Heat Island
NLCDlandcover, impervious, impervious_descriptor30 mAnnualNLCD (USGS)

Prerequisites

  • Python 3.10+
  • PostgreSQL 14+ database
  • Google Earth Engine account with project access (ee.Initialize())
  • Google Drive mounted locally (for ingest step only)

Installation

conda env create -f environment.yml
conda activate geometrics
pip install -e .

Setup

1. Configure

Run once. Saves connection settings to ~/.geometrics/config.json.

fromgeometricsimportGeoMetricsgm=GeoMetrics.configure(
db_url="postgresql://user:password@localhost:5432/geometrics",
gdrive_base="/path/to/My Drive",
backend="hiergp",
)

All subsequent calls to GeoMetrics() load the saved config automatically.

2. Initialize the database

gm=GeoMetrics()
gm.init_db()
# Database initialized.# Registered 7 new source(s): ['Landsat_NDVI', 'MODIS_NDVI', ...]

Safe to call on an existing database — creates tables only if they don't exist, and skips sources that are already registered.


Core workflow

Prepare your locations CSV

At minimum, three columns are required. Column names are configurable.

site_id,latitude,longitude,timestamp
WA001,47.6062,-122.3321,2023-07-15T14:30:00
WA002,46.8523,-121.7603,2023-07-15T11:00:00
BR001,-2.4297,-54.7083,2023-08-01T10:00:00

Check availability

importeeee.Initialize()
gm=GeoMetrics()
report=gm.check(
"locations.csv",
variables=["Landsat_NDVI:NDVI", "MODIS_Treecover:percent_tree_cover"],
)
# report["available"] — already in the store# report["missing"] — need to be extracted from GEE

Each row in both lists includes the original coordinates, the resolved grid cell, the requested timestamp, and the resolved timestamp.

Submit missing items to GEE

job_ids=gm.gee_submit(report["missing"], gdrive_folder="extract-01")
# Submitted 2 job(s). Track with: gm.jobs()

GEE runs the export tasks asynchronously. Results are written as CSVs to the specified Drive folder.

Track job status

gm.jobs() # DataFrame of all submitted jobsgm.jobs("RUNNING") # filter by statusgm.check_status() # poll GEE and update local DB

Status values: PENDINGRUNNINGCOMPLETED / FAILED / CANCELLED / EXPIREDINGESTED

Ingest completed results

Once GEE jobs are COMPLETED and the Drive folder has synced locally:

gm.ingest("extract-01")
# Ingesting Landsat_NDVI_batch_001.csv ... inserted 7 row(s), skipped 0 duplicate(s).# Ingesting MODIS_Treecover_batch_001.csv ... inserted 7 row(s), skipped 0 duplicate(s).

Re-running ingest is safe — duplicates are detected and skipped.

Fetch stored data

df=gm.fetch(
"locations.csv",
variables=["Landsat_NDVI:NDVI", "MODIS_Treecover:percent_tree_cover"],
)

Returns a DataFrame joined back to your original input, with one column per variable. Rows with no data are NaN by default.

Output format options:

# Long format (one row per location × variable)df=gm.fetch("locations.csv", variables=[...], output_format="long")
# Drop rows with no data at alldf=gm.fetch("locations.csv", variables=[...], preserve_rows=False)
# Strip extra input columns from outputdf=gm.fetch("locations.csv", variables=[...], preserve_cols=False)
# Long format with grid metadatadf=gm.fetch("locations.csv", variables=[...],
output_format="long", include_metadata=True)
# Extra columns: cell_id, level, aggregated, resolved_timestamp

Map viewer

GeoMetrics ships a browser-based map viewer for exploring what's in the database.

python viewer/server.py
# or with live reload:
uvicorn viewer.server:app --reload --port 8765

Open http://localhost:8765 in your browser.

Features:

  • Browse all sources, variables, and available years from a sidebar
  • Load up to 200,000 cells for the full dataset or just the current viewport ("Load focus area")
  • Circle radius scales with the dataset's spatial resolution
  • 12 colormaps (Viridis, Greens, NDVI Red→Green, Blues, Plasma, Inferno, Magma, Yellow→Red, Spectral, Cividis, Hot, Greys)
  • Adjustable opacity
  • Hover tooltip showing value and coordinates
  • Auto-updating legend with data min/max

The viewer API is also accessible directly:

  • GET /api/sources — all sources with variables and available timestamps
  • GET /api/data?source=&variable=&timestamp=&bbox= — cell data for a selection

API reference

Configuration and setup

GeoMetrics.configure(db_url, gdrive_base, backend) # save config, return instancegm.show_config() # print current config as dictgm.init_db() # create tables + register sourcesgm.register_sources() # register/update catalog in DB

Discovery

GeoMetrics.list_sources() # list[dict] — all catalog entriesGeoMetrics.list_variables(source) # list[dict] — variables for one sourcegm.jobs(status=None) # DataFrame of submitted jobs

Data pipeline

gm.check(locations, variables, lat_col, lon_col, timestamp_col)
# → {"available": [...], "missing": [...]}gm.gee_submit(missing_items, gdrive_folder, batch_size=1000)
# → list[int] of job_idsgm.check_status()
# → {status: count} summary dictgm.ingest(gdrive_folder)
# → {filename: rows_inserted}gm.fetch(locations, variables, lat_col, lon_col, timestamp_col,
output_format="wide", preserve_rows=True, preserve_cols=True,
include_metadata=False)
# → pd.DataFrame

Maintenance

gm.clear(source) # drop all observations for a source (keeps schema)gm.reset_db() # drop all observation tables and reinitialize

Architecture

Spatial grid

GeoMetrics uses HierGP, a recursive rectangular grid with a base cell size of 25 m. The grid has 15 levels:

Standard levelCell sizeTypical use
1525 mFinest — high-res imagery
1450 m
13100 m
12200 m
11400 m
10800 m
91.6 km
......
1~410 kmCoarsest

Each source is registered with a native_level that matches its pixel footprint. Locations are snapped to that level, so two sites that fall in the same 100 m cell share a single database row for that source.

Database schema

sources — one row per dataset (name, native_level, pixel_resolution_m, ...)
variables — one row per band within a source (name, unit)
cells — one row per unique grid cell (cell_id, backend, level)
hiergp_cells — HierGP-specific: x/y integer coordinates for each cell
spatiotemporal_units — one row per (cell × timestamp) pair; RANGE-partitioned by year
obs_{source_name} — wide observation table: unit_pk + one column per variable
jobs — GEE export task registry (status, file paths, row counts)

The observation tables are intentionally denormalized (wide format) so that fetching multiple variables for the same location requires only one join. The spatiotemporal_units table is partitioned by year in PostgreSQL for fast range scans.

Adding a new source

  1. Create geometrics/extraction/my_source.py and define a SOURCE_SPEC dict and a build_ee_image() function following the pattern in geometrics/extraction/ndvi.py.
  2. Import SOURCE_SPEC in geometrics/catalog.py and add it to CATALOG.
  3. Run gm.register_sources() to add the source and its variables to the database.

License

See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

GeoMetrics

A multi-resolution environmental data store backed by Google Earth Engine (GEE), with a built-in map viewer for exploration.

GeoMetrics solves a common research problem: you have a list of field sites and dates, and you need environmental covariates — vegetation indices, land cover, climate, water proximity — for each one. Pulling that data manually from different sources is slow and produces inconsistent spatial representations. GeoMetrics automates the full pipeline: it snaps your locations to a consistent spatial grid, checks what you already have in the database, submits missing extractions to GEE as batch jobs, ingests the results, and serves them back as a clean DataFrame.


How it works

Your locations CSV
│
▼
[snap to grid] ← HierGP rectangular grid, resolution-aware
│
▼
[check store] ← which (cell, variable, year) are already cached?
│
├─── available ─── gm.fetch() ──► DataFrame
│
└─── missing ──── gm.gee_submit() ──► GEE batch export jobs
│
(jobs run in GEE)
│
gm.ingest() ──► PostgreSQL
│
gm.fetch() ──► DataFrame

Spatial grid. All sources share a common HierGP rectangular grid. Each location is snapped to the nearest cell at the source's native resolution, so repeated queries for nearby points are automatically deduplicated and every dataset lines up spatially.

Temporal snapping. Timestamps are resolved to each dataset's temporal granularity — any date in 2023 maps to 2023-01-01 for annual datasets, to the nearest hour for ERA5-Land, and so on.

Backend-agnostic. The grid layer is pluggable. HierGP (rectangular) is the default; H3 (hexagonal) is also supported. The rest of the system — schema, ingest, query — works identically regardless of backend.


Supported datasets

SourceVariable(s)Native resolutionTemporalGEE collection
Landsat_NDVINDVI30 mAnnual medianLandsat 5/7/8/9 (USGS SR)
MODIS_NDVINDVI250 mAnnualMOD13Q1
MODIS_Treecoverpercent_tree_cover, percent_nontree_vegetation, percent_nonvegetated, quality, percent_tree_cover_sd, percent_nonvegetated_sd, cloud250 mAnnualMOD44B
ERA5_Landtemperature_2m, dewpoint_temperature_2m, surface_pressure, u_component_of_wind_10m, v_component_of_wind_10m, surface_thermal_radiation_downwards, surface_net_solar_radiation, total_precipitation~9 kmHourlyERA5-Land (ECMWF)
JRC_Waterwater_distance30 mAnnualJRC Global Surface Water
YALE_UHIyearly_daytime, yearly_nighttime, winter_daytime, winter_nighttime, summer_daytime, summer_nighttime1 kmAnnualYale Urban Heat Island
NLCDlandcover, impervious, impervious_descriptor30 mAnnualNLCD (USGS)

Prerequisites

  • Python 3.10+
  • PostgreSQL 14+ database
  • Google Earth Engine account with project access (ee.Initialize())
  • Google Drive mounted locally (for ingest step only)

Installation

conda env create -f environment.yml
conda activate geometrics
pip install -e .

Setup

1. Configure

Run once. Saves connection settings to ~/.geometrics/config.json.

fromgeometricsimportGeoMetricsgm=GeoMetrics.configure(
db_url="postgresql://user:password@localhost:5432/geometrics",
gdrive_base="/path/to/My Drive",
backend="hiergp",
)

All subsequent calls to GeoMetrics() load the saved config automatically.

2. Initialize the database

gm=GeoMetrics()
gm.init_db()
# Database initialized.# Registered 7 new source(s): ['Landsat_NDVI', 'MODIS_NDVI', ...]

Safe to call on an existing database — creates tables only if they don't exist, and skips sources that are already registered.


Core workflow

Prepare your locations CSV

At minimum, three columns are required. Column names are configurable.

site_id,latitude,longitude,timestamp
WA001,47.6062,-122.3321,2023-07-15T14:30:00
WA002,46.8523,-121.7603,2023-07-15T11:00:00
BR001,-2.4297,-54.7083,2023-08-01T10:00:00

Check availability

importeeee.Initialize()
gm=GeoMetrics()
report=gm.check(
"locations.csv",
variables=["Landsat_NDVI:NDVI", "MODIS_Treecover:percent_tree_cover"],
)
# report["available"] — already in the store# report["missing"] — need to be extracted from GEE

Each row in both lists includes the original coordinates, the resolved grid cell, the requested timestamp, and the resolved timestamp.

Submit missing items to GEE

job_ids=gm.gee_submit(report["missing"], gdrive_folder="extract-01")
# Submitted 2 job(s). Track with: gm.jobs()

GEE runs the export tasks asynchronously. Results are written as CSVs to the specified Drive folder.

Track job status

gm.jobs() # DataFrame of all submitted jobsgm.jobs("RUNNING") # filter by statusgm.check_status() # poll GEE and update local DB

Status values: PENDINGRUNNINGCOMPLETED / FAILED / CANCELLED / EXPIREDINGESTED

Ingest completed results

Once GEE jobs are COMPLETED and the Drive folder has synced locally:

gm.ingest("extract-01")
# Ingesting Landsat_NDVI_batch_001.csv ... inserted 7 row(s), skipped 0 duplicate(s).# Ingesting MODIS_Treecover_batch_001.csv ... inserted 7 row(s), skipped 0 duplicate(s).

Re-running ingest is safe — duplicates are detected and skipped.

Fetch stored data

df=gm.fetch(
"locations.csv",
variables=["Landsat_NDVI:NDVI", "MODIS_Treecover:percent_tree_cover"],
)

Returns a DataFrame joined back to your original input, with one column per variable. Rows with no data are NaN by default.

Output format options:

# Long format (one row per location × variable)df=gm.fetch("locations.csv", variables=[...], output_format="long")
# Drop rows with no data at alldf=gm.fetch("locations.csv", variables=[...], preserve_rows=False)
# Strip extra input columns from outputdf=gm.fetch("locations.csv", variables=[...], preserve_cols=False)
# Long format with grid metadatadf=gm.fetch("locations.csv", variables=[...],
output_format="long", include_metadata=True)
# Extra columns: cell_id, level, aggregated, resolved_timestamp

Map viewer

GeoMetrics ships a browser-based map viewer for exploring what's in the database.

python viewer/server.py
# or with live reload:
uvicorn viewer.server:app --reload --port 8765

Open http://localhost:8765 in your browser.

Features:

  • Browse all sources, variables, and available years from a sidebar
  • Load up to 200,000 cells for the full dataset or just the current viewport ("Load focus area")
  • Circle radius scales with the dataset's spatial resolution
  • 12 colormaps (Viridis, Greens, NDVI Red→Green, Blues, Plasma, Inferno, Magma, Yellow→Red, Spectral, Cividis, Hot, Greys)
  • Adjustable opacity
  • Hover tooltip showing value and coordinates
  • Auto-updating legend with data min/max

The viewer API is also accessible directly:

  • GET /api/sources — all sources with variables and available timestamps
  • GET /api/data?source=&variable=&timestamp=&bbox= — cell data for a selection

API reference

Configuration and setup

GeoMetrics.configure(db_url, gdrive_base, backend) # save config, return instancegm.show_config() # print current config as dictgm.init_db() # create tables + register sourcesgm.register_sources() # register/update catalog in DB

Discovery

GeoMetrics.list_sources() # list[dict] — all catalog entriesGeoMetrics.list_variables(source) # list[dict] — variables for one sourcegm.jobs(status=None) # DataFrame of submitted jobs

Data pipeline

gm.check(locations, variables, lat_col, lon_col, timestamp_col)
# → {"available": [...], "missing": [...]}gm.gee_submit(missing_items, gdrive_folder, batch_size=1000)
# → list[int] of job_idsgm.check_status()
# → {status: count} summary dictgm.ingest(gdrive_folder)
# → {filename: rows_inserted}gm.fetch(locations, variables, lat_col, lon_col, timestamp_col,
output_format="wide", preserve_rows=True, preserve_cols=True,
include_metadata=False)
# → pd.DataFrame

Maintenance

gm.clear(source) # drop all observations for a source (keeps schema)gm.reset_db() # drop all observation tables and reinitialize

Architecture

Spatial grid

GeoMetrics uses HierGP, a recursive rectangular grid with a base cell size of 25 m. The grid has 15 levels:

Standard levelCell sizeTypical use
1525 mFinest — high-res imagery
1450 m
13100 m
12200 m
11400 m
10800 m
91.6 km
......
1~410 kmCoarsest

Each source is registered with a native_level that matches its pixel footprint. Locations are snapped to that level, so two sites that fall in the same 100 m cell share a single database row for that source.

Database schema

sources — one row per dataset (name, native_level, pixel_resolution_m, ...)
variables — one row per band within a source (name, unit)
cells — one row per unique grid cell (cell_id, backend, level)
hiergp_cells — HierGP-specific: x/y integer coordinates for each cell
spatiotemporal_units — one row per (cell × timestamp) pair; RANGE-partitioned by year
obs_{source_name} — wide observation table: unit_pk + one column per variable
jobs — GEE export task registry (status, file paths, row counts)

The observation tables are intentionally denormalized (wide format) so that fetching multiple variables for the same location requires only one join. The spatiotemporal_units table is partitioned by year in PostgreSQL for fast range scans.

Adding a new source

  1. Create geometrics/extraction/my_source.py and define a SOURCE_SPEC dict and a build_ee_image() function following the pattern in geometrics/extraction/ndvi.py.
  2. Import SOURCE_SPEC in geometrics/catalog.py and add it to CATALOG.
  3. Run gm.register_sources() to add the source and its variables to the database.

License

See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

GeoMetrics

A multi-resolution environmental data store backed by Google Earth Engine (GEE), with a built-in map viewer for exploration.

GeoMetrics solves a common research problem: you have a list of field sites and dates, and you need environmental covariates — vegetation indices, land cover, climate, water proximity — for each one. Pulling that data manually from different sources is slow and produces inconsistent spatial representations. GeoMetrics automates the full pipeline: it snaps your locations to a consistent spatial grid, checks what you already have in the database, submits missing extractions to GEE as batch jobs, ingests the results, and serves them back as a clean DataFrame.


How it works

Your locations CSV
│
▼
[snap to grid] ← HierGP rectangular grid, resolution-aware
│
▼
[check store] ← which (cell, variable, year) are already cached?
│
├─── available ─── gm.fetch() ──► DataFrame
│
└─── missing ──── gm.gee_submit() ──► GEE batch export jobs
│
(jobs run in GEE)
│
gm.ingest() ──► PostgreSQL
│
gm.fetch() ──► DataFrame

Spatial grid. All sources share a common HierGP rectangular grid. Each location is snapped to the nearest cell at the source's native resolution, so repeated queries for nearby points are automatically deduplicated and every dataset lines up spatially.

Temporal snapping. Timestamps are resolved to each dataset's temporal granularity — any date in 2023 maps to 2023-01-01 for annual datasets, to the nearest hour for ERA5-Land, and so on.

Backend-agnostic. The grid layer is pluggable. HierGP (rectangular) is the default; H3 (hexagonal) is also supported. The rest of the system — schema, ingest, query — works identically regardless of backend.


Supported datasets

SourceVariable(s)Native resolutionTemporalGEE collection
Landsat_NDVINDVI30 mAnnual medianLandsat 5/7/8/9 (USGS SR)
MODIS_NDVINDVI250 mAnnualMOD13Q1
MODIS_Treecoverpercent_tree_cover, percent_nontree_vegetation, percent_nonvegetated, quality, percent_tree_cover_sd, percent_nonvegetated_sd, cloud250 mAnnualMOD44B
ERA5_Landtemperature_2m, dewpoint_temperature_2m, surface_pressure, u_component_of_wind_10m, v_component_of_wind_10m, surface_thermal_radiation_downwards, surface_net_solar_radiation, total_precipitation~9 kmHourlyERA5-Land (ECMWF)
JRC_Waterwater_distance30 mAnnualJRC Global Surface Water
YALE_UHIyearly_daytime, yearly_nighttime, winter_daytime, winter_nighttime, summer_daytime, summer_nighttime1 kmAnnualYale Urban Heat Island
NLCDlandcover, impervious, impervious_descriptor30 mAnnualNLCD (USGS)

Prerequisites

  • Python 3.10+
  • PostgreSQL 14+ database
  • Google Earth Engine account with project access (ee.Initialize())
  • Google Drive mounted locally (for ingest step only)

Installation

conda env create -f environment.yml
conda activate geometrics
pip install -e .

Setup

1. Configure

Run once. Saves connection settings to ~/.geometrics/config.json.

fromgeometricsimportGeoMetricsgm=GeoMetrics.configure(
db_url="postgresql://user:password@localhost:5432/geometrics",
gdrive_base="/path/to/My Drive",
backend="hiergp",
)

All subsequent calls to GeoMetrics() load the saved config automatically.

2. Initialize the database

gm=GeoMetrics()
gm.init_db()
# Database initialized.# Registered 7 new source(s): ['Landsat_NDVI', 'MODIS_NDVI', ...]

Safe to call on an existing database — creates tables only if they don't exist, and skips sources that are already registered.


Core workflow

Prepare your locations CSV

At minimum, three columns are required. Column names are configurable.

site_id,latitude,longitude,timestamp
WA001,47.6062,-122.3321,2023-07-15T14:30:00
WA002,46.8523,-121.7603,2023-07-15T11:00:00
BR001,-2.4297,-54.7083,2023-08-01T10:00:00

Check availability

importeeee.Initialize()
gm=GeoMetrics()
report=gm.check(
"locations.csv",
variables=["Landsat_NDVI:NDVI", "MODIS_Treecover:percent_tree_cover"],
)
# report["available"] — already in the store# report["missing"] — need to be extracted from GEE

Each row in both lists includes the original coordinates, the resolved grid cell, the requested timestamp, and the resolved timestamp.

Submit missing items to GEE

job_ids=gm.gee_submit(report["missing"], gdrive_folder="extract-01")
# Submitted 2 job(s). Track with: gm.jobs()

GEE runs the export tasks asynchronously. Results are written as CSVs to the specified Drive folder.

Track job status

gm.jobs() # DataFrame of all submitted jobsgm.jobs("RUNNING") # filter by statusgm.check_status() # poll GEE and update local DB

Status values: PENDINGRUNNINGCOMPLETED / FAILED / CANCELLED / EXPIREDINGESTED

Ingest completed results

Once GEE jobs are COMPLETED and the Drive folder has synced locally:

gm.ingest("extract-01")
# Ingesting Landsat_NDVI_batch_001.csv ... inserted 7 row(s), skipped 0 duplicate(s).# Ingesting MODIS_Treecover_batch_001.csv ... inserted 7 row(s), skipped 0 duplicate(s).

Re-running ingest is safe — duplicates are detected and skipped.

Fetch stored data

df=gm.fetch(
"locations.csv",
variables=["Landsat_NDVI:NDVI", "MODIS_Treecover:percent_tree_cover"],
)

Returns a DataFrame joined back to your original input, with one column per variable. Rows with no data are NaN by default.

Output format options:

# Long format (one row per location × variable)df=gm.fetch("locations.csv", variables=[...], output_format="long")
# Drop rows with no data at alldf=gm.fetch("locations.csv", variables=[...], preserve_rows=False)
# Strip extra input columns from outputdf=gm.fetch("locations.csv", variables=[...], preserve_cols=False)
# Long format with grid metadatadf=gm.fetch("locations.csv", variables=[...],
output_format="long", include_metadata=True)
# Extra columns: cell_id, level, aggregated, resolved_timestamp

Map viewer

GeoMetrics ships a browser-based map viewer for exploring what's in the database.

python viewer/server.py
# or with live reload:
uvicorn viewer.server:app --reload --port 8765

Open http://localhost:8765 in your browser.

Features:

  • Browse all sources, variables, and available years from a sidebar
  • Load up to 200,000 cells for the full dataset or just the current viewport ("Load focus area")
  • Circle radius scales with the dataset's spatial resolution
  • 12 colormaps (Viridis, Greens, NDVI Red→Green, Blues, Plasma, Inferno, Magma, Yellow→Red, Spectral, Cividis, Hot, Greys)
  • Adjustable opacity
  • Hover tooltip showing value and coordinates
  • Auto-updating legend with data min/max

The viewer API is also accessible directly:

  • GET /api/sources — all sources with variables and available timestamps
  • GET /api/data?source=&variable=&timestamp=&bbox= — cell data for a selection

API reference

Configuration and setup

GeoMetrics.configure(db_url, gdrive_base, backend) # save config, return instancegm.show_config() # print current config as dictgm.init_db() # create tables + register sourcesgm.register_sources() # register/update catalog in DB

Discovery

GeoMetrics.list_sources() # list[dict] — all catalog entriesGeoMetrics.list_variables(source) # list[dict] — variables for one sourcegm.jobs(status=None) # DataFrame of submitted jobs

Data pipeline

gm.check(locations, variables, lat_col, lon_col, timestamp_col)
# → {"available": [...], "missing": [...]}gm.gee_submit(missing_items, gdrive_folder, batch_size=1000)
# → list[int] of job_idsgm.check_status()
# → {status: count} summary dictgm.ingest(gdrive_folder)
# → {filename: rows_inserted}gm.fetch(locations, variables, lat_col, lon_col, timestamp_col,
output_format="wide", preserve_rows=True, preserve_cols=True,
include_metadata=False)
# → pd.DataFrame

Maintenance

gm.clear(source) # drop all observations for a source (keeps schema)gm.reset_db() # drop all observation tables and reinitialize

Architecture

Spatial grid

GeoMetrics uses HierGP, a recursive rectangular grid with a base cell size of 25 m. The grid has 15 levels:

Standard levelCell sizeTypical use
1525 mFinest — high-res imagery
1450 m
13100 m
12200 m
11400 m
10800 m
91.6 km
......
1~410 kmCoarsest

Each source is registered with a native_level that matches its pixel footprint. Locations are snapped to that level, so two sites that fall in the same 100 m cell share a single database row for that source.

Database schema

sources — one row per dataset (name, native_level, pixel_resolution_m, ...)
variables — one row per band within a source (name, unit)
cells — one row per unique grid cell (cell_id, backend, level)
hiergp_cells — HierGP-specific: x/y integer coordinates for each cell
spatiotemporal_units — one row per (cell × timestamp) pair; RANGE-partitioned by year
obs_{source_name} — wide observation table: unit_pk + one column per variable
jobs — GEE export task registry (status, file paths, row counts)

The observation tables are intentionally denormalized (wide format) so that fetching multiple variables for the same location requires only one join. The spatiotemporal_units table is partitioned by year in PostgreSQL for fast range scans.

Adding a new source

  1. Create geometrics/extraction/my_source.py and define a SOURCE_SPEC dict and a build_ee_image() function following the pattern in geometrics/extraction/ndvi.py.
  2. Import SOURCE_SPEC in geometrics/catalog.py and add it to CATALOG.
  3. Run gm.register_sources() to add the source and its variables to the database.

License

See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

GeoMetrics

A multi-resolution environmental data store backed by Google Earth Engine (GEE), with a built-in map viewer for exploration.

GeoMetrics solves a common research problem: you have a list of field sites and dates, and you need environmental covariates — vegetation indices, land cover, climate, water proximity — for each one. Pulling that data manually from different sources is slow and produces inconsistent spatial representations. GeoMetrics automates the full pipeline: it snaps your locations to a consistent spatial grid, checks what you already have in the database, submits missing extractions to GEE as batch jobs, ingests the results, and serves them back as a clean DataFrame.


How it works

Your locations CSV
│
▼
[snap to grid] ← HierGP rectangular grid, resolution-aware
│
▼
[check store] ← which (cell, variable, year) are already cached?
│
├─── available ─── gm.fetch() ──► DataFrame
│
└─── missing ──── gm.gee_submit() ──► GEE batch export jobs
│
(jobs run in GEE)
│
gm.ingest() ──► PostgreSQL
│
gm.fetch() ──► DataFrame

Spatial grid. All sources share a common HierGP rectangular grid. Each location is snapped to the nearest cell at the source's native resolution, so repeated queries for nearby points are automatically deduplicated and every dataset lines up spatially.

Temporal snapping. Timestamps are resolved to each dataset's temporal granularity — any date in 2023 maps to 2023-01-01 for annual datasets, to the nearest hour for ERA5-Land, and so on.

Backend-agnostic. The grid layer is pluggable. HierGP (rectangular) is the default; H3 (hexagonal) is also supported. The rest of the system — schema, ingest, query — works identically regardless of backend.


Supported datasets

SourceVariable(s)Native resolutionTemporalGEE collection
Landsat_NDVINDVI30 mAnnual medianLandsat 5/7/8/9 (USGS SR)
MODIS_NDVINDVI250 mAnnualMOD13Q1
MODIS_Treecoverpercent_tree_cover, percent_nontree_vegetation, percent_nonvegetated, quality, percent_tree_cover_sd, percent_nonvegetated_sd, cloud250 mAnnualMOD44B
ERA5_Landtemperature_2m, dewpoint_temperature_2m, surface_pressure, u_component_of_wind_10m, v_component_of_wind_10m, surface_thermal_radiation_downwards, surface_net_solar_radiation, total_precipitation~9 kmHourlyERA5-Land (ECMWF)
JRC_Waterwater_distance30 mAnnualJRC Global Surface Water
YALE_UHIyearly_daytime, yearly_nighttime, winter_daytime, winter_nighttime, summer_daytime, summer_nighttime1 kmAnnualYale Urban Heat Island
NLCDlandcover, impervious, impervious_descriptor30 mAnnualNLCD (USGS)

Prerequisites

  • Python 3.10+
  • PostgreSQL 14+ database
  • Google Earth Engine account with project access (ee.Initialize())
  • Google Drive mounted locally (for ingest step only)

Installation

conda env create -f environment.yml
conda activate geometrics
pip install -e .

Setup

1. Configure

Run once. Saves connection settings to ~/.geometrics/config.json.

fromgeometricsimportGeoMetricsgm=GeoMetrics.configure(
db_url="postgresql://user:password@localhost:5432/geometrics",
gdrive_base="/path/to/My Drive",
backend="hiergp",
)

All subsequent calls to GeoMetrics() load the saved config automatically.

2. Initialize the database

gm=GeoMetrics()
gm.init_db()
# Database initialized.# Registered 7 new source(s): ['Landsat_NDVI', 'MODIS_NDVI', ...]

Safe to call on an existing database — creates tables only if they don't exist, and skips sources that are already registered.


Core workflow

Prepare your locations CSV

At minimum, three columns are required. Column names are configurable.

site_id,latitude,longitude,timestamp
WA001,47.6062,-122.3321,2023-07-15T14:30:00
WA002,46.8523,-121.7603,2023-07-15T11:00:00
BR001,-2.4297,-54.7083,2023-08-01T10:00:00

Check availability

importeeee.Initialize()
gm=GeoMetrics()
report=gm.check(
"locations.csv",
variables=["Landsat_NDVI:NDVI", "MODIS_Treecover:percent_tree_cover"],
)
# report["available"] — already in the store# report["missing"] — need to be extracted from GEE

Each row in both lists includes the original coordinates, the resolved grid cell, the requested timestamp, and the resolved timestamp.

Submit missing items to GEE

job_ids=gm.gee_submit(report["missing"], gdrive_folder="extract-01")
# Submitted 2 job(s). Track with: gm.jobs()

GEE runs the export tasks asynchronously. Results are written as CSVs to the specified Drive folder.

Track job status

gm.jobs() # DataFrame of all submitted jobsgm.jobs("RUNNING") # filter by statusgm.check_status() # poll GEE and update local DB

Status values: PENDINGRUNNINGCOMPLETED / FAILED / CANCELLED / EXPIREDINGESTED

Ingest completed results

Once GEE jobs are COMPLETED and the Drive folder has synced locally:

gm.ingest("extract-01")
# Ingesting Landsat_NDVI_batch_001.csv ... inserted 7 row(s), skipped 0 duplicate(s).# Ingesting MODIS_Treecover_batch_001.csv ... inserted 7 row(s), skipped 0 duplicate(s).

Re-running ingest is safe — duplicates are detected and skipped.

Fetch stored data

df=gm.fetch(
"locations.csv",
variables=["Landsat_NDVI:NDVI", "MODIS_Treecover:percent_tree_cover"],
)

Returns a DataFrame joined back to your original input, with one column per variable. Rows with no data are NaN by default.

Output format options:

# Long format (one row per location × variable)df=gm.fetch("locations.csv", variables=[...], output_format="long")
# Drop rows with no data at alldf=gm.fetch("locations.csv", variables=[...], preserve_rows=False)
# Strip extra input columns from outputdf=gm.fetch("locations.csv", variables=[...], preserve_cols=False)
# Long format with grid metadatadf=gm.fetch("locations.csv", variables=[...],
output_format="long", include_metadata=True)
# Extra columns: cell_id, level, aggregated, resolved_timestamp

Map viewer

GeoMetrics ships a browser-based map viewer for exploring what's in the database.

python viewer/server.py
# or with live reload:
uvicorn viewer.server:app --reload --port 8765

Open http://localhost:8765 in your browser.

Features:

  • Browse all sources, variables, and available years from a sidebar
  • Load up to 200,000 cells for the full dataset or just the current viewport ("Load focus area")
  • Circle radius scales with the dataset's spatial resolution
  • 12 colormaps (Viridis, Greens, NDVI Red→Green, Blues, Plasma, Inferno, Magma, Yellow→Red, Spectral, Cividis, Hot, Greys)
  • Adjustable opacity
  • Hover tooltip showing value and coordinates
  • Auto-updating legend with data min/max

The viewer API is also accessible directly:

  • GET /api/sources — all sources with variables and available timestamps
  • GET /api/data?source=&variable=&timestamp=&bbox= — cell data for a selection

API reference

Configuration and setup

GeoMetrics.configure(db_url, gdrive_base, backend) # save config, return instancegm.show_config() # print current config as dictgm.init_db() # create tables + register sourcesgm.register_sources() # register/update catalog in DB

Discovery

GeoMetrics.list_sources() # list[dict] — all catalog entriesGeoMetrics.list_variables(source) # list[dict] — variables for one sourcegm.jobs(status=None) # DataFrame of submitted jobs

Data pipeline

gm.check(locations, variables, lat_col, lon_col, timestamp_col)
# → {"available": [...], "missing": [...]}gm.gee_submit(missing_items, gdrive_folder, batch_size=1000)
# → list[int] of job_idsgm.check_status()
# → {status: count} summary dictgm.ingest(gdrive_folder)
# → {filename: rows_inserted}gm.fetch(locations, variables, lat_col, lon_col, timestamp_col,
output_format="wide", preserve_rows=True, preserve_cols=True,
include_metadata=False)
# → pd.DataFrame

Maintenance

gm.clear(source) # drop all observations for a source (keeps schema)gm.reset_db() # drop all observation tables and reinitialize

Architecture

Spatial grid

GeoMetrics uses HierGP, a recursive rectangular grid with a base cell size of 25 m. The grid has 15 levels:

Standard levelCell sizeTypical use
1525 mFinest — high-res imagery
1450 m
13100 m
12200 m
11400 m
10800 m
91.6 km
......
1~410 kmCoarsest

Each source is registered with a native_level that matches its pixel footprint. Locations are snapped to that level, so two sites that fall in the same 100 m cell share a single database row for that source.

Database schema

sources — one row per dataset (name, native_level, pixel_resolution_m, ...)
variables — one row per band within a source (name, unit)
cells — one row per unique grid cell (cell_id, backend, level)
hiergp_cells — HierGP-specific: x/y integer coordinates for each cell
spatiotemporal_units — one row per (cell × timestamp) pair; RANGE-partitioned by year
obs_{source_name} — wide observation table: unit_pk + one column per variable
jobs — GEE export task registry (status, file paths, row counts)

The observation tables are intentionally denormalized (wide format) so that fetching multiple variables for the same location requires only one join. The spatiotemporal_units table is partitioned by year in PostgreSQL for fast range scans.

Adding a new source

  1. Create geometrics/extraction/my_source.py and define a SOURCE_SPEC dict and a build_ee_image() function following the pattern in geometrics/extraction/ndvi.py.
  2. Import SOURCE_SPEC in geometrics/catalog.py and add it to CATALOG.
  3. Run gm.register_sources() to add the source and its variables to the database.

License

See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

GeoMetrics

A multi-resolution environmental data store backed by Google Earth Engine (GEE), with a built-in map viewer for exploration.

GeoMetrics solves a common research problem: you have a list of field sites and dates, and you need environmental covariates — vegetation indices, land cover, climate, water proximity — for each one. Pulling that data manually from different sources is slow and produces inconsistent spatial representations. GeoMetrics automates the full pipeline: it snaps your locations to a consistent spatial grid, checks what you already have in the database, submits missing extractions to GEE as batch jobs, ingests the results, and serves them back as a clean DataFrame.


How it works

Your locations CSV
│
▼
[snap to grid] ← HierGP rectangular grid, resolution-aware
│
▼
[check store] ← which (cell, variable, year) are already cached?
│
├─── available ─── gm.fetch() ──► DataFrame
│
└─── missing ──── gm.gee_submit() ──► GEE batch export jobs
│
(jobs run in GEE)
│
gm.ingest() ──► PostgreSQL
│
gm.fetch() ──► DataFrame

Spatial grid. All sources share a common HierGP rectangular grid. Each location is snapped to the nearest cell at the source's native resolution, so repeated queries for nearby points are automatically deduplicated and every dataset lines up spatially.

Temporal snapping. Timestamps are resolved to each dataset's temporal granularity — any date in 2023 maps to 2023-01-01 for annual datasets, to the nearest hour for ERA5-Land, and so on.

Backend-agnostic. The grid layer is pluggable. HierGP (rectangular) is the default; H3 (hexagonal) is also supported. The rest of the system — schema, ingest, query — works identically regardless of backend.


Supported datasets

SourceVariable(s)Native resolutionTemporalGEE collection
Landsat_NDVINDVI30 mAnnual medianLandsat 5/7/8/9 (USGS SR)
MODIS_NDVINDVI250 mAnnualMOD13Q1
MODIS_Treecoverpercent_tree_cover, percent_nontree_vegetation, percent_nonvegetated, quality, percent_tree_cover_sd, percent_nonvegetated_sd, cloud250 mAnnualMOD44B
ERA5_Landtemperature_2m, dewpoint_temperature_2m, surface_pressure, u_component_of_wind_10m, v_component_of_wind_10m, surface_thermal_radiation_downwards, surface_net_solar_radiation, total_precipitation~9 kmHourlyERA5-Land (ECMWF)
JRC_Waterwater_distance30 mAnnualJRC Global Surface Water
YALE_UHIyearly_daytime, yearly_nighttime, winter_daytime, winter_nighttime, summer_daytime, summer_nighttime1 kmAnnualYale Urban Heat Island
NLCDlandcover, impervious, impervious_descriptor30 mAnnualNLCD (USGS)

Prerequisites

  • Python 3.10+
  • PostgreSQL 14+ database
  • Google Earth Engine account with project access (ee.Initialize())
  • Google Drive mounted locally (for ingest step only)

Installation

conda env create -f environment.yml
conda activate geometrics
pip install -e .

Setup

1. Configure

Run once. Saves connection settings to ~/.geometrics/config.json.

fromgeometricsimportGeoMetricsgm=GeoMetrics.configure(
db_url="postgresql://user:password@localhost:5432/geometrics",
gdrive_base="/path/to/My Drive",
backend="hiergp",
)

All subsequent calls to GeoMetrics() load the saved config automatically.

2. Initialize the database

gm=GeoMetrics()
gm.init_db()
# Database initialized.# Registered 7 new source(s): ['Landsat_NDVI', 'MODIS_NDVI', ...]

Safe to call on an existing database — creates tables only if they don't exist, and skips sources that are already registered.


Core workflow

Prepare your locations CSV

At minimum, three columns are required. Column names are configurable.

site_id,latitude,longitude,timestamp
WA001,47.6062,-122.3321,2023-07-15T14:30:00
WA002,46.8523,-121.7603,2023-07-15T11:00:00
BR001,-2.4297,-54.7083,2023-08-01T10:00:00

Check availability

importeeee.Initialize()
gm=GeoMetrics()
report=gm.check(
"locations.csv",
variables=["Landsat_NDVI:NDVI", "MODIS_Treecover:percent_tree_cover"],
)
# report["available"] — already in the store# report["missing"] — need to be extracted from GEE

Each row in both lists includes the original coordinates, the resolved grid cell, the requested timestamp, and the resolved timestamp.

Submit missing items to GEE

job_ids=gm.gee_submit(report["missing"], gdrive_folder="extract-01")
# Submitted 2 job(s). Track with: gm.jobs()

GEE runs the export tasks asynchronously. Results are written as CSVs to the specified Drive folder.

Track job status

gm.jobs() # DataFrame of all submitted jobsgm.jobs("RUNNING") # filter by statusgm.check_status() # poll GEE and update local DB

Status values: PENDINGRUNNINGCOMPLETED / FAILED / CANCELLED / EXPIREDINGESTED

Ingest completed results

Once GEE jobs are COMPLETED and the Drive folder has synced locally:

gm.ingest("extract-01")
# Ingesting Landsat_NDVI_batch_001.csv ... inserted 7 row(s), skipped 0 duplicate(s).# Ingesting MODIS_Treecover_batch_001.csv ... inserted 7 row(s), skipped 0 duplicate(s).

Re-running ingest is safe — duplicates are detected and skipped.

Fetch stored data

df=gm.fetch(
"locations.csv",
variables=["Landsat_NDVI:NDVI", "MODIS_Treecover:percent_tree_cover"],
)

Returns a DataFrame joined back to your original input, with one column per variable. Rows with no data are NaN by default.

Output format options:

# Long format (one row per location × variable)df=gm.fetch("locations.csv", variables=[...], output_format="long")
# Drop rows with no data at alldf=gm.fetch("locations.csv", variables=[...], preserve_rows=False)
# Strip extra input columns from outputdf=gm.fetch("locations.csv", variables=[...], preserve_cols=False)
# Long format with grid metadatadf=gm.fetch("locations.csv", variables=[...],
output_format="long", include_metadata=True)
# Extra columns: cell_id, level, aggregated, resolved_timestamp

Map viewer

GeoMetrics ships a browser-based map viewer for exploring what's in the database.

python viewer/server.py
# or with live reload:
uvicorn viewer.server:app --reload --port 8765

Open http://localhost:8765 in your browser.

Features:

  • Browse all sources, variables, and available years from a sidebar
  • Load up to 200,000 cells for the full dataset or just the current viewport ("Load focus area")
  • Circle radius scales with the dataset's spatial resolution
  • 12 colormaps (Viridis, Greens, NDVI Red→Green, Blues, Plasma, Inferno, Magma, Yellow→Red, Spectral, Cividis, Hot, Greys)
  • Adjustable opacity
  • Hover tooltip showing value and coordinates
  • Auto-updating legend with data min/max

The viewer API is also accessible directly:

  • GET /api/sources — all sources with variables and available timestamps
  • GET /api/data?source=&variable=&timestamp=&bbox= — cell data for a selection

API reference

Configuration and setup

GeoMetrics.configure(db_url, gdrive_base, backend) # save config, return instancegm.show_config() # print current config as dictgm.init_db() # create tables + register sourcesgm.register_sources() # register/update catalog in DB

Discovery

GeoMetrics.list_sources() # list[dict] — all catalog entriesGeoMetrics.list_variables(source) # list[dict] — variables for one sourcegm.jobs(status=None) # DataFrame of submitted jobs

Data pipeline

gm.check(locations, variables, lat_col, lon_col, timestamp_col)
# → {"available": [...], "missing": [...]}gm.gee_submit(missing_items, gdrive_folder, batch_size=1000)
# → list[int] of job_idsgm.check_status()
# → {status: count} summary dictgm.ingest(gdrive_folder)
# → {filename: rows_inserted}gm.fetch(locations, variables, lat_col, lon_col, timestamp_col,
output_format="wide", preserve_rows=True, preserve_cols=True,
include_metadata=False)
# → pd.DataFrame

Maintenance

gm.clear(source) # drop all observations for a source (keeps schema)gm.reset_db() # drop all observation tables and reinitialize

Architecture

Spatial grid

GeoMetrics uses HierGP, a recursive rectangular grid with a base cell size of 25 m. The grid has 15 levels:

Standard levelCell sizeTypical use
1525 mFinest — high-res imagery
1450 m
13100 m
12200 m
11400 m
10800 m
91.6 km
......
1~410 kmCoarsest

Each source is registered with a native_level that matches its pixel footprint. Locations are snapped to that level, so two sites that fall in the same 100 m cell share a single database row for that source.

Database schema

sources — one row per dataset (name, native_level, pixel_resolution_m, ...)
variables — one row per band within a source (name, unit)
cells — one row per unique grid cell (cell_id, backend, level)
hiergp_cells — HierGP-specific: x/y integer coordinates for each cell
spatiotemporal_units — one row per (cell × timestamp) pair; RANGE-partitioned by year
obs_{source_name} — wide observation table: unit_pk + one column per variable
jobs — GEE export task registry (status, file paths, row counts)

The observation tables are intentionally denormalized (wide format) so that fetching multiple variables for the same location requires only one join. The spatiotemporal_units table is partitioned by year in PostgreSQL for fast range scans.

Adding a new source

  1. Create geometrics/extraction/my_source.py and define a SOURCE_SPEC dict and a build_ee_image() function following the pattern in geometrics/extraction/ndvi.py.
  2. Import SOURCE_SPEC in geometrics/catalog.py and add it to CATALOG.
  3. Run gm.register_sources() to add the source and its variables to the database.

License

See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

GeoMetrics

A multi-resolution environmental data store backed by Google Earth Engine (GEE), with a built-in map viewer for exploration.

GeoMetrics solves a common research problem: you have a list of field sites and dates, and you need environmental covariates — vegetation indices, land cover, climate, water proximity — for each one. Pulling that data manually from different sources is slow and produces inconsistent spatial representations. GeoMetrics automates the full pipeline: it snaps your locations to a consistent spatial grid, checks what you already have in the database, submits missing extractions to GEE as batch jobs, ingests the results, and serves them back as a clean DataFrame.


How it works

Your locations CSV
│
▼
[snap to grid] ← HierGP rectangular grid, resolution-aware
│
▼
[check store] ← which (cell, variable, year) are already cached?
│
├─── available ─── gm.fetch() ──► DataFrame
│
└─── missing ──── gm.gee_submit() ──► GEE batch export jobs
│
(jobs run in GEE)
│
gm.ingest() ──► PostgreSQL
│
gm.fetch() ──► DataFrame

Spatial grid. All sources share a common HierGP rectangular grid. Each location is snapped to the nearest cell at the source's native resolution, so repeated queries for nearby points are automatically deduplicated and every dataset lines up spatially.

Temporal snapping. Timestamps are resolved to each dataset's temporal granularity — any date in 2023 maps to 2023-01-01 for annual datasets, to the nearest hour for ERA5-Land, and so on.

Backend-agnostic. The grid layer is pluggable. HierGP (rectangular) is the default; H3 (hexagonal) is also supported. The rest of the system — schema, ingest, query — works identically regardless of backend.


Supported datasets

SourceVariable(s)Native resolutionTemporalGEE collection
Landsat_NDVINDVI30 mAnnual medianLandsat 5/7/8/9 (USGS SR)
MODIS_NDVINDVI250 mAnnualMOD13Q1
MODIS_Treecoverpercent_tree_cover, percent_nontree_vegetation, percent_nonvegetated, quality, percent_tree_cover_sd, percent_nonvegetated_sd, cloud250 mAnnualMOD44B
ERA5_Landtemperature_2m, dewpoint_temperature_2m, surface_pressure, u_component_of_wind_10m, v_component_of_wind_10m, surface_thermal_radiation_downwards, surface_net_solar_radiation, total_precipitation~9 kmHourlyERA5-Land (ECMWF)
JRC_Waterwater_distance30 mAnnualJRC Global Surface Water
YALE_UHIyearly_daytime, yearly_nighttime, winter_daytime, winter_nighttime, summer_daytime, summer_nighttime1 kmAnnualYale Urban Heat Island
NLCDlandcover, impervious, impervious_descriptor30 mAnnualNLCD (USGS)

Prerequisites

  • Python 3.10+
  • PostgreSQL 14+ database
  • Google Earth Engine account with project access (ee.Initialize())
  • Google Drive mounted locally (for ingest step only)

Installation

conda env create -f environment.yml
conda activate geometrics
pip install -e .

Setup

1. Configure

Run once. Saves connection settings to ~/.geometrics/config.json.

fromgeometricsimportGeoMetricsgm=GeoMetrics.configure(
db_url="postgresql://user:password@localhost:5432/geometrics",
gdrive_base="/path/to/My Drive",
backend="hiergp",
)

All subsequent calls to GeoMetrics() load the saved config automatically.

2. Initialize the database

gm=GeoMetrics()
gm.init_db()
# Database initialized.# Registered 7 new source(s): ['Landsat_NDVI', 'MODIS_NDVI', ...]

Safe to call on an existing database — creates tables only if they don't exist, and skips sources that are already registered.


Core workflow

Prepare your locations CSV

At minimum, three columns are required. Column names are configurable.

site_id,latitude,longitude,timestamp
WA001,47.6062,-122.3321,2023-07-15T14:30:00
WA002,46.8523,-121.7603,2023-07-15T11:00:00
BR001,-2.4297,-54.7083,2023-08-01T10:00:00

Check availability

importeeee.Initialize()
gm=GeoMetrics()
report=gm.check(
"locations.csv",
variables=["Landsat_NDVI:NDVI", "MODIS_Treecover:percent_tree_cover"],
)
# report["available"] — already in the store# report["missing"] — need to be extracted from GEE

Each row in both lists includes the original coordinates, the resolved grid cell, the requested timestamp, and the resolved timestamp.

Submit missing items to GEE

job_ids=gm.gee_submit(report["missing"], gdrive_folder="extract-01")
# Submitted 2 job(s). Track with: gm.jobs()

GEE runs the export tasks asynchronously. Results are written as CSVs to the specified Drive folder.

Track job status

gm.jobs() # DataFrame of all submitted jobsgm.jobs("RUNNING") # filter by statusgm.check_status() # poll GEE and update local DB

Status values: PENDINGRUNNINGCOMPLETED / FAILED / CANCELLED / EXPIREDINGESTED

Ingest completed results

Once GEE jobs are COMPLETED and the Drive folder has synced locally:

gm.ingest("extract-01")
# Ingesting Landsat_NDVI_batch_001.csv ... inserted 7 row(s), skipped 0 duplicate(s).# Ingesting MODIS_Treecover_batch_001.csv ... inserted 7 row(s), skipped 0 duplicate(s).

Re-running ingest is safe — duplicates are detected and skipped.

Fetch stored data

df=gm.fetch(
"locations.csv",
variables=["Landsat_NDVI:NDVI", "MODIS_Treecover:percent_tree_cover"],
)

Returns a DataFrame joined back to your original input, with one column per variable. Rows with no data are NaN by default.

Output format options:

# Long format (one row per location × variable)df=gm.fetch("locations.csv", variables=[...], output_format="long")
# Drop rows with no data at alldf=gm.fetch("locations.csv", variables=[...], preserve_rows=False)
# Strip extra input columns from outputdf=gm.fetch("locations.csv", variables=[...], preserve_cols=False)
# Long format with grid metadatadf=gm.fetch("locations.csv", variables=[...],
output_format="long", include_metadata=True)
# Extra columns: cell_id, level, aggregated, resolved_timestamp

Map viewer

GeoMetrics ships a browser-based map viewer for exploring what's in the database.

python viewer/server.py
# or with live reload:
uvicorn viewer.server:app --reload --port 8765

Open http://localhost:8765 in your browser.

Features:

  • Browse all sources, variables, and available years from a sidebar
  • Load up to 200,000 cells for the full dataset or just the current viewport ("Load focus area")
  • Circle radius scales with the dataset's spatial resolution
  • 12 colormaps (Viridis, Greens, NDVI Red→Green, Blues, Plasma, Inferno, Magma, Yellow→Red, Spectral, Cividis, Hot, Greys)
  • Adjustable opacity
  • Hover tooltip showing value and coordinates
  • Auto-updating legend with data min/max

The viewer API is also accessible directly:

  • GET /api/sources — all sources with variables and available timestamps
  • GET /api/data?source=&variable=&timestamp=&bbox= — cell data for a selection

API reference

Configuration and setup

GeoMetrics.configure(db_url, gdrive_base, backend) # save config, return instancegm.show_config() # print current config as dictgm.init_db() # create tables + register sourcesgm.register_sources() # register/update catalog in DB

Discovery

GeoMetrics.list_sources() # list[dict] — all catalog entriesGeoMetrics.list_variables(source) # list[dict] — variables for one sourcegm.jobs(status=None) # DataFrame of submitted jobs

Data pipeline

gm.check(locations, variables, lat_col, lon_col, timestamp_col)
# → {"available": [...], "missing": [...]}gm.gee_submit(missing_items, gdrive_folder, batch_size=1000)
# → list[int] of job_idsgm.check_status()
# → {status: count} summary dictgm.ingest(gdrive_folder)
# → {filename: rows_inserted}gm.fetch(locations, variables, lat_col, lon_col, timestamp_col,
output_format="wide", preserve_rows=True, preserve_cols=True,
include_metadata=False)
# → pd.DataFrame

Maintenance

gm.clear(source) # drop all observations for a source (keeps schema)gm.reset_db() # drop all observation tables and reinitialize

Architecture

Spatial grid

GeoMetrics uses HierGP, a recursive rectangular grid with a base cell size of 25 m. The grid has 15 levels:

Standard levelCell sizeTypical use
1525 mFinest — high-res imagery
1450 m
13100 m
12200 m
11400 m
10800 m
91.6 km
......
1~410 kmCoarsest

Each source is registered with a native_level that matches its pixel footprint. Locations are snapped to that level, so two sites that fall in the same 100 m cell share a single database row for that source.

Database schema

sources — one row per dataset (name, native_level, pixel_resolution_m, ...)
variables — one row per band within a source (name, unit)
cells — one row per unique grid cell (cell_id, backend, level)
hiergp_cells — HierGP-specific: x/y integer coordinates for each cell
spatiotemporal_units — one row per (cell × timestamp) pair; RANGE-partitioned by year
obs_{source_name} — wide observation table: unit_pk + one column per variable
jobs — GEE export task registry (status, file paths, row counts)

The observation tables are intentionally denormalized (wide format) so that fetching multiple variables for the same location requires only one join. The spatiotemporal_units table is partitioned by year in PostgreSQL for fast range scans.

Adding a new source

  1. Create geometrics/extraction/my_source.py and define a SOURCE_SPEC dict and a build_ee_image() function following the pattern in geometrics/extraction/ndvi.py.
  2. Import SOURCE_SPEC in geometrics/catalog.py and add it to CATALOG.
  3. Run gm.register_sources() to add the source and its variables to the database.

License

See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

GeoMetrics

A multi-resolution environmental data store backed by Google Earth Engine (GEE), with a built-in map viewer for exploration.

GeoMetrics solves a common research problem: you have a list of field sites and dates, and you need environmental covariates — vegetation indices, land cover, climate, water proximity — for each one. Pulling that data manually from different sources is slow and produces inconsistent spatial representations. GeoMetrics automates the full pipeline: it snaps your locations to a consistent spatial grid, checks what you already have in the database, submits missing extractions to GEE as batch jobs, ingests the results, and serves them back as a clean DataFrame.


How it works

Your locations CSV
│
▼
[snap to grid] ← HierGP rectangular grid, resolution-aware
│
▼
[check store] ← which (cell, variable, year) are already cached?
│
├─── available ─── gm.fetch() ──► DataFrame
│
└─── missing ──── gm.gee_submit() ──► GEE batch export jobs
│
(jobs run in GEE)
│
gm.ingest() ──► PostgreSQL
│
gm.fetch() ──► DataFrame

Spatial grid. All sources share a common HierGP rectangular grid. Each location is snapped to the nearest cell at the source's native resolution, so repeated queries for nearby points are automatically deduplicated and every dataset lines up spatially.

Temporal snapping. Timestamps are resolved to each dataset's temporal granularity — any date in 2023 maps to 2023-01-01 for annual datasets, to the nearest hour for ERA5-Land, and so on.

Backend-agnostic. The grid layer is pluggable. HierGP (rectangular) is the default; H3 (hexagonal) is also supported. The rest of the system — schema, ingest, query — works identically regardless of backend.


Supported datasets

SourceVariable(s)Native resolutionTemporalGEE collection
Landsat_NDVINDVI30 mAnnual medianLandsat 5/7/8/9 (USGS SR)
MODIS_NDVINDVI250 mAnnualMOD13Q1
MODIS_Treecoverpercent_tree_cover, percent_nontree_vegetation, percent_nonvegetated, quality, percent_tree_cover_sd, percent_nonvegetated_sd, cloud250 mAnnualMOD44B
ERA5_Landtemperature_2m, dewpoint_temperature_2m, surface_pressure, u_component_of_wind_10m, v_component_of_wind_10m, surface_thermal_radiation_downwards, surface_net_solar_radiation, total_precipitation~9 kmHourlyERA5-Land (ECMWF)
JRC_Waterwater_distance30 mAnnualJRC Global Surface Water
YALE_UHIyearly_daytime, yearly_nighttime, winter_daytime, winter_nighttime, summer_daytime, summer_nighttime1 kmAnnualYale Urban Heat Island
NLCDlandcover, impervious, impervious_descriptor30 mAnnualNLCD (USGS)

Prerequisites

  • Python 3.10+
  • PostgreSQL 14+ database
  • Google Earth Engine account with project access (ee.Initialize())
  • Google Drive mounted locally (for ingest step only)

Installation

conda env create -f environment.yml
conda activate geometrics
pip install -e .

Setup

1. Configure

Run once. Saves connection settings to ~/.geometrics/config.json.

fromgeometricsimportGeoMetricsgm=GeoMetrics.configure(
db_url="postgresql://user:password@localhost:5432/geometrics",
gdrive_base="/path/to/My Drive",
backend="hiergp",
)

All subsequent calls to GeoMetrics() load the saved config automatically.

2. Initialize the database

gm=GeoMetrics()
gm.init_db()
# Database initialized.# Registered 7 new source(s): ['Landsat_NDVI', 'MODIS_NDVI', ...]

Safe to call on an existing database — creates tables only if they don't exist, and skips sources that are already registered.


Core workflow

Prepare your locations CSV

At minimum, three columns are required. Column names are configurable.

site_id,latitude,longitude,timestamp
WA001,47.6062,-122.3321,2023-07-15T14:30:00
WA002,46.8523,-121.7603,2023-07-15T11:00:00
BR001,-2.4297,-54.7083,2023-08-01T10:00:00

Check availability

importeeee.Initialize()
gm=GeoMetrics()
report=gm.check(
"locations.csv",
variables=["Landsat_NDVI:NDVI", "MODIS_Treecover:percent_tree_cover"],
)
# report["available"] — already in the store# report["missing"] — need to be extracted from GEE

Each row in both lists includes the original coordinates, the resolved grid cell, the requested timestamp, and the resolved timestamp.

Submit missing items to GEE

job_ids=gm.gee_submit(report["missing"], gdrive_folder="extract-01")
# Submitted 2 job(s). Track with: gm.jobs()

GEE runs the export tasks asynchronously. Results are written as CSVs to the specified Drive folder.

Track job status

gm.jobs() # DataFrame of all submitted jobsgm.jobs("RUNNING") # filter by statusgm.check_status() # poll GEE and update local DB

Status values: PENDINGRUNNINGCOMPLETED / FAILED / CANCELLED / EXPIREDINGESTED

Ingest completed results

Once GEE jobs are COMPLETED and the Drive folder has synced locally:

gm.ingest("extract-01")
# Ingesting Landsat_NDVI_batch_001.csv ... inserted 7 row(s), skipped 0 duplicate(s).# Ingesting MODIS_Treecover_batch_001.csv ... inserted 7 row(s), skipped 0 duplicate(s).

Re-running ingest is safe — duplicates are detected and skipped.

Fetch stored data

df=gm.fetch(
"locations.csv",
variables=["Landsat_NDVI:NDVI", "MODIS_Treecover:percent_tree_cover"],
)

Returns a DataFrame joined back to your original input, with one column per variable. Rows with no data are NaN by default.

Output format options:

# Long format (one row per location × variable)df=gm.fetch("locations.csv", variables=[...], output_format="long")
# Drop rows with no data at alldf=gm.fetch("locations.csv", variables=[...], preserve_rows=False)
# Strip extra input columns from outputdf=gm.fetch("locations.csv", variables=[...], preserve_cols=False)
# Long format with grid metadatadf=gm.fetch("locations.csv", variables=[...],
output_format="long", include_metadata=True)
# Extra columns: cell_id, level, aggregated, resolved_timestamp

Map viewer

GeoMetrics ships a browser-based map viewer for exploring what's in the database.

python viewer/server.py
# or with live reload:
uvicorn viewer.server:app --reload --port 8765

Open http://localhost:8765 in your browser.

Features:

  • Browse all sources, variables, and available years from a sidebar
  • Load up to 200,000 cells for the full dataset or just the current viewport ("Load focus area")
  • Circle radius scales with the dataset's spatial resolution
  • 12 colormaps (Viridis, Greens, NDVI Red→Green, Blues, Plasma, Inferno, Magma, Yellow→Red, Spectral, Cividis, Hot, Greys)
  • Adjustable opacity
  • Hover tooltip showing value and coordinates
  • Auto-updating legend with data min/max

The viewer API is also accessible directly:

  • GET /api/sources — all sources with variables and available timestamps
  • GET /api/data?source=&variable=&timestamp=&bbox= — cell data for a selection

API reference

Configuration and setup

GeoMetrics.configure(db_url, gdrive_base, backend) # save config, return instancegm.show_config() # print current config as dictgm.init_db() # create tables + register sourcesgm.register_sources() # register/update catalog in DB

Discovery

GeoMetrics.list_sources() # list[dict] — all catalog entriesGeoMetrics.list_variables(source) # list[dict] — variables for one sourcegm.jobs(status=None) # DataFrame of submitted jobs

Data pipeline

gm.check(locations, variables, lat_col, lon_col, timestamp_col)
# → {"available": [...], "missing": [...]}gm.gee_submit(missing_items, gdrive_folder, batch_size=1000)
# → list[int] of job_idsgm.check_status()
# → {status: count} summary dictgm.ingest(gdrive_folder)
# → {filename: rows_inserted}gm.fetch(locations, variables, lat_col, lon_col, timestamp_col,
output_format="wide", preserve_rows=True, preserve_cols=True,
include_metadata=False)
# → pd.DataFrame

Maintenance

gm.clear(source) # drop all observations for a source (keeps schema)gm.reset_db() # drop all observation tables and reinitialize

Architecture

Spatial grid

GeoMetrics uses HierGP, a recursive rectangular grid with a base cell size of 25 m. The grid has 15 levels:

Standard levelCell sizeTypical use
1525 mFinest — high-res imagery
1450 m
13100 m
12200 m
11400 m
10800 m
91.6 km
......
1~410 kmCoarsest

Each source is registered with a native_level that matches its pixel footprint. Locations are snapped to that level, so two sites that fall in the same 100 m cell share a single database row for that source.

Database schema

sources — one row per dataset (name, native_level, pixel_resolution_m, ...)
variables — one row per band within a source (name, unit)
cells — one row per unique grid cell (cell_id, backend, level)
hiergp_cells — HierGP-specific: x/y integer coordinates for each cell
spatiotemporal_units — one row per (cell × timestamp) pair; RANGE-partitioned by year
obs_{source_name} — wide observation table: unit_pk + one column per variable
jobs — GEE export task registry (status, file paths, row counts)

The observation tables are intentionally denormalized (wide format) so that fetching multiple variables for the same location requires only one join. The spatiotemporal_units table is partitioned by year in PostgreSQL for fast range scans.

Adding a new source

  1. Create geometrics/extraction/my_source.py and define a SOURCE_SPEC dict and a build_ee_image() function following the pattern in geometrics/extraction/ndvi.py.
  2. Import SOURCE_SPEC in geometrics/catalog.py and add it to CATALOG.
  3. Run gm.register_sources() to add the source and its variables to the database.

License

See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages