The Survey object#

Most of airbornegeo’s functions take a dataframe plus the names of the columns they need (line_column, distance_column, time_column, …), which quickly gets repetitive. The airbornegeo.Survey class stores the dataframe together with those column names, a coordinate reference system, free-form metadata, and the survey’s intersection table, and exposes most package functions as methods which fill the column names in for you.

Two kinds of methods:

  • Methods which add or alter a few columns (e.g. along_track_distance, filter_line, the crossover pipeline) operate in place on survey.data and return the survey itself, so calls can be chained.

  • Methods which replace the whole dataframe (block_reduce, resample, resample_as) return a new Survey, leaving the original untouched.

Summary statistics (bounding region, line counts, line lengths, spacings, and orientations) are computed lazily the first time you access them and cached afterwards.

The functional API is unchanged — every method simply wraps the function of the same name.

[249]:
%load_ext autoreload
%autoreload 2

import logging

import numpy as np
import pandas as pd

import airbornegeo

# setup logging to get some additional info from the airbornegeo functions
logging.getLogger("airbornegeo").setLevel("INFO")
logging.basicConfig()
The autoreload extension is already loaded. To reload it, use:
  %reload_ext autoreload

Load data#

This is a subset of the BAS AGAP aeromagnetic survey over Antarctica’s Gamburtsev Subglacial Mountains, already processed and block-reduced (see the notebooks AGAP_magnetic_survey and processing_AGAP_magnetics). We mark the ~E-W survey lines with line_type=0 and the ~N-S tie lines with line_type=1.

[250]:
df = pd.read_csv("data/AGAP_magnetic_survey_processed_blocked.csv")
df = df[["easting", "northing", "height", "line", "unixtime", "mag"]]
df = df.dropna(subset="mag")

# define survey lines (0) vs tie lines (1)
df["line_type"] = np.where(df.line >= 142, 1, 0)

# for speed, limit the number of lines and points
df = df[~df.line.between(133, 142)]
df = df[~df.line.between(168, 176)]
lines_to_keep = np.concat(
    [df[df.line_type == 0].line.unique()[::5], df[df.line_type == 1].line.unique()[::2]]
)
df = df[df.line.isin(lines_to_keep)]
df = df[::5]

df.head()
[250]:
easting northing height line unixtime mag line_type
0 621152.853769 159064.167598 4112.55 1 1.229500e+09 -34.245 0
5 622163.262927 159216.562790 4133.80 1 1.229500e+09 -47.965 0
10 623146.956750 159388.532824 4134.15 1 1.229500e+09 -58.360 0
15 624125.472066 159547.768230 4136.50 1 1.229500e+09 -66.160 0
20 625101.022414 159720.791113 4143.60 1 1.229500e+09 -70.900 0

Create a Survey#

We pass the dataframe once, along with the names of its structural columns, the coordinate reference system, and any metadata we want to keep with it. Only the projected coordinates default to a name (easting/northing, which the rest of the package requires); every other column must be named explicitly, since naming conventions vary too much between surveys to guess. Name them once here and every method will use them — a method needing a binding you did not set raises a ValueError telling you which one.

The repr shows the column bindings and which statistics have been computed so far — none yet, since they are lazy.

[251]:
survey = airbornegeo.Survey(
    df,
    line_column="line",
    line_type_column="line_type",
    time_column="unixtime",
    height_column="height",
    crs="EPSG:3031",
    metadata={"survey": "AGAP aeromagnetics", "source": "BAS", "units": "nT"},
)
survey
[251]:
airbornegeo.Survey 17,423 rows × 7 columns
Column attributes
lineline
line_typeline_type
distancenot set
timeunixtime
heightheight
latitudenot set
longitudenot set
CRS & metadata3 metadata entries
crsEPSG:3031
surveyAGAP aeromagnetics
sourceBAS
unitsnT
Statistics0/10 computed — access an attribute or call .describe()
regionnot computed
line_countsnot computed
line_lengthsnot computed
total_lengthnot computed
median_line_lengthsnot computed
line_azimuthsnot computed
mean_line_azimuthsnot computed
median_line_spacingsnot computed
line_point_spacingsnot computed
median_point_spacingsnot computed
IntersectionsNone

