Skip to content

Commit

Permalink
Merge pull request #16 from luhipi/dev
Browse files Browse the repository at this point in the history
Dev
  • Loading branch information
mahmud1 authored Nov 11, 2024
2 parents 9728eb4 + 8ceadcc commit 96b1e4d
Show file tree
Hide file tree
Showing 11 changed files with 1,482 additions and 435 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ InSAR Explorer supports visualizing outputs of [SARvey Open-source research soft
2. Click on the plugin icon in the toolbar or go to `Plugins` > `InSAR Explorer`.
3. Click on any point in the map to display the time series data.

## Sample data
A sample shapefile containing time series data for testing the plugin is available on [Zenodo repository](https://zenodo.org/records/14052814).

## Contributing
1. Fork the repository on GitHub.
2. Create a new branch for your feature or bug fix.
Expand Down
4 changes: 2 additions & 2 deletions help/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@
# built documents.
#
# The short X.Y.Z version.
version = '0.2.0'
version = '0.3.0'
# The full version, including alpha/beta/rc tags.
release = '0.2.0'
release = '0.3.0'

# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
Expand Down
719 changes: 397 additions & 322 deletions insar_explorer_dockwidget_base.ui

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions metadata.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
name=InSAR Explorer
qgisMinimumVersion=3.0
description=InSAR Explorer is a QGIS plugin that allows for dynamic visualization and analysis of InSAR time series data
version=0.2.0
version=0.3.0
author=Mahmud Haghighi, Andreas Piter
[email protected]

about=InSAR Explorer is a QGIS plugin designed for interactive visualization and analysis of InSAR time series results. With a user-friendly interface, it allows users to dynamically plot and explore ground displacement data over time.
A sample shapefile containing time series data for testing the plugin is available on Zenodo (https://zenodo.org/records/14052814).

tracker=https://github.com/luhipi/insar_explorer/issues
repository=https://github.com/luhipi/insar_explorer
Expand All @@ -19,13 +20,13 @@ hasProcessingProvider=no
# changelog=

# Tags are comma separated with spaces allowed
tags=python, InSAR,time series,plot,deformation,displacement,Sentinel,Sentinel-1,TerraSAR-X,SARvey
tags=InSAR,python,Time Series,displacement,Sentinel,Sentinel-1,deformation,TerraSAR-X,SARvey,Plot,Persistent Scatterer, PSI, Small baseline, SBAS

homepage=https://luhipi.github.io/insar_explorer/
category=Plugins
icon=icon.png
# experimental flag
experimental=True
experimental=False

# deprecated flag (applies to the whole plugin, not just a single version)
deprecated=False
Expand Down
1,010 changes: 953 additions & 57 deletions resources.py

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions resources.qrc
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
<qresource prefix="/plugins/insar_explorer">
<file>icon.png</file>
</qresource>
<qresource prefix="colormaps">
<file>icons/colormaps/turbo.png</file>
<file>icons/colormaps/roma.png</file>
<file>icons/colormaps/vik.png</file>
</qresource>
<qresource prefix="icons">
<file>icons/plot_fit_exp_amber.png</file>
<file>icons/symbology.png</file>
Expand Down
7 changes: 7 additions & 0 deletions src/gui_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def connectUiSignals(self):
# TS fit handler
self.ui.gb_ts_fit.buttonClicked.connect(self.timeseriesPlotFit)
self.ui.pb_ts_fit_seasonal.clicked.connect(self.timeseriesPlotFit)
self.ui.cb_plot_residuals.toggled.connect(self.timeseriesPlotResiduals)
# Replica
self.ui.pb_ts_replica.clicked.connect(self.timeseriesReplica)
self.ui.sb_ts_replica.valueChanged.connect(self.timeseriesReplica)
Expand Down Expand Up @@ -99,9 +100,15 @@ def timeseriesPlotFit(self):
selected_buttons]

self.choose_point_click_handler.plot_ts.fit_seasonal_flag = self.ui.pb_ts_fit_seasonal.isChecked()
self.timeseriesPlotResiduals()

self.choose_point_click_handler.plot_ts.fitModel()

def timeseriesPlotResiduals(self):
self.choose_point_click_handler.plot_ts.plot_residuals_flag = (
self.ui.cb_plot_residuals.isChecked() and not self.ui.pb_ts_nofit.isChecked())
self.choose_point_click_handler.plot_ts.plotTs()

def timeseriesReplica(self):
if self.ui.pb_ts_replica.isChecked():
self.choose_point_click_handler.plot_ts.replicate_flag = True
Expand Down
16 changes: 10 additions & 6 deletions src/map_click_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,22 +38,22 @@ def identifyClickedFeatureID(self, point: QgsPointXY, layer: QgsMapLayer = None)
layer = self.iface.activeLayer()

if not (layer and layer.isValid()):
self.ui.te_info.setPlainText("Invalid Layer: Please select a valid layer.")
self.ui.lb_msg_bar.setText('<span style="color:red;">Invalid Layer: Please select a valid layer.</span>')
return
elif not (layer.type() == QgsMapLayer.VectorLayer):
self.ui.te_info.setPlainText("Only vector layers supported: Please select a valid vector layer.")
self.ui.lb_msg_bar.setText('<span style="color:red;">Only vector layers supported: Please select a valid vector layer.</span>')
return
elif not (layer.geometryType() == 0):
self.ui.te_info.setPlainText("Invalid Layer: Please select a valid point layer.")
self.ui.lb_msg_bar.setText('<span style="color:red;">Invalid Layer: Please select a valid point layer.</span>')
return

closest_feature_id = self.findFeatureAtPoint(layer, point, self.iface.mapCanvas(),
only_the_closest_one=True, only_ids=True)

if closest_feature_id:
self.ui.te_info.setPlainText(f"Identify Result: Closest feature ID is {closest_feature_id}")
self.ui.lb_msg_bar.setText(f"Identify Result: Closest feature ID is {closest_feature_id}")
else:
self.ui.te_info.setPlainText("Identify Result: No nearby point found.")
self.ui.lb_msg_bar.setText("Identify Result: No nearby point found. Select another point.")

return closest_feature_id

Expand All @@ -75,7 +75,11 @@ def identifyClickedFeature(self, point: QgsPointXY, layer: QgsMapLayer = None, r
attributes_text = "\n".join(
[f"{field.name()}: {value}" for field, value in zip(layer.fields(), closest_feature.attributes())]
)
self.ui.te_info.setPlainText(f"Identify Result: Closest feature attributes:\n{attributes_text}")
point = closest_feature.geometry().asPoint()
if point:
x, y = point.x(), point.y()
coordinates_text = f"Coordinates: ({x:.5f}, {y:.5f})\n"
self.ui.te_info.setPlainText(f"Selected feature:\n{coordinates_text+attributes_text}")
if not ref:
self.highlightSelectedFeatures(closest_feature.geometry())
else:
Expand Down
12 changes: 10 additions & 2 deletions src/map_setting.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,16 @@ def setSymbology(self, layer=None, color_ramp_name=None):
# upper_range = QgsRendererRange(self.max_value, float('inf'), upper_symbol, f"> {self.max_value:.2f}")
# ranges.append(upper_range)

renderer = QgsGraduatedSymbolRenderer('velocity', ranges)
# renderer.setMode(QgsGraduatedSymbolRenderer.Custom)
# TODO: add support for different processors
velocity_field_name_options = ['velocity', 'VEL']
field_name = None
for velocity_field in velocity_field_name_options:
if layer.fields().lookupField(velocity_field) != -1:
field_name = velocity_field
break
if field_name:
renderer = QgsGraduatedSymbolRenderer(field_name, ranges)
# renderer.setMode(QgsGraduatedSymbolRenderer.Custom)

layer.setRenderer(renderer)
layer.triggerRepaint()
Expand Down
7 changes: 5 additions & 2 deletions src/model_fitting.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,15 @@ def modelExponential(x, a, b, c):


def fitExponential(x, y):
"""Try fitting exponential model, if it fails, fit polynomial model. Return the best fit model."""
try:
initial_params = [1, 1, 0.01]
popt, pcov = curve_fit(modelExponential, x, y, p0=initial_params, maxfev=2000)
model = modelExponential
except RuntimeError:
popt, pcov = curve_fit(modelPoly1, x, y)
return popt
model = modelPoly1
return popt, pcov, model


def normalize(x):
Expand Down Expand Up @@ -60,7 +63,7 @@ def fit(self, model=None, seasonal=False):
"poly-3": modelPoly3, "exp": modelExponential}
fit_model = fit_models_dict[model]
if fit_model == modelExponential:
popt = fitExponential(x, y)
popt, pcov, fit_model = fitExponential(x, y)
else:
popt, pcov = curve_fit(fit_model, x, y)

Expand Down
127 changes: 86 additions & 41 deletions src/plot_timeseries.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,18 @@ def __init__(self, ui):
self.ts_values = 0
self.ref_values = 0
self.plot_values = None
self.marker = 'o'
self.residuals_values = None
self.marker = '.'
self.residual_markers = '.'
self.fit_plot_list = []
self.fit_models = []
self.fit_seasonal_flag = False
self.replicate_flag = False
self.plot_replicates = []
self.replicate_value = 5.6/2
self.ax_residuals = None
self.plot_residuals_flag = False
self.plot_residuals_list = []

def prepareTsValues(self, *, dates, ts_values=None, ref_values=None):
if dates is not None:
Expand All @@ -34,9 +39,18 @@ def prepareTsValues(self, *, dates, ts_values=None, ref_values=None):

self.plot_values = self.ts_values - self.ref_values

def plotTs(self, *, dates=None, ts_values=None, ref_values=None, marker='o', marker_replicate='.k'):
def initializeAxes(self):
self.ui.figure.clear()
self.ax = self.ui.figure.add_subplot(111)
if self.plot_residuals_flag:
self.ax = self.ui.figure.add_subplot(211)
self.ax_residuals = self.ui.figure.add_subplot(212)
else:
self.ax = self.ui.figure.add_subplot(111)

def plotTs(self, *, dates=None, ts_values=None, ref_values=None, marker=None, marker_replicate='.k'):
if marker is None:
marker = self.marker
self.initializeAxes()

self.prepareTsValues(dates=dates, ts_values=ts_values, ref_values=ref_values)

Expand Down Expand Up @@ -64,50 +78,71 @@ def fitModel(self):
fit_line_color = 'black'
fit_seasonal = self.fit_seasonal_flag
for fit_model in self.fit_models:
_, model_x, model_y = (
model_values, model_x, model_y = (
FittingModels(self.dates, self.plot_values, model=fit_model).fit(seasonal=fit_seasonal))
plot = self.ax.plot(model_x, model_y, fit_line_type, color=fit_line_color)
self.fit_plot_list.append(plot[0])
self.ui.canvas.draw_idle()

def decoratePlot(self):
self.setXticks()
self.setYticks()
self.setGrid(True)
self.setXlims()
self.setYlims()

def setGrid(self, status):
self.ax.grid(status)
self.residuals_values = self.plot_values - model_values
self.plotResiduals()

def plotResiduals(self):
[plot.remove() for plot in self.plot_residuals_list]
self.plot_residuals_list = []
if self.plot_residuals_flag:
plot_residual = self.ax_residuals.plot(self.dates, self.residuals_values, self.residual_markers,
color='C2')
self.plot_residuals_list.append(plot_residual[0])
self.decoratePlot(ax=self.ax_residuals)
self.ui.canvas.draw_idle()

def setXticks(self):
def decoratePlot(self, ax=None):
if not ax:
ax = self.ax
self.setXticks(ax=ax)
self.setYticks(ax=ax)
self.setGrid(status=True, ax=ax)
self.setXlims(ax=ax)
self.setYlims(ax=ax)

def setGrid(self, status, ax=None):
if not ax:
ax = self.ax
ax.grid(status)

def setXticks(self, ax=None):
if not ax:
ax = self.ax
min_date = np.min(self.dates)
max_date = np.max(self.dates)
date_range = (max_date - min_date).days

if date_range >= 1461:
self.ax.xaxis.set_major_locator(mdates.YearLocator())
self.ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y'))
self.ax.xaxis.set_minor_locator(mdates.MonthLocator(bymonth=[1, 7]))
self.ax.xaxis.set_minor_formatter(mdates.DateFormatter(''))
elif date_range >= 730:
self.ax.xaxis.set_major_locator(mdates.MonthLocator(bymonth=(1, 7)))
self.ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y/%m'))
self.ax.xaxis.set_minor_locator(mdates.MonthLocator(bymonth=[1, 4, 7, 10]))
self.ax.xaxis.set_minor_formatter(mdates.DateFormatter(''))
ax.xaxis.set_major_locator(mdates.YearLocator())
ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y'))
ax.xaxis.set_minor_locator(mdates.MonthLocator(bymonth=[1, 7]))
ax.xaxis.set_minor_formatter(mdates.DateFormatter(''))
elif date_range >= 366:
ax.xaxis.set_major_locator(mdates.MonthLocator(bymonth=(1, 7)))
ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y/%m'))
ax.xaxis.set_minor_locator(mdates.MonthLocator(bymonth=[1, 4, 7, 10]))
ax.xaxis.set_minor_formatter(mdates.DateFormatter(''))
else:
self.ax.xaxis.set_major_locator(mdates.MonthLocator(interval=3))
self.ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y/%m'))
self.ax.xaxis.set_minor_locator(mdates.MonthLocator(interval=1))
self.ax.xaxis.set_minor_formatter(mdates.DateFormatter(''))

def setYticks(self):
self.ax.yaxis.set_major_locator(ticker.MultipleLocator(10))
self.ax.yaxis.set_minor_locator(ticker.MultipleLocator(1))
self.ax.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, _: f'{x:.0f}'))
self.ax.set_ylabel('[mm]')

def setXlims(self, *, use_data_xlim=True, padding=30):
ax.xaxis.set_major_locator(mdates.MonthLocator(bymonth=(1, 4, 7, 10)))
ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y/%m'))
ax.xaxis.set_minor_locator(mdates.MonthLocator(interval=1))
ax.xaxis.set_minor_formatter(mdates.DateFormatter(''))

def setYticks(self, ax=None):
if not ax:
ax = self.ax
ax.yaxis.set_major_locator(ticker.MultipleLocator(10))
ax.yaxis.set_minor_locator(ticker.MultipleLocator(1))
ax.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, _: f'{x:.0f}'))
ax.set_ylabel('[mm]')

