From 6e95973296c74e02a95089332437ed7ca770a6c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Sp=C3=A1=C4=8Dil?= Date: Fri, 2 Oct 2026 17:17:28 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20v=C3=A1ha=20prvku=20komponent=20po=C4=8D?= =?UTF-8?q?=C3=ADtan=C3=A1=20z=20komponent=20pro=C5=A1l=C3=BDch=20filtrem?= =?UTF-8?q?=20(#55)=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: váha prvku komponent počítaná z komponent prošlých filtrem (#55) Váha prvku_vaha = 1/n se dosud počítala ze všech komponent dokumentační jednotky ještě před filtrem na období a areál, takže při aktivním filtru váhy prvků jedné DJ nedávaly v součtu 1 (DJ se 4 komponentami, filtru vyhoví 1 → váha 0,25 místo 1). - nová funkce _component_entries: nejdřív vyfiltruje komponenty, pak přidělí váhu 1/n z těch, které prošly; DJ bez komponent má váhu 1 - smoke test vaha_komponent (4×0,25; 1 ze 4 → 1; 2 ze 3 → 2×0,5; bez komponent → 1) - README: pole prvek_vaha v tabulce atributů komponent - changelog v2.2.0 doplněn (bez povýšení verze) - OpenSpec změna openspec/changes/fix-component-feature-weight Ověřeno: živá data (Praha, novověk) – 0 z 1204 DJ se součtem vah ≠ 1, původní kód 921; check_sources, bandit, detect-secrets, flake8, ruff, pyqgis4-checker, smoke test v qgis/qgis:ltr i :stable. Implementace připravena AI (Claude, subagent), ověřena a zkontrolována. * openspec: archivovat fix-component-feature-weight (#55) Ruční test v QGIS ověřen správcem: akce i lokality s Načíst komponenty, bez filtru i s filtrem období – váhy prvků jedné DJ dávají součet 1 a počítají se jen z vyfiltrovaných komponent. Úkol 3.2 odškrtnut, změna archivována přes openspec archive --skip-specs. Připraveno s pomocí AI (Claude). --- README.md | 1 + amcr_viewer/amcr_tools.py | 91 ++++++++++++------- amcr_viewer/metadata.txt | 2 +- .../.openspec.yaml | 2 + .../design.md | 49 ++++++++++ .../proposal.md | 42 +++++++++ .../specs/component-features/spec.md | 35 +++++++ .../tasks.md | 37 ++++++++ tests/smoke_test.py | 72 +++++++++++++++ 9 files changed, 297 insertions(+), 34 deletions(-) create mode 100644 openspec/changes/archive/2026-10-02-fix-component-feature-weight/.openspec.yaml create mode 100644 openspec/changes/archive/2026-10-02-fix-component-feature-weight/design.md create mode 100644 openspec/changes/archive/2026-10-02-fix-component-feature-weight/proposal.md create mode 100644 openspec/changes/archive/2026-10-02-fix-component-feature-weight/specs/component-features/spec.md create mode 100644 openspec/changes/archive/2026-10-02-fix-component-feature-weight/tasks.md diff --git a/README.md b/README.md index c978730..8f628dd 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,7 @@ order *common → entity-specific → `pristupnost` → component fields*. | `komponenta` | Komponenta | Component identifier. | | `komponenta_areal` | Areál | Activity area \[settlement / burial area / field / …\]. | | `komponenta_obdobi` | Období | Period \[Neolithic / High Middle Ages–Modern Period / …\]. | +| `prvek_vaha` | Váha prvku | Feature weight: 1/*n*, where *n* is the number of features created from the same documentation unit after the period/area filters, so the weights of one documentation unit sum to 1. | ### 3.5 When a query returns nothing diff --git a/amcr_viewer/amcr_tools.py b/amcr_viewer/amcr_tools.py index 78955ff..896b062 100644 --- a/amcr_viewer/amcr_tools.py +++ b/amcr_viewer/amcr_tools.py @@ -386,6 +386,49 @@ def tr_code(code): return TRANSLATIONS.get(code, code) +def _component_entries(dj_meta, komps, passes): + """ + Builds the feature metadata entries of the "Načíst komponenty" + mode: one entry per component that passes the given predicate, + with the weight 1/n where n is the number of passing components, + so the weights of one documentation unit sum to 1 even when a + period/area filter removes some of them. A documentation unit + without components gets a single entry with empty component + fields and weight 1. + + dj_meta: metadata shared by the documentation unit (spread into + every entry); komps: its component documents; passes: predicate + komp -> bool deciding whether a component becomes a feature. + """ + if not komps: + # DJ without components – still one feature, weight 1 + return [{ + **dj_meta, + 'komponenta_id': "", + 'komponenta_areal': "", + 'komponenta_obdobi': "", + 'vaha': 1, + }] + + prochazejici = [komp for komp in komps if passes(komp)] + vaha = 1 / len(prochazejici) if prochazejici else 1 + + return [ + { + **dj_meta, + 'komponenta_id': komp.get('ident_cely', ""), + 'komponenta_areal': ( + komp.get('komponenta_areal') or {} + ).get('value', ""), + 'komponenta_obdobi': ( + komp.get('komponenta_obdobi') or {} + ).get('value', ""), + 'vaha': vaha, + } + for komp in prochazejici + ] + + def komp_projde_filtrem(komp, filter_areal, filter_datace, filters): # 'or {}' – the key may be present with a None value areal_id = (komp.get('komponenta_areal') or {}).get('id', "") @@ -826,31 +869,16 @@ def load_amcr_data(canvas, bb, filters=None, # One feature per component – # all data on a single row, no relations needed if komps: - komps_count = len(komps) - - for komp in komps: - if not komp_projde_filtrem( - komp, filter_areal, + # The weight is 1/n of the components + # that pass the period/area filter, + # so one DJ sums to 1 + for komp_meta in _component_entries( + dj_meta, komps, + lambda k: komp_projde_filtrem( + k, filter_areal, filter_datace, filters - ): - continue - - komp_meta = { - **dj_meta, - 'komponenta_id': komp.get( - 'ident_cely', - "" - ), - 'komponenta_areal': ( - komp.get('komponenta_areal') - or {} - ).get('value', ""), - 'komponenta_obdobi': ( - komp.get('komponenta_obdobi') - or {} - ).get('value', ""), - 'vaha': 1/komps_count, - } + ) + ): pian_lookup[dj_pian_value].append( komp_meta) target_pian_ids_count += 1 @@ -860,15 +888,12 @@ def load_amcr_data(canvas, bb, filters=None, if filter_areal or filter_datace: continue - empty_meta = { - **dj_meta, - 'komponenta_id': "", - 'komponenta_areal': "", - 'komponenta_obdobi': "", - } - pian_lookup[dj_pian_value].append( - empty_meta) - target_pian_ids_count += 1 + for komp_meta in _component_entries( + dj_meta, [], lambda k: True + ): + pian_lookup[dj_pian_value].append( + komp_meta) + target_pian_ids_count += 1 else: target_pian_ids_count += 1 pian_lookup[dj_pian_value].append(dj_meta) diff --git a/amcr_viewer/metadata.txt b/amcr_viewer/metadata.txt index 6ad24f7..f2b1522 100644 --- a/amcr_viewer/metadata.txt +++ b/amcr_viewer/metadata.txt @@ -27,7 +27,7 @@ changelog= v2.2.0 (2026-09-02) * Filter labels unified: "Specifikace nálezu" renamed to "Materiál" to match the attribute alias * README rewritten to match the current state of the code - * Tables for Akce and Lokality contain a field with feature weight, when Komponenty rendering is enabled + * Tables for Akce and Lokality contain a field with feature weight, when Komponenty rendering is enabled; the weights of one documentation unit sum to 1 also with period/area filters * The login state is verified before each download via /api/user/islogged; an expired session is renewed automatically * When a logged-in download falls back to anonymous access, a message bar warning says so (only access level A data) * Removing the stored credentials also logs the user out of the Digital Archive diff --git a/openspec/changes/archive/2026-10-02-fix-component-feature-weight/.openspec.yaml b/openspec/changes/archive/2026-10-02-fix-component-feature-weight/.openspec.yaml new file mode 100644 index 0000000..ad94bad --- /dev/null +++ b/openspec/changes/archive/2026-10-02-fix-component-feature-weight/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-02 diff --git a/openspec/changes/archive/2026-10-02-fix-component-feature-weight/design.md b/openspec/changes/archive/2026-10-02-fix-component-feature-weight/design.md new file mode 100644 index 0000000..a57cf44 --- /dev/null +++ b/openspec/changes/archive/2026-10-02-fix-component-feature-weight/design.md @@ -0,0 +1,49 @@ +## Context + +`load_amcr_data` in `amcr_viewer/amcr_tools.py` (section B) builds, for each +DJ with a PIAN and *Načíst komponenty* on, one metadata dict per component +and appends it to `pian_lookup[pian_id]`. The weight is set as +`'vaha': 1/komps_count` with `komps_count = len(komps)` computed **before** +the loop that skips components failing `komp_projde_filtrem`. The empty-DJ +branch leaves `vaha` out and the feature builder falls back to +`meta.get('vaha', 1)`. + +## Goals / Non-Goals + +**Goals:** weights of one DJ sum to 1 under any filter; the rule is +testable offline. + +**Non-Goals:** weighting across DJs or across records that share one PIAN +(a PIAN shared by several DJs still yields several features – the weight +only de-duplicates components of one DJ, as #55 asked); changing the layer +schema. + +## Decisions + +1. **Filter first, then weigh.** Build the list of passing components, then + set `vaha = 1/len(passing)`. Alternative – a second counting pass with + `sum(komp_projde_filtrem(...))` – duplicates the filter call and drifts + if the filter changes (#70 replaces it). +2. **Extract a small pure helper** (e.g. `_component_entries(dj_meta, komps, + passes)` returning the list of per-component dicts with `vaha`, where + `passes` is a predicate) so the smoke test can check weights without + QGIS layers or network. The helper must not depend on how components are + selected, so #70 can pass a different predicate. +3. **Explicit weight 1 for a DJ without components** instead of relying on + the `meta.get('vaha', 1)` default – the default stays as a safety net. + +## Risks / Trade-offs + +- Floating-point: 1/3 weights sum to 0.999…; acceptable for analyses, + test with a tolerance. +- The helper extraction touches a long function; keep the diff limited to + the component branch. + +## Verification + +- Smoke test cases: 4 components no filter → 4×0.25; filter keeps 1 of 4 → + weight 1; keeps 2 of 3 → 2×0.5; no components → 1 entry, weight 1. +- Full AGENTS.md check set (ltr + stable smoke test, pyqgis4-checker). +- Manual QGIS test by the user: download akce with *Načíst komponenty* and + a period filter, check in the attribute table that `prvek_vaha` sums to 1 + per `dj_id`. diff --git a/openspec/changes/archive/2026-10-02-fix-component-feature-weight/proposal.md b/openspec/changes/archive/2026-10-02-fix-component-feature-weight/proposal.md new file mode 100644 index 0000000..5445fe0 --- /dev/null +++ b/openspec/changes/archive/2026-10-02-fix-component-feature-weight/proposal.md @@ -0,0 +1,42 @@ +## Why + +Issue #55 added the `prvek_vaha` (feature weight) attribute: when *Načíst +komponenty* is on, every component of a documentation unit (DJ) becomes its +own feature on the same PIAN geometry, and the weight 1/*n* lets spatial +analyses count the geometry once. The unreleased implementation on +`version/v2.2.0` takes *n* from **all** components of the DJ, before the +period/area filter. With a component filter active the weights of one DJ no +longer sum to 1 (DJ with 4 components, 1 passes the Neolithic filter → one +feature with weight 0.25 instead of 1), so weighted counts are wrong exactly +when users filter. See the comment on #55. + +## What Changes + +- *n* in `prvek_vaha = 1/n` is the number of component features actually + created for the DJ, i.e. components that pass the period/area filters. +- The weights of all features created from one DJ sum to 1 with or without + filters. +- A DJ without components keeps its single feature with weight 1 (today the + value comes from a default; it becomes explicit). +- `README.md` documents `prvek_vaha` in the component fields table (it is + missing there today). +- Changelog entry under v2.2.0 in `amcr_viewer/metadata.txt` is extended + (the feature is unreleased, no separate version bump). + +## Capabilities + +### New Capabilities +- `component-features`: one feature per component of a fieldwork event or + site, and the weight attribute that de-duplicates shared geometries. + +### Modified Capabilities + +## Impact + +- `amcr_viewer/amcr_tools.py` – component feature creation in + `load_amcr_data` (section B, attribute parsing). +- `tests/smoke_test.py` – offline check of the weights. +- `README.md`, `amcr_viewer/metadata.txt` (changelog only). +- No change to the digiarchiv API contract, layer schema or stored settings. +- `filter-components-via-component-endpoint` (#70) changes how components + are selected; it builds on this change and must keep the weight rule. diff --git a/openspec/changes/archive/2026-10-02-fix-component-feature-weight/specs/component-features/spec.md b/openspec/changes/archive/2026-10-02-fix-component-feature-weight/specs/component-features/spec.md new file mode 100644 index 0000000..44f60b5 --- /dev/null +++ b/openspec/changes/archive/2026-10-02-fix-component-feature-weight/specs/component-features/spec.md @@ -0,0 +1,35 @@ +# Spec Delta + +## Purpose + +Describes how components of fieldwork events and sites become map features +and how their weight lets spatial analyses count a shared geometry once. + +## ADDED Requirements + +### Requirement: Weight of component features sums to one per DJ +When components are loaded as features, each feature SHALL carry the weight +`prvek_vaha = 1/n`, where *n* is the number of features created from the +same documentation unit in this download. Components excluded by the period +or area filter SHALL NOT count towards *n*. + +#### Scenario: No component filter +- **WHEN** a documentation unit has 4 components and no period or area filter is set +- **THEN** 4 features are created, each with weight 0.25 + +#### Scenario: Filter keeps some components +- **WHEN** a documentation unit has 4 components and the period filter matches 1 of them +- **THEN** 1 feature is created with weight 1 + +#### Scenario: Filter keeps two of three components +- **WHEN** a documentation unit has 3 components and the filter matches 2 of them +- **THEN** 2 features are created, each with weight 0.5, and their weights sum to 1 + +### Requirement: Documentation unit without components has weight one +When components are loaded and a documentation unit has no component, the +single feature created for it SHALL have weight 1 and empty component +fields. + +#### Scenario: DJ without components, no filter +- **WHEN** a documentation unit with a PIAN has no components and no component filter is set +- **THEN** one feature is created with empty component fields and weight 1 diff --git a/openspec/changes/archive/2026-10-02-fix-component-feature-weight/tasks.md b/openspec/changes/archive/2026-10-02-fix-component-feature-weight/tasks.md new file mode 100644 index 0000000..bb1990c --- /dev/null +++ b/openspec/changes/archive/2026-10-02-fix-component-feature-weight/tasks.md @@ -0,0 +1,37 @@ +# Tasks + +## 1. Weight computed from passing components + +- [x] 1.1 In `amcr_viewer/amcr_tools.py` extract the per-component entry + building of the *Načíst komponenty* branch into a pure helper that takes + the DJ metadata, the component list and a pass predicate, filters first + and sets `vaha = 1/len(passing)`; set `vaha = 1` explicitly for a DJ + without components; verify with `python3 tests/check_sources.py`, + `flake8 --isolated amcr_viewer/` and `ruff check .` +- [x] 1.2 Extend `tests/smoke_test.py` with an offline case for the helper + (4 components no filter → 4×0.25; 1 of 4 passes → 1.0; 2 of 3 pass → + 2×0.5, sum 1 within tolerance; no components → 1 entry, weight 1); + verify the smoke test passes in `qgis/qgis:ltr` and `qgis/qgis:stable` + +## 2. Documentation + +- [x] 2.1 Add `prvek_vaha` (alias *Váha prvku*) to the component fields + table in `README.md` with the rule "1/n, n = features created from the + same documentation unit after filters"; verify by reading the rendered + table +- [x] 2.2 Extend the v2.2.0 changelog bullet about the feature weight in + `amcr_viewer/metadata.txt` (weights of one documentation unit sum to 1 + also with period/area filters); no version bump – 2.2.0 is unreleased and + `CITATION.cff` already says 2.2.0; verify both versions match + +## 3. Verification + +- [x] 3.1 Run the full local check set from `AGENTS.md` (check_sources, + bandit, detect-secrets `--all-files`, flake8 `--isolated`, ruff, + pyqgis4-checker log empty, smoke test ltr + stable); verify all clean +- [x] 3.2 Manual test in QGIS (user): akce in a small window with *Načíst + komponenty* and one period filter; verify in the attribute table that + `prvek_vaha` sums to 1 per `dj_id` + - Verified by the maintainer 2026-10-02: akce and lokality with + *Načíst komponenty*, without and with a period filter – weights sum + to 1 per DJ and are computed only from the filtered components diff --git a/tests/smoke_test.py b/tests/smoke_test.py index 5eec5cb..4a3e415 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -289,12 +289,84 @@ def odhlaseni(): return "odhlášení, chyba sítě → zahozeno lokálně, bez session → nic" +def vaha_komponent(): + """ + _component_entries: the weight is 1/n of the components that pass + the predicate, so the weights of one documentation unit sum to 1 + even with a period/area filter active. + + The cases come from the spec of the fix (issue #55); the sums are + compared with a tolerance because 1/3 weights add up to 0.999… + """ + tools = amcr_viewer.amcr_tools + + def komponenta(ident, areal=None, obdobi=None): + return { + "ident_cely": ident, + "komponenta_areal": ({"id": areal} if areal else None), + "komponenta_obdobi": ({"id": obdobi} if obdobi else None), + } + + dj_meta = {"dj_id": "X-M-000001"} + + # 4 components, no filter: 4 features, each 0.25 + komps = [komponenta(f"K{i}") for i in range(4)] + zaznamy = tools._component_entries(dj_meta, komps, lambda k: True) + assert len(zaznamy) == 4, len(zaznamy) + assert all(z["vaha"] == 0.25 for z in zaznamy), \ + [z["vaha"] for z in zaznamy] + + # Period filter keeps 1 of 4: single feature with weight 1 + komps = [ + komponenta("K0", obdobi="neolit"), + komponenta("K1"), komponenta("K2"), komponenta("K3"), + ] + zaznamy = tools._component_entries( + dj_meta, komps, lambda k: k["komponenta_obdobi"] is not None + ) + assert len(zaznamy) == 1, len(zaznamy) + assert zaznamy[0]["vaha"] == 1.0, zaznamy[0]["vaha"] + + # Filter keeps 2 of 3: 2 features, each 0.5, sum 1 within tolerance + komps = [ + komponenta("K0", obdobi="neolit"), komponenta("K1", obdobi="bronz"), + komponenta("K2"), + ] + zaznamy = tools._component_entries( + dj_meta, komps, lambda k: k["komponenta_obdobi"] is not None + ) + assert len(zaznamy) == 2, len(zaznamy) + assert all(z["vaha"] == 0.5 for z in zaznamy), \ + [z["vaha"] for z in zaznamy] + assert abs(sum(z["vaha"] for z in zaznamy) - 1) < 1e-9 + + # Sum with tolerance also for an indivisible split (1/3) + komps = [komponenta(f"K{i}") for i in range(3)] + zaznamy = tools._component_entries(dj_meta, komps, lambda k: True) + assert abs(sum(z["vaha"] for z in zaznamy) - 1) < 1e-9 + + # No components: one entry with empty component fields, weight 1 + zaznamy = tools._component_entries(dj_meta, [], lambda k: True) + assert len(zaznamy) == 1, zaznamy + assert zaznamy[0]["vaha"] == 1, zaznamy[0]["vaha"] + assert zaznamy[0]["komponenta_id"] == "" + + # The shared DJ metadata and component fields travel along + komps = [komponenta("K0", areal="sidelni", obdobi="neolit")] + zaznamy = tools._component_entries(dj_meta, komps, lambda k: True) + assert zaznamy[0]["dj_id"] == "X-M-000001" + assert zaznamy[0]["komponenta_id"] == "K0" + + return "4×0.25; 1/4 → 1.0; 2/3 → 2×0.5; prázdné → 1" + + zkouska("scoped enumy", enumy) zkouska("UpdateCodelistsTask", uloha) zkouska("filtrační dialogy", dialogy) zkouska("filtr podle data", filtr_datumu) zkouska("stav přihlášení", prihlasovaci_stav) zkouska("odhlášení", odhlaseni) +zkouska("váha komponent", vaha_komponent) qgs.exitQgis()