None — call create_intersection_table()

Datafirst 5 rows
easting northing height line unixtime mag line_type
0 621152.853769 159064.167598 4112.55 1 1.229500e+09 -34.245 0
5 622163.262927 159216.562790 4133.80 1 1.229500e+09 -47.965 0
10 623146.956750 159388.532824 4134.15 1 1.229500e+09 -58.360 0
15 624125.472066 159547.768230 4136.50 1 1.229500e+09 -66.160 0
20 625101.022414 159720.791113 4143.60 1 1.229500e+09 -70.900 0

Summary statistics#

Each statistic is computed on first access and cached. Accessing them again is free, and the cache is cleared automatically whenever a method changes the survey’s geometry (e.g. re-splitting lines or block-reducing).

[252]:
survey.region
[252]:
(406015.150105372, 1636043.0769911269, -451144.7242889665, 607524.3407102434)
[253]:
survey.line_counts
[253]:
{'total': 55, 'flight': 27, 'tie': 28}

Line spacings are estimated per line type with airbornegeo.median_line_spacing, and line orientations are compass azimuths (degrees clockwise from north, in the axial 0–180° range) of each line’s minimum rotated bounding box.

[254]:
survey.median_line_spacings
[254]:
{'flight': 24370.825789257113, 'tie': 65923.78618791168}
[255]:
survey.mean_line_azimuths
[255]:
{'flight': 79.95588628453424, 'tie': 169.96912559755293}

describe() computes everything at once and returns a formatted summary.

[256]:
print(survey.describe())
airbornegeo.Survey (17,423 rows x 7 cols)
  crs: EPSG:3031
  metadata: {'survey': 'AGAP aeromagnetics', 'source': 'BAS', 'units': 'nT'}
  region (W, E, S, N): 406,015.2, 1,636,043.1, -451,144.7, 607,524.3
  line counts: total: 55, flight: 27, tie: 28
  total length: 17,405,082.7
  median line lengths: flight: 312,632.5, tie: 246,316.5
  mean line azimuths: flight: 80.0, tie: 170.0
  median line spacings: flight: 24,370.8, tie: 65,923.8
  median point spacings: flight: 1,000.0, tie: 999.6
[257]:
survey
[257]:
airbornegeo.Survey 17,423 rows × 7 columns
Column attributes
lineline
line_typeline_type
distancenot set
timeunixtime
heightheight
latitudenot set
longitudenot set
CRS & metadata3 metadata entries
crsEPSG:3031
surveyAGAP aeromagnetics
sourceBAS
unitsnT
Statisticsall computed
region406,015.2, 1,636,043.1, -451,144.7, 607,524.3
line_countstotal: 55, flight: 27, tie: 28
line_lengths55 lines | min 27,901.8 | median 282,018.7 | max 744,780.9
total_length17,405,082.7
median_line_lengthsflight: 312,632.5, tie: 246,316.5
line_azimuths55 lines | min 79.8 | median 169.6 | max 170.4
mean_line_azimuthsflight: 80.0, tie: 170.0
median_line_spacingsflight: 24,370.8, tie: 65,923.8
line_point_spacings55 lines | min 993.1 | median 999.7 | max 1,004.5
median_point_spacingsflight: 1,000.0, tie: 999.6
IntersectionsNone

None — call create_intersection_table()

Datafirst 5 rows
easting northing height line unixtime mag line_type
0 621152.853769 159064.167598 4112.55 1 1.229500e+09 -34.245 0
5 622163.262927 159216.562790 4133.80 1 1.229500e+09 -47.965 0
10 623146.956750 159388.532824 4134.15 1 1.229500e+09 -58.360 0
15 624125.472066 159547.768230 4136.50 1 1.229500e+09 -66.160 0
20 625101.022414 159720.791113 4143.60 1 1.229500e+09 -70.900 0