def setXlims(self, *, ax=None, use_data_xlim=True, padding=30):
"""
Set the x-axis limits.
Expand All @@ -117,23 +152,33 @@ def setXlims(self, *, use_data_xlim=True, padding=30):
:param padding: int
Number of days to pad the x-axis limits.
"""
if not ax:
ax = self.ax
min_date = np.min(self.dates)
max_date = np.max(self.dates)

if use_data_xlim:
self.ax.set_xlim(min_date-timedelta(days=padding),
ax.set_xlim(min_date-timedelta(days=padding),
max_date+timedelta(days=padding))
else:
start_of_year = mdates.num2date(mdates.datestr2num(f'{min_date.year}-01-01'))
end_of_year = mdates.num2date(mdates.datestr2num(f'{max_date.year+1}-01-01'))
self.ax.set_xlim(start_of_year, end_of_year)
ax.set_xlim(start_of_year, end_of_year)

def setYlims(self, ax=None):
if not ax:
ax = self.ax

if ax == self.ax:
y_min = np.min(self.plot_values)
y_max = np.max(self.plot_values)
elif ax == self.ax_residuals:
y_max = np.max(np.abs(self.residuals_values))
y_min = -y_max

def setYlims(self):
y_min = np.min(self.plot_values)
y_max = np.max(self.plot_values)
y_min_rounded = np.floor(y_min / 10) * 10
y_max_rounded = np.ceil(y_max / 10) * 10
self.ax.set_ylim(y_min_rounded, y_max_rounded)
ax.set_ylim(y_min_rounded, y_max_rounded)


# import plotly.graph_objs as go
Expand Down

0 comments on commit 96b1e4d

Please sign in to comment.