Example for Sea Surface Calibration (SSC) and Sea Surface Leveling (SSL)
The Sea Surface Leveling (SSL) has been introduced by Rott et al. 2022 to align scanning lidars in offshore wind farms.
The Sea Surface Calibration (SSC) builds on this work to calibration the elevation offsets of lidar scanners from multi-elevation scans of the sea surface. Further, it corrects the lidar-sea surface range determination.
Lets apply both to an example dataset, that has been uploaded on Zenodo with the contribution Meyer et al. 2026.
from lidalign.SSC import SSC
import numpy as np
import pandas as pd
import xarray as xr
import plotly.graph_objects as go
import plotly.express as px
from plotly.subplots import make_subplots
from itertools import cycle
from lidalign.SSC import db2linear, linear2db, WaterRangeDetection, GaussianTruncatedPulse
Example Dataset
We have provided one multi-elevation scan for the Sea Surface Calibration, that has been obtained offshore by a scanning wind lidar (WindCubeScan 400s) at three negative elevation angles. For other applications, see the scripts of the validation campaign in Heligoland in this repository.
ds = xr.open_dataset('/home/paul/Downloads/Offshore_SSC_Scan.nc') # you will need to adjust the path here.
ds
<xarray.Dataset> Size: 4MB
Dimensions: (time: 1740, range: 315)
Coordinates:
* range (range) int32 1kB 250 265 280 295 310 ... 4915 4930 4945 4960
* time (time) datetime64[ns] 14kB 2025-08-12T05:04:03.036000 ... 2025...
Data variables:
azimuth (time) float64 14kB ...
elevation (time) float64 14kB ...
cnr (time, range) float64 4MB ...Visualisation of data
Let us visualise our dataset:
fig = px.imshow(ds['cnr'].T, origin = 'lower', color_continuous_scale = 'viridis', zmax = 0, zmin = -28)
fig.update_layout(yaxis_title = 'Radial distance [m]', xaxis_title = 'Time', title_text = 'Carrier to noise ratio (CNR) as function of the range over time')
fig.show(renderer= 'notebook')
fig = go.Figure()
fig.add_trace(go.Scattergl(x = ds['time'], y = ds['elevation']))
fig.update_layout(yaxis_title = 'Elevation [deg]', xaxis_title = 'Time', title_text = 'Elevation of the scans over time')
fig.show(renderer = 'notebook')
You can clearly see, that with increased negative elevation angle, the CNR drop to the noise level (~-28dB) happens at shorter ranges. With only one elevation angle, we can only determine the external misalignments. For the internal misalignment (elevation offset), multiple elevations must be considered.
Determination of Lidar-sea surface distance
For each Line of Sight, a distance to the sea surface from the lidar needs to be determined. Some methods exist for this, which we want to visualize here. All of them are based on the work by Rott et al. 2022.
Method Name |
Reference |
Description |
|---|---|---|
Rot22 |
Inverse sigmoid, initial method for following work |
|
Gra24 |
Extension of inverse sigmoid for linear (in dB scale) decay/increas of the CNR due to atmospheric backscatter at longer ranges |
|
Gra24-PV2 |
Observed possible error in range determination, removed half of the probe volume length from the obtained lidar-sea surface range. |
|
LinSig |
Meyer et al. 2026 (in preparation) |
Conversion of the inverse sigmoid into linear scale of CNR and fit of function in linear scale |
dBSig |
Meyer et al. 2026 (in preparation) |
Conversion of the inverse sigmoid into linear scale of CNR and fit of function in dB scale |
Convo |
Meyer et al. 2026 (in preparation) |
Convolution approach. The probe length is here given as fixed input and does not need to be fitted anymore |
The LinSig method uses: $$ S_{\rm sig, lin} (r) = \begin{cases} 10^{\left(A \cdot (r - r_{\rm w})\right)/10} \cdot S_{\rm sig}, & r< r_{\rm w} \ S_{\rm sig}, & \rm{else}. \end{cases}, \mathrm{with} \ S_{\rm sig} = \frac{S_{\rm high}}{1 +\exp{(g \cdot (r-r_{\rm w}))}}. $$
For more details on the methods see the description in Meyer et al. 2026.
Conversion between dB and linear scale
Whereas the carrier to noise ratio (CNR) normally is provided in decibel (dB), it is measured in linear scale. Thus, the inverse sigmoid decay should describe the CNR in linear scale. Conversion between both is simple:
$$ \begin{equation} S_{\rm dB} = 10 \log_{10}(S_{\rm lin}), \end{equation} $$
For details of the corresponding methods, we refere to the mentioned references.
Examplary determination of the lidar-sea surface range
We demonstrate all methods for a single Line of Sight from our dataset. Shown are the CNR from the measurements and from the fitted models for the CNR in decibel scale (upper plot) and in linear scale (lower plot).
The obtained ranges from the methods are plotted in the corresponding figures as vertical lines.
dst = ds.sel(time = '2025-08-12 05:09', method = 'nearest')
#dst = ds.sel(time = '2025-08-12 05:05:44.83', method = 'nearest')
pulse = GaussianTruncatedPulse(75)
fig = make_subplots(rows = 2, shared_xaxes=True)
kw = dict(mode = 'markers', marker = dict(color = 'black', size = 10), name = 'Measurement', line= dict(color = 'black'), legendgroup= 'Measurement')
fig.add_trace(go.Scattergl(x = dst.range, y = dst['cnr'], **kw), row = 1, col = 1)
fig.add_trace(go.Scattergl(x = dst.range, y = db2linear(dst['cnr']), showlegend= False, **kw), row = 2, col = 1)
fig.update_layout(yaxis_title = 'CNR [dB]', height = 800)
fig.update_yaxes(title = 'CNR [linear]', col= 1, row = 2)
fig.update_xaxes(title = 'Range [m]', col = 1, row = 2)
colors = cycle(px.colors.qualitative.Plotly)
yvals = np.array([dst['cnr'].min(), min(dst['cnr'].max(),10)+3])
for method in ["LinSig", "dBSig", "Gra24", "Rot21","Convo", 'Gra24-PV']:
WRD = WaterRangeDetection(dst.copy(), pulse= pulse, verbose=2)
if method == 'Gra24-PV':
res = WRD.get_water_range_from_cnr(func = 'Gra24', return_fit=True)
res.r_water -= pulse.FWHM//2
else:
res = WRD.get_water_range_from_cnr(func = method, return_fit=True)
kw = dict(name = f"{method}: {res.r_water:.1f}m", legendgroup = method, line = dict(color = next(colors)))
fig.add_trace(go.Scattergl(x = dst.range, y = res.fit_data['fit_db'], **kw), row =1, col = 1)
fig.add_trace(go.Scattergl(x = dst.range, y = res.fit_data['fit_lin'],showlegend=False, **kw), row =2, col = 1)
fig.add_trace(go.Scattergl(x = [res.r_water, res.r_water], y = yvals, mode = 'lines', showlegend=False, **kw), row = 1, col = 1)
fig.add_trace(go.Scattergl(x = [res.r_water, res.r_water], y = db2linear(yvals), mode = 'lines', showlegend=False, **kw), row = 2, col = 1)
fig.show(renderer = 'notebook')
first guess for middle range: 1870
first_guess: [np.int32(1870), np.float64(0.0007379042301291007), np.float64(0.08035261221856173), 0.01, 0.01]
bounds: Bounds(array([ 2.5e+02, -4.0e+01, -2.3e+01, 5.0e-04, -1.5e-02]), array([ 4.96e+03, -2.00e+01, -3.00e+00, 1.00e+00, 3.00e-02]))
First guess 0.0007379042301291007 not in bounds -40.0 - -20.0, clipping
First guess 0.08035261221856173 not in bounds -23.0 - -3.0, clipping
Using fit method: LSQ
first guess for middle range: 1870
first_guess: [np.int32(1870), np.float64(-31.32), np.float64(-10.95), 0.01, 0.01]
bounds: Bounds(array([ 2.5e+02, -4.0e+01, -2.3e+01, 5.0e-04, -1.5e-02]), array([ 4.96e+03, -2.00e+01, -3.00e+00, 1.00e+00, 3.00e-02]))
Using fit method: LSQ
first guess for middle range: 1870
first_guess: [np.int32(1870), np.float64(-31.32), np.float64(-10.95), 0.01, 0.01]
bounds: Bounds(array([ 2.5e+02, -4.0e+01, -2.3e+01, 5.0e-04, -1.5e-02]), array([ 4.96e+03, -2.00e+01, -3.00e+00, 1.00e+00, 3.00e-02]))
Using fit method: LSQ
first guess for middle range: 1870
first_guess: [np.int32(1870), np.float64(-31.32), np.float64(-10.95), 0.01]
bounds: Bounds(array([ 2.5e+02, -4.0e+01, -2.3e+01, 5.0e-04]), array([4960, -20, -3, 1]))
Using fit method: LSQ
first guess for middle range: 1870
Had to switch to minimize for convolution fit method, due to fitting problems with curve fit and ODR
first_guess: [np.int32(1870), 0.01, np.float64(-10.95), -32]
bounds: Bounds(array([ 2.5e+02, -1.5e-02, -2.2e+01, -4.0e+01]), array([ 4.96e+03, 3.00e-02, 0.00e+00, -2.50e+01]))
Using fit method: minimize
first guess for middle range: 1870
first_guess: [np.int32(1870), np.float64(-31.32), np.float64(-10.95), 0.01, 0.01]
bounds: Bounds(array([ 2.5e+02, -4.0e+01, -2.3e+01, 5.0e-04, -1.5e-02]), array([ 4.96e+03, -2.00e+01, -3.00e+00, 1.00e+00, 3.00e-02]))
Using fit method: LSQ
We can see the differences in the obtained CNR curves. Whereas the methods (except Rot21) show similar CNR curves, the Linsig, Convo and Gra24-PV yield significantly lower lidar-sea surface ranges.
Parameter explanation
There are a few parameters, that need to be explained for the usage of the scripts. As each lidar system might have different internal noise and measurement capabilities, also the CNR distribution will look different and needs to be adjusted. Also, atmospheric conditions will change the resulting CNR distribution.
The most important parameters are:
Parameter |
Recommendation |
Explanation |
|---|---|---|
min_cnr |
-22 [dB] |
CNR of LOS must reach at least this value, to be considered valid (if beam is blocked, CNR is always < than this value) |
cnr_hard_target |
0 [dB] |
CNR of LOS must be lower than this value, otherwise it is considered a hard target |
use_bounds |
True |
Use given bounds for fit (improves fit) |
func |
See above |
see above |
growth_rate_bounds |
[0.0005, 1] |
Fit boundaries for the growth rate. This parameter actually depends on the probe volume and should be adjusted accordingly. Larger probe volumes (~100m) give smaller values, whereas shorte probe volumes result in higher values. The lower bound might need to be adjusted according to your prove volume! |
cnr_noise_cut |
-40 dB |
Noise level for CNR cut. If the cnr goes below this level, all CNR readings afterwards are thrown away, as they are considered as noise. Has an impact on the sigmoid functions and should be used for LinSig (-28 e.g., in the case of a WindCube400S) |
_first_guess_roll_window |
10 |
Depending on the radial resolution of your input CNR data, you should adjust this value, which is used to find the steepest CNR drop (of a rolling window with size _first_guess_roll_window) as first guess for r_water |
There are some more parameters, see the code for explanation.
For the whole scan
Lets perform the detection for the whole scan (and make some adjustments through the kw arguments)
dist_ds = SSC(ds, verbose = 1).get_all_water_ranges(func = 'LinSig',growth_rate_bounds = [0.01, 1], min_cnr = -20, cnr_hard_target = -3).distance_ds
fig = px.imshow(ds['cnr'].T, origin = 'lower', color_continuous_scale = 'viridis', zmax = 0, zmin = -28)
fig.add_trace(go.Scattergl(x = dist_ds.time, y = dist_ds['water_range'], name = 'Obtained Lidar-sea surface range', mode = 'markers', showlegend=True))
fig.update_layout(legend=dict(
yanchor="top",
y=0.99,
xanchor="left",
x=0.05
))
fig.show(renderer= 'notebook')