Calling a class method#

Functions such as along_track_distance require parameters such as groupby_column to inform the function which column of the dataframe denotes separate groups (lines). When we call these functions as class methods, those parameters which are stored as class column attributes,are automatically passed to the function. And in the below example, the resulting new column name along_track_dist is added as the distance column so other functions when required distances know when column to use.

[258]:
survey.along_track_distance("along_track_dist")
survey
[258]:
airbornegeo.Survey 17,423 rows × 8 columns
Column attributes
lineline
line_typeline_type
distancealong_track_dist
timeunixtime
heightheight
latitudenot set
longitudenot set
CRS & metadata3 metadata entries
crsEPSG:3031
surveyAGAP aeromagnetics
sourceBAS
unitsnT
Statisticsall computed
region406,015.2, 1,636,043.1, -451,144.7, 607,524.3
line_countstotal: 55, flight: 27, tie: 28
line_lengths55 lines | min 27,901.8 | median 282,018.7 | max 744,780.9
total_length17,405,082.7
median_line_lengthsflight: 312,632.5, tie: 246,316.5
line_azimuths55 lines | min 79.8 | median 169.6 | max 170.4
mean_line_azimuthsflight: 80.0, tie: 170.0
median_line_spacingsflight: 24,370.8, tie: 65,923.8
line_point_spacings55 lines | min 993.1 | median 999.7 | max 1,004.5
median_point_spacingsflight: 1,000.0, tie: 999.6
IntersectionsNone

None — call create_intersection_table()

Datafirst 5 rows
easting northing height line unixtime mag line_type along_track_dist
0 621152.853769 159064.167598 4112.55 1 1.229500e+09 -34.245 0 0.000000
5 622163.262927 159216.562790 4133.80 1 1.229500e+09 -47.965 0 1021.837051
10 623146.956750 159388.532824 4134.15 1 1.229500e+09 -58.360 0 2020.449705
15 624125.472066 159547.768230 4136.50 1 1.229500e+09 -66.160 0 3011.836682
20 625101.022414 159720.791113 4143.60 1 1.229500e+09 -70.900 0 4002.611833

Chained processing#

Methods which add columns mutate survey.data in place and return the survey, so processing steps chain naturally. Here we compute the distance along each line (written to the survey’s distance_column) and low-pass filter the magnetic data along each line (written to mag_filtered by default).

[259]:
survey.ground_speed().filter_line(
    data_column="ground_speed",
    filter_width=5e3,
)

survey.data[["line", "ground_speed", "ground_speed", "ground_speed_filtered"]].head()
[259]:
line ground_speed ground_speed ground_speed_filtered
0 1 53.780897 53.780897 54.306611
5 1 54.652629 54.652629 54.868767
10 1 56.072838 56.072838 56.072502
15 1 57.477601 57.477601 57.302141
20 1 58.299015 58.299015 58.254057

Frame-replacing methods return a new Survey#

block_reduce, resample and resample_as produce an entirely new dataframe, so instead of overwriting the survey they return a new Survey carrying over the column bindings, CRS and metadata — the original survey is retained.

[260]:
blocked = survey.block_reduce(np.median, spacing=1e3)

print(f"original: {len(survey.data)} rows, blocked: {len(blocked.data)} rows")
blocked
original: 17423 rows, blocked: 14978 rows
[260]:
airbornegeo.Survey 14,978 rows × 10 columns
Column attributes
lineline
line_typeline_type
distancealong_track_dist
timeunixtime
heightheight
latitudenot set
longitudenot set
CRS & metadata3 metadata entries
crsEPSG:3031
surveyAGAP aeromagnetics
sourceBAS
unitsnT
Statistics0/10 computed — access an attribute or call .describe()
regionnot computed
line_countsnot computed
line_lengthsnot computed
total_lengthnot computed
median_line_lengthsnot computed
line_azimuthsnot computed
mean_line_azimuthsnot computed
median_line_spacingsnot computed
line_point_spacingsnot computed
median_point_spacingsnot computed
IntersectionsNone

