From d41be0f8d88b60864625c14158e6fc03a89ebb59 Mon Sep 17 00:00:00 2001 From: dfguerrerom Date: Wed, 15 Jul 2026 10:34:10 +0200 Subject: [PATCH 01/19] refactor: share echarts theming; add RawEChartsWidget --- component/widget/echarts.py | 32 ++++++++++++++++++++++---------- tests/test_echarts_theme.py | 24 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 10 deletions(-) create mode 100644 tests/test_echarts_theme.py diff --git a/component/widget/echarts.py b/component/widget/echarts.py index d07f3fc..eb460ab 100644 --- a/component/widget/echarts.py +++ b/component/widget/echarts.py @@ -1,25 +1,37 @@ import ipyvuetify as v +from ipecharts import EChartsRawWidget as BaseEChartsRawWidget from ipecharts import EChartsWidget as BaseEChartsWidget -class EChartsWidget(BaseEChartsWidget): - def __init__(self, theme_toggle=None, *args, **kwargs): - super().__init__(*args, **kwargs) +class _EChartsThemeMixin: + """Shared light/dark theming for ipecharts widgets. + Observes an optional ThemeToggle (or the global ``v.theme``) and keeps the + echarts ``theme`` trait in sync. SVG renderer for crisp static output. + """ + + def _init_theme(self, theme_toggle): self.renderer = "svg" self.theme_toggle = theme_toggle self.theme = self.get_theme() - - if self.theme_toggle: - self.theme_toggle.observe(self.set_theme, "dark") - else: - v.theme.observe(self.set_theme, "dark") + target = self.theme_toggle if self.theme_toggle else v.theme + target.observe(self.set_theme, "dark") def get_theme(self): - obj = self.theme_toggle if self.theme_toggle else v.theme - return "dark" if getattr(obj, "dark") else "light" def set_theme(self, _): self.theme = self.get_theme() + + +class EChartsWidget(BaseEChartsWidget, _EChartsThemeMixin): + def __init__(self, theme_toggle=None, *args, **kwargs): + super().__init__(*args, **kwargs) + self._init_theme(theme_toggle) + + +class RawEChartsWidget(BaseEChartsRawWidget, _EChartsThemeMixin): + def __init__(self, theme_toggle=None, *args, **kwargs): + super().__init__(*args, **kwargs) + self._init_theme(theme_toggle) diff --git a/tests/test_echarts_theme.py b/tests/test_echarts_theme.py new file mode 100644 index 0000000..0aa36a8 --- /dev/null +++ b/tests/test_echarts_theme.py @@ -0,0 +1,24 @@ +# tests/test_echarts_theme.py +from component.widget.echarts import EChartsWidget, RawEChartsWidget + + +class _Toggle: + dark = True + + def observe(self, *_args, **_kwargs): + pass + + +def test_raw_widget_binds_toggle_and_theme(): + tt = _Toggle() + w = RawEChartsWidget(theme_toggle=tt, option={"series": []}) + assert w.theme_toggle is tt + assert w.theme == "dark" + assert w.renderer == "svg" + + +def test_typed_widget_still_themes(): + tt = _Toggle() + w = EChartsWidget(theme_toggle=tt) + assert w.theme == "dark" + assert w.renderer == "svg" From 12ffab6a88c4ec23a7d6f9e7451052d8af953f65 Mon Sep 17 00:00:00 2001 From: dfguerrerom Date: Wed, 15 Jul 2026 10:46:06 +0200 Subject: [PATCH 02/19] feat: confusion-matrix heatmap chart --- component/widget/analysis_chart.py | 83 +++++++++++++++++++++++++++++- tests/test_analysis_charts.py | 73 ++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 tests/test_analysis_charts.py diff --git a/component/widget/analysis_chart.py b/component/widget/analysis_chart.py index 63d9926..fd01138 100644 --- a/component/widget/analysis_chart.py +++ b/component/widget/analysis_chart.py @@ -7,7 +7,7 @@ from component.model import app_state from component.scripts.accuracy import convert_area -from component.widget.echarts import EChartsWidget +from component.widget.echarts import EChartsWidget, RawEChartsWidget @solara.component @@ -72,3 +72,84 @@ def AreaEstimateChart(results: dict, unit: str, theme_toggle=None): style={"height": "420px", "width": "100%"}, theme_toggle=theme_toggle, ) + + +def confusion_heatmap_data(confusion_matrix: dict): + """Reshape a confusion-matrix dict into echarts heatmap inputs. + + Returns (x_labels, y_labels, triples, max_count): + x_labels = reference classes (columns) as strings + y_labels = mapped classes (index) as strings + triples = [x_index, y_index, count] for every cell + max_count = largest cell value, at least 1 (for visualMap.max) + """ + x_labels = [str(c) for c in confusion_matrix.get("columns", [])] + y_labels = [str(i) for i in confusion_matrix.get("index", [])] + triples = [] + max_count = 0.0 + for y, row in enumerate(confusion_matrix.get("data", [])): + for x, value in enumerate(row): + count = float(value) + triples.append([x, y, count]) + max_count = max(max_count, count) + return x_labels, y_labels, triples, max(max_count, 1.0) + + +@solara.component +def ConfusionMatrixChart(results: dict, theme_toggle=None): + cm = results.get("confusion_matrix") + if not cm: + return + x_labels, y_labels, triples, max_count = confusion_heatmap_data(cm) + option = { + "backgroundColor": "#1e1e1e00", + "title": { + "text": "Confusion matrix (map -> reference)", + "left": "center", + "textStyle": {"fontSize": 13, "fontWeight": "normal"}, + }, + "tooltip": {"position": "top"}, + "grid": {"top": "14%", "bottom": "18%", "left": "16%", "right": "8%"}, + "xAxis": { + "type": "category", + "data": x_labels, + "name": "reference", + "nameLocation": "middle", + "nameGap": 28, + "splitArea": {"show": True}, + "axisLabel": {"fontSize": 10}, + }, + "yAxis": { + "type": "category", + "data": y_labels, + "name": "map", + "splitArea": {"show": True}, + "axisLabel": {"fontSize": 10}, + }, + "visualMap": { + "min": 0, + "max": max_count, + "calculable": True, + "orient": "horizontal", + "left": "center", + "bottom": "2%", + }, + "series": [ + { + "type": "heatmap", + "data": triples, + "label": {"show": True, "fontSize": 9}, + "emphasis": { + "itemStyle": { + "shadowBlur": 6, + "shadowColor": "rgba(0,0,0,0.3)", + } + }, + } + ], + } + RawEChartsWidget.element( + option=option, + style={"height": "420px", "width": "100%"}, + theme_toggle=theme_toggle, + ) diff --git a/tests/test_analysis_charts.py b/tests/test_analysis_charts.py new file mode 100644 index 0000000..b40551a --- /dev/null +++ b/tests/test_analysis_charts.py @@ -0,0 +1,73 @@ +# tests/test_analysis_charts.py +import solara + +from component.widget.analysis_chart import ( + ConfusionMatrixChart, + confusion_heatmap_data, +) +from component.widget.echarts import RawEChartsWidget + +_RESULTS = { + "confusion_matrix": { + "index": [11, 12], + "columns": [11, 12], + "data": [[8, 2], [1, 9]], + }, + "accuracy_rows": [ + { + "class_name": "Forest", + "map_code": 11, + "users_accuracy": 0.8, + "producers_accuracy": 0.89, + "weighted_producers_accuracy": 0.87, + }, + { + "class_name": "Non-forest", + "map_code": 12, + "users_accuracy": 0.9, + "producers_accuracy": 0.82, + "weighted_producers_accuracy": 0.83, + }, + ], + "class_estimates": [ + { + "class_name": "Forest", + "map_code": 11, + "number_samples": 10, + "area_estimate": 100.0, + "confidence_interval": 5.0, + "map_pixel_count": 90.0, + "srs_area_estimate": 98.0, + }, + { + "class_name": "Non-forest", + "map_code": 12, + "number_samples": 10, + "area_estimate": 50.0, + "confidence_interval": 3.0, + "map_pixel_count": 60.0, + "srs_area_estimate": 52.0, + }, + ], +} + + +def test_confusion_heatmap_data_shapes(): + x, y, triples, max_count = confusion_heatmap_data(_RESULTS["confusion_matrix"]) + assert x == ["11", "12"] + assert y == ["11", "12"] + assert len(triples) == 4 + assert [0, 0, 8.0] in triples and [1, 1, 9.0] in triples + assert max_count == 9.0 + + +def test_confusion_matrix_chart_renders_raw_widget(): + _, rc = solara.render( + ConfusionMatrixChart(_RESULTS, theme_toggle=None), handle_error=False + ) + widgets = rc.find(RawEChartsWidget).widgets + assert len(widgets) == 1 + opt = widgets[0].option + assert opt["backgroundColor"] == "#1e1e1e00" + assert opt["series"][0]["type"] == "heatmap" + assert "visualMap" in opt From 1a5eea795306a9849db746ca89bfea653abcc28a Mon Sep 17 00:00:00 2001 From: dfguerrerom Date: Wed, 15 Jul 2026 10:53:58 +0200 Subject: [PATCH 03/19] feat: accuracy-by-class bar chart --- component/widget/analysis_chart.py | 35 +++++++++++++++++++++++++++++- tests/test_analysis_charts.py | 14 +++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/component/widget/analysis_chart.py b/component/widget/analysis_chart.py index fd01138..8ff77b1 100644 --- a/component/widget/analysis_chart.py +++ b/component/widget/analysis_chart.py @@ -1,7 +1,7 @@ """Bar chart of error-adjusted area per class, with confidence-interval bars.""" import solara -from ipecharts.option import Grid, Option, Title, Tooltip, XAxis, YAxis +from ipecharts.option import Grid, Legend, Option, Title, Tooltip, XAxis, YAxis from ipecharts.option.series import Bar, Custom from ipecharts.tools import encode_js_fn @@ -153,3 +153,36 @@ def ConfusionMatrixChart(results: dict, theme_toggle=None): style={"height": "420px", "width": "100%"}, theme_toggle=theme_toggle, ) + + +@solara.component +def AccuracyByClassChart(results: dict, theme_toggle=None): + rows = results.get("accuracy_rows", []) + if not rows: + return + names = [r["class_name"] for r in rows] + users = [round(r["users_accuracy"] * 100, 1) for r in rows] + producers = [round(r["producers_accuracy"] * 100, 1) for r in rows] + option = Option( + backgroundColor="#1e1e1e00", + title=Title( + text="Accuracy by class", + left="center", + textStyle={"fontSize": 13, "fontWeight": "normal"}, + ), + tooltip=Tooltip(trigger="axis", axisPointer={"type": "shadow"}), + legend=Legend(bottom=0), + xAxis=XAxis( + type="category", + data=names, + axisLabel={"fontSize": 10, "interval": 0, "rotate": 30}, + ), + yAxis=YAxis(type="value", name="%", max=100), + grid=Grid(left="10%", right="6%", top="14%", bottom="18%"), + series=[Bar(name="User's", data=users), Bar(name="Producer's", data=producers)], + ) + EChartsWidget.element( + option=option, + style={"height": "420px", "width": "100%"}, + theme_toggle=theme_toggle, + ) diff --git a/tests/test_analysis_charts.py b/tests/test_analysis_charts.py index b40551a..28a0d06 100644 --- a/tests/test_analysis_charts.py +++ b/tests/test_analysis_charts.py @@ -2,10 +2,11 @@ import solara from component.widget.analysis_chart import ( + AccuracyByClassChart, ConfusionMatrixChart, confusion_heatmap_data, ) -from component.widget.echarts import RawEChartsWidget +from component.widget.echarts import EChartsWidget, RawEChartsWidget _RESULTS = { "confusion_matrix": { @@ -71,3 +72,14 @@ def test_confusion_matrix_chart_renders_raw_widget(): assert opt["backgroundColor"] == "#1e1e1e00" assert opt["series"][0]["type"] == "heatmap" assert "visualMap" in opt + + +def test_accuracy_by_class_chart_two_series(): + _, rc = solara.render( + AccuracyByClassChart(_RESULTS, theme_toggle=None), handle_error=False + ) + widgets = rc.find(EChartsWidget).widgets + assert len(widgets) == 1 + series = widgets[0].option.series + assert [s.name for s in series] == ["User's", "Producer's"] + assert series[0].data == [80.0, 90.0] From fca796698fe69530745639011e164c4aa6549fec Mon Sep 17 00:00:00 2001 From: dfguerrerom Date: Wed, 15 Jul 2026 11:04:06 +0200 Subject: [PATCH 04/19] feat: estimated-area proportion pie chart --- component/widget/analysis_chart.py | 57 +++++++++++++++++++++++++++++- tests/test_analysis_charts.py | 11 ++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/component/widget/analysis_chart.py b/component/widget/analysis_chart.py index 8ff77b1..65f36f9 100644 --- a/component/widget/analysis_chart.py +++ b/component/widget/analysis_chart.py @@ -2,7 +2,7 @@ import solara from ipecharts.option import Grid, Legend, Option, Title, Tooltip, XAxis, YAxis -from ipecharts.option.series import Bar, Custom +from ipecharts.option.series import Bar, Custom, Pie from ipecharts.tools import encode_js_fn from component.model import app_state @@ -186,3 +186,58 @@ def AccuracyByClassChart(results: dict, theme_toggle=None): style={"height": "420px", "width": "100%"}, theme_toggle=theme_toggle, ) + + +_PIE_FALLBACK = [ + "#5470c6", + "#91cc75", + "#fac858", + "#ee6666", + "#73c0de", + "#3ba272", + "#fc8452", + "#9a60b4", + "#ea7ccc", +] + + +@solara.component +def AreaProportionChart(results: dict, theme_toggle=None): + rows = results.get("class_estimates", []) + if not rows: + return + colors = app_state.class_colors.value or {} + total = sum(max(r["area_estimate"], 0.0) for r in rows) or 1.0 + pie_data = [] + chart_colors = [] + for idx, r in enumerate(rows): + pct = 100.0 * max(r["area_estimate"], 0.0) / total + chart_colors.append( + colors.get(r["map_code"], _PIE_FALLBACK[idx % len(_PIE_FALLBACK)]) + ) + pie_data.append( + {"value": round(pct, 2), "name": f"{r['class_name']} ({pct:.1f}%)"} + ) + pie = Pie( + data=pie_data, + radius=[50, 100], + itemStyle={"borderRadius": 5, "borderColor": "#fff", "borderWidth": 2}, + label={"show": False, "position": "center"}, + emphasis={"label": {"show": True, "fontSize": 12}}, + ) + option = Option( + backgroundColor="#1e1e1e00", + legend=Legend(bottom=0), + series=[pie], + color=chart_colors, + title=Title( + text="Estimated area proportion", + left="center", + textStyle={"fontSize": 13, "fontWeight": "normal"}, + ), + ) + EChartsWidget.element( + option=option, + style={"height": "420px", "width": "100%"}, + theme_toggle=theme_toggle, + ) diff --git a/tests/test_analysis_charts.py b/tests/test_analysis_charts.py index 28a0d06..07e70e5 100644 --- a/tests/test_analysis_charts.py +++ b/tests/test_analysis_charts.py @@ -3,6 +3,7 @@ from component.widget.analysis_chart import ( AccuracyByClassChart, + AreaProportionChart, ConfusionMatrixChart, confusion_heatmap_data, ) @@ -83,3 +84,13 @@ def test_accuracy_by_class_chart_two_series(): series = widgets[0].option.series assert [s.name for s in series] == ["User's", "Producer's"] assert series[0].data == [80.0, 90.0] + + +def test_area_proportion_chart_pie_sums_to_100(): + _, rc = solara.render( + AreaProportionChart(_RESULTS, theme_toggle=None), handle_error=False + ) + widgets = rc.find(EChartsWidget).widgets + assert len(widgets) == 1 + pie = widgets[0].option.series[0] + assert round(sum(d["value"] for d in pie.data), 1) == 100.0 From 382277a771b169efeef3c628ac6df904ed5f35ec Mon Sep 17 00:00:00 2001 From: dfguerrerom Date: Wed, 15 Jul 2026 11:14:25 +0200 Subject: [PATCH 05/19] feat: analysis dashboard modal --- component/widget/analysis_dashboard.py | 73 ++++++++++++++++++++++++++ tests/test_analysis_dashboard.py | 34 ++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 component/widget/analysis_dashboard.py create mode 100644 tests/test_analysis_dashboard.py diff --git a/component/widget/analysis_dashboard.py b/component/widget/analysis_dashboard.py new file mode 100644 index 0000000..db224ce --- /dev/null +++ b/component/widget/analysis_dashboard.py @@ -0,0 +1,73 @@ +"""Right-panel results summary + the analysis dashboard modal.""" + +import solara + +from component.model import app_state +from component.widget.analysis_chart import ( + AccuracyByClassChart, + AreaEstimateChart, + AreaProportionChart, + ConfusionMatrixChart, +) +from component.widget.analysis_results import ( + _Accuracy, + _AreaEstimates, + _ConfusionMatrix, +) + + +def dashboard_kpis(results: dict) -> dict: + """Scalar KPIs for the summary card / modal header.""" + rows = results.get("class_estimates", []) + return { + "overall_accuracy_pct": round(results.get("overall_accuracy", 0.0) * 100, 1), + "confidence_level": results.get("confidence_level", 95.0), + "n_samples": int(sum(r.get("number_samples", 0) for r in rows)), + "n_classes": len(rows), + } + + +@solara.component +def _DashboardHeader(results: dict): + k = dashboard_kpis(results) + with solara.Row(style="align-items: center; gap: 16px; flex-wrap: wrap;"): + solara.Text( + f"Overall {k['overall_accuracy_pct']:.1f}%", + style="font-weight: 700; font-size: 18px;", + ) + solara.Text(f"CL {k['confidence_level']:.0f}%") + solara.Text(f"n={k['n_samples']} samples") + solara.Text(f"{k['n_classes']} classes") + solara.v.Spacer() + solara.ToggleButtonsSingle( + value=app_state.analysis_area_unit.value, + values=["ha", "m2"], + on_value=app_state.analysis_area_unit.set, + ) + + +@solara.component +def AnalysisDashboardModal(open, theme_toggle=None): + results = app_state.analysis_results.value + if not results: + return + unit = app_state.analysis_area_unit.value + with solara.v.Dialog( + v_model=open.value, on_v_model=open.set, max_width=1100, eager=True + ): + with solara.v.Card(): + solara.v.CardTitle(children=["Accuracy assessment results"]) + with solara.v.CardText(style="max-height: 80vh; overflow-y: auto;"): + _DashboardHeader(results) + with solara.ColumnsResponsive(6, small=12): + ConfusionMatrixChart(results, theme_toggle=theme_toggle) + AccuracyByClassChart(results, theme_toggle=theme_toggle) + AreaEstimateChart(results, unit, theme_toggle=theme_toggle) + AreaProportionChart(results, theme_toggle=theme_toggle) + with solara.Details("Tables"): + _AreaEstimates(results, unit) + _Accuracy(results) + _ConfusionMatrix(results) + with solara.v.CardActions(): + solara.v.Spacer() + solara.Button("Close", text=True, on_click=lambda: open.set(False)) diff --git a/tests/test_analysis_dashboard.py b/tests/test_analysis_dashboard.py new file mode 100644 index 0000000..fee4966 --- /dev/null +++ b/tests/test_analysis_dashboard.py @@ -0,0 +1,34 @@ +# tests/test_analysis_dashboard.py +import solara + +from component.model import app_state +from component.widget.analysis_dashboard import ( + AnalysisDashboardModal, + dashboard_kpis, +) +from component.widget.echarts import EChartsWidget, RawEChartsWidget +from tests.test_analysis_charts import _RESULTS + + +def test_dashboard_kpis(): + results = {**_RESULTS, "overall_accuracy": 0.85, "confidence_level": 95.0} + k = dashboard_kpis(results) + assert k["overall_accuracy_pct"] == 85.0 + assert k["confidence_level"] == 95.0 + assert k["n_samples"] == 20 + assert k["n_classes"] == 2 + + +def test_modal_renders_all_charts_when_open(): + app_state.analysis_results.value = { + **_RESULTS, + "overall_accuracy": 0.85, + "confidence_level": 95.0, + } + open_r = solara.reactive(True) + _, rc = solara.render( + AnalysisDashboardModal(open_r, theme_toggle=None), handle_error=False + ) + # 3 typed charts (accuracy, area, pie) + 1 raw (heatmap) + assert len(rc.find(EChartsWidget).widgets) == 3 + assert len(rc.find(RawEChartsWidget).widgets) == 1 From f9bb3daa16b3cc1aa7ac403360181d2c4de971de Mon Sep 17 00:00:00 2001 From: dfguerrerom Date: Wed, 15 Jul 2026 11:27:11 +0200 Subject: [PATCH 06/19] fix: transparent background for AreaEstimateChart --- component/widget/analysis_chart.py | 1 + tests/test_analysis_charts.py | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/component/widget/analysis_chart.py b/component/widget/analysis_chart.py index 65f36f9..0637215 100644 --- a/component/widget/analysis_chart.py +++ b/component/widget/analysis_chart.py @@ -53,6 +53,7 @@ def AreaEstimateChart(results: dict, unit: str, theme_toggle=None): ) option = Option( + backgroundColor="#1e1e1e00", title=Title( text="Error-adjusted area by class", left="center", diff --git a/tests/test_analysis_charts.py b/tests/test_analysis_charts.py index 07e70e5..dd4a3a9 100644 --- a/tests/test_analysis_charts.py +++ b/tests/test_analysis_charts.py @@ -3,6 +3,7 @@ from component.widget.analysis_chart import ( AccuracyByClassChart, + AreaEstimateChart, AreaProportionChart, ConfusionMatrixChart, confusion_heatmap_data, @@ -94,3 +95,12 @@ def test_area_proportion_chart_pie_sums_to_100(): assert len(widgets) == 1 pie = widgets[0].option.series[0] assert round(sum(d["value"] for d in pie.data), 1) == 100.0 + + +def test_area_estimate_chart_has_transparent_background(): + _, rc = solara.render( + AreaEstimateChart(_RESULTS, "ha", theme_toggle=None), handle_error=False + ) + widgets = rc.find(EChartsWidget).widgets + assert len(widgets) == 1 + assert widgets[0].option.backgroundColor == "#1e1e1e00" From 5f579f72212b00d6b8362c12c340b7311bbbf48b Mon Sep 17 00:00:00 2001 From: dfguerrerom Date: Wed, 15 Jul 2026 11:37:06 +0200 Subject: [PATCH 07/19] feat: results summary card opens dashboard modal --- component/widget/analysis_dashboard.py | 33 +++++++++++++++++++++++++- component/widget/analysis_results.py | 18 ++++---------- tests/test_analysis_dashboard.py | 29 ++++++++++++++++++++++ 3 files changed, 65 insertions(+), 15 deletions(-) diff --git a/component/widget/analysis_dashboard.py b/component/widget/analysis_dashboard.py index db224ce..9b07754 100644 --- a/component/widget/analysis_dashboard.py +++ b/component/widget/analysis_dashboard.py @@ -1,4 +1,4 @@ -"""Right-panel results summary + the analysis dashboard modal.""" +"""Results summary card (KPIs + button) and the analysis dashboard modal it opens.""" import solara @@ -13,6 +13,7 @@ _Accuracy, _AreaEstimates, _ConfusionMatrix, + _Downloads, ) @@ -71,3 +72,33 @@ def AnalysisDashboardModal(open, theme_toggle=None): with solara.v.CardActions(): solara.v.Spacer() solara.Button("Close", text=True, on_click=lambda: open.set(False)) + + +@solara.component +def AnalysisSummaryCard(theme_toggle=None): + results = app_state.analysis_results.value + # Hook must run unconditionally, before the early return, for hook-order + # stability across renders (see solara's rules-of-hooks). + open_modal = solara.use_reactive(False) + if not results: + return + k = dashboard_kpis(results) + with solara.Column(gap="8px"): + solara.Text( + f"{k['overall_accuracy_pct']:.1f}%", + style="font-weight: 700; font-size: 22px;", + ) + solara.Text( + f"overall accuracy - CL {k['confidence_level']:.0f}% - " + f"n={k['n_samples']} - {k['n_classes']} classes", + style="opacity: 0.8;", + ) + solara.Button( + "Ver dashboard", + icon_name="mdi-view-dashboard", + color="primary", + block=True, + on_click=lambda: open_modal.set(True), + ) + _Downloads() + AnalysisDashboardModal(open_modal, theme_toggle=theme_toggle) diff --git a/component/widget/analysis_results.py b/component/widget/analysis_results.py index 91548bc..5598625 100644 --- a/component/widget/analysis_results.py +++ b/component/widget/analysis_results.py @@ -12,23 +12,13 @@ def _unit_label(unit: str) -> str: @solara.component -def AnalysisResultsView(): +def AnalysisResultsView(theme_toggle=None): results = app_state.analysis_results.value if not results: return - # Live unit: read the reactive so the ha/m² toggle updates the display - # without requiring a recompute (rather than the stale value baked into - # the results dict at compute time). - unit = app_state.analysis_area_unit.value - with solara.Column(style="gap: 14px;"): - _OverallAccuracy(results) - _ConfusionMatrix(results) - _AreaEstimates(results, unit) - _Accuracy(results) - from component.widget.analysis_chart import AreaEstimateChart # Task 10 - - AreaEstimateChart(results, unit) - _Downloads() + from component.widget.analysis_dashboard import AnalysisSummaryCard + + AnalysisSummaryCard(theme_toggle=theme_toggle) @solara.component diff --git a/tests/test_analysis_dashboard.py b/tests/test_analysis_dashboard.py index fee4966..ce03fb5 100644 --- a/tests/test_analysis_dashboard.py +++ b/tests/test_analysis_dashboard.py @@ -1,4 +1,6 @@ # tests/test_analysis_dashboard.py +import ipyvuetify as v +import pytest import solara from component.model import app_state @@ -6,10 +8,23 @@ AnalysisDashboardModal, dashboard_kpis, ) +from component.widget.analysis_results import AnalysisResultsView from component.widget.echarts import EChartsWidget, RawEChartsWidget from tests.test_analysis_charts import _RESULTS +@pytest.fixture(autouse=True) +def _reset_analysis_results(): + """Reset the shared analysis_results reactive after each test. + + Several tests in this module set ``app_state.analysis_results.value`` + directly on the shared singleton without resetting it, which could + otherwise leak into tests that run later (in this file or others). + """ + yield + app_state.analysis_results.value = None + + def test_dashboard_kpis(): results = {**_RESULTS, "overall_accuracy": 0.85, "confidence_level": 95.0} k = dashboard_kpis(results) @@ -32,3 +47,17 @@ def test_modal_renders_all_charts_when_open(): # 3 typed charts (accuracy, area, pie) + 1 raw (heatmap) assert len(rc.find(EChartsWidget).widgets) == 3 assert len(rc.find(RawEChartsWidget).widgets) == 1 + + +def test_summary_card_shows_kpis_and_button(): + app_state.analysis_results.value = { + **_RESULTS, + "overall_accuracy": 0.85, + "confidence_level": 95.0, + } + _, rc = solara.render(AnalysisResultsView(theme_toggle=None), handle_error=False) + text = " ".join(str(c) for w in rc.find(v.Html).widgets for c in (w.children or [])) + assert "85.0%" in text + # a "Ver dashboard" button exists among the rendered buttons + labels = [str(c) for b in rc.find(v.Btn).widgets for c in (b.children or [])] + assert any("dashboard" in lbl.lower() for lbl in labels) From eb5a64edf24430f94cd24bc1c2ed872abe162f30 Mon Sep 17 00:00:00 2001 From: dfguerrerom Date: Wed, 15 Jul 2026 11:55:08 +0200 Subject: [PATCH 08/19] chore: drop dead _OverallAccuracy; fix analysis_chart docstring --- component/widget/analysis_chart.py | 6 +++++- component/widget/analysis_results.py | 9 --------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/component/widget/analysis_chart.py b/component/widget/analysis_chart.py index 0637215..2eae46c 100644 --- a/component/widget/analysis_chart.py +++ b/component/widget/analysis_chart.py @@ -1,4 +1,8 @@ -"""Bar chart of error-adjusted area per class, with confidence-interval bars.""" +"""Charts for the analysis results. + +Error-adjusted area bars, confusion-matrix heatmap, accuracy-by-class bars, +and estimated-area proportion pie. +""" import solara from ipecharts.option import Grid, Legend, Option, Title, Tooltip, XAxis, YAxis diff --git a/component/widget/analysis_results.py b/component/widget/analysis_results.py index 5598625..5af9ba1 100644 --- a/component/widget/analysis_results.py +++ b/component/widget/analysis_results.py @@ -21,15 +21,6 @@ def AnalysisResultsView(theme_toggle=None): AnalysisSummaryCard(theme_toggle=theme_toggle) -@solara.component -def _OverallAccuracy(results): - oa = results.get("overall_accuracy", 0.0) * 100 - ci = results.get("confidence_level", 95.0) - with solara.Column(gap="4px"): - Section("Overall accuracy", "mdi-bullseye-arrow") - solara.Markdown(f"**{oa:.1f}%** · confidence level {ci:.0f}%") - - @solara.component def _ConfusionMatrix(results): cm = results.get("confusion_matrix") From 488464997cbb5b196831655d71cd40494cb6b697 Mon Sep 17 00:00:00 2001 From: dfguerrerom Date: Wed, 15 Jul 2026 14:00:30 +0200 Subject: [PATCH 09/19] test: harden empty-matrix guard; cover chart edge cases --- component/widget/analysis_chart.py | 2 +- tests/test_analysis_charts.py | 37 ++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/component/widget/analysis_chart.py b/component/widget/analysis_chart.py index 2eae46c..471c2c4 100644 --- a/component/widget/analysis_chart.py +++ b/component/widget/analysis_chart.py @@ -103,7 +103,7 @@ def confusion_heatmap_data(confusion_matrix: dict): @solara.component def ConfusionMatrixChart(results: dict, theme_toggle=None): cm = results.get("confusion_matrix") - if not cm: + if not cm or not cm.get("data"): return x_labels, y_labels, triples, max_count = confusion_heatmap_data(cm) option = { diff --git a/tests/test_analysis_charts.py b/tests/test_analysis_charts.py index dd4a3a9..0222d16 100644 --- a/tests/test_analysis_charts.py +++ b/tests/test_analysis_charts.py @@ -104,3 +104,40 @@ def test_area_estimate_chart_has_transparent_background(): widgets = rc.find(EChartsWidget).widgets assert len(widgets) == 1 assert widgets[0].option.backgroundColor == "#1e1e1e00" + + +def test_confusion_matrix_chart_empty_matrix_renders_nothing(): + results = {"confusion_matrix": {"index": [], "columns": [], "data": []}} + _, rc = solara.render( + ConfusionMatrixChart(results, theme_toggle=None), handle_error=False + ) + assert len(rc.find(RawEChartsWidget).widgets) == 0 + + +def test_accuracy_by_class_chart_empty_renders_nothing(): + _, rc = solara.render( + AccuracyByClassChart({}, theme_toggle=None), handle_error=False + ) + assert len(rc.find(EChartsWidget).widgets) == 0 + + +def test_area_proportion_chart_empty_renders_nothing(): + _, rc = solara.render( + AreaProportionChart({}, theme_toggle=None), handle_error=False + ) + assert len(rc.find(EChartsWidget).widgets) == 0 + + +def test_area_proportion_chart_all_zero_area_does_not_crash(): + results = { + "class_estimates": [ + {"class_name": "A", "map_code": 1, "area_estimate": 0.0}, + {"class_name": "B", "map_code": 2, "area_estimate": 0.0}, + ] + } + _, rc = solara.render( + AreaProportionChart(results, theme_toggle=None), handle_error=False + ) + assert ( + len(rc.find(EChartsWidget).widgets) == 1 + ) # zero-total guard -> no ZeroDivisionError From 570bf6533fb4dda2366c268aee241fc84d3d8a95 Mon Sep 17 00:00:00 2001 From: dfguerrerom Date: Wed, 15 Jul 2026 17:31:59 +0200 Subject: [PATCH 10/19] feat: extract_map_codes samples a classification raster at points --- component/scripts/geospatial.py | 40 +++++++++++++++++ tests/test_extract_map_codes.py | 78 +++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 tests/test_extract_map_codes.py diff --git a/component/scripts/geospatial.py b/component/scripts/geospatial.py index 1a9cfa7..57830ba 100644 --- a/component/scripts/geospatial.py +++ b/component/scripts/geospatial.py @@ -13,6 +13,7 @@ import pandas as pd import rasterio from rasterio.transform import xy +from rasterio.warp import transform as warp_transform from rasterio.windows import Window from shapely.geometry import Point @@ -1243,3 +1244,42 @@ def get_crs_string(crs): info["error"] = str(e) return info + + +def extract_map_codes( + reference_df: pd.DataFrame, + raster_path: str, + x_col: str, + y_col: str, + points_crs: str = "EPSG:4326", +) -> "tuple[pd.DataFrame, int]": + """Sample a classification raster at each reference point to fill map_code. + + Reprojects the (x_col, y_col) points from points_crs to the raster CRS, + samples band 1, and returns (df_with_map_code, dropped_count). Rows whose + point falls outside the raster footprint or on the nodata value are dropped. + """ + df = reference_df.copy() + xs = df[x_col].astype(float).to_numpy() + ys = df[y_col].astype(float).to_numpy() + with rasterio.open(raster_path) as src: + if src.crs is not None and points_crs and str(src.crs) != str(points_crs): + rxs, rys = warp_transform(points_crs, src.crs, xs.tolist(), ys.tolist()) + else: + rxs, rys = xs.tolist(), ys.tolist() + left, bottom, right, top = src.bounds + nodata = src.nodata + pts = list(zip(rxs, rys)) + codes = [] + for (x, y), val in zip(pts, src.sample(pts)): + v = val[0] + inside = left <= x <= right and bottom <= y <= top + if inside and not (nodata is not None and v == nodata): + codes.append(int(v)) + else: + codes.append(None) + df["map_code"] = codes + dropped = int(df["map_code"].isna().sum()) + df = df[df["map_code"].notna()].copy() + df["map_code"] = df["map_code"].astype(int) + return df, dropped diff --git a/tests/test_extract_map_codes.py b/tests/test_extract_map_codes.py new file mode 100644 index 0000000..1a434e0 --- /dev/null +++ b/tests/test_extract_map_codes.py @@ -0,0 +1,78 @@ +import numpy as np +import pandas as pd +import rasterio +from rasterio.transform import from_origin + +from component.scripts.geospatial import extract_map_codes + + +def _write(path, data, crs, transform, nodata=None): + with rasterio.open( + path, + "w", + driver="GTiff", + height=data.shape[0], + width=data.shape[1], + count=1, + dtype=data.dtype, + crs=crs, + transform=transform, + nodata=nodata, + ) as dst: + dst.write(data, 1) + + +def test_samples_expected_classes(tmp_path): + data = np.array( + [[1, 1, 2, 2], [1, 1, 2, 2], [3, 3, 4, 4], [3, 3, 4, 4]], dtype=np.uint8 + ) + p = tmp_path / "clas.tif" + _write(p, data, "EPSG:4326", from_origin(0, 4, 1, 1)) + df = pd.DataFrame( + {"x": [0.5, 2.5], "y": [3.5, 0.5]} + ) # -> data[0][0]=1, data[3][2]=4 + out, dropped = extract_map_codes(df, str(p), "x", "y") + assert dropped == 0 + assert out["map_code"].tolist() == [1, 4] + + +def test_drops_out_of_bounds(tmp_path): + data = np.array([[1, 1], [1, 1]], dtype=np.uint8) + p = tmp_path / "clas.tif" + _write(p, data, "EPSG:4326", from_origin(0, 2, 1, 1)) + df = pd.DataFrame({"x": [0.5, 100.0], "y": [1.5, 100.0]}) + out, dropped = extract_map_codes(df, str(p), "x", "y") + assert dropped == 1 + assert out["map_code"].tolist() == [1] + + +def test_drops_nodata(tmp_path): + data = np.array([[5, 255], [5, 5]], dtype=np.uint8) + p = tmp_path / "clas.tif" + _write(p, data, "EPSG:4326", from_origin(0, 2, 1, 1), nodata=255) + df = pd.DataFrame({"x": [1.5], "y": [1.5]}) # -> data[0][1]=255 (nodata) + out, dropped = extract_map_codes(df, str(p), "x", "y") + assert dropped == 1 + assert out.empty + + +def test_reprojects_points(tmp_path): + # Raster in Web Mercator, placed ~500km from the coordinate origin so a + # broken implementation that forgot to reproject (used raw lon/lat as if + # already in EPSG:3857 metres) could never land inside the footprint by + # coincidence -- only a correct reprojection does. The point below is the + # mid-pixel of cell (row0, col0) -- x,y = (500500, 500500) in EPSG:3857 -- + # converted to EPSG:4326 via the spherical Web Mercator inverse formula + # and cross-checked against rasterio.warp.transform's forward transform + # (both agree to sub-metre precision; see task-1-report.md for the + # derivation). Using the raw (4.496068, 4.491461) directly as metres + # would fall far outside the raster's [500000, 502000] x [499000, 501000] + # bounds, so a non-reprojecting implementation fails this test (dropped + # == 1) rather than passing it by accident. + data = np.array([[7, 7], [7, 7]], dtype=np.uint8) + p = tmp_path / "merc.tif" + _write(p, data, "EPSG:3857", from_origin(500000, 501000, 1000, 1000)) + df = pd.DataFrame({"x": [4.496068], "y": [4.491461]}) + out, dropped = extract_map_codes(df, str(p), "x", "y") + assert dropped == 0 + assert out["map_code"].tolist() == [7] From df3350017af7abfe257d8053bb94dcb02a0370b4 Mon Sep 17 00:00:00 2001 From: dfguerrerom Date: Wed, 15 Jul 2026 17:46:08 +0200 Subject: [PATCH 11/19] fix: drop reference points on the raster right/bottom edge --- component/scripts/geospatial.py | 6 +++--- tests/test_extract_map_codes.py | 11 +++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/component/scripts/geospatial.py b/component/scripts/geospatial.py index 57830ba..ea390a6 100644 --- a/component/scripts/geospatial.py +++ b/component/scripts/geospatial.py @@ -1267,13 +1267,13 @@ def extract_map_codes( rxs, rys = warp_transform(points_crs, src.crs, xs.tolist(), ys.tolist()) else: rxs, rys = xs.tolist(), ys.tolist() - left, bottom, right, top = src.bounds nodata = src.nodata pts = list(zip(rxs, rys)) codes = [] - for (x, y), val in zip(pts, src.sample(pts)): + for (x, y), val in zip(pts, src.sample(pts, indexes=1)): + row, col = src.index(x, y) v = val[0] - inside = left <= x <= right and bottom <= y <= top + inside = 0 <= row < src.height and 0 <= col < src.width if inside and not (nodata is not None and v == nodata): codes.append(int(v)) else: diff --git a/tests/test_extract_map_codes.py b/tests/test_extract_map_codes.py index 1a434e0..e5d2e3d 100644 --- a/tests/test_extract_map_codes.py +++ b/tests/test_extract_map_codes.py @@ -56,6 +56,17 @@ def test_drops_nodata(tmp_path): assert out.empty +def test_drops_point_on_right_bottom_edge(tmp_path): + data = np.array([[1, 1], [1, 1]], dtype=np.uint8) + p = tmp_path / "edge.tif" + _write(p, data, "EPSG:4326", from_origin(0, 2, 1, 1)) # bounds x[0,2] y[0,2] + # (2.0, 1.0): x == right edge -> maps to col 2 (out of range) -> must be dropped + df = pd.DataFrame({"x": [2.0, 0.5], "y": [1.0, 0.5]}) + out, dropped = extract_map_codes(df, str(p), "x", "y") + assert dropped == 1 + assert out["map_code"].tolist() == [1] + + def test_reprojects_points(tmp_path): # Raster in Web Mercator, placed ~500km from the coordinate origin so a # broken implementation that forgot to reproject (used raw lon/lat as if From 1b3d9de91ca4cb9f3c73dcef18fedd81b8ed0260 Mon Sep 17 00:00:00 2001 From: dfguerrerom Date: Wed, 15 Jul 2026 17:53:20 +0200 Subject: [PATCH 12/19] feat: map area source derives map_code + areas from a raster --- component/analysis/service.py | 4 +++ component/model/state_manager.py | 7 ++++- component/scripts/accuracy.py | 20 +++++++++++++ tests/test_analysis_service.py | 16 +++++++++++ tests/test_derive_from_classification.py | 36 ++++++++++++++++++++++++ 5 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 tests/test_derive_from_classification.py diff --git a/component/analysis/service.py b/component/analysis/service.py index 574edbc..81d4cc0 100644 --- a/component/analysis/service.py +++ b/component/analysis/service.py @@ -44,6 +44,10 @@ def create_inputs_from_state(app_state) -> AnalysisInputs: if raw_area is not None and not raw_area.empty else pd.DataFrame() ) + elif app_state.analysis_area_source.value == "map": + # raster-derived area table is already canonical map_code / map_area + raw_area = app_state.analysis_area_df.value + area = raw_area.copy() if raw_area is not None else pd.DataFrame() else: # design side already yields map_code / map_area area = app_state.area_data.value diff --git a/component/model/state_manager.py b/component/model/state_manager.py index 4c56637..f32a144 100644 --- a/component/model/state_manager.py +++ b/component/model/state_manager.py @@ -82,8 +82,13 @@ def __init__(self): # --- Analysis (accuracy assessment) --- self.analysis_reference_df = solara.reactive(pd.DataFrame()) - self.analysis_area_source = solara.reactive("design") # "design" | "upload" + self.analysis_area_source = solara.reactive( + "design" + ) # "design" | "upload" | "map" self.analysis_area_df = solara.reactive(pd.DataFrame()) + self.analysis_classification_path = solara.reactive( + None + ) # raster path for "map" source self.analysis_column_mapping = solara.reactive({}) self.analysis_filter = solara.reactive(None) self.analysis_confidence_level = solara.reactive(95.0) diff --git a/component/scripts/accuracy.py b/component/scripts/accuracy.py index 9dbcdb2..cf31d74 100644 --- a/component/scripts/accuracy.py +++ b/component/scripts/accuracy.py @@ -153,6 +153,26 @@ def overall_accuracy(pij: pd.DataFrame) -> float: return float(np.trace(m.values)) +def derive_from_classification(reference_df, mapping, raster_path): + """Fill map_code by sampling a raster and compute the per-class area table. + + Returns (reference_df_with_map_code, area_df, dropped_count). The map wins: + any existing map_code column is overwritten by the sampled value. + """ + from component.scripts.geospatial import ( + compute_area_from_raster, + extract_map_codes, + ) + + x_col = mapping.get("x") + y_col = mapping.get("y") + if not x_col or not y_col: + raise ValueError("Classification-map analysis requires x/y column mapping.") + ref_out, dropped = extract_map_codes(reference_df, raster_path, x_col, y_col) + area_df = compute_area_from_raster(raster_path) + return ref_out, area_df, dropped + + def convert_area(value: float, unit: str) -> float: """Convert a native (m2) area for display. 'ha' -> /10000; else identity.""" if unit == "ha": diff --git a/tests/test_analysis_service.py b/tests/test_analysis_service.py index 68ce8b6..e24b38e 100644 --- a/tests/test_analysis_service.py +++ b/tests/test_analysis_service.py @@ -45,3 +45,19 @@ def test_not_ready_when_no_reference(): st.analysis_reference_df = _R(pd.DataFrame()) assert AnalysisService.is_ready(st) is False assert AnalysisService.get_validation_errors(st) + + +def test_create_inputs_map_source_uses_area_df(): + from component.model.state_manager import AppState + + st = AppState() + st.analysis_reference_df.value = pd.DataFrame( + {"map_code": [1, 2], "ref_code": [1, 1]} + ) + st.analysis_column_mapping.value = {"map": "map_code", "ref": "ref_code"} + st.analysis_area_df.value = pd.DataFrame( + {"map_code": [1, 2], "map_area": [100.0, 50.0]} + ) + st.analysis_area_source.value = "map" + inputs = AnalysisService.create_inputs_from_state(st) + assert list(inputs.area_data["map_area"]) == [100.0, 50.0] diff --git a/tests/test_derive_from_classification.py b/tests/test_derive_from_classification.py new file mode 100644 index 0000000..4249a3f --- /dev/null +++ b/tests/test_derive_from_classification.py @@ -0,0 +1,36 @@ +import numpy as np +import pandas as pd +import rasterio +from rasterio.transform import from_origin + +from component.scripts.accuracy import derive_from_classification + + +def _write(path, data): + with rasterio.open( + path, + "w", + driver="GTiff", + height=data.shape[0], + width=data.shape[1], + count=1, + dtype=data.dtype, + crs="EPSG:4326", + transform=from_origin(0, 4, 1, 1), + ) as dst: + dst.write(data, 1) + + +def test_derive_fills_map_code_and_areas(tmp_path): + data = np.array( + [[1, 1, 2, 2], [1, 1, 2, 2], [3, 3, 4, 4], [3, 3, 4, 4]], dtype=np.uint8 + ) + p = tmp_path / "clas.tif" + _write(p, data) + ref = pd.DataFrame({"lon": [0.5, 2.5], "lat": [3.5, 0.5], "ref_code": [1, 2]}) + mapping = {"x": "lon", "y": "lat", "ref": "ref_code"} + ref_out, area_out, dropped = derive_from_classification(ref, mapping, str(p)) + assert dropped == 0 + assert ref_out["map_code"].tolist() == [1, 4] + assert set(area_out.columns) >= {"map_code", "map_area"} + assert sorted(area_out["map_code"].tolist()) == [1, 2, 3, 4] From 890724417b5197474e21b8aeb932e346387ad17d Mon Sep 17 00:00:00 2001 From: dfguerrerom Date: Wed, 15 Jul 2026 18:14:38 +0200 Subject: [PATCH 13/19] feat: classification-map area source in the analysis tab Add a "map" area-source option that uploads a classification GeoTIFF and derives map_code + per-class areas by sampling it at the reference points, off the UI thread. Column mapping hides the map role and marks x/y required for that source. clear_analysis_data() now also drops the uploaded classification path. --- component/model/state_manager.py | 1 + component/widget/analysis_tab.py | 65 ++++++++++++++++++++++++++++--- tests/test_analysis_ui_widgets.py | 19 +++++++++ 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/component/model/state_manager.py b/component/model/state_manager.py index f32a144..6c993e2 100644 --- a/component/model/state_manager.py +++ b/component/model/state_manager.py @@ -574,6 +574,7 @@ def clear_analysis_data(self): self.analysis_reference_df.value = pd.DataFrame() self.analysis_area_source.value = "design" self.analysis_area_df.value = pd.DataFrame() + self.analysis_classification_path.value = None self.analysis_column_mapping.value = {} self.analysis_filter.value = None self.analysis_confidence_level.value = 95.0 diff --git a/component/widget/analysis_tab.py b/component/widget/analysis_tab.py index 2b507cd..8df3dbf 100644 --- a/component/widget/analysis_tab.py +++ b/component/widget/analysis_tab.py @@ -1,5 +1,6 @@ """Accuracy-assessment analysis UI: upload -> mapping -> compute -> results.""" +import asyncio import logging from pathlib import Path @@ -264,7 +265,9 @@ def close_ref_modal_when_loaded(): ) if ref_loaded: - _ColumnMappingCard(list(ref_df.columns)) + _ColumnMappingCard( + list(ref_df.columns), app_state.analysis_area_source.value + ) _AnalysisControls() _FilterCard(list(ref_df.columns)) @@ -319,7 +322,7 @@ def _ReferenceUploadDialog(ref_path, on_close=None): @solara.component -def _ColumnMappingCard(columns: list): +def _ColumnMappingCard(columns: list, area_source: str = "upload"): """Dropdowns mapping CSV columns to analysis roles.""" mapping = app_state.analysis_column_mapping.value options = [None, *list(columns)] @@ -332,13 +335,16 @@ def _set(value): return _set + map_source = area_source == "map" labels = { "map": "Map / predicted class *", "ref": "Reference class *", - "x": "X / longitude", - "y": "Y / latitude", + "x": "X / longitude *" if map_source else "X / longitude", + "y": "Y / latitude *" if map_source else "Y / latitude", "sample_area": "Per-sample area (optional)", } + if map_source: + del labels["map"] # map_code is derived from the raster with solara.Column(gap="4px"): Section("Column mapping", "mdi-swap-horizontal") for role, label in labels.items(): @@ -362,7 +368,7 @@ def _AnalysisControls(): solara.Select( label="Area / strata source", value=app_state.analysis_area_source.value, - values=["design", "upload"], + values=["design", "upload", "map"], on_value=lambda v: app_state.analysis_area_source.set(v), ) if app_state.analysis_area_source.value == "design" and not has_design_area: @@ -372,6 +378,8 @@ def _AnalysisControls(): ) if app_state.analysis_area_source.value == "upload": _AreaUpload() + if app_state.analysis_area_source.value == "map": + _ClassificationMapUpload() solara.Select( label="Confidence level (%)", value=app_state.analysis_confidence_level.value, @@ -490,3 +498,50 @@ def _set(v): values=[None, *cols], on_value=set_area_role("area_value"), ) + + +@solara.component +def _ClassificationMapUpload(): + """Pick a classification GeoTIFF and derive map_code + areas from it. + + Samples the raster at each reference point to fill map_code and computes + the per-class area table — the map is the source of truth. + """ + status = solara.use_reactive("") + path = app_state.analysis_classification_path + + def run_derivation(): + ref = app_state.analysis_reference_df.value + raster = path.value + if not raster or ref is None or ref.empty: + return + from component.scripts.accuracy import derive_from_classification + + ref_out, area_df, dropped = derive_from_classification( + ref, app_state.analysis_column_mapping.value or {}, raster + ) + mapping = dict(app_state.analysis_column_mapping.value or {}) + mapping["map"] = "map_code" + app_state.analysis_column_mapping.value = mapping + app_state.analysis_area_df.value = area_df + app_state.analysis_reference_df.value = ref_out + status.value = ( + f"{len(ref_out)} points sampled, {dropped} dropped " + "(outside raster / nodata)" + ) + + async def _derive_task(): + await asyncio.to_thread(run_derivation) + + solara.lab.use_task(_derive_task, dependencies=[path.value], prefer_threaded=False) + + if path.value: + with solara.Row(style="align-items: center; gap: 8px;"): + solara.Text(Path(path.value).name) + solara.Button( + icon_name="mdi-close", icon=True, on_click=lambda: path.set(None) + ) + else: + FileInputComponent(extensions=[".tif", ".tiff"], on_value=lambda p: path.set(p)) + if status.value: + solara.Text(status.value, style="opacity: 0.8;") diff --git a/tests/test_analysis_ui_widgets.py b/tests/test_analysis_ui_widgets.py index a0d152e..adb34d8 100644 --- a/tests/test_analysis_ui_widgets.py +++ b/tests/test_analysis_ui_widgets.py @@ -5,6 +5,7 @@ import solara from component.model import app_state +from component.model.state_manager import AppState from component.widget.analysis_results import _ConfusionMatrix from component.widget.analysis_tab import ( AnalysisPanel, @@ -182,3 +183,21 @@ def test_section_without_description_renders_only_title_text(): ] assert len(text_spans) == 1 assert "Generate Points" in (text_spans[0].children or []) + + +def test_column_mapping_hides_map_role_for_map_source(monkeypatch): + from component.widget import analysis_tab + + st = AppState() + st.analysis_area_source.value = "map" + st.analysis_column_mapping.value = {} + monkeypatch.setattr(analysis_tab, "app_state", st) + _, rc = solara.render( + analysis_tab._ColumnMappingCard(["a", "b"], area_source="map"), + handle_error=False, + ) + # Select labels are a v.Select widget trait (rendered client-side by + # Vuetify), not v.Html children, so inspect the Select widgets directly. + labels = " ".join(str(s.label) for s in rc.find(v.Select).widgets) + assert "Reference class" in labels + assert "Map / predicted" not in labels # map role hidden when the map derives it From d4e5a31ad9e74567f02b499000738fb2de21fb56 Mon Sep 17 00:00:00 2001 From: dfguerrerom Date: Wed, 15 Jul 2026 18:44:15 +0200 Subject: [PATCH 14/19] fix: surface classification-map derivation errors instead of crashing the page --- component/widget/analysis_tab.py | 18 ++++++-- tests/test_analysis_ui_widgets.py | 73 +++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/component/widget/analysis_tab.py b/component/widget/analysis_tab.py index 8df3dbf..ae2f9ab 100644 --- a/component/widget/analysis_tab.py +++ b/component/widget/analysis_tab.py @@ -517,9 +517,14 @@ def run_derivation(): return from component.scripts.accuracy import derive_from_classification - ref_out, area_df, dropped = derive_from_classification( - ref, app_state.analysis_column_mapping.value or {}, raster - ) + try: + ref_out, area_df, dropped = derive_from_classification( + ref, app_state.analysis_column_mapping.value or {}, raster + ) + except Exception as e: # surface, don't crash the page + status.value = f"Could not sample the classification map: {e}" + app_state.add_error(f"Classification-map analysis failed: {e}") + return mapping = dict(app_state.analysis_column_mapping.value or {}) mapping["map"] = "map_code" app_state.analysis_column_mapping.value = mapping @@ -533,7 +538,12 @@ def run_derivation(): async def _derive_task(): await asyncio.to_thread(run_derivation) - solara.lab.use_task(_derive_task, dependencies=[path.value], prefer_threaded=False) + solara.lab.use_task( + _derive_task, + dependencies=[path.value], + prefer_threaded=False, + raise_error=False, + ) if path.value: with solara.Row(style="align-items: center; gap: 8px;"): diff --git a/tests/test_analysis_ui_widgets.py b/tests/test_analysis_ui_widgets.py index adb34d8..bd9c623 100644 --- a/tests/test_analysis_ui_widgets.py +++ b/tests/test_analysis_ui_widgets.py @@ -1,11 +1,14 @@ """UI-widget tests for the analysis tab: current-table card and download menu.""" +import asyncio + import ipyvuetify as v import pandas as pd import solara from component.model import app_state from component.model.state_manager import AppState +from component.widget import analysis_tab from component.widget.analysis_results import _ConfusionMatrix from component.widget.analysis_tab import ( AnalysisPanel, @@ -201,3 +204,73 @@ def test_column_mapping_hides_map_role_for_map_source(monkeypatch): labels = " ".join(str(s.label) for s in rc.find(v.Select).widgets) assert "Reference class" in labels assert "Map / predicted" not in labels # map role hidden when the map derives it + + +def _walk_widgets(widget): + yield widget + for child in getattr(widget, "children", ()) or (): + yield from _walk_widgets(child) + + +def _run_with_task_loop(coro_factory): + """Run ``coro_factory()`` on a real event loop, then restore loop state. + + ``asyncio.run`` unconditionally clears the process' "current" event loop + when it tears down, which sticks for the rest of the test session and + breaks later tests that rely on ``asyncio.get_event_loop()``'s legacy + auto-create fallback (e.g. solara's task runner). Save whatever loop was + current beforehand and restore it afterward so this test stays isolated. + """ + try: + previous_loop = asyncio.get_event_loop_policy().get_event_loop() + except RuntimeError: + previous_loop = None + try: + return asyncio.run(coro_factory()) + finally: + asyncio.set_event_loop(previous_loop) + + +def test_classification_map_upload_survives_derivation_error(monkeypatch, tmp_path): + """A raster picked before x/y mapping must not crash the whole widget tree. + + Regression test for a bug where ``derive_from_classification`` raising + inside the ``use_task`` derivation (e.g. because x/y columns aren't + mapped yet) propagated out of render: with reacton's default + ``handle_error=True`` the whole widget tree -- clear button included -- + gets replaced by a raw traceback ``ipywidgets.HTML``, wedging the page. + Verified empirically against the pre-fix code: the tree collapsed to a + single traceback widget and the clear button vanished. Post-fix the + error is caught, surfaced via ``status`` + ``app_state.add_error``, and + the normal widget tree (clear button included) is preserved. + """ + raster_path = tmp_path / "classification.tif" + raster_path.write_bytes(b"") # never opened: the x/y check raises first + + st = AppState() + st.analysis_reference_df.value = pd.DataFrame( + {"x": [1, 2], "y": [3, 4], "ref_code": [1, 2]} + ) + st.analysis_column_mapping.value = {} # x/y NOT mapped yet + st.analysis_classification_path.value = str(raster_path) + monkeypatch.setattr(analysis_tab, "app_state", st) + + async def _runner(): + element = analysis_tab._ClassificationMapUpload.widget() + # Let the use_task's background thread run the derivation and the + # resulting re-render settle (see pysepal's export-test pattern). + for _ in range(20): + await asyncio.sleep(0.05) + return element + + element = _run_with_task_loop(_runner) + + widgets = list(_walk_widgets(element)) + # No raw traceback widget replaced the tree. + assert not [w for w in widgets if type(w).__name__ == "HTML"] + # The clear (mdi-close) button is still there -- the session isn't stuck. + assert [w for w in widgets if isinstance(w, v.Btn)] + # The error is surfaced through the app's error channel. + assert any( + "x/y column mapping" in msg for msg in st.error_messages.value + ), st.error_messages.value From 6eff5656ecd6ddaf739e092e096685f7dfeeab2d Mon Sep 17 00:00:00 2001 From: dfguerrerom Date: Wed, 15 Jul 2026 19:01:13 +0200 Subject: [PATCH 15/19] feat: visualize the analysis classification map + points --- component/widget/analysis_tab.py | 29 ++++- component/widget/sample_configuration.py | 6 +- tests/test_analysis_ui_widgets.py | 132 +++++++++++++++++++++++ 3 files changed, 159 insertions(+), 8 deletions(-) diff --git a/component/widget/analysis_tab.py b/component/widget/analysis_tab.py index ae2f9ab..19de4b8 100644 --- a/component/widget/analysis_tab.py +++ b/component/widget/analysis_tab.py @@ -147,7 +147,7 @@ def CurrentTableDisplay(title: str, df, name: str = "", on_clear=None): @solara.component -def AnalysisPanel(): +def AnalysisPanel(sbae_map=None): """Full analysis panel filling the Analysis tab.""" reading = solara.use_reactive(False) ref_path = solara.use_reactive(None) @@ -268,7 +268,7 @@ def close_ref_modal_when_loaded(): _ColumnMappingCard( list(ref_df.columns), app_state.analysis_area_source.value ) - _AnalysisControls() + _AnalysisControls(sbae_map=sbae_map) _FilterCard(list(ref_df.columns)) # Results section, always present like the design's Summary: the worked @@ -357,7 +357,7 @@ def _set(value): @solara.component -def _AnalysisControls(): +def _AnalysisControls(sbae_map=None): """Area source, confidence level, unit, and optional filter controls.""" with solara.Column(gap="8px"): Section("Options", "mdi-tune") @@ -379,7 +379,7 @@ def _AnalysisControls(): if app_state.analysis_area_source.value == "upload": _AreaUpload() if app_state.analysis_area_source.value == "map": - _ClassificationMapUpload() + _ClassificationMapUpload(sbae_map=sbae_map) solara.Select( label="Confidence level (%)", value=app_state.analysis_confidence_level.value, @@ -501,7 +501,7 @@ def _set(v): @solara.component -def _ClassificationMapUpload(): +def _ClassificationMapUpload(sbae_map=None): """Pick a classification GeoTIFF and derive map_code + areas from it. Samples the raster at each reference point to fill map_code and computes @@ -530,6 +530,25 @@ def run_derivation(): app_state.analysis_column_mapping.value = mapping app_state.analysis_area_df.value = area_df app_state.analysis_reference_df.value = ref_out + + # Already running off-thread (this function is invoked via + # asyncio.to_thread by _derive_task below), so call the map methods + # directly -- no nested asyncio.to_thread, no sync render-path call. + if sbae_map is not None: + colors = app_state.class_colors.value or {} + sbae_map.add_class_raster( + raster, colors, "Classification (analysis)", "clas_an" + ) + sbae_map.add_sample_points( + pd.DataFrame( + { + "longitude": ref_out[mapping["x"]], + "latitude": ref_out[mapping["y"]], + "map_code": ref_out["map_code"], + } + ) + ) + status.value = ( f"{len(ref_out)} points sampled, {dropped} dropped " "(outside raster / nodata)" diff --git a/component/widget/sample_configuration.py b/component/widget/sample_configuration.py index 08c3e77..f1d5435 100644 --- a/component/widget/sample_configuration.py +++ b/component/widget/sample_configuration.py @@ -157,7 +157,7 @@ def run_calculation(): point_generation_controller=point_generation_controller, ) else: - AnalysisTab() + AnalysisTab(sbae_map=sbae_map) @solara.component @@ -257,11 +257,11 @@ def DesignOutputs(sbae_map=None, theme_toggle=None, point_generation_controller= @solara.component -def AnalysisTab(): +def AnalysisTab(sbae_map=None): """Accuracy-assessment analysis (area estimation + accuracies).""" from component.widget.analysis_tab import AnalysisPanel - AnalysisPanel() + AnalysisPanel(sbae_map=sbae_map) @solara.component diff --git a/tests/test_analysis_ui_widgets.py b/tests/test_analysis_ui_widgets.py index bd9c623..4321937 100644 --- a/tests/test_analysis_ui_widgets.py +++ b/tests/test_analysis_ui_widgets.py @@ -3,8 +3,11 @@ import asyncio import ipyvuetify as v +import numpy as np import pandas as pd +import rasterio import solara +from rasterio.transform import from_origin from component.model import app_state from component.model.state_manager import AppState @@ -274,3 +277,132 @@ async def _runner(): assert any( "x/y column mapping" in msg for msg in st.error_messages.value ), st.error_messages.value + + +def test_analysis_panel_accepts_sbae_map(): + import inspect + + from component.widget.analysis_tab import AnalysisPanel + + fn = getattr(AnalysisPanel, "f", AnalysisPanel) + assert "sbae_map" in inspect.signature(fn).parameters + + +class _FakeSbaeMap: + """Records add_class_raster/add_sample_points calls instead of a real map.""" + + def __init__(self): + self.class_raster_calls = [] + self.sample_points_calls = [] + + def add_class_raster(self, path, class_colors, layer_name, key): + self.class_raster_calls.append( + { + "path": path, + "class_colors": class_colors, + "layer_name": layer_name, + "key": key, + } + ) + + def add_sample_points(self, points_df): + self.sample_points_calls.append(points_df) + + +def test_classification_map_upload_renders_layers_on_success(monkeypatch, tmp_path): + """A successful derivation adds the classification raster + ref points to sbae_map. + + Drives the real ``use_task`` derivation (real rasterio round-trip, same + fixture as ``test_derive_from_classification.py``) with a fake ``sbae_map`` + standing in for ``SbaeMap``, so this exercises the actual success-path + wiring rather than asserting it by inspection. + """ + data = np.array( + [[1, 1, 2, 2], [1, 1, 2, 2], [3, 3, 4, 4], [3, 3, 4, 4]], dtype=np.uint8 + ) + raster_path = tmp_path / "clas.tif" + with rasterio.open( + raster_path, + "w", + driver="GTiff", + height=data.shape[0], + width=data.shape[1], + count=1, + dtype=data.dtype, + crs="EPSG:4326", + transform=from_origin(0, 4, 1, 1), + ) as dst: + dst.write(data, 1) + + st = AppState() + st.analysis_reference_df.value = pd.DataFrame( + {"lon": [0.5, 2.5], "lat": [3.5, 0.5], "ref_code": [1, 2]} + ) + st.analysis_column_mapping.value = {"x": "lon", "y": "lat", "ref": "ref_code"} + st.analysis_classification_path.value = str(raster_path) + st.class_colors.value = {1: "#ff0000", 2: "#00ff00", 3: "#0000ff", 4: "#ffff00"} + monkeypatch.setattr(analysis_tab, "app_state", st) + + fake_map = _FakeSbaeMap() + + async def _runner(): + element = analysis_tab._ClassificationMapUpload.widget(sbae_map=fake_map) + # Let the use_task's background thread run the derivation and the + # resulting re-render settle (see pysepal's export-test pattern). + for _ in range(20): + await asyncio.sleep(0.05) + return element + + _run_with_task_loop(_runner) + + assert len(fake_map.class_raster_calls) == 1 + call = fake_map.class_raster_calls[0] + assert call["path"] == str(raster_path) + assert call["class_colors"] == st.class_colors.value + assert call["key"] == "clas_an" + + assert len(fake_map.sample_points_calls) == 1 + points_df = fake_map.sample_points_calls[0] + assert set(points_df.columns) >= {"latitude", "longitude", "map_code"} + assert points_df["map_code"].tolist() == [1, 4] + assert points_df["longitude"].tolist() == [0.5, 2.5] + assert points_df["latitude"].tolist() == [3.5, 0.5] + + +def test_classification_map_upload_skips_layers_without_sbae_map(monkeypatch, tmp_path): + """No sbae_map -> derivation still succeeds; no AttributeError from a None map.""" + data = np.array( + [[1, 1, 2, 2], [1, 1, 2, 2], [3, 3, 4, 4], [3, 3, 4, 4]], dtype=np.uint8 + ) + raster_path = tmp_path / "clas.tif" + with rasterio.open( + raster_path, + "w", + driver="GTiff", + height=data.shape[0], + width=data.shape[1], + count=1, + dtype=data.dtype, + crs="EPSG:4326", + transform=from_origin(0, 4, 1, 1), + ) as dst: + dst.write(data, 1) + + st = AppState() + st.analysis_reference_df.value = pd.DataFrame( + {"lon": [0.5, 2.5], "lat": [3.5, 0.5], "ref_code": [1, 2]} + ) + st.analysis_column_mapping.value = {"x": "lon", "y": "lat", "ref": "ref_code"} + st.analysis_classification_path.value = str(raster_path) + monkeypatch.setattr(analysis_tab, "app_state", st) + + async def _runner(): + element = analysis_tab._ClassificationMapUpload.widget() # sbae_map=None + for _ in range(20): + await asyncio.sleep(0.05) + return element + + _run_with_task_loop(_runner) + + assert st.analysis_reference_df.value["map_code"].tolist() == [1, 4] + assert not st.error_messages.value From 9bcf4a49630cfc7d9854e692bd347a54de1f759f Mon Sep 17 00:00:00 2001 From: dfguerrerom Date: Wed, 15 Jul 2026 19:17:14 +0200 Subject: [PATCH 16/19] fix: surface classification-map layer-render failures instead of failing silently --- component/widget/analysis_tab.py | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/component/widget/analysis_tab.py b/component/widget/analysis_tab.py index 19de4b8..4d9d989 100644 --- a/component/widget/analysis_tab.py +++ b/component/widget/analysis_tab.py @@ -531,28 +531,35 @@ def run_derivation(): app_state.analysis_area_df.value = area_df app_state.analysis_reference_df.value = ref_out + status.value = ( + f"{len(ref_out)} points sampled, {dropped} dropped " + "(outside raster / nodata)" + ) + # Already running off-thread (this function is invoked via # asyncio.to_thread by _derive_task below), so call the map methods # directly -- no nested asyncio.to_thread, no sync render-path call. + # A failure here (e.g. a corrupt raster the tile server can't open) + # must not discard the valid analysis results computed above. if sbae_map is not None: - colors = app_state.class_colors.value or {} - sbae_map.add_class_raster( - raster, colors, "Classification (analysis)", "clas_an" - ) - sbae_map.add_sample_points( - pd.DataFrame( + try: + colors = app_state.class_colors.value or {} + sbae_map.add_class_raster( + raster, colors, "Classification (analysis)", "clas_an" + ) + points = pd.DataFrame( { "longitude": ref_out[mapping["x"]], "latitude": ref_out[mapping["y"]], "map_code": ref_out["map_code"], } ) - ) - - status.value = ( - f"{len(ref_out)} points sampled, {dropped} dropped " - "(outside raster / nodata)" - ) + sbae_map.add_sample_points(points) + except Exception as e: # results are valid; only the map layer failed + app_state.add_error( + "Analysis ran, but the classification map could not be " + f"rendered on the map: {e}" + ) async def _derive_task(): await asyncio.to_thread(run_derivation) From fc5963a00c262d8bfb64e5c2cf95de2c3ded33e6 Mon Sep 17 00:00:00 2001 From: dfguerrerom Date: Wed, 15 Jul 2026 19:23:29 +0200 Subject: [PATCH 17/19] test: standalone classification-map analysis end-to-end --- tests/test_analysis_workflow.py | 48 +++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/test_analysis_workflow.py b/tests/test_analysis_workflow.py index 521d9ff..a2fbf46 100644 --- a/tests/test_analysis_workflow.py +++ b/tests/test_analysis_workflow.py @@ -2,9 +2,12 @@ import numpy as np import pandas as pd +import rasterio +from rasterio.transform import from_origin from component.analysis.service import AnalysisService from component.model.state_manager import AppState +from component.scripts.accuracy import derive_from_classification from component.widget.analysis_tab import ( AnalysisPanel, guess_column_mapping, @@ -12,6 +15,21 @@ ) +def _write(path, data): + with rasterio.open( + path, + "w", + driver="GTiff", + height=data.shape[0], + width=data.shape[1], + count=1, + dtype=data.dtype, + crs="EPSG:4326", + transform=from_origin(0, 4, 1, 1), + ) as dst: + dst.write(data, 1) + + def test_guess_column_mapping_matches_common_headers(): cols = ["id", "PredictedClass", "ReferenceClass", "location_x", "location_y"] m = guess_column_mapping(cols) @@ -69,3 +87,33 @@ def test_load_example_analysis_data_runs_analysis(): # area estimates sum to A_total (native units of the strata file) total_area = sum(c.area_estimate for c in res.class_estimates) assert np.isclose(total_area, res.total_area) + + +def test_standalone_map_analysis_end_to_end(tmp_path): + data = np.array( + [[1, 1, 2, 2], [1, 1, 2, 2], [3, 3, 4, 4], [3, 3, 4, 4]], dtype=np.uint8 + ) + p = tmp_path / "clas.tif" + _write(p, data) + ref = pd.DataFrame( + { + "lon": [0.5, 1.5, 2.5, 3.5, 0.5, 3.5], + "lat": [3.5, 3.5, 0.5, 0.5, 0.5, 3.5], + "ref_code": [1, 1, 4, 4, 3, 2], + } + ) + mapping = {"x": "lon", "y": "lat", "ref": "ref_code"} + ref_out, area_df, dropped = derive_from_classification(ref, mapping, str(p)) + assert dropped == 0 + + st = AppState() + st.analysis_reference_df.value = ref_out + st.analysis_area_df.value = area_df + st.analysis_area_source.value = "map" + st.analysis_column_mapping.value = {**mapping, "map": "map_code"} + st.analysis_confidence_level.value = 95.0 + results = AnalysisService.analyze_from_state(st) + assert results.success + d = results.to_dict() + assert d["confusion_matrix"] is not None + assert len(d["class_estimates"]) >= 1 From 65b7a3f9f85cdf9e32b01cbe6db76f579f2b8227 Mon Sep 17 00:00:00 2001 From: dfguerrerom Date: Wed, 15 Jul 2026 19:36:34 +0200 Subject: [PATCH 18/19] test: assert correctness (accuracy, areas, diagonal matrix) in standalone e2e --- tests/test_analysis_workflow.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/test_analysis_workflow.py b/tests/test_analysis_workflow.py index a2fbf46..ee107cd 100644 --- a/tests/test_analysis_workflow.py +++ b/tests/test_analysis_workflow.py @@ -115,5 +115,14 @@ def test_standalone_map_analysis_end_to_end(tmp_path): results = AnalysisService.analyze_from_state(st) assert results.success d = results.to_dict() - assert d["confusion_matrix"] is not None - assert len(d["class_estimates"]) >= 1 + # every reference point's map_code == ref_code by construction -> perfect accuracy + assert d["overall_accuracy"] == 1.0 + assert len(d["class_estimates"]) == 4 + # each of 4 classes covers 4 of 16 unit-area pixels -> per-class 4.0, total 16.0 + assert sum(c["area_estimate"] for c in d["class_estimates"]) == 16.0 + # perfect agreement => diagonal confusion matrix (no off-diagonal confusion) + cm = d["confusion_matrix"] + for i, row in enumerate(cm["data"]): + for j, val in enumerate(row): + if i != j: + assert val == 0 From 089169b873560bf5c0ad029f6760c676ba3c625e Mon Sep 17 00:00:00 2001 From: dfguerrerom Date: Wed, 15 Jul 2026 19:50:42 +0200 Subject: [PATCH 19/19] fix: derive class colors from the raster for the standalone map --- component/widget/analysis_tab.py | 13 +++++++++++++ tests/test_analysis_ui_widgets.py | 10 +++++++++- tests/test_extract_map_codes.py | 10 ++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/component/widget/analysis_tab.py b/component/widget/analysis_tab.py index 4d9d989..934e877 100644 --- a/component/widget/analysis_tab.py +++ b/component/widget/analysis_tab.py @@ -536,6 +536,19 @@ def run_derivation(): "(outside raster / nodata)" ) + # Standalone mode never runs the design-step upload that populates + # class_colors, so it's empty here -- without this, add_class_raster + # falls back to a continuous colormap and the map renders near-black. + # Populate it from the raster so both the map layer and the dashboard + # charts (which also read app_state.class_colors) get consistent + # categorical colors. Guarded so a real design-step palette is kept. + if not app_state.class_colors.value: + from component.scripts.geospatial import get_color_palette + + app_state.class_colors.value = get_color_palette( + raster, sorted(int(c) for c in area_df["map_code"].tolist()) + ) + # Already running off-thread (this function is invoked via # asyncio.to_thread by _derive_task below), so call the map methods # directly -- no nested asyncio.to_thread, no sync render-path call. diff --git a/tests/test_analysis_ui_widgets.py b/tests/test_analysis_ui_widgets.py index 4321937..7f23cb8 100644 --- a/tests/test_analysis_ui_widgets.py +++ b/tests/test_analysis_ui_widgets.py @@ -340,7 +340,10 @@ def test_classification_map_upload_renders_layers_on_success(monkeypatch, tmp_pa ) st.analysis_column_mapping.value = {"x": "lon", "y": "lat", "ref": "ref_code"} st.analysis_classification_path.value = str(raster_path) - st.class_colors.value = {1: "#ff0000", 2: "#00ff00", 3: "#0000ff", 4: "#ffff00"} + # Standalone mode: class_colors starts EMPTY, as it would with no + # design-step upload. run_derivation must derive it from the raster + # (see test assertions below) instead of leaving it empty. + assert st.class_colors.value == {} monkeypatch.setattr(analysis_tab, "app_state", st) fake_map = _FakeSbaeMap() @@ -360,6 +363,11 @@ async def _runner(): assert call["path"] == str(raster_path) assert call["class_colors"] == st.class_colors.value assert call["key"] == "clas_an" + # The map layer must never fall back to a continuous colormap: with no + # design-step upload, class_colors starts empty and run_derivation must + # derive it from the raster (one entry per class present in the raster). + assert call["class_colors"], "class_colors must not be empty (near-black map)" + assert set(call["class_colors"]) == {1, 2, 3, 4} assert len(fake_map.sample_points_calls) == 1 points_df = fake_map.sample_points_calls[0] diff --git a/tests/test_extract_map_codes.py b/tests/test_extract_map_codes.py index e5d2e3d..4980e3d 100644 --- a/tests/test_extract_map_codes.py +++ b/tests/test_extract_map_codes.py @@ -67,6 +67,16 @@ def test_drops_point_on_right_bottom_edge(tmp_path): assert out["map_code"].tolist() == [1] +def test_existing_map_code_is_overwritten(tmp_path): + data = np.array([[1, 1], [1, 1]], dtype=np.uint8) + p = tmp_path / "clas.tif" + _write(p, data, "EPSG:4326", from_origin(0, 2, 1, 1)) + df = pd.DataFrame({"x": [0.5], "y": [1.5], "map_code": [99]}) # bogus pre-existing + out, dropped = extract_map_codes(df, str(p), "x", "y") + assert dropped == 0 + assert out["map_code"].tolist() == [1] # raster value wins, 99 overwritten + + def test_reprojects_points(tmp_path): # Raster in Web Mercator, placed ~500km from the coordinate origin so a # broken implementation that forgot to reproject (used raw lon/lat as if