From 5294bc854aa6d5f114be5e192b3b5830b59dd819 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:17:51 +0300 Subject: [PATCH 1/2] docs(architecture): match the docs to the code Audit of docs/architecture and AGENTS.md against the code: stale counts and member names, claims no code backs (statement_timeout read, messages read, bad-quality drop), the tpdefault rule, the converge database guard, owner sections for four key families, and history phrasing in living docs. --- AGENTS.md | 16 ++- SemiPlot/SemiPlot.AppHost/AppHost.cs | 3 +- .../SeededArchiveTests.cs | 4 +- .../PartitionScriptTests.cs | 5 +- .../PartitionScript.cs | 4 +- docs/architecture/README.md | 8 +- docs/architecture/bench.md | 46 ++++---- docs/architecture/charting.md | 21 ++-- docs/architecture/data-integration.md | 78 +++++++------ docs/architecture/overview.md | 36 +++--- docs/architecture/postgres-instance.md | 16 +-- docs/architecture/postgres-topology.md | 31 ++--- docs/architecture/scada-archive-observed.md | 7 +- docs/architecture/scada-archive.md | 10 +- docs/architecture/sources.md | 8 +- docs/architecture/testing-strategy.md | 40 ++++--- docs/architecture/trend-feature-spec.md | 14 +-- docs/architecture/trend-interaction.md | 61 ++++++---- docs/architecture/ui-text.md | 106 +++++++++++++----- docs/architecture/ui-theme.md | 15 +-- 20 files changed, 316 insertions(+), 213 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bd62297..75f9652 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,8 @@ next start except the theme, which every running process applies live `.editorconfig`'s style and quality analyzer rules fail `dotnet build` (`TreatWarningsAsErrors`, `EnforceCodeStyleInBuild`) and `dotnet format SemiPlot.slnx --verify-no-changes` alike, so a regression stops both the build and the pre-commit hook. The hook is `.githooks/pre-commit`; wire it -once per clone with `git config core.hooksPath .githooks`. Over the staged `.cs` files it runs +once per clone with `git config core.hooksPath .githooks`. It refuses a commit on `master` or `main`, +so branch first. Over the staged `.cs` files it runs `dotnet format --verify-no-changes` and the comment linter [terse](https://github.com/mrcsin/terse), pinned in `.config/dotnet-tools.json` and restored by the hook itself: ASCII-only source, a `` of at most three lines, no `//` essays, banners or @@ -131,7 +132,9 @@ Two projects, split on one axis: crosses a machine boundary (a socket or a conta - `SemiPlot.Tests.Unit` sets `failSkips` in `xunit.runner.json`, so no gated test may live there. `SemiPlot.Tests.Integration` carries no `xunit.runner.json`. - An xunit v3 test project is an executable: a hung test leaves `SemiPlot.Tests.Unit.exe` locked and - the next build fails with MSB3027 until it is killed. The container half is bounded at two minutes. + the next build fails with MSB3027 until it is killed. `PostgresContainerFixture` bounds the image + pull at two minutes, and the image build, the container start and the readiness wait at a second + two minutes. - The way to hang one: build `PenCatalogueSync` over `ImmediateScheduler.Instance`. Its loop runs through `ScheduleAsync` and waits on `Schedule(ReadInterval)` before every read, and `ImmediateScheduler` runs each wait by sleeping on the calling thread, so `Start()` never returns. A @@ -169,9 +172,9 @@ dotnet test SemiPlot.slnx --filter "FullyQualifiedName~TestMethodName" CI has two jobs: `unit-windows` runs `SemiPlot.Tests.Unit` on `windows-latest`; `linux` builds the solution once on `ubuntu-latest` and runs both test projects, the Docker daemon of the runner serving -the container fixture. A Windows-only API fails -the Linux leg only once a test executes the call (`CA1416` is a warning); a Windows path used as a -string does not fail it at all. The fail-only behavior is in `docs/architecture/testing-strategy.md`. +the container fixture. A call to a Windows-only API fails the build on both legs: `CA1416` is a +warning, and `TreatWarningsAsErrors` makes it an error. Behaviour that differs only at run time fails +the Linux leg once a test executes it, and a Windows path used as a string does not fail it at all. Test traits: `[Trait("Component", "Core|UI")]`, `[Trait("Area", "Data|Bridge|Chart|Di|Messages")]`, @@ -440,4 +443,5 @@ No abbreviations in names. This is the project overview file; do not add specifics here. See the machine-readable architecture docs in `docs/architecture/*` (English). Plans live in `docs/plans/` -(`YYYYMMDD-.md`; completed ones in `docs/plans/completed/`). +(`YYYYMMDD-.md`; completed ones in `docs/plans/completed/`), multi-PR roadmaps in +`docs/plans/roadmaps/`, and deferred follow-ups in `docs/plans/backlog.md`. diff --git a/SemiPlot/SemiPlot.AppHost/AppHost.cs b/SemiPlot/SemiPlot.AppHost/AppHost.cs index 499ff21..563bb1a 100644 --- a/SemiPlot/SemiPlot.AppHost/AppHost.cs +++ b/SemiPlot/SemiPlot.AppHost/AppHost.cs @@ -6,8 +6,7 @@ var builder = DistributedApplication.CreateBuilder(args); // The Aspire AppHost SDK does not add a project resource's assembly as a compile reference, so the -// role names and passwords cannot come from BenchRoles.cs (SemiPlot.Tools.ArchiveSeeder) directly; -// they are repeated here, as they were in the now-deleted scripts/bench-demo.ps1. +// role names and passwords repeat BenchRoles.cs (SemiPlot.Tools.ArchiveSeeder) here. const string StandDatabase = "semiplot_app"; const string MaintenanceDatabase = "postgres"; const string ProvisionedDatabase = "semiplot_provisioned"; diff --git a/SemiPlot/SemiPlot.Tests.Integration/SeededArchiveTests.cs b/SemiPlot/SemiPlot.Tests.Integration/SeededArchiveTests.cs index 2e12efe..125d6ee 100644 --- a/SemiPlot/SemiPlot.Tests.Integration/SeededArchiveTests.cs +++ b/SemiPlot/SemiPlot.Tests.Integration/SeededArchiveTests.cs @@ -39,8 +39,8 @@ public async Task PerLayerRowCountsMatchTheGenerator() counts.Should().Equal(_generated.Value); } - // A missing daily partition sends rows to the catch-all instead of failing the write, so a non-empty - // tpdefault is the documented fault signal (docs/architecture/scada-archive.md#reader-hazards). + // A missing daily partition sends rows to the catch-all instead of failing the write. The seeder creates + // every partition its rows need and the bench runs no SCADA, so tpdefault holds nothing here. [Fact] public async Task TheDefaultPartitionIsEmpty() { diff --git a/SemiPlot/SemiPlot.Tests.Unit/PartitionScriptTests.cs b/SemiPlot/SemiPlot.Tests.Unit/PartitionScriptTests.cs index 05e1b08..cbac26d 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/PartitionScriptTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/PartitionScriptTests.cs @@ -6,9 +6,8 @@ namespace SemiPlot.Tests.Unit; -// docs/architecture/scada-archive.md#database-objects for the tpYYYYmMMdDD name, #reader-hazards for -// why a missing partition matters: rows land in tpdefault, which the later slices read as a fault -// signal. +// docs/architecture/scada-archive.md#database-objects for the tpYYYYmMMdDD name; a missing partition +// sends the seeder's rows to tpdefault instead of failing the write. [Trait("Component", "Core")] [Trait("Area", "Data")] [Trait("Category", "Unit")] diff --git a/SemiPlot/SemiPlot.Tools.ArchiveSeeder/PartitionScript.cs b/SemiPlot/SemiPlot.Tools.ArchiveSeeder/PartitionScript.cs index cb1508a..1cf00d6 100644 --- a/SemiPlot/SemiPlot.Tools.ArchiveSeeder/PartitionScript.cs +++ b/SemiPlot/SemiPlot.Tools.ArchiveSeeder/PartitionScript.cs @@ -3,8 +3,8 @@ namespace SemiPlot.Tools.ArchiveSeeder; // One day partition per covered day, named tpYYYYmMMdDD (docs/architecture/scada-archive.md#database-objects); -// a row with no partition lands in tpdefault, a fault signal (#reader-hazards), so the writer creates them -// all before it copies anything. +// a row with no partition lands in tpdefault instead of failing the write (#reader-hazards), so the writer +// creates them all before it copies anything. public static class PartitionScript { // Invariant throughout: a machine running a non-Gregorian calendar would otherwise name the diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 3e2d077..471f9a5 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -33,8 +33,12 @@ implementation plans see `docs/plans/`. role's contract, the configuration tables and the columns read, the provisioning states the client must survive, retention and capacity. Installation, configuration, provisioning order and the role definitions are owned by SemiBase (`github.com/Semiteq/SemiBase`) and cross-referenced there. +- [postgres-topology.md](./postgres-topology.md) - the map of the database: who owns and who writes + each object, the archive's layers and partitions, the classes SemiPlot reads and writes through, the + provisioning states, and the bench. - [sources.md](./sources.md) — citation convention and the registry every factual claim resolves - against: vendor manual pages, vendor forum topics, our own measurements, our own decisions. + against: vendor manual pages, vendor forum topics, vendor support answers, our own measurements, our + own decisions. - [bench.md](./bench.md) — the seeded PostgreSQL bench: the seeder and the demo writer, the lattice they share, the layer thinning, the container fixture and its template-and-clone lifecycle, the application bench recipe, and the headless render and input guards. @@ -74,7 +78,7 @@ SemiBase creates `public.trends`, as `bench.md` and `postgres-instance.md` state | Platform | .NET 10 (`net10.0`), ships on Windows, C# 14 | | Desktop shell | Avalonia 12.0.5 (Win32 + Skia + HarfBuzz + `Semi.Avalonia`), ReactiveUI MVVM | | Chart renderer | ScottPlot 5 (`ScottPlot.Avalonia` 5.1.59, MIT, SkiaSharp) — native control | -| Data source | One read-only Npgsql data source (a connection pool) on the Simple-Scada archive (`trends` / `messages`) — history, extent and realtime alike; a history read holds up to four pooled connections at once (`data-integration.md#operation-to-statement`). No application server, no OPC UA client, no local TCP protocol | +| Data source | One read-only Npgsql data source (a connection pool) on the Simple-Scada archive (`trends`; the role may read `messages`, and no statement does) — history, extent and realtime alike; a history read holds up to four pooled connections at once (`data-integration.md#operation-to-statement`). No application server, no OPC UA client, no local TCP protocol | | Wide windows | The SCADA's own archive layers (`l = 1/2`); no summary tables, aggregator service, scheduler or extensions of ours (`history-read-path-evaluation.md`) | | Retention | One depth for all archived data; coarse layers cannot outlive raw data | | Tag names | Our own `semiplot_tags` table, one row per key SCADA writes, registered by `semiplot_register_new_pens()` — the archive stores numbers only | diff --git a/docs/architecture/bench.md b/docs/architecture/bench.md index b47711f..d8bf5c4 100644 --- a/docs/architecture/bench.md +++ b/docs/architecture/bench.md @@ -15,10 +15,11 @@ is remains in `scada-archive.md`; what SemiPlot reads from it remains in `data-i | Template build, the clones, teardown | `SemiPlot.Tests.Integration` | The archive table is the provisioner's, not this repository's: nothing here transcribes the vendor's -DDL. The seeder creates the day partitions its rows land in and nothing else, and never destroys. -An absent `public.trends` is a failure naming the provisioning that did not run; a table already -carrying rows or day partitions is refused. Only the test fixture drops databases, and only ones it -created itself. +DDL. The seeding run creates the day partitions its rows land in and nothing else, and issues no +`DROP`. An absent `public.trends` is a failure naming the provisioning that did not run; a table already +carrying rows or day partitions is refused. Two things drop databases: the test fixture, only ones it +created itself, and `converge`, which recreates the `semiplot_` database `--connection` names with +`DROP DATABASE IF EXISTS ... WITH (FORCE)` (`#the-converge-verb`). ## The standard slice @@ -78,6 +79,8 @@ run. `--follow ` runs the seeder as a demo writer: it appends to an archive somebody else seeded, so it plants no break and fills no tag catalogue. `--end`, `--days` and `--admin-connection` are rejected in this mode. `--pens`, `--seed` and `--change-seconds` mean what they mean in a seeding run. +`--follow` and `--change-seconds` each take a finite number of seconds above 0 and at most one day, +`FollowOptions.MaximumSeconds` (86 400), in every run. ```powershell dotnet run --project SemiPlot/SemiPlot.Tools.ArchiveSeeder/SemiPlot.Tools.ArchiveSeeder.csproj -- ` @@ -165,10 +168,10 @@ reason. The fixture builds that image in-process from the `bench/` directory copied to the output directory, under the fixed tag `semiplot-bench:test`. Readiness is the wait strategy's `psql` against `public.trends` over TCP — the entrypoint's temporary server listens on the unix socket only -— so a container whose provisioning did not complete never becomes ready. The pull, the build, the -start and the readiness wait share one two-minute bound, -`PostgresContainerFixture._startupBound`, so a bench that never comes up fails rather than hanging -`SemiPlot.Tests.Integration.exe`. +— so a container whose provisioning did not complete never becomes ready. The build, the start and +the readiness wait share one two-minute bound, `PostgresContainerFixture._startupBound`, so a bench that +never comes up fails rather than hanging `SemiPlot.Tests.Integration.exe`. The pull ahead of them runs +under a two-minute bound of its own, and a pull that runs out of it is reported like any failed pull. `PostgresContainerFixture` maps the runtime's `localhost` host to `127.0.0.1` and passes any other host through, so a remote `DOCKER_HOST` that reports a hostname is not a supported setup: the connection @@ -236,14 +239,15 @@ dotnet run --project SemiPlot/SemiPlot.Tools.ArchiveSeeder -- converge ` --config-dir SemiPlot\Artifacts\dev-config ``` -It waits for the admin connection up to 60 s, then unconditionally `DROP DATABASE IF EXISTS ... -WITH (FORCE)` and `CREATE DATABASE ... TEMPLATE semiplot_provisioned` against the database -`--connection` names, seeds it with `SeederOptions` at the defaults (`--change-seconds` may override +It first refuses a database `--connection` names unless the name starts with `semiplot_` and is not +`semiplot_provisioned` itself (`Converge.RequireBenchDatabase`), before it opens any connection. It then +waits for the admin connection up to 60 s, runs `DROP DATABASE IF EXISTS ... WITH (FORCE)` and +`CREATE DATABASE ... TEMPLATE semiplot_provisioned` against that database, seeds it with `SeederOptions` at the defaults (`--change-seconds` may override the change interval; the AppHost passes the writer's 0.5 s so the seeded day and the live tail share one density) up to `--end` or this machine's clock, fills the catalogue through `--admin-connection` re-pointed at the stand database, and writes `connection/connection.yaml` under `--config-dir` with the bench `semiplot` role's fixed -password. +password and a `poll_interval_ms` of 1000. The catalogue is written once, here. `TagCatalogWriter` upserts `semiplot_tags`, inserts the group names into `semiplot_groups` and replaces each pen's rows in `semiplot_pen_groups`, all three in one @@ -286,8 +290,8 @@ construction, on a fixed host port with `isProxied: false` and one database name | `%TEMP%\SemiPlot\ConfigFiles` | A copy of the repository's `ConfigFiles/`, `converge`'s rewritten `connection/connection.yaml` included | Swept and recreated at start, removed on `ApplicationStopping` | | `%TEMP%\SemiPlot\Logs` | `semiplot.log`, and the `semiplot_NNN.log` files it rolls into at 5 MB | Swept and recreated at start, kept after the stand stops | -`DemoDirectories.Prepare` resolves the tracked set two levels above `builder.AppHostDirectory` and -throws `DirectoryNotFoundException` when it is absent, so a missing source fails the AppHost at once +`AppHost.cs` resolves the tracked set two levels above `builder.AppHostDirectory`, and +`DemoDirectories.Prepare` throws `DirectoryNotFoundException` when it is absent, so a missing source fails the AppHost at once rather than producing an empty copy the viewer reports later. The AppHost passes `%TEMP%\SemiPlot\ConfigFiles` as `--config-dir` to both `converge` and the viewer, and the log path plus `--logging-level information` to the viewer, which needs all three keys. @@ -300,7 +304,9 @@ removes the configuration copy when it stops (`DemoDirectories.cs`). Serilog file sink declared `shared: true`, so deleting the directory during shutdown would race that handle, and the log of a run that failed is the one thing worth keeping after the stand is gone. The sweep deletes file by file and tolerates a file still held open, so a viewer that outlived its stand -cannot stop the next one from starting. `ApplicationStopping` does not fire when the AppHost is +cannot stop the next one from starting. The configuration sweep tolerates no survivor: +`DemoDirectories.RequireSwept` fails the AppHost start with an `IOException` naming every file the sweep +left, because such a file would merge with the fresh copy. `ApplicationStopping` does not fire when the AppHost is killed rather than stopped; the sweep at the next start is the backstop for that path. A window started from the viewer's `File` menu holds `semiplot.log` open the same way and can outlive the stand. @@ -322,8 +328,8 @@ file for a refused login, delete the catalogue rows for an empty catalogue. Nothing machine-dependent lives in `AppHost.cs`: the role passwords are `BenchRoles`' public constants, the port is 55432, the configuration and log paths come from `DemoDirectories`, and the seeder and the viewer both take the machine's own time zone. The -`Live demo` run configuration at the repository root's `.run/` is a `DotNetProject` configuration -over `SemiPlot.AppHost`, in the shape of `Debug.run.xml`. +`Live demo` run configuration at the repository root's `.run/` is an `AspireHostConfiguration` over +`SemiPlot.AppHost`'s `http` launch profile. The launchers that start the viewer alone - `.run/Debug.run.xml` and the `.zed/` run and debug entries - point `--config-dir` at `SemiPlot/Artifacts/dev-config` through `$PROJECT_DIR$` and @@ -347,8 +353,10 @@ where a realised control sits in the strip, at what size and with which figures. | `UI/Minimap/MinimapHoverTests` | headless pointer events into `MinimapView` | a hover shows a line at the pointer and its time centred under it in the label row, inside the row at either end, where the time hides only the end label it covers, also after a new extent relabels that label; the line stays inside the strip at its right edge and keeps its fraction through a resize; a newer sample relabels the time; a drag keeps the line under the pointer, and a move off the strip or an exit hides the line and the time | | `UI/Minimap/MinimapViewTests` | a realised `MinimapView` with a published band | the strip holds the band, the marker and the hover line in that order and no baseline; the band is sized to the strip, with figures in the pen's colour that a pan leaves as they are; the end labels sit in a row under the strip, and the view keeps its height when the extent fills them | -**The render guard.** `Plot.RenderInMemory(width, height)` rasterises through SkiaSharp with no -Avalonia in the loop, so it is a plain `[Fact]`; `Plot.RenderManager.LastRender.Layout.DataRect` +**The render guard.** `Plot.GetImage(width, height)` rasterises through SkiaSharp with no Avalonia in +the loop. `LogAxisRenderTests` builds a bare `Plot` and is a plain `[Fact]`; `ChartGapRenderTests` builds +its plot through `TrendChartViewModel`, a ReactiveUI object, and runs under `[AvaloniaFact]`. +`Plot.RenderManager.LastRender.Layout.DataRect` describes that same render and `Plot.GetPixel(Coordinates)` maps a time to its pixel column. Detection is by colour band — a dominance test across the channels — never by exact byte, so antialiasing and theme changes move bytes without moving the answer. A golden image would fail for diff --git a/docs/architecture/charting.md b/docs/architecture/charting.md index 89fef0e..5df8f61 100644 --- a/docs/architecture/charting.md +++ b/docs/architecture/charting.md @@ -139,7 +139,10 @@ carried shows again only when a later read covers that span. share one, whatever their unit or group. Every axis is a left axis — `Axes.Left` for the first pen, `AddLeftAxis()` for the rest — because only the active pen's axis is drawn and alternating sides would move it across the plot as the active pen changes. Non-active axes are -`IsVisible = false`, and the active-pen switch toggles visibility without rebuilding. Scaling is +`IsVisible = false`, and the active-pen switch toggles visibility without rebuilding. `ChartAxisBinder` +fixes the drawn axis's panel at 56 px (`AxisPanelWidth`, as both `MinimumSize` and `MaximumSize`) and a +hidden axis's at 0, so the data area keeps its place when the active pen changes; a wider tick label +overflows the panel. Scaling is driven per-axis via `SetLimitsY(min, max, axis)` from the Core `PenScaleModel` output (no global `AutoScale`). Every plottable is pinned to `plot.Axes.Bottom` explicitly at creation, so all pens share one X axis and per-pen axes are Y-only. @@ -466,9 +469,10 @@ models, backed by renderer-agnostic models in `SemiPlot.Core`. Responsibilities: every visible pen's value at the cursor X; gap or missing pen → dash) that feeds the Avalonia overlay `TextBlock`. No plottable; unit-tested as plain `[Fact]`. - `Chart/ChartCursorOverlay` — pure projection (no Avalonia/`AvaPlot` deps): cursor pixel X + `DataRect` - + render scale → crosshair endpoints + readout anchor in DIP space, clamped to the data rect; unit-tested. + + render scale → crosshair endpoints in DIP space, or hidden when the cursor X falls outside the data + rect's X range; unit-tested. The view (`TrendChartView`) renders the result onto a transparent overlay `Canvas` (crosshair `Line` + - readout `Border`), suppressed during drag / delta mode. + readout `Border` at the line's top end), suppressed during drag / delta mode. - `Chart/LeftButtonTool` (enum `Pan | DeltaPlacement`) — the single left-button gesture state, sourced from the navigation bar's delta toggle. - `Chart/ChartAxisRegion` — axis band hit-tests from the last render layout: `TryCreate`, the active pen's @@ -476,7 +480,8 @@ models, backed by renderer-agnostic models in `SemiPlot.Core`. Responsibilities: (`ScaleActivePen`); `TryCreateTimeAxis`, the bottom axis band under the data area, for the press (`PanOrEditTimeWindow`). - `Chart/ChartPressRouter` — the pure routing of a left press (`EditAxisScale`, `ShiftActivePen`, - `PanOrEditTimeWindow`, `PlaceDeltaCursor`, `DragMarker`, `PanOrPickPen`) and the 4 px click slop that + `PanOrEditTimeWindow`, `PlaceDeltaCursor`, `DragMarker`, `DeleteMarker`, `PanOrPickPen`, `AddMarker`) + and the 4 px click slop that separates a click from a pan. - `Chart/ChartWheelRouter` — the pure routing of a wheel event (`ScaleActivePen`, `ShiftActivePen`, `PanTime`, `ZoomTime`, `None`) and its notch sign (`trend-interaction.md`, "The wheel map"). @@ -495,9 +500,8 @@ models, backed by renderer-agnostic models in `SemiPlot.Core`. Responsibilities: `Chart/TrendChartView.Markers.cs`. - `Chart/LocalTimeAxis` — UTC↔local-OADate conversion at every render boundary. - `Navigation/NavigationBarView` + `NavigationBarViewModel` — time navigation only: jump-to-now, - sticky toggle, delta-mode toggle + inline Δt/Δy readout (ReactiveUI commands). Autoscale, the two - limit boxes and set-limits left with the axis scale panel taking them over; the layer label left - for the status bar. + sticky toggle, delta-mode toggle + inline Δt/Δy readout (ReactiveUI commands). The axis scale panel + holds autoscale, the two limit boxes and set-limits, and the status bar shows the layer. - `Navigation/WallClockRangeForm` — the date-plus-`HH:mm:ss` range fields and the five range rules that `Export/ExportRangeForm` and `Navigation/TimeWindowForm` derive from; `TimeWindowForm` adds the width field, the skipped-time and span rules and the per-edge comparison with its seed. @@ -565,7 +569,8 @@ models, backed by renderer-agnostic models in `SemiPlot.Core`. Responsibilities: - `MainWindow/MainWindow` + `MainWindowViewModel` — the six-row window grid, the flags its View menu writes and the menu's two axis commands under View -> Pen scale, which call `AutoscaleActivePen` and `RestoreInitialScale` on the chart, and the submenu header that names the drawn pen; the window's - code-behind owns the three view-side requests (close, About dialog, settings dialog). + code-behind owns the five view-side requests (close, About dialog, settings dialog, pen editor, export + window). - `MainWindow/AppMenuBar` — the File / Edit / View / Help menu. Each checkable item reads its flag `Mode=OneWay` and writes it only through the command it invokes (`AGENTS.md`, UI). - `MainWindow/AppStatusBar` + `AppStatusBarViewModel` — current connection state and the active diff --git a/docs/architecture/data-integration.md b/docs/architecture/data-integration.md index 156440d..81318f4 100644 --- a/docs/architecture/data-integration.md +++ b/docs/architecture/data-integration.md @@ -93,8 +93,9 @@ Contract points every implementation keeps: - `QueryArchiveDaysAsync` takes both days inclusive and answers in the archive's wall-clock days, the local days its partitions cut, with no time conversion (Time boundary, below). A day "holds a row" when at least one `l = 0` row of a catalogue pen is dated that day; a partition that exists is no evidence, - because a partition is created ahead of its day and outlives a stopped server. The time window panel is - its only caller (`trend-interaction.md#the-time-window-panel`). A cancelled read ends in + because a partition is created ahead of its day and outlives a stopped server. The time window panel and + the export window call it, both through `TrendCoordinator.QueryArchiveDaysAsync` + (`trend-interaction.md#the-time-window-panel`, `overview.md#the-export-window`). A cancelled read ends in `OperationCanceledException`, as a history read does. The CSV export reads through a third interface, `IArchiveRowReader` @@ -313,15 +314,15 @@ of the repeated autumn hour. Every statement the provider, the editor, the row reader and the marker store issue is a constant in `SemiPlot.DataSource.Postgres/ArchiveStatements.cs`; parameters are always bound. The bench seeder and the test harness own SQL of their own (`bench.md`). `ArchiveStatementTextTests` pins each -provider constant below clause by clause; `ExplainPlanTests` asserts each plan's shape against a -container. The editor's and the marker store's statements are in their own tables above. +provider constant below clause by clause except `PenCatalog` and `ArchiveExtent`; `ExplainPlanTests` +asserts the plan shape of each one except `PenCatalog` against a container. The editor's and the marker store's statements are in their own tables above. | Operation | Constant | What the statement must keep, and why | | --- | --- | --- | | Pen catalogue | `PenCatalog` | `semiplot_tags` left-joined through `semiplot_pen_groups` to `semiplot_groups`, the names aggregated with `array_agg` under `GROUP BY tag.id`: a pen in two groups stays one row and a pen in none survives the outer join. `ORDER BY tag.name, tag.id`, because a pen has no single group to sort by, and the id keeps two pens that share a name in one order across reads. An empty table is an empty list; a missing one is `ArchiveFault.TableMissing` naming all three relations. | | Archive extent | `ArchiveExtent` | Rooted at `semiplot_tags`, one `min(t)`/`max(t)` subquery pair per configured `id` at `l = 0`. A bare `min(t)` over `trends` cannot use `PRIMARY KEY (id, l, t)` and scans the archive. Nulls map to `ArchiveExtent.Empty`; an empty catalogue over a full archive is also `Empty`, since no pen could draw it. | | Archive days | `ArchiveDays` | `generate_series(@firstDay, @lastDay, interval '1 day')` filtered by `EXISTS` over `semiplot_tags` with the extent's lateral probe per `id`: `l = 0 AND t >= day.start AND t < day.start + interval '1 day' ORDER BY t LIMIT 1`, then `ORDER BY 1`. The probe stops at the first row of each pen's day, and the `ORDER BY t` keeps it one index edge rather than a `Bitmap Heap Scan` over the pen's whole day ("Read cost"). Both days bind as `Timestamp` values of kind `Unspecified` at midnight, unconverted. An empty catalogue returns no day; a failure maps with `TagCatalogAndTrendsRelations`, `semiplot_tags, trends`. | -| History | `BucketedHistoryWindow` | Two branches at `l = @layer` under one outer `ORDER BY id, t`. The seed branch reads per pen one row strictly before `@from`, bounded to the wider of the window and one day: the bound prunes older partitions from the seed's `Merge Append`, and the seed keeps a steady variable on the chart as a horizontal line. The server reduces the window branch to one row per column: `GROUP BY id, segment, date_bin(@bucket, t, @from)`. `segment` counts the `q = 32` markers strictly before each row, so a marker closes its own bucket and no bucket straddles a break. Each bucket carries `min(v)`, `max(v)`, whether it ends in a gap, and the bucket's newest non-null sample as the column's timestamp and value, which is what the legend reads at the cursor. A `q = 32` marker and a null `v` both end the bucket in a gap. `BucketedRowFold` groups by consecutive identifier, so the single total ordering keeps each pen one run. Every layer takes this statement: a coarse row is a verbatim raw row and the markers are copied into the coarse layers (`scada-archive.md#layers`), so the buckets and the break split hold at `Minute` and `Hour` as at `Raw`. `@bucket` is `(to - from) / targetColumnCount`, never below one millisecond, the column's own resolution. | +| History | `BucketedHistoryWindow` | Two branches at `l = @layer` under one outer `ORDER BY id, t`. The seed branch reads per pen one row strictly before `@from`, bounded to the wider of the window and one day: the bound prunes older partitions from the seed's `Merge Append`, and the seed keeps a steady variable on the chart as a horizontal line. The server reduces the window branch to one row per column: `GROUP BY id, segment, date_bin(@bucket, t, @from)`. `segment` counts the `q = 32` markers strictly before each row, so a marker closes its own bucket and no bucket straddles a break. Each bucket carries `min(v)`, `max(v)`, whether it ends in a gap, and the bucket's newest non-null sample as the column's timestamp and value, which is what the legend reads at the cursor. A `q = 32` marker and a null `v` both end the bucket in a gap. `BucketedRowFold` groups by consecutive identifier, so the single total ordering keeps each pen one run. Every layer takes this statement: a coarse row is a verbatim raw row and the markers are copied into the coarse layers (`scada-archive.md#layers`), so the buckets and the break split hold at `Minute` and `Hour` as at `Raw`. `@bucket` is `(to - from) / targetColumnCount`, never below one millisecond, the column's own resolution, and truncated down to whole microseconds (`PostgresDataProvider.BucketFor`). | | Realtime poll | `RealtimePoll` | `id = ANY(@ids) AND l = 0 AND t > @lastSeen ORDER BY t`. The variable list is mandatory or the read scans the day's partition; the bound is strict so the row that set `@lastSeen` is never returned twice. | | Realtime baseline | `RealtimeBaseline` | The extent's lateral shape over `DISTINCT unnest(@ids)`: one index probe per variable. `NULL` means no row yet — a state, not a failure. | | Export, seed row | `ExportSeedRow` | One pen's last `l = 0` row strictly before `@from` and not older than one day: `t < @from AND t >= @from - interval '1 day'`, `ORDER BY t DESC LIMIT 1`. The strict bound keeps a boundary row from returning on both export statements; the one-day bound is the history statement's seed bound for a window of a day or less, which the export range always is. A pen last written earlier has no seed. | @@ -871,13 +872,15 @@ SemiPlot reads that is not naive local is `semiplot_markers.t`, a `timestamptz` (`DateTimeKind.Unspecified`) and returns it in `StoredArchiveRow.Time` unconverted. The export window takes each bound as a date from a `CalendarDatePicker` plus a typed `HH:mm:ss` time and reads the pair as a wall-clock value, and the CSV writes the stored `t` verbatim. Because - nothing converts through UTC, the repeated autumn hour loses no row. The one conversion on that path is - the window's prefill, UTC navigation edges to wall-clock through the archive's zone - (`PostgresConnectionSettings.SourceTimeZone`), which is a function and drops nothing. + nothing converts through UTC, the repeated autumn hour loses no row. The conversions on that path go + from UTC to wall-clock through the archive's zone (`PostgresConnectionSettings.SourceTimeZone`): the + window's prefill of the navigation edges, and the calendars' first-sample and newest-sample days + (`ArchiveDayCalendar.Seed`). Each is a function and drops nothing. - The archive days read: `QueryArchiveDaysAsync` binds two `DateOnly` days as midnight `Timestamp` values and returns each day as a `date`, both in the archive's wall-clock days, the ones its partitions cut. The time - window panel computes the days it asks for as the local days of UTC navigation edges, through - `TimeWindowForm.LocalDay` in the zone `TrendChartViewModel` holds, production's `archiveZone`. The + window panel and the export window compute the days they ask for in `Navigation/ArchiveDayCalendar`: from + the local day of the first sample, or an earlier selected start date, to the local day of the newest + sample, each through `WallClockRangeForm.WallClockDay` in production's `archiveZone`. The panel's typed edges go the other way through `TimeZoneInfo.ConvertTimeToUtc` in the same zone: an ambiguous autumn hour takes the standard offset, under the cosmetic rule below, and a time the spring change skips is a form problem, never converted (`trend-interaction.md#the-time-window-panel`). @@ -1047,8 +1050,8 @@ empty window, an empty `semiplot_tags` — travel in the success channel. reader, ahead of any typed deserialize, so reaching the archive at all is not a precondition for it, and by the settings save: `Unwritable` for a target or staging folder it cannot write, `KeyAbsent` for an edited key no file carries (`overview.md#the-settings-window`). -Reading a section folder is the only file access left on the connection path, which is why -`ConnectionFileError` no longer carries a file-access kind. +Reading a section folder is the only file access on the connection path, so `ConnectionFileError` +carries no file-access kind. | `ArchiveFault` | Raised by | Detail | | --- | --- | --- | @@ -1078,14 +1081,14 @@ change, including a `Name` change; for a rename it is the new name when the serv stored name when the group is gone; a delete names the group; a membership names the pen when the server reports the pen's key `semiplot_pen_groups_pen_id_fkey` violated, and the group otherwise. No word of either language lives in `Detail`: the mapper wraps the subject in each kind's resourced detail. A `ReadFailed` with no detail is a fault in this code: -`ArchiveFailureLog.LogIfUnexpected`, which the provider and the editor both call, logs it with the -exception. +`ArchiveFailureLog.LogIfUnexpected`, which the provider, the editor, the row reader and the marker store +all call, logs it with the exception. `AccessDenied`, `Unreachable`, `QueryTimedOut`, `TableMissing` and `ReadFailed` serve reads and writes alike, so their operator text and their `ArchiveError.Describe` lines name a statement, never a read (`ui-text.md#the-pen-editors-failure-text`). `ReadFailed` stays the default arm of both mappings and keeps its name, which the operator never reads. There is no `WriteFailed` kind: its title, detail and -remedy would repeat the reworded `ReadFailed` ones. A missing registration function is a missing +remedy would repeat the `ReadFailed` ones. A missing registration function is a missing relation to the operator, so `42883` takes the `TableMissing` remedy, "run semibase site". `ArchiveFailureMapper` (`SemiPlot.UI/Messages/`) turns each kind into a title, a detail, a remedy @@ -1100,12 +1103,15 @@ drops the marker, and the operator adds it again if it is still needed. `FailureSeverityTests.EveryArchiveFault_CarriesTheSeverityTheTableNames` pins that split. `ConnectionLost` never opens the startup-failure window; it is an entry in the message panel under a chart that works. -The twelve rows above are the `ArchiveError` arm alone. `Map` has eight further arms — the startup +The twelve rows above are the `ArchiveError` arm alone. `Map` has ten further arms: the startup arguments, the log file, the configuration section, the app settings, the connection file, the -startup read timeout, an `IExceptionalError` and the `_` fallback — and every one of them is -`Error`, because each is reachable only at startup, where nothing recovers until the operator edits -something. The app settings and the configuration section arms are also reachable later, from the live -theme's reload and the settings save, and the same holds there (`overview.md#the-live-theme`). `MessageSeverity.Info` has exactly one writer in the whole tree, the connection-restored +startup read timeout, the unknown instance host, the export file, an `IExceptionalError` and the `_` +fallback. Every one of them is `Error`. The startup arguments, the log file and the startup read timeout +are reachable only at startup, where nothing recovers until the operator edits something. The app +settings, the configuration section and the connection file arms are also reachable later, from the live +theme's reload and the settings save, and the same holds there (`overview.md#the-live-theme`). The +unknown instance host comes from `New window` and `Restart` (`overview.md#another-instance`), the export +file from an export, and the last two arms from any point of a session. `MessageSeverity.Info` has exactly one writer in the whole tree, the connection-restored entry `AppStatusBarViewModel` writes. `SemiPlot.Tests.Unit/UI/Messages/FailureSeverityTests.cs` holds the `ArchiveFault` severity table and asserts it covers `Enum.GetValues()`; the app-settings severities are pinned in `ArchiveFailureMapperTests`, and `EveryEnumMapperArm_AnswersForEveryMemberOfItsEnum` @@ -1114,9 +1120,8 @@ calls every other enum-switching arm with every member, so a new member that rea ### No failure stops at the log Every failure on the data path reaches the operator through `Messages/MessagePanelViewModel`, not -only the log. The three that used to log a warning and return now report as well: the failed history -query in `Chart/TrendChartViewModel` (which still runs its `ApplyAxisModel` / `RequestRedraw` -recovery afterwards), the failed extent query in `Minimap/MinimapViewModel`, and the unobserved +only the log. That includes the failed history query in `Chart/TrendChartViewModel` (which runs its +`ApplyAxisModel` / `RequestRedraw` recovery after the report), the failed extent query in `Minimap/MinimapViewModel`, and the unobserved `LoadExtentAsync` task the composition root starts. `ApplyRealtimeBatch` carries the try-report-continue shape that `Chart/ChartHistoryRequestDebouncer.Deliver` applies to the history it hands on, so one bad batch does not end the realtime subscription. `Deliver` guards that hand-off @@ -1140,8 +1145,9 @@ that report from a thread of their own. A handler that runs detached — an Rx `onNext`, an Rx `onError`, a job posted to the UI scheduler — wraps its whole body, not its report alone, and hands the throw to `TryReportFailure`: `TrendChartViewModel.OnHistoryQueryFailed`, `MainWindow/AppStatusBarViewModel.ApplyConnectionState`, -`Bridge/ArchiveReadLoop.RunAsync`, the loop of both the catalogue and the marker sync, whose guard lets -the cancellation of its own stop end the loop unreported, since the marker read honours that token, +`Bridge/ArchiveReadLoop.RunAsync`, the loop of both the catalogue and the marker sync, whose guard +covers each read and its apply but not the wait between reads, and lets the cancellation of its own stop +end the loop unreported, since the marker read honours that token, `MainWindow/PenCatalogueApplier.ApplyAsync`, the last through `TrendChartViewModel.ReportFailure`, the minimap band's `Minimap/MinimapBandFeed.ApplyRead`, whose guard covers the next read's scheduling too, and `TryRequestRead`, which the schedule and the pen-change @@ -1177,9 +1183,11 @@ failure out of the provider takes the same channel: a `Catch` ahead of the `Publ one `RealtimeFailures` message and completes the batch stream, so `RealtimeBatches` never faults and the one failure is not counted once per subscriber of it. `TrendChartViewModel` is the only reader of that channel, and it reports through the guarded `ResultReporting.TryReportFailure` because an escape -would end the subscription that feeds the live edge. `TrendChartView`'s own subscriptions, the marker -layer's `Changed` among them, report through -`TrendChartViewModel.ReportFailure`, because a pipeline that ends stops repainting the chart. +would end the subscription that feeds the live edge. `TrendChartView`'s own six subscriptions, the two +panels' `CloseRequests`, `IsDeltaModeEnabled`, `ScalesRevision`, `RedrawRequested` and the marker layer's +`Changed`, take `ReportFailureOf` as their `onError`: it writes a `Log.Error` line naming the pipeline, +then reports through `TrendChartViewModel.ReportFailure`, because a pipeline that ends stops repainting +the chart. A marker write reports once, in `MarkerSync`: a failed write goes to the panel through `ResultReporting.ReportFailure` and returns to `ChartMarkers`, which reverts the marker and @@ -1203,8 +1211,10 @@ reaches the panel through `TimeWindowPanelViewModel.ReportFailure`, and the day through `TrendChartViewModel.ReportFailure`. `57014` maps unconditionally to `QueryTimedOut`. The statements a caller cancels are a history read the -chart superseded, the export's count, seed and slice reads, and the time window panel's archive days read, -which `TimeWindowPanelViewModel.Close()` cancels when the panel closes before it lands. Npgsql raises +chart superseded, the export's count, seed and slice reads, the time window panel's archive days read, +which `TimeWindowPanelViewModel.Close()` cancels when the panel closes before it lands, and the export +window's archive days read, which `CsvExportViewModel.Dispose` cancels when the window closes before it +lands. Npgsql raises `OperationCanceledException` for a cancellation it requested, which the provider and the row reader rethrow ahead of the mapper; a `57014` that reaches the mapper is therefore the server's own timeout or an administrator's cancel. Of `IDataProvider`, only `QueryHistoryAsync` and `QueryArchiveDaysAsync` take a @@ -1341,9 +1351,11 @@ When a chart is empty, check in this order. Each step distinguishes a different `[DEC:machine-time-zone]` does not hold on this installation (`sources.md`). At `information` or more verbose, the startup log line `Reading the archive in the time zone ...` names the zone the viewer applied. -6. Is `tpdefault` non-empty? Rows there mean the SCADA failed to create a daily partition. They are - not a cause of an empty chart — every read still returns them — but partition elimination is - lost for reads that cannot skip that partition. +6. Does `tpdefault` hold rows with `t >= current_date`? Only those mean the SCADA failed to create a + day partition; its other rows, the coarse layers' 1899-12-30 junk rows and late rows of dropped days, + are no fault (`scada-archive.md`). The server truncates `tpdefault` at 02:00 and at every project + start, so run the check before 02:00. Such rows are not a cause of an empty chart, because every read + still returns them, but partition elimination is lost for reads that cannot skip that partition. When the chart draws a straight segment across a span the archive holds, or fills slowly, run the viewer at `--logging-level Debug` and match the segment's timestamps against the history-read entries diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 2793283..7e96b42 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -58,6 +58,8 @@ Constraint: **$0 budget** — only free/OSS components. | +-- AppStatusBar connection state, layer | | +-- PenEditorWindow pens and groups dialog | | +-- CsvExportWindow range to CSV file dialog | +| +-- SettingsDialog app and connection settings | +| +-- AboutDialog version and config folder | | ViewModels (ReactiveUI) ◄── TrendCoordinator (Rx hub) | | ◄── PenCatalogueSync (5 s reads) | | ◄── MarkerSync (5 s reads) | @@ -129,10 +131,12 @@ archive's time zone (`PostgresConnectionSettings.SourceTimeZone`), and construct 6. the catalogue sync; 7. the navigation bar and the legend; 8. the catalogue applier; -9. `MainWindowViewModel`, over the chart, the status bar, the minimap, the navigation bar, the legend, - the catalogue sync's `ReadNow` and a factory for `CsvExportViewModel`, which `Build` closes over the container's - `IArchiveRowReader`, the `CsvDialect`, the UI scheduler and the archive zone, so the window view model - holds none of them. +9. `MainWindowViewModel`, over the message panel, the status bar, the chart, the minimap, the navigation + bar, the legend, the catalogue sync's `ReadNow`, the logger factory, the container's `IPenCatalogueEditor`, + the `InstanceLauncher` and a factory for `CsvExportViewModel`. `Build` closes the factory over the + container's `IArchiveRowReader`, the coordinator's `QueryArchiveDaysAsync`, the `CsvDialect`, the message + panel, a logger, the UI scheduler, the archive zone and the chart navigation's `FirstSample` and + `NewestSample`, so the window view model holds none of the export's own inputs. The coordinator republishes the provider's connection stream through `ObserveOn` on the UI scheduler. `AvaloniaScheduler` runs a zero-delay action at once when the caller is already on the dispatcher thread, @@ -149,8 +153,11 @@ container service first, so a missing registration throws before any part exists `TrendWindow`, shows its view model, and disposes the `TrendWindow` from the main window's `Closed` event on the UI thread. -The container keeps the process services: the data source, the provider, the editor, the row reader, the -marker store, the message panel and the logger factory. It registers no window part, the status bar included. The UI scheduler is not a +The container keeps the process services. `AddPostgresData()` registers the data scheduler, +`TimeProvider.System`, the `PostgresConnectionSettings`, the `NpgsqlDataSource`, the `ArchiveTimeConverter`, +the `ArchiveExceptionMapper`, the provider, the editor, the marker store and the row reader; `AddUi()` +registers the message panel, and `StartupProbe.BuildArchiveServiceProvider` adds the logger factory. The +container holds no window part, the status bar included. The UI scheduler is not a container registration; `App` passes `AvaloniaScheduler.Instance` to `TrendWindow.Build`, while the data scheduler comes from the container. @@ -275,8 +282,9 @@ Row 4 is the one whose flag the operator is not the only source of. `MessagePane closed, so a session that never fails is never given the row, and `MessagePanelViewModel.Report` opens it for a failure the list does not already carry — an entry that landed off screen would be a failure nobody is shown. A repeat of the entry on top does not reopen a panel the operator closed. -The row, the `View` item's check state and the status bar's connection indicator all read the same -flag, so one click always moves the row and the check state always describes it. +The row and the `View` item's check state read the same flag, and the `View` item and the status bar's +connection indicator both invoke `MessagePanel.ToggleCommand`, so one click always moves the row and the +check state always describes it. The status bar carries current state only: whether the archive answers, and the layer the chart reads, named from resx rather than from `AggregationLayer.ToString()`. It holds no pen count and no @@ -311,7 +319,7 @@ is why `StartupSequence.Run` touches no ReactiveUI type and why `MessagePanelVie two `ReactiveCommand`s, is resolved from the container inside `.AfterSetup(...)` and never before it. On a failed start no container exists, and `StartupFailureViewModel` builds its panel when `App.CreateMainWindow` runs, after `Setup()`. -`RxApp` itself is gone from the installed ReactiveUI 23.2.28; the schedulers live on `RxSchedulers` +`RxApp` does not exist in the installed ReactiveUI 23.2.28; the schedulers live on `RxSchedulers` and the handler on `RxState`. The same ordering forces the one static read this tree allows. The handler is taken before any @@ -330,7 +338,7 @@ one method: nothing else may resolve a service through a static. message panel records. - **History:** the chart requests a window → `TrendCoordinator.QueryHistoryAsync` (the single history query, reached through the debouncer by the initial load and every gesture alike) → provider - returns one decimated `PenHistoryEnvelope` per pen (ascending `X` + `Min`/`Max`/`Center`) → the view + returns one server-reduced `PenHistoryEnvelope` per pen (ascending `Timestamps` + `Min`/`Max`/`Center`) → the view model applies the result into the plot; the debouncer runs one query at a time and lets the newest window asked for run last. The minimap band is a second reader outside the debouncer: one pen at 250 columns every 2-60 s (`trend-interaction.md#archive-overview-minimap`). @@ -346,7 +354,6 @@ one method: nothing else may resolve a service through a static. `OutputType=WinExe` and the Avalonia Win32 backend are what make that machine the deliberate Windows-only target. The plain TFM exists so the test projects build on the Linux CI runners, and changes nothing about where the application ships. -- Auto-update of the app itself via Velopack if/when needed. - Site paths follow the `C:\DISTR\` convention of the sibling SemiStep installation: configuration in `C:\DISTR\Config\SemiPlot`, logs in `C:\DISTR\Logs\SemiPlot\`. Neither sits beside the executable and neither is per-user. Nothing in the application knows those paths: the three launch @@ -787,7 +794,7 @@ text are in `ui-text.md#the-export-windows-text`, the layout in | Pen checklist | Every pen of the snapshot, ticked when it was visible, in a bordered list under the header "Pens (2 of 3)", which reads the ticked and total counts through a `MultiBinding`. The list sizes to its rows up to a `MaxHeight` of 226 px, eight 28 px rows plus the border (`CsvExportWindowTests` measures the row), and never auto-hides its scroll bar. It has no horizontal scroll bar: a long label is a `TextBlock` trimmed with an ellipsis, and the full label is the row's tooltip. A pen whose name another pen of the snapshot shares shows ` (id)` after it (`ExportPenChoice.Label`). The pickers, the time boxes and the list are disabled while an export runs. | | Row count | `CsvExportViewModel` reads `IArchiveRowReader.CountRowsAsync` 300 ms after the last edit of the range or the ticks. A count still running when an edit lands is cancelled at once, and the next count starts 300 ms after that edit, so a result of the old range never shows. A failed count shows "Row count unavailable" and reports to the message panel through `ArchiveFailureMapper.Map`. A count above Excel's 1 048 575 data rows adds a notice to the message line and does not block the export. An edit of the range or of a tick clears the status line of an earlier export, so the notice shows again. | | Message line | A full-width row of its own above the buttons, two lines reserved. Its `ToolTip` is `CsvExportViewModel.MessageToolTip`: the full path while the line shows a success and no form rule is broken, null otherwise. | -| Calendars | Both pickers offer the days from the first sample's to the newest sample's, widened to the prefilled dates, and mark the days without a row as blackout dates: `CsvExportViewModel.Days` is an `ArchiveDayCalendar` with the time window panel's rules (`trend-interaction.md#the-time-window-panel`). `TrendWindow.Build` hands the view model `Navigation.FirstSample`, `Navigation.NewestSample` and `TrendCoordinator.QueryArchiveDaysAsync`; the view model reads the days once on construction, and a failed read reaches the message panel and leaves no blackout date. The code-behind syncs the set into both pickers' `BlackoutDates` as the panel's does and drops the subscription on `Closed`. | +| Calendars | Both pickers offer the days from the first sample's to the newest sample's, widened to the prefilled dates, and mark the days without a row as blackout dates: `CsvExportViewModel.Days` is an `ArchiveDayCalendar` with the time window panel's rules (`trend-interaction.md#the-time-window-panel`). `TrendWindow.Build` hands the view model `Navigation.FirstSample`, `Navigation.NewestSample` and `TrendCoordinator.QueryArchiveDaysAsync`; the view model reads the days once, on construction and only when a newest sample exists, and a failed read reaches the message panel and leaves no blackout date. A date edit recomputes neither the offered days nor the blackout dates. The code-behind syncs the set into both pickers' `BlackoutDates` as the panel's does and drops the subscription on `Closed`. | | Buttons | `Save as...` opens the system save dialog and passes the chosen local path to `ExportCommand`; it is disabled while the form is invalid or an export runs. `Cancel` is enabled only while an export runs. `Close` closes the window. Right-aligned, below the message line. | The range is wall-clock time end to end (`data-integration.md#time-boundary`). The 24 h limit bounds the @@ -827,8 +834,9 @@ All three keys are required and none carries a default. | `--log-file ` | Log file, rolling 5 MB / 5 files | | `--logging-level ` | `verbose` \| `debug` \| `info` (or `information`) \| `warning` \| `error` \| `fatal`, case-insensitive | -`File` -> `New window`, `Restart` and `Restart now` start a copy with the same three keys verbatim -(`#another-instance`). +`File` -> `New window`, `Restart` and `Restart now` start a copy with the same three keys +(`#another-instance`). The two paths go as given; the logging level goes as the `LogEventLevel` member +name the parse produced (`Information` for `info`), which the copy's parse reads back to the same level. `StartupOptions.Parse` returns `Result`. A missing key, a key the parser does not know, a valued key given last with nothing after it, and an unusable logging level are each a diff --git a/docs/architecture/postgres-instance.md b/docs/architecture/postgres-instance.md index e1dfb5e..728ade6 100644 --- a/docs/architecture/postgres-instance.md +++ b/docs/architecture/postgres-instance.md @@ -54,11 +54,7 @@ Both timeouts are set on the role by `semibase` as session defaults, so they app session SemiPlot opens. They are defaults, not enforcement: PostgreSQL classes `statement_timeout` as `USERSET`, and a startup option or a plain `SET` overrides a role default from the client side. SemiPlot's contract is that it never sends `statement_timeout` in any form, so the value the role -carries is the value every SemiPlot session runs under; the client reads the effective value -only after a read has failed, from a fresh session of the same role, to report which bound that read -hit. The number is stable while the role default is: role and database defaults bind at backend -start and a pooled physical connection keeps its startup value, so an administrative change to the -default mid-run can leave one report one increment stale. `-c jit=off` is the one startup option +carries is the value every SemiPlot session runs under. `-c jit=off` is the one startup option SemiPlot sends, and it overrides no role default (`data-integration.md#configuration`). The credential is what makes the plaintext password in SemiPlot's configuration file an acceptable @@ -132,11 +128,11 @@ SemiBase creates `public.trends` and the configuration tables in one run, in bot provisioning did not complete — and one command restores the set, which is why the second state covers the set rather than ordering it. -One operational state belongs beside them: a non-empty `tpdefault` means a daily partition was -missing at write time. The partition itself arrives with the provisioning and is empty by -construction, so anything in it is a row the SCADA wrote with no daily partition to take it. It is -read straight from the database at commissioning, and SemiPlot treats it as the fault signal -`scada-archive.md` describes. +One operational state belongs beside them: rows with `t >= current_date` in `tpdefault` mean a day +partition was missing at write time. The partition's other rows, the coarse layers' 1899-12-30 rows +and, with an archive limit set, late rows of dropped days, are normal. The SCADA erases `tpdefault` +at 02:00, so the check runs before 02:00, straight against the database at commissioning; no +SemiPlot code reads `tpdefault` (`scada-archive.md#reader-hazards`). ## Retention and capacity diff --git a/docs/architecture/postgres-topology.md b/docs/architecture/postgres-topology.md index afca5eb..5b3068c 100644 --- a/docs/architecture/postgres-topology.md +++ b/docs/architecture/postgres-topology.md @@ -30,6 +30,7 @@ flowchart TB prov2["PostgresDataProvider"] editor["PostgresPenCatalogueEditor"] markers["PostgresMarkerStore"] + rows["PostgresArchiveRowReader"] end scada -- "writes rows" --> trends @@ -38,9 +39,10 @@ flowchart TB prov -- "creates, never writes rows" --> config prov -- "creates" --> roles prov2 -- "SELECT only" --> trends - prov2 -- "SELECT only" --> config - editor -- "UPDATE of pen settings · group DML
EXECUTE semiplot_register_new_pens()" --> config - markers -- "semiplot_markers DML" --> config + prov2 -- "SELECT only
semiplot_tags · semiplot_groups · semiplot_pen_groups" --> config + rows -- "SELECT only" --> trends + editor -- "SELECT · UPDATE of pen settings · group DML
EXECUTE semiplot_register_new_pens()" --> config + markers -- "semiplot_markers SELECT · DML" --> config classDef write stroke-width:3px class scada,prov,editor,markers write @@ -53,7 +55,7 @@ defect, and the server answers `42501`. The configuration tables are the one place SemiPlot writes, from two places: the pen editor (`Edit` -> `Pens and groups`) and the chart's time markers. SemiBase gives the `semiplot` role a -column-level `UPDATE` of the eight pen settings on `semiplot_tags`, full DML on `semiplot_groups` and +column-level `UPDATE` of the nine pen settings columns on `semiplot_tags`, full DML on `semiplot_groups` and `semiplot_pen_groups`, `EXECUTE` on `semiplot_register_new_pens()`, and full DML on `semiplot_markers`, which only `PostgresMarkerStore` uses (`data-integration.md#the-marker-store`). A pen's `id` is the SCADA variable number, so the role never inserts, deletes or re-keys a pen; the function registers new keys, and the editor calls it only from @@ -87,8 +89,9 @@ flowchart LR ``` Partitions are daily ranges over `t`, named `tpYYYYmMMdDD`, with `tpdefault` catching anything that -misses one. A non-empty `tpdefault` means a partition was missing at write time — a fault signal, not -a normal state. +misses one. Rows with `t >= current_date` in `tpdefault` mean a day partition was missing at write +time; the SCADA erases `tpdefault` at 02:00, so a check for them runs before 02:00. Its other rows are +normal (`scada-archive.md#reader-hazards`). The primary key is `(id, l, t)` and its leading column is `id`. Every query therefore carries the variable list, or it cannot use the key and reads every partition instead. @@ -108,7 +111,7 @@ flowchart TB ds["NpgsqlDataSource
pooled connections"] conv["ArchiveTimeConverter
naive local ⇄ UTC"] mapper["ArchiveExceptionMapper
SQLSTATE → typed error"] - loader["PostgresConnectionLoader
the connection/ section
eight keys"] + loader["PostgresConnectionLoader
the connection/ section
six keys, optional schema"] end errs["SemiPlot.Core/Data/Errors
two sealed errors, two enums"] @@ -124,16 +127,16 @@ flowchart TB ``` The composition root resolves `PostgresDataProvider`, and the pen editor's -`PostgresPenCatalogueEditor` and the chart's `PostgresMarkerStore` over the same `NpgsqlDataSource`; +`PostgresPenCatalogueEditor`, the chart's `PostgresMarkerStore` and the export window's +`PostgresArchiveRowReader` over the same `NpgsqlDataSource`; there is no fallback to resolve: an archive that does not answer opens the startup-failure window rather than falling back to invented data, which `data-integration.md` states under **Startup**. -`ArchiveFailureMapper` turns each public error type into a title, a detail and a remedy — Core's ten -plus the UI-local `StartupReadTimedOutError`, eleven in all; two of them reach the operator as a -message-panel entry under a working chart rather than as the startup panel. Every member of -`IDataProvider` is -implemented — the pen catalogue, the archive extent, the windowed history read and the live-edge -poll. +`ArchiveFailureMapper` turns each error type into a title, a detail, a remedy and a severity: one arm +per type, Core's and the UI-local ones, and inside the `ArchiveError` arm one per `ArchiveFault` +member, twelve. Which kind gets which severity, and why, is in +`data-integration.md#two-error-planes`. Every member of `IDataProvider` is implemented: the pen +catalogue, the archive extent, the archive days, the windowed history read and the live-edge poll. ## The three provisioning states, and the fourth diff --git a/docs/architecture/scada-archive-observed.md b/docs/architecture/scada-archive-observed.md index f5c1b0c..1740afc 100644 --- a/docs/architecture/scada-archive-observed.md +++ b/docs/architecture/scada-archive-observed.md @@ -564,7 +564,7 @@ plant sensor with a well-chosen deadband writes far less often than this random | Topic | Manual, forum or `scada-archive.md` | Observed in this run | | --- | --- | --- | -| Coarse selection | up to 4 points per period: first, last, min, max (`[FORUM:1032]`, partly inferred) | min and max of the calendar period; first and last only when extreme | +| Coarse selection | up to 4 points per period (`[FORUM:1454]`): first, last, min, max (`[FORUM:1032]`, partly inferred) | min and max of the calendar period; first and last only when extreme | | Tie-break | the later row for a repeated extreme | the later row for a repeated maximum, the earlier row for a repeated minimum | | Layer spacing | 15 s, 15 min, 6 h | 30 s, 30 min; layer 3 empty | | Period alignment | open question | calendar minute and hour | @@ -591,8 +591,9 @@ For SemiPlot: build writes (`data-integration.md#layer-ladder`). 3. **The layer spacings are half the density assumed.** Addressed: `AggregationLayer.ToPointSpacing` takes two rows per period, half the real spacing (`data-integration.md#layer-ladder`). -4. **Unbounded reads of layers 1 and 2 meet 1899-12-30.** Every read is bounded by time today; it - has to stay so. +4. **Unbounded reads of layers 1 and 2 meet 1899-12-30.** Every read of layers 1 and 2 is bounded by + time, and it has to stay so. The two reads with no time bound, `ArchiveExtent` and + `RealtimeBaseline`, read `l = 0` only, where these rows do not land. 5. **A pen that does not change has one point per day, and its newest point can be a day old.** Holding the last value as a step is what draws it correctly. 6. **A silent hole looks like a straight line.** Nothing in the archive distinguishes it from a diff --git a/docs/architecture/scada-archive.md b/docs/architecture/scada-archive.md index 0817dd1..607a408 100644 --- a/docs/architecture/scada-archive.md +++ b/docs/architecture/scada-archive.md @@ -130,8 +130,9 @@ the name of the v1 table. | `uid` | `integer` | user identifier | Originating user, `-1` for system messages. | | `r`, `c` | `timestamp(3)` | | Alarm recovery and acknowledgement instants; null for non-alarm events. | -SemiPlot reads `messages` for one purpose only: explaining a gap to the operator. It is not a data -source for trends. A project start writes «Проект запущен» and a stop «Проект остановлен» +SemiPlot issues no statement on `messages`. The `semiplot` role holds `SELECT` on it, and a gap +explanation over it is designed, not shipped (`data-integration.md`). It is not a data source for +trends. A project start writes «Проект запущен» and a stop «Проект остановлен» (`gid = -6`, `k = 2`); a database outage writes nothing there `[MEAS:capture-20261006]`. ## Time semantics @@ -209,13 +210,14 @@ rendering: A **gap** is an interval during which archiving did not happen. It is not "value unknown": it is "no data was recorded, and none ever will be". -Three states are easy to confuse and must be distinguished by a reader of this archive: +Two states leave no rows, are easy to confuse and must be distinguished by a reader of this archive. A +third leaves a row and draws as any other: | State | Rows present | Correct rendering | | --- | --- | --- | | Value unchanged | none | horizontal line at the last recorded value | | Gap | none | broken line | -| Bad quality | row present, value present | point discarded | +| Bad quality, any `q` other than `0`, `16` and `32` | row present, value present | an ordinary point; no such code appeared in either measured archive `[MEAS:dump-20260805]` `[MEAS:capture-20261006]` | Absence of rows alone cannot separate the first two, which is why the engine marks the boundaries in the quality column. The manual states that the quality code follows the OPC UA specification except diff --git a/docs/architecture/sources.md b/docs/architecture/sources.md index b4dfb50..220a692 100644 --- a/docs/architecture/sources.md +++ b/docs/architecture/sources.md @@ -20,7 +20,8 @@ Rules: - A claim resting on two classes carries both markers; that combination is the strongest evidence available here (e.g. vendor says X, we measured X). - Russian quotes are reproduced verbatim, followed by an English rendering. -- Dates belong in this file only. Living architecture docs state the current design, not its history. +- Citation dates live in this file. A measurement date stays inline with the measurement it dates; + apart from that, living architecture docs state the current design, not its history. ## Vendor manual @@ -36,8 +37,6 @@ is the version cited here. | `[MAN:archsysv2]` | `archsysv2.html` | Система архивации v2 — differences from v1, in-memory accumulation limit during a database outage | | `[MAN:messet]` | `messet.html` | Настройки проекта: Сообщения — «Ограничение архива сообщений» | | `[MAN:db-access-rights]` | `db-access-rights.html` | Права доступа к БД — the privilege set the SCADA needs, and when `DROP` can be withheld | -| `[MAN:postgresql]` | `postgresql.html` | PostgreSQL setup for the archive | -| `[MAN:whats-new]` | `whats-new.html` | Release notes — archive system v2 history | ## Vendor forum @@ -52,7 +51,6 @@ replies from the vendor's own account. | `[FORUM:345]` | 345 «Работа системы архивирования данных» | 2017-03-15 | **v1** | Flush cadence: «данные накапливаются в буферы и сбрасываются на жесткий диск каждые 5 минут (для основного слоя) ... Минутный слой сбрасывается каждую минуту, часовой - час, суточный - день.» Era flag matters: this describes archive system v1. Whether v2 kept these cadences is `UNVERIFIED`. | | `[FORUM:1847]` | 1847 | 2024-07-31 | v2 | Buffering: values accumulate in memory and are flushed periodically; a rarely changing variable reaches the database rarely; a write can also land within a millisecond. | | `[FORUM:1388]` | 1388 | — | v2 | Partitioning: «Новая система архивации разбивает БД на разделы для увеличения быстродействия». | -| `[FORUM:1753]` | 1753 | — | — | The product is written in Object Pascal (Delphi). | Vendor download page `https://simple-scada.com/download/` defines the demo builds: `DEMO-64` (feature-limited, 64 tags) and `DEMO-TIME` (full functionality, server limited to one hour of @@ -76,7 +74,7 @@ continuous operation; a restart grants another hour). | Marker | Decision | | --- | --- | -| `[DEC:read-only-consumer]` | SemiPlot is a strict read-only consumer of `trends` and `messages`. No writes, no schema changes, no indexes, no triggers on vendor objects. | +| `[DEC:read-only-consumer]` | SemiPlot is a strict read-only consumer of `trends` and `messages`. No writes, no schema changes, no indexes, no triggers on vendor objects. No shipped statement touches `messages`; the role's `SELECT` on it serves a gap explanation that is designed, not shipped. | | `[DEC:vendor-layers]` | Wide time windows are served by the vendor's own archive layers rather than by summary tables of our own. No rollup tables, no aggregator service, no scheduler, no PostgreSQL extensions. Rationale in `history-read-path-evaluation.md`. | | `[DEC:common-retention]` | One retention depth applies to all archived data. Raw samples are never dropped earlier than the coarse layers. | | `[DEC:semiplot-tags]` | The absent variable-number-to-name mapping is supplied by our own `semiplot_tags` table. SemiBase's `semiplot_register_new_pens()` adds a hidden default row for every key in `trends`; names and settings are edited afterwards. | diff --git a/docs/architecture/testing-strategy.md b/docs/architecture/testing-strategy.md index 86c5d98..21e81ab 100644 --- a/docs/architecture/testing-strategy.md +++ b/docs/architecture/testing-strategy.md @@ -84,8 +84,8 @@ polls through `HistoryDebouncerTestBuilder.AwaitCondition`, which references no reaches no further: the clock bounds the wait and no test asserts on elapsed time, and a pipeline that takes an `IScheduler` runs on a `TestScheduler` instead. Its users besides the theme tests are `UI/MainWindow/MainWindowViewTests.cs`, `UI/Startup/StartupFailureWindowTests.cs`, -`UI/Settings/SettingsViewModelTests.cs`, `SettingsViewTests.cs`, `UI/Chart/TrendChartViewModelTests.cs` and -`ChartTestBuilder`. +`UI/Settings/SettingsViewModelTests.cs`, `SettingsViewTests.cs`, `UI/Chart/TrendChartViewModelTests.cs`, +`ChartTestBuilder`, `UI/Export/CsvExportViewModelTests.cs` and `CsvExportWindowTests.cs`. `Journeys/JourneyWait.cs` is its copy for the journeys, with the same contract (10 ms poll, dispatcher pump, 30 s bound), except that it pumps unconditionally, which is safe only because every journey is an @@ -144,13 +144,13 @@ An integration test crosses exactly one boundary to a real implementation of som repository does not build, to verify the translation across that seam. A fake cannot do this job: it would encode our own assumption on both sides. The value is contract verification. -There are three families — the same category with different foreign parties. +There are four families — the same category with different foreign parties. -**Against a real PostgreSQL**, in `SemiPlot.Tests.Integration/`: `PostgresCatalogReadTests`, -`PostgresExtentReadTests`, `PostgresArchiveDaysReadTests`, `PostgresHistoryReadTests`, `RealtimePollReadTests`, -`RealtimeSubscriptionTests`, `RealtimeEmptyArchiveTests`, -`ArchiveWriterTransactionTests`, `CoarseFlushTests`, `ProviderSessionTests`, -`ExplainPlanTests`, `ArchiveRowReaderTests`, `PenCatalogueEditorTests`, `PenRegistrationTests`, `LiveCatalogueTests`. The realtime classes +**Against a real PostgreSQL**, in `SemiPlot.Tests.Integration/`: every class carrying +`[Collection(ArchiveDatabaseCollection.Name)]` outside `Journeys/`, for example `PostgresHistoryReadTests`, +`PostgresCatalogReadTests`, `RealtimePollReadTests`, `RealtimeSubscriptionTests`, `RealtimeEmptyArchiveTests`, +`CoarseFlushTests`, `ProviderSessionTests`, `ExplainPlanTests`, `ArchiveRowReaderTests`, +`PenCatalogueEditorTests`, `MarkerStoreTests`, and the seeder's `ConvergeTests` and `TagCatalogWriterTests`. The realtime classes share `RealtimeArchive` (rows, subscription and append path), `BatchCollector` and `ArmedGate`. Seams guarded: statement text, type mapping, the demo writer's coarse flush (`CoarseFlushTests` pins period bounds, which periods close, the NULL skip, `ON CONFLICT` and the @@ -174,24 +174,28 @@ listener). They cross the machine boundary and need no container, so they carry `PostgresContainerFixture`; a missing container runtime does not fail them. `UnreachableArchive.Settings` gives them the unreachable address and an adjustable poll interval. -**Against a real Avalonia** — `SemiPlot.Tests.Unit/UI/`, under `[AvaloniaFact]`: -`ChartPointerInputTests`, `MinimapPointerInputTests`, `MinimapHoverTests`, `MinimapViewTests`, `TrendChartViewTests`, -`NavigationBarViewTests`, `AppMenuBarTests`, `AppStatusBarViewTests`, `MainWindowViewTests`, `MessagePanelViewTests`, -`TrendLegendViewTests`, `PenEditorViewTests`, `PenGroupsViewTests`, `ThemeTests`, -`StartupFailureWindowTests`, `TrendWindowTests`, `AppMainWindowTests`, -`TrendCoordinatorTests` with `FakeDataProvider`. Seams guarded: the dispatcher, layout, hit-testing, +**Against a real Avalonia** — `SemiPlot.Tests.Unit/UI/`, under `[AvaloniaFact]` and `[AvaloniaTheory]`, +for example the classes that realise a view: `ChartPointerInputTests`, `MinimapPointerInputTests`, +`MinimapViewTests`, `TrendChartViewTests`, `ChartMarkerViewTests`, `AxisScalePanelViewTests`, +`TimeWindowPanelViewTests`, `CsvExportWindowTests`, `MainWindowViewTests`, `TrendLegendViewTests`, +`PenEditorViewTests`, `ThemeTests` and `StartupFailureWindowTests`. Seams guarded: the dispatcher, layout, hit-testing, pointer capture and event routing. Real framework, synthetic data. These are what catch a rendering-stack version bump. The headless platform draws no pixels, so `Minimap/MinimapBand.Render` has no test: `MinimapViewTests` pins the figures it receives, and the minimap's smoke walk on the demo stand covers what it paints. **Against a real rasterizer** — `SemiPlot.Tests.Unit/UI/Chart/ChartGapRenderTests.cs`, under `[AvaloniaFact]`: it renders through SkiaSharp and asserts on pixels that a `NaN` column breaks the -line. `LogAxisRenderTests.cs` renders a log axis the same way and asserts on pixels that the 1e-4 plateau +line. `LogAxisRenderTests.cs`, under plain `[Fact]` and `[Theory]` over a bare `Plot`, renders a log axis +through the same rasterizer and asserts on pixels that the 1e-4 plateau of a 1e-6..1e-2 axis draws at the data area's vertical middle and that a plateau at `0`, or under the -axis minimum, draws 2 px above the data area's bottom row. `EnvelopeLineTests.cs` uses the same rasterizer -twice. As a render thread, not for its pixels: a background task renders frames while the test thread +axis minimum, draws 2 px above the data area's bottom row. `EnvelopeLineTests.cs`, under plain `[Fact]`, +uses the same rasterizer twice. As a render thread, not for its pixels: in +`Render_WhileTheUiThreadRewritesTheColumns_DoesNotThrow` a background task renders frames while the test thread rewrites the same pen's columns, and the pin is that the render task throws nothing and reaches its frame -budget, the run bounded by that budget and a cancellation token. And for its pixels in +budget, the run bounded by that budget and a cancellation token. `RenderWhileMutating.RunAsync` +(`UI/Chart/RenderWhileMutating.cs`) holds that loop, and `TrendChartRenderThreadTests` runs it under +`[AvaloniaFact]` while the chart applies a catalogue and switches an axis between linear and log10. +`EnvelopeLineTests` reads pixels in `Render_ProjectsThroughLog10OnlyUnderALogTickGenerator`: one flat line lands inside a -6..-2 axis only while the axis carries a `LogTickGenerator`. `StrokeProbe` (`UI/Chart/StrokeProbe.cs`) is the only pixel probe. It holds the channel-dominance band, the ink measure `InkOf` (a channel's lead over the other diff --git a/docs/architecture/trend-feature-spec.md b/docs/architecture/trend-feature-spec.md index 06167b4..91295b0 100644 --- a/docs/architecture/trend-feature-spec.md +++ b/docs/architecture/trend-feature-spec.md @@ -62,11 +62,11 @@ Acceptance-criterion labels: "UI" — verified in the running application; "Core **Note.** Closed: `PenScaleSettings` carries no axis key at all — the axis is the pen. The stored `scale_min_on_start`/`scale_max_on_start` pair is what forced it: seeded into a group-keyed axis, one pen's range would have been applied to the whole group and the rest silently discarded. ### AY-3 — Manual min/max for an axis (MUST) -**Definition.** `Manual` mode: fixed `ManualMin/ManualMax` for an axis; on swapped bounds — auto-swap. Bound editing is available from the UI: a click on the active pen's axis opens a panel with Maximum and Minimum fields, and the pair is written when focus leaves both fields or on Enter. +**Definition.** `Manual` mode: fixed `ManualMin/ManualMax` for an axis; on swapped bounds — auto-swap. Bound editing is available from the UI: a click on the active pen's axis opens a panel with Maximum and Minimum fields, and the pair is written when focus leaves both fields or on Enter. Switching the panel's Autoscale toggle off also writes a manual pair, the range `Auto` shows (AY-4). **Acceptance.** Core/test: `ScaleMode.Manual` returns exactly the given bounds; swapped input is swapped back. UI: a click on the axis opens the panel seeded with the current bounds; a changed pair is set as a manual scale when focus leaves both fields, on Enter or when the panel is dismissed from outside, and Escape closes the panel without writing; an empty or inverted pair is refused on the panel's message line and not written. ### AY-4 — Autoscale to the visible window (MUST) -**Definition.** `Auto` mode: min/max over the columns inside the current `[windowStart, windowEnd]` with 5% padding; a flat range → ±0.5; a window holding no column → the whole fetched envelope, and the default range only when no envelope carries a usable value at all. View -> Pen scale -> Autoscale and the panel's Autoscale button switch the active pen to `Auto`, which refits on every window change, and View -> Pen scale -> Restore initial scale and the panel's Initial scale button set the active pen to its stored pair, or to `Auto` when the pen has none; the navigation bar carries no autoscale button. +**Definition.** `Auto` mode: min/max over the columns inside the current `[windowStart, windowEnd]` with 5% padding; a flat range → ±0.5; a window holding no column → the whole fetched envelope, and the default range only when no envelope carries a usable value at all. View -> Pen scale -> Autoscale switches the active pen to `Auto`, which refits on every window change. The panel's Autoscale toggle shows whether the pen is in `Auto`: switching it on does the same, switching it off keeps the range `Auto` shows as the manual pair, and the panel stays open either way (trend-interaction.md). View -> Pen scale -> Restore initial scale and the panel's Initial scale button set the active pen to its stored pair, or to `Auto` when the pen has none; the navigation bar carries no autoscale button. **Acceptance.** Core/test: `ScaleMode.Auto` — the bounds span min..max of the columns in the window with padding; a flat range gives ±0.5; columns outside the window do not widen the range. UI: Autoscale fits the active pen over the window it shows; Restore initial scale sets the active pen to its stored pair, or to `Auto` when the pen has none. **Note.** The fetched envelope carries a margin on each side of the window, so the bound is what keeps a pan from re-scaling on data the operator cannot see. A window that leaves the fetched band outright shows none of those columns, and the chart view model then skips the axis recompute entirely: the axis holds its last range until the query the pan issued lands. The whole-envelope fallback inside `PenScaleModel` is left for the sticky live edge, where the window runs ahead of the newest fetched column with no query behind it. @@ -122,7 +122,7 @@ through `TrendChartViewModel.SetActivePen`; the picked line is drawn 2 px wide a ### PN-8 — Pen label/legend (SHOULD) **Definition.** A legend (detailed view): pen name, current value, value at cursor, scale range. A label at the right edge (Name/Value/Name+Value). **Acceptance.** UI: the legend shows the current value, value-at-cursor, and range; changing the label mode changes the text at the edge. -**Note.** As-built the sidebar row carries the on/off box, a colour dot, the name, the current value in the pen's own mask and the unit. The value at the cursor is the chart's own hover readout (§CU-2) rather than a second copy in the row, and the range cell is gone: it showed the padded axis bound over the visible window, not a measurement (trend-interaction.md). +**Note.** As-built the sidebar row carries the on/off box, a colour dot, the name, the current value in the pen's own mask and the unit. The value at the cursor is the chart's own hover readout (§CU-2) rather than a second copy in the row, and the row has no range cell: it would show the padded axis bound over the visible window, not a measurement (trend-interaction.md). ### PN-9 — kx+b scaling and formatters (NICE) **Definition.** A multiplier (k) and offset (b) per pen; value formatters for X and Y (number of digits, exponential / floating-point). @@ -168,7 +168,7 @@ through `TrendChartViewModel.SetActivePen`; the picked line is drawn 2 px wide a ## 5. Data and archive (PostgreSQL) ### DA-1 — The data source is always PostgreSQL (MUST) -**Definition.** The production provider for history and extent is PostgreSQL. The provider contract: `QueryHistoryAsync` (window + target column count), `QueryArchiveExtentAsync` (full range), `QueryArchiveDaysAsync` (the local days of a date range that hold a layer-0 row, for the time window panel's calendars). The history read returns a `PenHistoryEnvelope` (Min/Max/Center, NaN gaps). Errors flow via `FluentResults`, with no exceptions on the UI thread. +**Definition.** The production provider for history and extent is PostgreSQL. The provider contract: `QueryHistoryAsync` (window + target column count), `QueryArchiveExtentAsync` (full range), `QueryArchiveDaysAsync` (the local days of a date range that hold a layer-0 row, for the calendars of the time window panel and the export window). The history read returns a `PenHistoryEnvelope` (Min/Max/Center, NaN gaps). Errors flow via `FluentResults`, with no exceptions on the UI thread. **Acceptance.** DB: against a real PostgreSQL, history load fills the canvas; the extent is pulled from the DB; a dropped connection yields a failed `Result` rather than a UI crash. A stub provider is acceptable only in tests/demos, not in production. ### DA-2 — Archive aggregation layers (MUST) @@ -180,7 +180,7 @@ through `TrendChartViewModel.SetActivePen`; the picked line is drawn 2 px wide a **Acceptance.** Core/test: `LayerForWidth` — layer transitions at the computed thresholds for a given column count, with hysteresis preventing flicker when the width oscillates around a boundary. ### DA-4 — Manual layer selection (SHOULD) -**Definition.** The operator can pin a layer manually (a combo box + a lock button), overriding the automatic selection. Currently the layer is read-only text in the status bar; an interactive selector is needed. +**Definition.** The operator can pin a layer manually (a combo box + a lock button), overriding the automatic selection. The status bar shows the layer as read-only text (`AppStatusBarViewModel.LayerText`); an interactive selector is needed. **Acceptance.** UI: a manual layer selection applies to queries and is held until the lock is released; on release the automatic selection returns. **Note.** Closes gap (b)1: there is no manual selector, the layer is only auto-by-zoom. @@ -199,7 +199,7 @@ through `TrendChartViewModel.SetActivePen`; the picked line is drawn 2 px wide a **Note.** Closes the most real correctness risk (b)7. ### DA-8 — Quality and gaps (SHOULD) -**Definition.** The archive marks a break with `q = 32` on the last sample before it and `q = 16` on the first sample after it; absence of rows without a preceding `q = 32` means the value did not change and the line continues horizontally (`scada-archive.md`). The provider maps a break to a NaN anchor in the envelope along the whole path from PostgreSQL — from the archive's own `q` column, never from a synthesised marker. Quality codes outside the good set mark an untrustworthy sample, which is also dropped. +**Definition.** The archive marks a break with `q = 32` on the last sample before it and `q = 16` on the first sample after it; absence of rows without a preceding `q = 32` means the value did not change and the line continues horizontally (`scada-archive.md`). The provider maps a break to a NaN anchor in the envelope along the whole path from PostgreSQL — from the archive's own `q` column, never from a synthesised marker. Any other quality code is drawn as an ordinary point; no such code appeared in either measured archive (`scada-archive.md#quality-and-gaps`). **Acceptance.** DB: a marked break produces a line break, while a long run without rows produces a continuous horizontal line; the readout inside a break is "no data". **Note.** Closes gap (b)10. @@ -271,4 +271,4 @@ through `TrendChartViewModel.SetActivePen`; the picked line is drawn 2 px wide a **Direct user requirements (all MUST):** continuous time canvas → TM-1; multiple Y axes → AY-1/AY-2; resize a pen's Y layer, min/max, disable → PN-2/PN-3/PN-4; T1/T2 markers → CU-3; pen value at a point → CU-2; source always PostgreSQL → DA-1. -**Known "model exists — UI missing" discrepancies promoted to requirements:** AY-6 (log axis, closed), DA-4 (manual layer), AY-2 (axis ≠ group), DA-7 (X monotonicity), CU-6 (readout at the live edge), DA-6 (column-count stability). +**Known "model exists — UI missing" discrepancies promoted to requirements:** AY-6 (log axis, closed), DA-4 (manual layer), AY-2 (axis ≠ group, closed), DA-7 (X monotonicity, closed), CU-6 (readout at the live edge), DA-6 (column-count stability, closed). diff --git a/docs/architecture/trend-interaction.md b/docs/architecture/trend-interaction.md index 3ab395a..7334f0a 100644 --- a/docs/architecture/trend-interaction.md +++ b/docs/architecture/trend-interaction.md @@ -67,10 +67,16 @@ operator interaction. - **Decimation envelope contract:** history record per pen = ascending `X[]` + `Min[]` + `Max[]` + center `Y[]`; realtime stays single-value `double?[]` (null = gap); rendered as one `EnvelopeLine` polyline (see Renderer). + *As-built note:* realtime is `double`, not `double?`: `PenRealtimeValues` carries each pen's own + timestamps and values, and the live edge has no null, so every gap the chart draws comes from the + history path (charting.md, "Gaps via NaN"). - **Δ cursors:** Δy is reported only for the **active/selected pen** (pens share X but have independent Y scales, so a global Δy is meaningless). - **Bad quality → gap:** OPC bad-quality is mapped to `null` at the `IDataProvider` boundary, reusing the null = gap path; a distinct value-present-but-bad-quality flag is deferred. + *As-built note:* nothing maps to `null`. The archive marks the last sample before a break with + `q = 32`, the server closes the bucket there, and `BucketedRowFold` writes a `NaN` column one tick after + it. `q = 16` takes no branch: the next bucket resumes the line (data-integration.md#quality-and-gaps). - **Real-time return:** "jump to real-time" re-attaches sticky immediately; now-marker at the **right edge** (centering is at most a transient transition animation). - **Many-axes management:** **single active Y axis + per-pen autoscale** (legacy SCADA model); @@ -90,10 +96,14 @@ operator interaction. - **Performance:** **FPS locked at 30**; data updates no faster than **10 Hz (100 ms)**; up to **50 displayed pens**. *As-built note:* the chart redraws at most once per display frame (§RT-4, - charting.md#the-frame-paced-redraw). + charting.md#the-frame-paced-redraw). The 50 pens are a sizing budget: no code caps the pen count. - **Decimation backend:** kept behind the data-provider **stub** for now; production backend will be **PostgreSQL**, but whether it stores pre-trimmed/layered data is **unknown** — the data layer must support either server-side aggregation or in-process decimation. + *As-built note:* the backend is PostgreSQL, and the archive stores the layers in its `l` column + (`AggregationLayer`: `Raw`, `Minute`, `Hour`; `scada-archive.md#layers`). The server buckets every + layer in the query (`BucketedHistoryWindow`), and no decimator runs on the client (see "Decimation & + performance"). - **Dropped / out of scope:** horizontal cursor (dropped); alarm/event overlays (out of scope). Time markers on the chart are in scope and built (`#the-time-markers`). **`[LATER]`:** view persistence, snapshot/print. CSV value export ships @@ -175,7 +185,7 @@ as-built mechanics that realize them: Requirements: multiple independent Y axes (trend-feature-spec.md §AY-1), the per-pen "each on its own axis" case (§AY-2), and the shared-X / independent-Y invariant (§TM-1). As-built mechanics: -- Plot up to **50 pens**, each with a scale of its own. +- The chart is sized for **50 pens**, each with a scale of its own; no code caps the count. - **Axis management = single active axis + per-pen autoscale:** the active pen's scale is surfaced on the primary axis; non-active pens scale individually with their axes hidden, so many pens do not spill many visible axes. The literal "N lines, each on its own axis" case (§AY-2) is the @@ -196,16 +206,16 @@ are routed through `Chart/TrendChartView` onto the navigation controller. `Disab the wheel: `AvaPlot.OnPointerWheelChanged` ends with `e.Handled = HandleMouseWheelEvent`, outside the delta guard and unconditioned on the input processor, and Avalonia runs that class handler before the view's instance handler on the same element — so a wheel event arrives already handled and the view -never sees it. `TrendChartView.InitializeComponent` therefore also sets -`_plotControl.HandleMouseWheelEvent = false`, which is what keeps wheel zoom working; the view's own +never sees it. The `TrendChartView` constructor therefore also sets +`PlotControl.HandleMouseWheelEvent = false`, which is what keeps wheel zoom working; the view's own handler is then the only writer of `Handled` for the wheel. Each event kind has one pure decision point, and the handlers carry no modifier test of their own: the wheel goes through `Chart/ChartWheelRouter.Route(isAxisRegionHit, modifiers, delta)`, the left press through -`Chart/ChartPressRouter.Route(region, modifiers, activeTool)`. The view names the one +`Chart/ChartPressRouter.Route(region, modifiers, activeTool, clickCount)`. The view names the one `ChartPressRegion` the press lands on (`ActivePenAxis`, `TimeAxis`, `Marker` or `DataArea`; the two bands do not overlap, and `Marker` is a press inside the data rect within 4 px of a time marker), and the press router checks the active pen's axis first, then Ctrl, then the time axis band, then the tool, then the -marker. The left +marker, then the click count (`#the-time-markers`, Add and delete). The left button is a single tool with an explicit state (`Chart/LeftButtonTool` = `Pan` | `DeltaPlacement`); the active tool is sourced from the navigation bar's delta-mode toggle (`TrendChartViewModel.ActiveLeftButtonTool`), so there is one left-button gesture, not overlapping hidden branches. Ctrl is a held key, not a tool. @@ -278,16 +288,17 @@ pointer, so a second finger neither moves, pans nor ends the first finger's drag stays as it is and keeps its emphasis. The branch sits ahead of the tool, so delta mode does not change a time-axis click and places no cursor there. A Ctrl press on the band shifts the active pen, as anywhere else. - **Sticky to real-time by default.** A button detaches sticky (pan into the past); clicking it - again re-attaches and returns to real-time. `WindowChanged` is the single writer of the navigation - bar's `IsSticky` (refreshed from `Navigation.IsSticky`), so auto-detach and `JumpToNow` re-attach stay - in sync with the button — no double write path. + again re-attaches and returns to real-time. The constructor seeds the navigation bar's `IsSticky` from + `Navigation.IsSticky`, and after that the `WindowChanged` handler is its only writer, refreshing it from + the same flag, so auto-detach and `JumpToNow` re-attach stay in sync with the button. - **Panning so the live edge scrolls out of the view** auto-detaches sticky. - **Hover readout + crosshair (X-trace) live in an Avalonia overlay, not on the plot.** Moving the pointer does NOT trigger a ScottPlot re-render. The crosshair is an Avalonia `Line` and the readout an Avalonia `Border`/`TextBlock` laid out on a transparent `Canvas` over the `AvaPlot` (`Chart/TrendChartView`). On hover the view positions them by projecting the view model's cursor X through `Plot.GetPixel` and `Chart/ChartCursorOverlay` (cursor pixel X + `DataRect` + render scale → - crosshair endpoints + readout anchor in DIP space, clamped). The readout text is the pure + crosshair endpoints in DIP space; the readout hangs from the line's top end). A cursor X outside the + data rect's X range hides the crosshair and the readout. The readout text is the pure `Chart/ChartHoverReadout.BuildContent` string: the local timestamp plus every *visible* pen's value at the cursor X (one line per pen; gap or missing pen → dash). With the pointer on a time marker the cursor X is the marker's moment, so the readout lists every visible pen's value there @@ -299,8 +310,7 @@ pointer, so a second finger neither moves, pans nor ends the first finger's drag (`NavigationBarViewModel.IsDeltaModeEnabled`) sets the chart into `DeltaPlacement`: two left clicks place the cursors and drag does NOT pan; toggling off clears the placed cursors and hides the lines. Δt and the **active-pen** Δy (`Core/Trends/DeltaCursorModel` → `DeltaReadout`) are shown in - an inline readout next to the toggle. (The legacy `DeltaCursorsEnabled` flag and the hidden - left-click hijack branch were deleted.) + an inline readout next to the toggle. - **Y-axis click-region scale edit.** A press on the active pen's Y-axis panel band (`Chart/ChartAxisRegion`, computed from the last render layout) is handled before Ctrl, the time axis, pan and delta routing and opens the axis scale panel (below). The same region routes the wheel to `ScaleActivePen`. @@ -428,9 +438,9 @@ under sticky is now. edges write no edge and a valid changed width still lands; an invalid field keeps its typed text until a write re-seeds the form, so a form with nothing valid to write keeps all of it. - The edges and the width validate apart, so a bad width never blocks the edges. The edge rules, in the - order the message line names them: a date missing, a time unreadable (start, then end), the end not after - the start, a time the spring-forward change skips (`zone.IsInvalidTime`), and a span over 365 days - measured between the UTC edges. The skipped time comes before the span because a skipped time has no UTC + order the message line names them: the start date missing, the start time unreadable, the end date + missing, the end time unreadable, the end not after the start, a time the spring-forward change skips + (`zone.IsInvalidTime`), and a span over 365 days measured between the UTC edges. The skipped time comes before the span because a skipped time has no UTC value; an ambiguous autumn hour takes the standard offset (`data-integration.md#time-boundary`). The width rules: unreadable, or outside `0:00:01` .. `8760:00:00`. - The panel commits on Enter, on a preset and on close, never on focus loss. The axis scale panel commits when @@ -615,7 +625,7 @@ and the render budget (§RT-4). The as-built rationale and mechanics: whole coarse layer reaches the client, and no decimator runs there. `BucketedRowFold` maps the rows onto the one envelope vocabulary the chart reads, behind `IDataProvider`. - **Performance budget (§RT-4):** at most one redraw per display frame on pan/zoom; input data ≤ 10 Hz; - ≤ 50 simultaneous pens; + sized for 50 simultaneous pens, with no cap in code; points per pen handed to the chart ≈ viewport width × 2–4. ## Data quality & line rendering @@ -631,8 +641,10 @@ Local-time display is required by trend-feature-spec.md §TM-1 (the single `Loca As-built: samples are UTC over the wire (data-integration.md). All UTC↔OADate conversion is funneled through `Chart/LocalTimeAxis` at every render boundary (plotted X, axis limits, cursor/delta X), with a `DateTimeAutomatic` tick generator on the shared bottom axis so labels -read local time. DST boundary behavior follows the machine's local-time conversion (cosmetic; no -special-casing). +read local time. Across a daylight-saving change the axis follows the machine's local-time conversion, +and `LocalTimeAxis.FromAxis` gives one instant for a pixel of the repeated autumn hour. The time window +panel refuses a time the spring change skips and gives an ambiguous autumn time the standard offset +(`#the-time-window-panel`). The archive's naive times follow `data-integration.md#time-boundary`. ## Legend @@ -647,8 +659,8 @@ with its rows indented beneath it; the active pen's row is marked by an accent b bar on its left edge, not by bold text. The panel has two states: collapsed, it leaves the box, the dot and the name. A handle on the panel's left edge drags its width in either state; each state remembers its own width for the session, and a restart returns both to their defaults. The value at the cursor is the -chart's own hover readout (§CU-2) and no longer a legend cell; the grey scale range is gone with it — -it read as the pen's measured extremes and was the padded axis bound. +chart's own hover readout (§CU-2), not a legend cell. The row shows no scale range: the padded axis +bound would read as the pen's measured extremes. ## Archive-overview minimap @@ -823,8 +835,8 @@ the next read's scheduling included, goes to `TryReportFailure` as well. envelope; a NaN column segments the line at gaps; `DataLogger` is prior art, not the pattern. Shared-X invariant: all pens pinned to `plot.Axes.Bottom`. - **Avalonia 12.0.5 / net10** (as-built): hosts `ScottPlot.Avalonia` 5.1.59, which depends on Avalonia - 12.0.0. Mirrors SemiStep's patterns (ReactiveUI + MS.DI + Serilog + Semi.Avalonia) and now its - versions too. `Avalonia.HarfBuzz` 12.0.5 is referenced and `UseHarfBuzz()` is called explicitly; Skia carries + 12.0.0. Mirrors SemiStep's patterns (ReactiveUI + MS.DI + Serilog + Semi.Avalonia) and its + versions. `Avalonia.HarfBuzz` 12.0.5 is referenced and `UseHarfBuzz()` is called explicitly; Skia carries no text shaper. - No qualifying open-source .NET SCADA trend-viewer reference repo exists; built from library primitives, with ScottPlot's `DataLogger` demo as the nearest realtime pattern. @@ -835,8 +847,9 @@ Same author, same conventions — reuse directly: - DI + two-phase startup (validate config → build provider → `App.RunStarted`); `IServiceCollection` extension methods. -- Coordinator-as-event-hub: expose realtime as `IObservable`, `ObserveOn(MainThreadScheduler) - .Publish().RefCount()` at the source; subscribers `DisposeWith(_disposables)`. This is the +- Coordinator-as-event-hub: expose realtime as `IObservable`, `ObserveOn(uiScheduler) + .Publish().RefCount()` at the source, `uiScheduler` being the `AvaloniaScheduler.Instance` the + composition root passes in; subscribers `DisposeWith(_disposables)`. This is the realtime sample → chart pipeline. - Serilog config; `MainWindow` `Grid` layout (menu / content / message panel / status bar) with the chart in the content row; ReactiveUI VM base; code style / naming. diff --git a/docs/architecture/ui-text.md b/docs/architecture/ui-text.md index fc4f6ea..1579a70 100644 --- a/docs/architecture/ui-text.md +++ b/docs/architecture/ui-text.md @@ -20,21 +20,22 @@ compiles to a `ru` satellite; the SDK takes the culture from the file name, so t accessor serves both. The `locale` key of the `app/` section selects between them. `Startup/StartupSequence.Run` sets -`CultureInfo.DefaultThreadCurrentUICulture` twice: to `ru` before the settings section is read -(`StartupSequence.cs:22`, `:37`), and to the configured language immediately after (`:31`). Both -writes happen before Avalonia is configured, so no window exists on the wrong language. The argument -failure path never reaches `Run`, so `Program.Main` calls `ApplyBootstrapCulture` itself; without -that its window would fall back to the neutral English set while every other failure window is -Russian. `ui-theme.md` owns the other half of the same section, the `theme` key. +`CultureInfo.DefaultThreadCurrentUICulture` twice, both times through its private `ApplyCulture`: to +`ru` through `ApplyBootstrapCulture` before the settings section is read, and to the configured language +immediately after. Both writes happen before Avalonia is configured, so no window exists on the wrong +language. The argument failure and the log file failure never reach `Run`, so +`Program.ReportStartupFailure` calls `ApplyBootstrapCulture` itself; without that their windows would +fall back to the neutral English set while every other failure window is Russian. `ui-theme.md` owns the other half of the same section, the `theme` key. `Resources.Culture` stays unassigned. The generated accessor reads `ResourceManager.GetString(key, Culture)`, and a null `Culture` falls through to `CultureInfo.CurrentUICulture`, which leaves the thread culture the single writer. -A failure reporting the settings section itself, and a failure reporting the launch arguments, are -both emitted before any `locale` is known, so those windows are read in the bootstrap language, -Russian. The comment above `ArchiveFailureMapper.MapAppSettings` states the settings half in one -line; the arguments half is `Program.ReportStartupFailure`, which applies the culture itself. +A failure reporting the settings section itself, a failure reporting the launch arguments, and a +log file `LogFileTarget.Prepare` cannot open are all emitted before any `locale` is known, so those +windows are read in the bootstrap language, Russian. The comment above +`ArchiveFailureMapper.MapAppSettings` states the settings case in one line; the other two go through +`Program.ReportStartupFailure`, which applies the culture itself. Number and timestamp formatting is a separate question and stays on `CultureInfo.CurrentCulture`: `Chart/ChartHoverReadout` and `Chart/ChartDeltaCursorReader` format what the operator reads on their own @@ -83,7 +84,7 @@ targets instead: measured on 2026-09-09, a cold build fails with ## What stays a literal -- The `h`, `m` and `s` suffixes `Chart/ChartDeltaCursorReader.FormatDeltaTime:55-67` glues to its +- The `h`, `m` and `s` suffixes `Chart/ChartDeltaCursorReader.FormatDeltaTime` glues to its numbers. A unit suffix glued to a number is part of the format, not a caption. - The CSV export's file format, which belongs to the file and not to the window: the non-finite spellings `NaN`, `Infinity` and `-Infinity` (`ArchiveCsvWriter`), and the time boxes' format `HH:mm:ss` @@ -106,7 +107,7 @@ targets instead: measured on 2026-09-09, a cold build fails with programming error reads as English developer text in a Russian window, the message panel included. That is the trade taken for an error class the operator cannot act on: the alternative is a sentence that says nothing, and the log carries the stack either way. It also widens what the - window shows: text these arms used to write only to the log now stands on screen, so a driver-level + window shows: the text of these arms stands on screen as well as in the log, so a driver-level exception over a malformed connection string can echo the string it was handed. The connection file's own arms name host, port, database and role and never the password. @@ -121,10 +122,10 @@ so two failures sharing a title but differing in detail stay two entries. The pe lines read the title alone, beside the panel entry the same failure adds (`#the-pen-editors-text`). -Because both surfaces read the same arms, no title may name a phase. `FailureThrownTitle` used to -read "Startup failed unexpectedly", which was true while the mapper fed the startup window alone and -false the moment a mid-session ReactiveUI exception reached the panel under it; it now reads -"Unexpected failure" / "Непредвиденный сбой" and its detail names the exception rather than the startup sequence. +Because both surfaces read the same arms, no title may name a phase: a mid-session ReactiveUI exception +reaches the panel under the same title as a startup one. `FailureThrownTitle` reads "SemiPlot failed +unexpectedly" / "Произошла непредвиденная ошибка", and its detail names the exception rather than the +startup sequence. The aggregation layer is named the same way. `AppStatusBarViewModel.LayerNameOf` switches each `AggregationLayer` member onto a resx key and the bar reads `StatusLayerFormat` around it, so the @@ -135,6 +136,37 @@ CS8509, so no compiler check stands behind it: a fourth `AggregationLayer` membe `Enum.GetValues()` against the three members it names. `ArchiveFailureMapper.NameOf` for `StartupRead` is the same shape. +## The message panel's text + +`Messages/MessagePanelView.axaml` reads the panel's own strings; each entry's title, detail and remedy +come from the mapper (`#what-the-mappers-two-consumers-read`): + +| Key | English / Russian | Where | +| --- | --- | --- | +| `MessagePanelTitle`, `MessagePanelClear` | "Messages" / "Сообщения", "Clear" / "Очистить" | The panel's header and its clear button | +| `MessagePanelEmpty` | "No messages" / "Сообщений нет" | Under the header while the panel holds no entry | +| `MessagePanelRepeatFormat` | "repeated {0} times" / "повторений: {0}" | Beside an entry that repeated, `{0}` the repeat count, through `StringFormat` | + +## The navigation bar's text + +`Navigation/NavigationBarView.axaml` reads the bar's three buttons: + +| Key | English / Russian | Where | +| --- | --- | --- | +| `NavigationJumpToNow` | "Jump to now" / "Перейти к текущему времени" | The jump button | +| `NavigationSticky`, `NavigationDelta` | "Sticky" / "Следовать за текущим временем", "Delta" / "Приращение" | The two toggle buttons | + +## The sidebar's text + +`Legend/TrendLegendView.axaml` reads the sidebar's strings through `TrendLegendViewModel` and +`TrendLegendGroupViewModel`. Pen and group names are data and pass through no resource. + +| Key | English / Russian | Where | +| --- | --- | --- | +| `LegendCollapsePanel`, `LegendExpandPanel` | "Collapse" / "Свернуть", "Expand" / "Развернуть" | The panel's toggle button, its text and accessible name (`TrendLegendViewModel.ToggleText`) | +| `LegendUngroupedHeader` | "Ungrouped" / "Без группы" | The last group's header, over the pens that belong to no group, in capitals through `LegendConverters.ToCapitals`; absent while no pen has a group | +| `LegendGroupSwitch` | "Show or hide every pen of group {0}" / "Показать или скрыть все перья группы {0}" | The accessible name of a group header's check box, `{0}` the group name (`TrendLegendGroupViewModel.SwitchName`) | + ## The settings window's text The `Edit` menu is `MenuEdit` with two items, `MenuEditSettings` and `MenuEditPensAndGroups` @@ -145,10 +177,10 @@ and `SettingsClose`. The language and theme choices read `SettingsLanguageRussia and the file keeps the token `SettingsVocabulary` pairs with it. The dialog's message line, left of the buttons, shows `SettingsSectionUnreadable` with the section header as `{0}` when a section fails to read, else `SettingsKeyAbsent` with the first edited key no file of its section carries as `{0}`, else the rule -the first invalid field breaks, in form order: `SettingsHostInvalid`, `SettingsPortInvalid`, -`SettingsPollIntervalInvalid`, and `SettingsFieldRequired` for a blank database, user or password or an -unchosen language or theme, which takes the field's label as `{0}`. `SettingsViewModel.ValidationMessage` -picks it. The line is not under the field, so each message opens with the field it names, and each is one +the first invalid field breaks, in form order: `SettingsFieldRequired` for an unchosen language or theme, +`SettingsHostInvalid`, `SettingsPortInvalid`, `SettingsFieldRequired` for a blank database, user or +password, and `SettingsPollIntervalInvalid`. `SettingsFieldRequired` takes the field's label as `{0}`. +`SettingsViewModel.ValidationMessage` picks it. The line is not under the field, so each message opens with the field it names, and each is one short line in both languages ("Host: expected an IP address such as 127.0.0.1"); `ui-theme.md#a-form-never-resizes-on-validation` holds the width it has to fit. `SettingsRestartNotice` is the dialog's own text after a save that changed any key but `theme`, on the same line while no field is invalid; @@ -229,7 +261,7 @@ format over the three labels the operator clicks: `{0}` is `MenuEdit`, `{1}` `Me and `{2}` `PenEditorRefresh`. The text withdraws when a catalogue read gives the chart its first pens, in the same session (`TrendChartViewModel.HasNoPens`). `Chart/TrendChartView.axaml` reads it through a `MultiBinding` whose `StringFormat` is `{x:Static text:Resources.EmptyCatalogueMessage}` over three `x:Static` sources, the -AXAML form The generated accessor states, so a relabelled menu item or button changes the sentence +AXAML form of the generated `Resources.FormatEmptyCatalogueMessage` accessor, so a relabelled menu item or button changes the sentence with it. `TrendChartViewTests.EmptyCatalogueMessage_ShowsWithNoPensAndWithdrawsWhenOneArrives` compares the realised text with `Resources.FormatEmptyCatalogueMessage(...)` over the three labels. @@ -248,14 +280,14 @@ removed the marker, says that the chart no longer shows it and asks for it to be still needed. It is a `Warning`. The Russian term for a time marker is "метка" in every key, and "подпись" for its label. -The kinds a write shares with a read name a statement, never a read, because the same text now serves -both. The reworded keys are `FailureArchiveAccessDeniedTitle` and `...Remedy`, +The kinds a write shares with a read name a statement, never a read, because the same text serves +both. The shared keys are `FailureArchiveAccessDeniedTitle` and `...Remedy`, `FailureArchiveTableMissingDetail` and `...Remedy`, `FailureArchiveQueryTimedOutTitle`, `...Detail` and `...Remedy`, `FailureArchiveReadFailedTitle` and `...Detail`, and `FailureArchiveReadUnnamedDetail`. -The `TableMissing` pair drops the word "table", because a missing function takes the same arm. The -placeholders did not move, which +The `TableMissing` pair carries no word "table", because a missing function takes the same arm. Each +Russian value uses the placeholder indices of the neutral one, which `ResourcesTests.EveryRussianValue_UsesThePlaceholderIndicesOfTheNeutralOne` gates, and the timed-out -detail keeps `(SQLSTATE 57014)`, which `ArchiveFailureMapperTests` asserts. `ArchiveError.Describe`, the +detail carries `(SQLSTATE 57014)`, which `ArchiveFailureMapperTests` asserts. `ArchiveError.Describe`, the English log line, names a statement the same way (`data-integration.md#two-error-planes`). ## The axis scale panel's text @@ -382,6 +414,19 @@ The editor has no message line: nothing in it can be invalid, and the watermark is allowed (`ui-theme.md#the-marker-label-editor`). A failed marker write reaches the message panel, never the editor, which is closed by then. A label is operator data and passes through no resource. +## The About dialog's text + +`MainWindow/AboutDialog.axaml` reads the dialog's strings. It opens from `MenuHelpAbout` and from the +startup failure window's `StartupFailureAbout`; its first line is `WindowTitle`, and the version and the +configuration directory are data (`MainWindow/AboutInfo`). + +| Key | English / Russian | Where | +| --- | --- | --- | +| `AboutTitle` | "About SemiPlot" / "О программе SemiPlot" | The window title | +| `AboutVersionLabel`, `AboutConfigurationLabel` | "Version:" / "Версия:", "Configuration:" / "Настройки:" | The labels left of the version and the configuration directory | +| `AboutConfigurationUnknown` | "unknown" / "не определены" | In place of the directory when the launch arguments do not parse (`AboutInfo.For`) | +| `AboutClose` | "Close" / "Закрыть" | The close button | + ## Adding a key 1. Add the `` entry to `Resources.resx`, value in English, and the same key to @@ -395,8 +440,10 @@ never the editor, which is closed by then. A label is operator data and passes t 3. `SemiPlot.Tests.Unit/UI/Localization/ResourcesTests.cs` enumerates the generated accessors by reflection and pins that each resolves to a non-empty string, and reads both sets back through `ResourceManager.GetResourceSet(culture, createIfNotExists: true, tryParents: false)`, so an - ordinary key needs no test of its own. A key whose value carries a glyph or a `{0}` placeholder - gets one assertion there. + ordinary key needs no test of its own. A key whose value carries a glyph gets one assertion in + `GlyphValues_KeepTheCodePointsTheOperatorSees`. A placeholder needs none of its own: + `EveryRussianValue_UsesThePlaceholderIndicesOfTheNeutralOne` compares the `{N}` indices of every + key across the two sets. Both resx files are hand-edited. An IDE resx editor rewrites the header, adds the `xsd:schema` block and a byte order mark; revert that. @@ -409,5 +456,4 @@ process-global and the project runs its other classes in parallel. A glyph with no ASCII form belongs in the resx and nowhere else, never as a `\u` escape in production code: the escape hides the string from whoever reads the code, and `terse` does not read -`.resx`. The only escapes in the repository are the three in `ResourcesTests`, which spell the code -points they pin while the test file stays ASCII. +`.resx`. A test file may spell the code points it pins as escapes, so the test file stays ASCII. diff --git a/docs/architecture/ui-theme.md b/docs/architecture/ui-theme.md index 4dd0377..936e36a 100644 --- a/docs/architecture/ui-theme.md +++ b/docs/architecture/ui-theme.md @@ -123,7 +123,7 @@ tests in every state it reaches. `SemiTheme` keys its built-in strings by specific culture and falls through to `zh-CN` for a neutral one, so `App.ApplyAppearance` calls `SemiTheme.OverrideLocaleResources` with `App.SemiLocaleFor(settings?.Locale ?? StartupSequence.BootstrapLocale)`, outside the -`settings is not null` guard that the variant sits inside (`App.axaml.cs:133-141`): a settings failure +`settings is not null` guard that the variant sits inside: a settings failure has no configured locale and reads its window in the bootstrap one. `AppConfigurationTests.ASettingsFailure_StillHandsSemiTheBootstrapLocale` calls `App.ConfigureFailed` with null settings and reads `STRING_MENU_COPY` back off `Application.Resources`, so moving the call @@ -152,12 +152,12 @@ Semi owns the controls; these fifteen keys are ours, and each exists in both var | Key | Consumers | Light | Dark | | --- | --- | --- | --- | -| `AppPanelBackgroundBrush` | Navigation bar, legend panel, message panel, status bar, startup-failure window's message panel, minimap frame, chart hover readout | `#F7F8FA` | `#2B2D30` | -| `AppContentBackgroundBrush` | Chart area, minimap strip canvas, the sidebar's resize handle | `#FFFFFF` | `#1E1F22` | -| `AppBorderBrush` | Every separator in the three views | `#EBECF0` | `#393B40` | +| `AppPanelBackgroundBrush` | Navigation bar, legend panel, message panel, status bar, startup-failure window's message panel, minimap frame, chart hover readout, the plot's figure background | `#F7F8FA` | `#2B2D30` | +| `AppContentBackgroundBrush` | Chart area and the plot's data background, minimap strip canvas, the sidebar's resize handle, the ground of the active pen's reading at each marker | `#FFFFFF` | `#1E1F22` | +| `AppBorderBrush` | The border of the navigation bar, legend panel, message panel and status bar, of the startup-failure window's message panel, of the minimap frame, of the chart hover readout and of the export window's pen list | `#EBECF0` | `#393B40` | | `AppSubtleLineBrush` | Plot grid, the line above every sidebar group header but the first, the status bar's separator after the connection indicator, the navigation bar's group separator | `#EBECF0` | `#393B40` | | `AppMinorGridLineBrush` | The plot's minor gridlines, drawn on a log10 axis only | `#F4F5F7` | `#2B2D30` | -| `AppSecondaryForegroundBrush` | Minimap end labels, hover line and hover time, chart crosshair, plot axis furniture, the sidebar group header caption and a row's unit | `#818594` | `#6F737A` | +| `AppSecondaryForegroundBrush` | Minimap end labels, hover line and hover time, chart crosshair, plot axis furniture, the chart's empty-catalogue message, the sidebar group header caption and a row's unit, the axis scale panel's unit, the message panel's empty text and an entry's remedy, repeat count and time | `#818594` | `#6F737A` | | `AppAccentBrush` | Minimap window highlight border, the active sidebar row's left bar | `#3574F0` | `#3574F0` | | `AppAccentFillBrush` | Minimap window highlight fill, the active sidebar row's background | `#3574F0` at 0.25 opacity | `#3574F0` at 0.25 opacity | | `AppSeverityErrorBrush` | The message panel's dot on an `Error` entry; a form's invalid field border and its message line | `#DB3B4B` | `#E55765` | @@ -363,7 +363,8 @@ the working area of a 768 px screen. The rule above holds for every part but the - The bottom bar, on both tabs, holds the added count and the refresh button in a right-aligned `Auto,Auto` grid, 12 px apart, and carries no notice. Each tab has its own reserved `form-message` line. - An invalid field is marked by the `invalid` class alone. The line-style combo box and the "on start" - checkbox refuse no value, and `Forms.axaml` styles `invalid` on `TextBox` and `NumericUpDown` only, so + checkbox refuse no value, and `Forms.axaml` styles `invalid` on `TextBox`, `NumericUpDown` and + `CalendarDatePicker` only, not on a `ComboBox` or a `CheckBox`, so a failed write of either shows on the message line and in the panel only. The log scale checkbox refuses a tick while the pen stores a minimum at or below zero; the refusal shows on the message line alone, and a minimum that breaks the rule while the box is ticked marks the minimum `TextBox` `invalid`. @@ -411,7 +412,7 @@ the 100 px groups column at `MinWidth` and equal header button heights. ## How the variant reaches the application `Startup/AppSettingsLoader` reads `theme` into `AppSettings.Theme` -(`Startup/AppSettings.cs:14-19`), and `App.ApplyAppearance`, which `App.ConfigureStarted` and +(an `AppThemeVariant`, `Startup/AppSettings.cs`), and `App.ApplyAppearance`, which `App.ConfigureStarted` and `App.ConfigureFailed` call inside `AfterSetup`, assigns `RequestedThemeVariant` from it before either path builds a window. So an archive failure still renders on the configured variant. `settings` is null only when the settings load itself failed; that window renders on the `Light` variant `App.axaml:5` declares, which is also From 2644cc5fff78472f1ae62243741487cb2a7ff4df Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:46:09 +0300 Subject: [PATCH 2/2] docs(architecture): name the IsSticky seed and the restart erase --- AGENTS.md | 4 ++-- docs/architecture/postgres-instance.md | 2 +- docs/architecture/postgres-topology.md | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 75f9652..0920072 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -339,8 +339,8 @@ No abbreviations in names. a single one would pan or pick adds a marker (`ChartPressAction.AddMarker`), and one where it would drag a marker deletes it (`ChartPressAction.DeleteMarker`, `docs/architecture/trend-interaction.md#the-time-markers`). The bar's `IsSticky` has a single writer - (the `WindowChanged` handler refreshing from `Navigation.IsSticky`) — do not reintroduce imperative - `IsSticky =` assignments. + after the constructor seeds it (the `WindowChanged` handler refreshing from `Navigation.IsSticky`) — do + not reintroduce imperative `IsSticky =` assignments. - A checkable menu item reads its flag `Mode=OneWay` and writes it only through the command it invokes, so the command is the flag's single writer. A two-way `IsChecked` would make the control a second writer and the two halves would drift. The sidebar's visibility box follows the same rule: it diff --git a/docs/architecture/postgres-instance.md b/docs/architecture/postgres-instance.md index 728ade6..6fc643f 100644 --- a/docs/architecture/postgres-instance.md +++ b/docs/architecture/postgres-instance.md @@ -131,7 +131,7 @@ covers the set rather than ordering it. One operational state belongs beside them: rows with `t >= current_date` in `tpdefault` mean a day partition was missing at write time. The partition's other rows, the coarse layers' 1899-12-30 rows and, with an archive limit set, late rows of dropped days, are normal. The SCADA erases `tpdefault` -at 02:00, so the check runs before 02:00, straight against the database at commissioning; no +at 02:00 and at every project start, so the check runs before 02:00 and before a project restart, straight against the database at commissioning; no SemiPlot code reads `tpdefault` (`scada-archive.md#reader-hazards`). ## Retention and capacity diff --git a/docs/architecture/postgres-topology.md b/docs/architecture/postgres-topology.md index 5b3068c..f040bc4 100644 --- a/docs/architecture/postgres-topology.md +++ b/docs/architecture/postgres-topology.md @@ -90,7 +90,8 @@ flowchart LR Partitions are daily ranges over `t`, named `tpYYYYmMMdDD`, with `tpdefault` catching anything that misses one. Rows with `t >= current_date` in `tpdefault` mean a day partition was missing at write -time; the SCADA erases `tpdefault` at 02:00, so a check for them runs before 02:00. Its other rows are +time; the SCADA erases `tpdefault` at 02:00 and at every project start, so a check for them runs before +02:00 and before a project restart. Its other rows are normal (`scada-archive.md#reader-hazards`). The primary key is `(id, l, t)` and its leading column is `id`. Every query therefore carries the