None — call create_intersection_table()

Datafirst 5 rows
along_track_dist easting northing height line unixtime mag line_type ground_speed ground_speed_filtered
0 0.000000 621152.853769 159064.167598 4112.55 1 1.229500e+09 -34.245 0.0 53.780897 54.306611
1 1021.837051 622163.262927 159216.562790 4133.80 1 1.229500e+09 -47.965 0.0 54.652629 54.868767
2 2020.449705 623146.956750 159388.532824 4134.15 1 1.229500e+09 -58.360 0.0 56.072838 56.072502
3 3011.836682 624125.472066 159547.768230 4136.50 1 1.229500e+09 -66.160 0.0 57.477601 57.302141
4 4002.611833 625101.022414 159720.791113 4143.60 1 1.229500e+09 -70.900 0.0 58.299015 58.254057

Plotting#

The plot() method plots a simple overview map of the survey data. You can choose which data column to color the points by.

[261]:
survey.plot(
    color_by="line_type",
    categorical=True,
    label_categories=True,
)
[261]:
<Axes: title={'center': 'Survey: AGAP aeromagnetics'}, xlabel='easting', ylabel='northing'>
_images/survey_object_22_1.png
[262]:
survey.plot(color_by="mag")
[262]:
<Axes: title={'center': 'Survey: AGAP aeromagnetics'}, xlabel='easting', ylabel='northing'>
_images/survey_object_23_1.png

To plot a profile of a line, use plot_profiles and pass the line number and the column you want on the y axis.

[274]:
ax = survey.plot_profiles(
    86,
    y="mag",
)
_images/survey_object_25_0.png

Crossovers and levelling#

The crossover pipeline stores the intersection table on the survey as survey.intersections and updates it, together with survey.data, as each chained step runs. Here we find the intersections between the survey lines and the tie lines (the groups method, using the line_type column), add them to the dataframe, interpolate the magnetic data onto them, and calculate the crossover errors.

(The network method finds intersections between all line pairs instead; note that pairs of lines crossing more than once must be deduplicated before the rest of the pipeline — see the crossovers notebooks for details.)

[264]:
survey.create_intersection_table(
    method="groups",
    buffer_dist=500,
    cutoff_dist=1e3,
)
survey.intersections.head()
INFO:airbornegeo:found 132 intersections
INFO:airbornegeo:removed 0 intersection point(s) with a max distance greater than 1.0 km
[264]:
line1 line2 line1_along_dist line2_along_dist is_buffered is_proximity geometry easting northing max_dist
0 1 143 545635.514241 137755.398042 False False POINT (1158154 254086) 1158154.0 254086.0 396.438502
1 1 145 612039.124020 146343.411164 False False POINT (1223519 265688) 1223519.0 265688.0 393.967055
2 1 147 678454.809205 153987.235731 False False POINT (1288902 277251) 1288902.0 277251.0 312.700690
3 1 151 49724.770644 117174.975042 False False POINT (670005 167672) 670005.0 167672.0 283.606212
4 1 153 82657.056363 122714.949843 True False POINT (702428 173420) 702428.0 173420.0 331.990443
[265]:
survey.add_intersections().interpolate_intersections(
    to_interp="mag"
).calculate_crossover_errors(data_col="mag")

survey.intersections.crossover_error_0.describe()
[265]:
count    131.000000
mean      10.590973
std       40.155999
min      -78.528582
25%      -23.436373
50%       14.900799
75%       41.849618
max      102.565149
Name: crossover_error_0, dtype: float64
[266]:
survey.plot_intersections(
    "crossover_error_0",
)
[266]:
<Axes: title={'center': 'Crossover errors: crossover_error_0'}, xlabel='easting', ylabel='northing'>
_images/survey_object_29_1.png

Now we can level the survey using those crossover errors. The network levelling method updates both survey.data (adding the mag_levelled column) and survey.intersections (appending a new crossover_error_N column per iteration).

[267]:
survey.crossover_network_levelling(
    data_col="mag",
    levelled_col="mag_levelled",
    degree=1,
    max_iterations=5,
)
WARNING:airbornegeo:
Levelling terminated after 5 iterations with RMS of levelling correction of 7.63 because maximum number of iterations (5) reached.
_images/survey_object_31_2.png
[267]:
airbornegeo.Survey 17,685 rows × 14 columns
Column attributes
lineline
line_typeline_type
distancealong_track_dist
timeunixtime
heightheight
latitudenot set
longitudenot set
CRS & metadata3 metadata entries
crsEPSG:3031
surveyAGAP aeromagnetics
sourceBAS
unitsnT
Statistics0/10 computed — access an attribute or call .describe()
regionnot computed
line_countsnot computed
line_lengthsnot computed
total_lengthnot computed
median_line_lengthsnot computed
line_azimuthsnot computed
mean_line_azimuthsnot computed
median_line_spacingsnot computed
line_point_spacingsnot computed
median_point_spacingsnot computed
Intersections131 rows

131 intersections with columns: ['line1', 'line2', 'line1_along_dist', 'line2_along_dist', 'is_buffered', 'is_proximity', 'geometry', 'easting', 'northing', 'max_dist', 'dist_along_line1', 'dist_along_line2', 'line1_interpolation_type', 'line2_interpolation_type', 'crossover_error_0', 'crossover_error_1', 'crossover_error_2', 'crossover_error_3', 'crossover_error_4', 'crossover_error_5']

Datafirst 5 rows
easting northing height line unixtime mag line_type along_track_dist ground_speed ground_speed_filtered is_intersection intersecting_line mag_interpolation_type mag_levelled
0 621152.853769 159064.167598 4112.55 1 1.229500e+09 -34.245 0.0 0.000000 53.780897 54.306611 False NaN none -26.447144
1 622163.262927 159216.562790 4133.80 1 1.229500e+09 -47.965 0.0 1021.837051 54.652629 54.868767 False NaN none -40.193800
2 623146.956750 159388.532824 4134.15 1 1.229500e+09 -58.360 0.0 2020.449705 56.072838 56.072502 False NaN none -50.614850
3 624125.472066 159547.768230 4136.50 1 1.229500e+09 -66.160 0.0 3011.836682 57.477601 57.302141 False NaN none -58.440711
4 625101.022414 159720.791113 4143.60 1 1.229500e+09 -70.900 0.0 4002.611833 58.299015 58.254057 False NaN none -63.206556
[268]:
error_cols = [
    c for c in survey.intersections.columns if c.startswith("crossover_error_")
]
rms_before = airbornegeo.rmse(survey.intersections[error_cols[0]])
rms_after = airbornegeo.rmse(survey.intersections[error_cols[-1]])
print(f"crossover error RMS: {rms_before:.2f} -> {rms_after:.2f} nT")
crossover error RMS: 41.38 -> 12.29 nT

The functional equivalent of this whole notebook would have passed line_column, distance_column and the intersection table around at every step — with the Survey object they are stored once and injected for you, while airbornegeo’s functions remain available for anything not wrapped as a method (e.g. the Eötvös corrections and plotting utilities).

Saving and reloading a Survey#

Survey has two built-in ways to save and reload its full state — the data, the intersection table (if any), the CRS, the metadata, and the column-name bindings:

  • ``to_parquet``/``from_parquet`` (recommended): the data and intersections are saved as Parquet files, plus a small JSON sidecar for the CRS/metadata/bindings. Parquet preserves dtypes exactly and is fast and compact, so prefer it unless you need a plain-text format.

  • ``to_csv``/``from_csv``: the same layout, but with CSV data files instead. Maximally portable and human-readable, at the cost of dtype fidelity (e.g. integer columns come back as floats) and file size.

Either way, the main data file is a plain Parquet/CSV file with no airbornegeo-specific encoding — anyone can read <path>.parquet/<path>.csv with bare pandas, no airbornegeo import required.

[269]:
import tempfile
from pathlib import Path

# replace this with your real local path you want to use
example_path = Path(tempfile.mkdtemp()) / "agap_survey"
example_path
[269]:
PosixPath('/tmp/tmpe0kdf8s2/agap_survey')
[270]:
# save the survey to the set path
survey.to_parquet(example_path)

# show the 3 file which were created
sorted(p.name for p in example_path.parent.iterdir())
[270]:
['agap_survey.json',
 'agap_survey.parquet',
 'agap_survey_intersections.parquet']
[271]:
reloaded = airbornegeo.Survey.from_parquet(example_path)
reloaded
[271]:
airbornegeo.Survey 17,685 rows × 14 columns
Column attributes
lineline
line_typeline_type
distancealong_track_dist
timeunixtime
heightheight
latitudenot set
longitudenot set
CRS & metadata3 metadata entries
crsEPSG:3031
surveyAGAP aeromagnetics
sourceBAS
unitsnT
Statistics0/10 computed — access an attribute or call .describe()
regionnot computed
line_countsnot computed
line_lengthsnot computed
total_lengthnot computed
median_line_lengthsnot computed
line_azimuthsnot computed
mean_line_azimuthsnot computed
median_line_spacingsnot computed
line_point_spacingsnot computed
median_point_spacingsnot computed
Intersections131 rows

131 intersections with columns: ['line1', 'line2', 'line1_along_dist', 'line2_along_dist', 'is_buffered', 'is_proximity', 'geometry', 'easting', 'northing', 'max_dist', 'dist_along_line1', 'dist_along_line2', 'line1_interpolation_type', 'line2_interpolation_type', 'crossover_error_0', 'crossover_error_1', 'crossover_error_2', 'crossover_error_3', 'crossover_error_4', 'crossover_error_5']

Datafirst 5 rows
easting northing height line unixtime mag line_type along_track_dist ground_speed ground_speed_filtered is_intersection intersecting_line mag_interpolation_type mag_levelled
0 621152.853769 159064.167598 4112.55 1 1.229500e+09 -34.245 0.0 0.000000 53.780897 54.306611 False NaN none -26.447144
1 622163.262927 159216.562790 4133.80 1 1.229500e+09 -47.965 0.0 1021.837051 54.652629 54.868767 False NaN none -40.193800
2 623146.956750 159388.532824 4134.15 1 1.229500e+09 -58.360 0.0 2020.449705 56.072838 56.072502 False NaN none -50.614850
3 624125.472066 159547.768230 4136.50 1 1.229500e+09 -66.160 0.0 3011.836682 57.477601 57.302141 False NaN none -58.440711
4 625101.022414 159720.791113 4143.60 1 1.229500e+09 -70.900 0.0 4002.611833 58.299015 58.254057 False NaN none -63.206556

The data file needs nothing but pandas to read — no airbornegeo import required:

[272]:
import pandas as pd

pd.read_parquet(example_path.with_suffix(".parquet")).head()
[272]:
easting northing height line unixtime mag line_type along_track_dist ground_speed ground_speed_filtered is_intersection intersecting_line mag_interpolation_type mag_levelled
0 621152.853769 159064.167598 4112.55 1 1.229500e+09 -34.245 0.0 0.000000 53.780897 54.306611 False NaN none -26.447144
1 622163.262927 159216.562790 4133.80 1 1.229500e+09 -47.965 0.0 1021.837051 54.652629 54.868767 False NaN none -40.193800
2 623146.956750 159388.532824 4134.15 1 1.229500e+09 -58.360 0.0 2020.449705 56.072838 56.072502 False NaN none -50.614850
3 624125.472066 159547.768230 4136.50 1 1.229500e+09 -66.160 0.0 3011.836682 57.477601 57.302141 False NaN none -58.440711
4 625101.022414 159720.791113 4143.60 1 1.229500e+09 -70.900 0.0 4002.611833 58.299015 58.254057 False NaN none -63.206556