diff --git a/.github/ISSUE_TEMPLATE/01-bug-report.yml b/.github/ISSUE_TEMPLATE/01-bug-report.yml new file mode 100644 index 0000000..f238427 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/01-bug-report.yml @@ -0,0 +1,40 @@ +name: Bug report +description: Report a reproducible problem in RMC-BestFit +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thank you for reporting a problem. Please include enough detail for maintainers to reproduce it. + - type: textarea + id: summary + attributes: + label: Summary + description: What happened, and what did you expect instead? + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Include inputs, options, distributions, estimation methods, and a minimal dataset when possible. + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: RMC-BestFit version, .NET SDK version, operating system, and whether you used the library, API, or desktop app. + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs or screenshots + description: Paste relevant exception messages, output, screenshots, or stack traces. + render: text \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/02-feature-request.yml b/.github/ISSUE_TEMPLATE/02-feature-request.yml new file mode 100644 index 0000000..89675c4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/02-feature-request.yml @@ -0,0 +1,37 @@ +name: Feature request +description: Suggest an improvement or new capability +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem or use case + description: What engineering, analysis, or developer workflow would this help? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed behavior + description: Describe the feature you would like to see. + validations: + required: true + - type: textarea + id: references + attributes: + label: References + description: Link guidance documents, journal articles, validation data, or comparable software behavior if relevant. + - type: dropdown + id: area + attributes: + label: Area + options: + - Model library + - API + - Desktop application + - Documentation + - Validation + - Other + validations: + required: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/03-documentation.yml b/.github/ISSUE_TEMPLATE/03-documentation.yml new file mode 100644 index 0000000..3625e5f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/03-documentation.yml @@ -0,0 +1,24 @@ +name: Documentation issue +description: Report missing, unclear, or incorrect documentation +title: "[Docs]: " +labels: ["documentation"] +body: + - type: textarea + id: location + attributes: + label: Documentation location + description: Link or name the README, docs page, example, XML docs, or API area. + validations: + required: true + - type: textarea + id: issue + attributes: + label: Issue + description: What is missing, unclear, incorrect, or outdated? + validations: + required: true + - type: textarea + id: suggestion + attributes: + label: Suggested improvement + description: Optional wording, examples, equations, or references that would help. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/04-question.yml b/.github/ISSUE_TEMPLATE/04-question.yml new file mode 100644 index 0000000..6f716e3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/04-question.yml @@ -0,0 +1,17 @@ +name: Question +description: Ask a usage, modeling, or development question +title: "[Question]: " +labels: ["question"] +body: + - type: textarea + id: question + attributes: + label: Question + description: What would you like to understand? + validations: + required: true + - type: textarea + id: context + attributes: + label: Context + description: Include the analysis type, data type, code snippet, or documentation link if relevant. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ec4bb38 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..531571c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,28 @@ +## Summary + + + +## Related Issue + + + +Fixes # + +## Changes + +- +- + +## Test Plan + +- [ ] Model-library tests pass locally (`dotnet test src/RMC.BestFit.Tests/RMC.BestFit.Tests.csproj -c Release`) +- [ ] New or changed public behavior has unit tests +- [ ] Documentation or examples were updated when behavior changed +- [ ] No new build warnings introduced + +## Checklist + +- [ ] Public types and members have XML documentation +- [ ] Changes are focused and do not include unrelated formatting churn +- [ ] I have read [CONTRIBUTING.md](../CONTRIBUTING.md) +- [ ] I agree to the Developer Certificate of Origin (DCO) \ No newline at end of file diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..2712a64 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,14 @@ +changelog: + categories: + - title: Features + labels: + - enhancement + - title: Bug fixes + labels: + - bug + - title: Documentation + labels: + - documentation + - title: Other changes + labels: + - "*" \ No newline at end of file diff --git a/.github/workflows/Integration.yml b/.github/workflows/Integration.yml new file mode 100644 index 0000000..57939bb --- /dev/null +++ b/.github/workflows/Integration.yml @@ -0,0 +1,34 @@ +name: Integration + +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + workflow_dispatch: + +jobs: + model-library: + name: Model library + runs-on: windows-latest + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore + run: dotnet restore src/RMC.BestFit.Tests/RMC.BestFit.Tests.csproj + + - name: Build + run: dotnet build src/RMC.BestFit.Tests/RMC.BestFit.Tests.csproj -c Release --no-restore /p:Version=2.0.0 + + - name: Test + env: + VSTEST_CONNECTION_TIMEOUT: '600' + run: dotnet test src/RMC.BestFit.Tests/RMC.BestFit.Tests.csproj -c Release --no-build \ No newline at end of file diff --git a/.github/workflows/NuGetPublish.yml b/.github/workflows/NuGetPublish.yml new file mode 100644 index 0000000..501b627 --- /dev/null +++ b/.github/workflows/NuGetPublish.yml @@ -0,0 +1,62 @@ +name: Publish to NuGet.org + +on: + release: + types: [published] + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: windows-latest + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Extract and validate version + shell: pwsh + run: | + $version = $env:GITHUB_REF_NAME -replace '^v', '' + if ($version -ne '2.0.0') { + throw "The first public NuGet publish is restricted to v2.0.0; got '$env:GITHUB_REF_NAME'." + } + "VERSION=$version" >> $env:GITHUB_ENV + + - name: Restore + run: dotnet restore src/RMC.BestFit/RMC.BestFit.csproj + + - name: Build + shell: pwsh + run: dotnet build src/RMC.BestFit/RMC.BestFit.csproj -c Release --no-restore /p:Version=$env:VERSION + + - name: Pack + shell: pwsh + run: dotnet pack src/RMC.BestFit/RMC.BestFit.csproj -c Release --no-build --no-restore -o packages /p:Version=$env:VERSION + + - name: NuGet login + uses: NuGet/login@v1 + id: login + with: + user: USACE-RMC + + - name: Push to NuGet.org + shell: pwsh + env: + NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }} + run: | + $packagePath = "packages/RMC.BestFit.$env:VERSION.nupkg" + if (-not (Test-Path $packagePath)) { + throw "Missing package: $packagePath" + } + dotnet nuget push $packagePath --api-key $env:NUGET_API_KEY --source "https://api.nuget.org/v3/index.json" --skip-duplicate \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1bd755c..abde5e5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,54 +1,106 @@ -# Build outputs -**/bin/ -**/obj/ - -# NuGet packages -**/packages/ - -# Visual Studio -.vs/ -**/.vs/ +## Ignore Visual Studio temporary files, build results, and +## files generated by common .NET tooling. # User-specific files -*.user +*.rsuser *.suo +*.user *.userosscache *.sln.docstates +*.env + +# Visual Studio and editor state +.vs/ +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets +.idea/ +*.sln.iml # Build results [Dd]ebug/ +[Dd]ebugPublic/ [Rr]elease/ +[Rr]eleases/ x64/ x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ +artifacts/ +publish/ -# NuGet -*.nupkg -*.snupkg +# .NET and NuGet +.nuget/ +.dotnet-home/ project.lock.json project.fragment.lock.json -artifacts/ -/nul - -# Local .NET CLI home used by sandboxed build/test runs -/.dotnet-home/.dotnet/TelemetryStorageService/ -/.dotnet-home/.dotnet/sdk-advertising/ -/.codex-tmp +*.nupkg +*.snupkg +*.nuget.props +*.nuget.targets +**/[Pp]ackages/* +!**/[Pp]ackages/build/ -# Test results / coverage (anticipated for incoming test projects) -TestResults/ +# Test and coverage output +[Tt]est[Rr]esult*/ +*.trx +coverage*.json +coverage*.xml +coverage*.info *.coverage *.coveragexml -coverage/ +TestResult.xml +nunit-*.xml +BenchmarkDotNet.Artifacts/ -# JetBrains / VS Code (anticipated for incoming devs) -.idea/ -.vscode/ +# Build logs and temporary files +*.binlog +MSBuild_Logs/ +*.log +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.cache +!?*.cache/ +*.pdb +*.ipdb +*.ilk +*.obj +*.iobj +*.idb +*.pch +*.rsp +!Directory.Build.rsp +*.tlog +*.vspscc +*.vssscc +*.svclog +*.scc +/nul + +# Python helper output +__pycache__/ +*.pyc -# OS / filesystem cruft -.DS_Store -Thumbs.db -desktop.ini +# Local AI-assistant metadata and scratch files. These stay on developer machines. +AGENTS.md +**/AGENTS.md +CLAUDE.md +**/CLAUDE.md +.agents/ +.claude/ +.codex/ +.codex-tmp/ -# Editor swap / backup files -*.swp -*~ +# Verification project is not ready for the public repository yet. +src/RMC.BestFit.Verification/ \ No newline at end of file diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..a8e4bb0 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,43 @@ +cff-version: 1.2.0 +message: "If you use this software, please cite it as below." +type: software +title: "RMC-BestFit: Bayesian Estimation and Fitting Software for Flood Frequency Analysis" +version: "2.0.0" +date-released: "2026-07-17" +license: 0BSD +repository-code: "https://github.com/USACE-RMC/RMC-BestFit" +url: "https://github.com/USACE-RMC/RMC-BestFit" +abstract: >- + RMC-BestFit is a free and open-source .NET codebase for Bayesian estimation, + distribution fitting, and frequency analysis in flood risk and water resources + engineering. The first public package target is the portable RMC.BestFit model + library. +keywords: + - Bayesian inference + - flood frequency analysis + - hydrology + - probability distributions + - MCMC + - risk assessment + - water resources engineering + - .NET + - C# +authors: + - family-names: "Smith" + given-names: "C. Haden" + email: "cole.h.smith@usace.army.mil" + affiliation: "U.S. Army Corps of Engineers, Risk Management Center, Lakewood, Colorado, USA" + orcid: "https://orcid.org/0000-0002-4651-9890" + - family-names: "Fields" + given-names: "Woodrow L." + affiliation: "U.S. Army Corps of Engineers, Risk Management Center, Lakewood, Colorado, USA" + orcid: "https://orcid.org/0009-0008-7454-5552" + - family-names: "Skahill" + given-names: "Brian" + affiliation: "Fariborz Maseeh Department of Mathematics and Statistics, Portland State University, Portland, Oregon, USA" + orcid: "https://orcid.org/0000-0002-2164-0301" +contact: + - family-names: "Smith" + given-names: "C. Haden" + email: "cole.h.smith@usace.army.mil" + orcid: "https://orcid.org/0000-0002-4651-9890" \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..fe1b819 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,13 @@ +# Code of Conduct + +RMC-BestFit follows the [Contributor Covenant Code of Conduct](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). + +## Our Standards + +We expect participants to communicate respectfully, assume good intent, and focus discussions on improving the software, documentation, and validation record. + +Unacceptable behavior includes harassment, personal attacks, discriminatory language, publishing private information without permission, or other conduct that would reasonably make participation unsafe or unproductive. + +## Enforcement + +Project maintainers may moderate issues, pull requests, discussions, or other project spaces to preserve a constructive technical environment. Serious concerns should be reported through official USACE-RMC channels. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..dbd9481 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,78 @@ +# Contributing to RMC-BestFit + +Thank you for your interest in contributing to RMC-BestFit. We welcome bug reports, feature requests, documentation improvements, validation results, and other feedback from the community. + +## Review Capacity + +RMC-BestFit is maintained by a small team within the U.S. Army Corps of Engineers Risk Management Center (USACE-RMC). Our capacity to review external pull requests is limited. We prioritize issues and bug reports, which are always welcome and will be reviewed as resources permit. + +If you plan to submit a pull request, please open an issue first to discuss your proposed change. This helps avoid duplicated effort and ensures the contribution aligns with the project's direction. + +## How to Contribute + +### Report a Bug + +If you find a bug, please open a bug report and include: + +- Steps to reproduce the problem +- Input data and configuration, if applicable +- Expected behavior and actual behavior +- Software version, operating system, and .NET SDK version +- Relevant error messages, logs, screenshots, or small sample files + +### Request a Feature + +Feature requests are welcome. Please open a feature request describing: + +- The use case or problem you are trying to solve +- How you envision the feature working +- How important the request is to your work +- Relevant statistical methods, guidance documents, or published literature + +### Report Validation Results + +Given the life-safety applications of this software, independent validation is especially valuable. If you have compared RMC-BestFit results against R packages, published tables, analytical solutions, or other flood-frequency software, please share the result through an issue. + +### Submit a Pull Request + +Pull requests may take several weeks or longer to review. Before submitting code: + +1. Open an issue first to discuss the proposed change. +2. Keep changes focused and avoid unrelated formatting or refactoring. +3. Preserve the public API unless the issue explicitly discusses a breaking change. +4. Include XML documentation for public types and members. +5. Include unit tests for new model-library behavior. +6. Use MSTest for tests in this repository. +7. Ensure a clean build with zero errors and zero warnings. +8. For model-library changes, run the public model test project locally. + +## Coding Standards + +RMC-BestFit is used for flood risk and life-safety engineering decisions. Code should be explicit, well-tested, and numerically defensive. + +- Prefer clear validation at method entry. +- Use `double.NegativeInfinity` for impossible log-likelihood values. +- Guard logarithms, divisions, transformations, and integration bounds. +- Keep statistical notation clear in names and comments. +- Add comments for non-obvious numerical or domain decisions, not for routine assignments. +- Do not suppress XML documentation warnings to pass a build. + +## Developer Certificate of Origin + +By submitting a pull request, you certify under the [Developer Certificate of Origin (DCO) Version 1.1](https://developercertificate.org/) that you have the right to submit the work under the license associated with this project and that you agree to the DCO. + +All contributions will be released under the same license as the project. See [LICENSE](LICENSE). + +## Federal Government Contributors + +U.S. Federal law prevents the government from accepting gratuitous services unless certain conditions are met. By submitting a pull request, you acknowledge that your services are offered without expectation of payment and that you expressly waive any future pay claims against the U.S. Federal government related to your contribution. + +If you are a U.S. Federal government employee and use a `*.mil` or `*.gov` email address, your contribution is understood to have been created in whole or in part as part of your official duties and is not subject to domestic copyright protection under 17 USC 105. + +## Security + +If you discover a security vulnerability, please do not open a public issue. See [SECURITY.md](SECURITY.md) for reporting guidance. + +## License + +RMC-BestFit is released under the [Zero-Clause BSD (0BSD)](LICENSE) license. \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..70b3201 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,19 @@ + + + + $(MSBuildThisFileDirectory) + $(RepositoryRoot)..\hec-dss\dotnet\Hec.Dss\ + $(HecDssRoot)bin\Debug\net10.0\Hec.Dss.dll + $(HecDssRoot)native-lib\win\hecdss.dll + $(HecDssRoot)native-lib\linux\libhecdss.so + 2.0.0 + 2.0.0.0 + 2.0.0.0 + + + + true + $(WarningsAsErrors);CS1570;CS1571;CS1572;CS1573;CS1574;CS1584;CS1587;CS1589;CS1591 + + + \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..dc2a441 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,23 @@ + + + true + true + 1.2.39 + 10.0.102 + [2.1.4,3.0.0) + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d61efa4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,10 @@ +Permission to use, copy, modify, and/or distribute this software for +any purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE +FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY +DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 0000000..3a9f6b3 --- /dev/null +++ b/NuGet.config @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 3d40c3c..80ff40a 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,134 @@ # RMC-BestFit -> [!IMPORTANT] -> **Going fully open source.** We are in the process of making all RMC-BestFit source code publicly available. This effort will be complete before the official release of Version 2.0 in July 2026. +[![CI](https://github.com/USACE-RMC/RMC-BestFit/actions/workflows/Integration.yml/badge.svg)](https://github.com/USACE-RMC/RMC-BestFit/actions/workflows/Integration.yml) +[![DOI](https://zenodo.org/badge/697453440.svg)](https://zenodo.org/badge/latestdoi/697453440) +[![NuGet](https://img.shields.io/nuget/v/rmc.bestfit)](https://www.nuget.org/packages/rmc.bestfit/) +[![License: 0BSD](https://img.shields.io/badge/License-0BSD-blue.svg)](LICENSE) -**RMC-BestFit** is a state-of-the-art Bayesian estimation and fitting software developed collaboratively by the U.S. Army Corps of Engineers' Risk Management Center (RMC) and Engineer Research and Development Center's Coastal and Hydraulics Laboratory (CHL). Tailored to expedite flood hazard assessments for the Flood Risk Management, Planning, and Dam and Levee Safety communities, the software employs a Bayesian framework to integrate a variety of data sources, including historical records, paleoflood evidence, regional data, rainfall-runoff models, and expert judgment. +***RMC-BestFit*** is free and open-source software developed by the U.S. Army Corps of Engineers Risk Management Center (USACE-RMC) for Bayesian flood-frequency analysis, distribution fitting, uncertainty quantification, rating curves, bivariate and coincident frequency analysis, time-series modeling, and related hydrologic risk workflows. Version 2.0 exposes the core statistical engine as a reusable .NET model library while retaining the desktop application, UI/project layer, and REST API source in the same repository. -This intuitive, menu-driven application provides a comprehensive environment for distribution fitting and Bayesian estimation. Its modern graphical interface, combined with robust data input, analysis, and reporting functionalities, enables users to effectively conduct flood frequency analyses and produce high-quality visualizations. +> [!NOTE] +> RMC-BestFit 2.0.0 is the official public 2.0 release. Expect ongoing bug fixes, minor enhancements, and documentation improvements as the project continues to evolve. -![BestFit](https://user-images.githubusercontent.com/123974306/232252850-cc900b2c-108a-4c93-81a4-50cfe5f1d5a9.png) +![RMC-BestFit desktop application](docs/images/rmc-bestfit-gui.png) -## Downloads -* [Download Version 1.0](https://github.com/USACE-RMC/RMC-BestFit/releases/download/v1.0/RMC-BestFit.v1.0.zip) -* [Download Version 2.0 Beta](https://github.com/USACE-RMC/RMC-BestFit/releases/latest/download/RMC-BestFit.Version.2.0.Beta-3.zip) +## Supported Frameworks + +| Component | Target | Notes | +|-----------|--------|-------| +| `RMC.BestFit` | .NET 10.0 | Portable model library and first public NuGet package | +| `RMC.BestFit.Tests` | .NET 10.0 | Public model-library unit tests run by CI | +| `RMC.BestFit.Api` | .NET 10.0 | REST API and MCP server source over the model library | +| `RMC-BestFit` desktop app | .NET 10.0 Windows | Windows 10+ WPF application; HEC-DSS still resolves through explicit local binary/native references during migration | + +## Installation + +Install the model library from NuGet: + +```powershell +dotnet add package RMC.BestFit --version 2.0.0 +``` + +Or search for [RMC.BestFit](https://www.nuget.org/packages/RMC.BestFit/) in the NuGet Package Manager. The package depends on [RMC.Numerics](https://github.com/USACE-RMC/Numerics) 2.x for probability distributions, optimization, MCMC sampling, and numerical methods. + +Source builds include the model library, unit tests, UI/project layer, desktop application, and API projects. Public CI initially restores, builds, and tests the portable model-library path while public packaging for the broader desktop stack and HEC-DSS dependency is finalized. ## Documentation -* [RMC-TR-2020-02 - Verification of the Bayesian Estimation and Fitting Software](https://usace-rmc.github.io/RMC-Software-Documentation/source-documents/desktop-applications/rmc-bestfit/verification-report/RMC-BestFit-Verification-Report.pdf) -* [RMC-TR-2020-03 - RMC-BestFit - User's Guide](https://usace-rmc.github.io/RMC-Software-Documentation/docs/desktop-applications/rmc-bestfit/users-guide/v1.0/preface/) -* [ANCOLD 2019 - Estimating Design Floods with a Specified Return Period Using Bayesian Analysis](https://github.com/USACE-RMC/RMC-BestFit/files/12751836/ANCOLD.2019.-.Bayesian.Analysis.-.HadenSmith.6-27-19.pdf) -* [ASDSO 2021 - Incorporating Regional Rainfall-Frequency into Flood Frequency using RMC-RRFT and RMC-BestFit](https://github.com/USACE-RMC/RMC-BestFit/files/12751831/ASDSO.RRFT.Paper_Avance.pdf) -## Training -* [Risk Management Center Training Center](https://www.rmc.usace.army.mil/Training/) -* [2021 RMC-BestFit and RMC-RFA Training Videos](https://www.youtube.com/playlist?list=PLEIlpoX-ZknTLKrNq7qeVrCIxT_QtLLSF) -* [2022 Fitting the Curve - Australian Water School](https://www.youtube.com/watch?v=ekduoQrOU2o&t=22s) -* [2024 RMC-BestFit New Release! - Australian Water School](https://www.youtube.com/watch?v=GPMfnkdGxqQ&t=2319s) +> [!NOTE] +> Documentation for RMC-BestFit 2.0 is published with this release and will continue to expand with additional examples, verification materials, and API coverage. The Version 1.0 User's Guide and Verification Report linked below remain published references for the previous major release. + +| Document | Description | +|----------|-------------| +| [Getting Started](docs/getting-started.md) | Minimal namespaces and first model-library workflows | +| [Technical Reference](docs/index.md) | Model, data, distribution, estimation, analysis, and diagnostic documentation | +| [REST API + MCP Server](docs/api.md) | Headless API and MCP server over `RMC.BestFit.dll` | +| [References](docs/references.md) | Consolidated bibliography for the public documentation | +| [Version 1.0 User's Guide](https://usace-rmc.github.io/RMC-Software-Documentation/docs/desktop-applications/rmc-bestfit/users-guide/v1.0/preface/) | Published desktop user guide for the previous major release | +| [Version 1.0 Verification Report](https://usace-rmc.github.io/RMC-Software-Documentation/source-documents/desktop-applications/rmc-bestfit/verification-report/RMC-BestFit-Verification-Report.pdf) | Published verification report for the previous major release | + +## Solution Structure + +| Area | Path | Description | +|------|------|-------------| +| Model Library | `src/RMC.BestFit/` | Core statistical models, analyses, diagnostics, data frames, trend functions, and link functions | +| Model Tests | `src/RMC.BestFit.Tests/` | Public unit tests for data, estimation, distributions, analyses, diagnostics, rating curves, time series, bivariate analysis, and spatial extremes | +| UI Layer | `src/RMC.BestFit.UI/` | Project serialization and UI wrapper layer used by the desktop application | +| Desktop App | `src/RMC.BestFit.App/` | WPF application shell and RMC-BestFit desktop interface | +| API | `src/RMC.BestFit.Api/` | REST API and MCP server source for programmatic workflows | +| Documentation | `docs/` | Public technical documentation and references | +| Examples | `examples/` | Tutorial `.bestfit` projects, input data, spreadsheets, and walkthrough markdown | + +The long-running verification project, verification datasets, and additional validation reports are intentionally excluded from the initial public source release. Those materials are being prepared for follow-on publication. + +## Quick Start + +Restore, build, and test the public model-library path: + +```powershell +dotnet restore src/RMC.BestFit.Tests/RMC.BestFit.Tests.csproj +dotnet build src/RMC.BestFit.Tests/RMC.BestFit.Tests.csproj -c Release --no-restore +dotnet test src/RMC.BestFit.Tests/RMC.BestFit.Tests.csproj -c Release --no-build +``` + +Create the public NuGet package locally: + +```powershell +dotnet pack src/RMC.BestFit/RMC.BestFit.csproj -c Release -o packages /p:Version=2.0.0 +``` + +## Key Capabilities + +| Domain | Public API and workflows | +|--------|--------------------------| +| Input data | `DataFrame` with `ExactSeries`, `UncertainSeries`, `IntervalSeries`, and `ThresholdSeries` for exact, uncertain, interval-censored, and threshold observations | +| Estimation | `MaximumLikelihood`, `MaximumAPosteriori`, `GeneralizedMethodOfMoments`, and `BayesianAnalysis` with information criteria and MCMC diagnostics | +| Flood-frequency models | `FittingAnalysis`, `UnivariateAnalysis`, `UnivariateDistribution`, `MixtureAnalysis`, `CompetingRiskAnalysis`, `PointProcessAnalysis`, and `CompositeAnalysis` | +| Bulletin 17C | `Bulletin17CAnalysis` and `Bulletin17CDistribution` workflows for Log-Pearson Type III frequency analysis, penalties, and uncertainty methods | +| Bivariate and coincident frequency | `BivariateDistribution`, `BivariateAnalysis`, and `CoincidentFrequencyAnalysis` for copula-based joint frequency and response-frequency workflows | +| Rating curves | `RatingCurve` and `RatingCurveAnalysis` for Bayesian stage-discharge relationships with one to three segments | +| Time series | `AutoRegressive`, `MovingAverage`, `ARIMA`, `ARIMAX`, and corresponding AR/MA/ARIMA/ARIMAX analyses | +| Spatial extremes | `SpatialGEV`, `SpatialGEVAnalysis`, Gaussian copula support, and spatial correlation models | +| Diagnostics | Prior and posterior predictive checks, observation influence, prior influence, leverage diagnostics, threshold diagnostics, R-hat, ESS, WAIC, LOO-CV, DIC, AIC, BIC, and RMSE outputs | +| Automation | REST API and MCP server endpoints for time-series retrieval, input data creation, analysis execution, workflow chaining, metadata discovery, and JSON results | + +## Examples + +The [examples](examples/README.md) folder contains tutorial `.bestfit` projects and supporting files that can be opened in RMC-BestFit 2.0. + +| Chapter | Topic | +|---------|-------| +| [Time Series Data](examples/1-time-series-data/) | Importing and downloading raw time series from USGS, GHCN, CHMN, ABOM, HEC-DSS, and manual entry | +| [Input Data](examples/2-input-data/) | Extracting block maxima, peaks-over-threshold, and USGS peak-flow samples | +| [Univariate Distribution Analysis](examples/4-univariate-distribution-analysis/) | Bayesian, Bulletin 17C, point-process, mixture, and composite frequency analyses | +| [Bivariate Distribution Analysis](examples/5-bivariate-distribution-analysis/) | Copula-based bivariate fitting and coincident frequency analysis | +| [Rating Curve Analysis](examples/6-rating-curve-analysis/) | Bayesian piecewise power-law stage-discharge rating curves | +| [Time Series Analysis](examples/7-time-series-analysis/) | ARIMA, ARIMAX, and regression fitting with autocorrelated residuals | + +## Publications + +- [2026 - Improving Bulletin 17C using the Generalized Method of Moments](https://essopenarchive.org/doi/full/10.22541/essoar.15005816/v1) +- [2026 - Nonstationary Flood Frequency Analysis for Urban Watersheds Using Open-Source Bayesian Software: Contrasting Case Studies from Texas](https://www.mdpi.com/2073-4441/18/5/636) +- [2024 - Nonstationary Flood Frequency Analysis with RMC-BestFit](https://www.researchgate.net/publication/386078504) +- [2023 - Moving Beyond Bulletin 17C with Bayesian Flow Frequency Analysis](https://www.researchgate.net/publication/370833315) +- [2021 - Incorporating Regional Rainfall-Frequency into Flood Frequency using RMC-RRFT and RMC-BestFit](https://www.researchgate.net/publication/354477063) +- [2019 - Estimating Design Floods with a Specified Return Period Using Bayesian Analysis](https://www.researchgate.net/publication/344320855) + +## RMC Training + +- [Risk Management Center Training Center](https://www.rmc.usace.army.mil/Training/) +- [2021 RMC-BestFit and RMC-RFA Training Videos](https://www.youtube.com/playlist?list=PLEIlpoX-ZknTLKrNq7qeVrCIxT_QtLLSF) + +## Support + +USACE-RMC maintains RMC-BestFit 2.0 with regular bug fixes, documentation improvements, and validation updates. Public issues should include the RMC-BestFit version, operating system, .NET SDK version when relevant, the workflow or project file involved, and enough detail to reproduce the behavior. + +The repository includes a fast model-library test suite that is intended to serve both as regression coverage and as API usage examples. Longer-running verification materials are being prepared separately for public release. + +## Contributing +Bug reports, feature requests, documentation feedback, and independent validation results are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. Review capacity is limited, so proposed code changes should start with an issue discussion. +## License +RMC-BestFit is provided under the [Zero-Clause BSD (0BSD)](LICENSE) license. diff --git a/RMC.BestFit.sln b/RMC.BestFit.sln new file mode 100644 index 0000000..60a8f98 --- /dev/null +++ b/RMC.BestFit.sln @@ -0,0 +1,81 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 18 +VisualStudioVersion = 18.2.11415.280 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RMC.BestFit", "src\RMC.BestFit\RMC.BestFit.csproj", "{8F9CF461-4CE3-D031-4A63-B35AEFA6F4DC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RMC.BestFit.Tests", "src\RMC.BestFit.Tests\RMC.BestFit.Tests.csproj", "{C511A356-BF7C-4503-7C07-8009962B2198}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RMC.BestFit.UI", "src\RMC.BestFit.UI\RMC.BestFit.UI.csproj", "{B76A4EC2-E7D5-729B-4128-75D08442893C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RMC.BestFit.App", "src\RMC.BestFit.App\RMC.BestFit.App.csproj", "{18B1D87A-FDAF-D377-002B-D97DDE4B9CC4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RMC.BestFit.UI.Tests", "src\RMC.BestFit.UI.Tests\RMC.BestFit.UI.Tests.csproj", "{3A7F9C2D-0002-4B14-8000-000000000002}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RMC.BestFit.App.Tests", "src\RMC.BestFit.App.Tests\RMC.BestFit.App.Tests.csproj", "{3A7F9C2D-0003-4B14-8000-000000000003}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RMC.BestFit.Api", "src\RMC.BestFit.Api\RMC.BestFit.Api.csproj", "{3A7F9C2D-0004-4B14-8000-000000000004}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RMC.BestFit.Api.Tests", "src\RMC.BestFit.Api.Tests\RMC.BestFit.Api.Tests.csproj", "{3A7F9C2D-0005-4B14-8000-000000000005}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{A1B2C3D4-1111-4000-8000-000000000001}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{A1B2C3D4-2222-4000-8000-000000000002}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8F9CF461-4CE3-D031-4A63-B35AEFA6F4DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F9CF461-4CE3-D031-4A63-B35AEFA6F4DC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F9CF461-4CE3-D031-4A63-B35AEFA6F4DC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F9CF461-4CE3-D031-4A63-B35AEFA6F4DC}.Release|Any CPU.Build.0 = Release|Any CPU + {C511A356-BF7C-4503-7C07-8009962B2198}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C511A356-BF7C-4503-7C07-8009962B2198}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C511A356-BF7C-4503-7C07-8009962B2198}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C511A356-BF7C-4503-7C07-8009962B2198}.Release|Any CPU.Build.0 = Release|Any CPU + {B76A4EC2-E7D5-729B-4128-75D08442893C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B76A4EC2-E7D5-729B-4128-75D08442893C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B76A4EC2-E7D5-729B-4128-75D08442893C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B76A4EC2-E7D5-729B-4128-75D08442893C}.Release|Any CPU.Build.0 = Release|Any CPU + {18B1D87A-FDAF-D377-002B-D97DDE4B9CC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {18B1D87A-FDAF-D377-002B-D97DDE4B9CC4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {18B1D87A-FDAF-D377-002B-D97DDE4B9CC4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {18B1D87A-FDAF-D377-002B-D97DDE4B9CC4}.Release|Any CPU.Build.0 = Release|Any CPU + {3A7F9C2D-0002-4B14-8000-000000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3A7F9C2D-0002-4B14-8000-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A7F9C2D-0002-4B14-8000-000000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3A7F9C2D-0002-4B14-8000-000000000002}.Release|Any CPU.Build.0 = Release|Any CPU + {3A7F9C2D-0003-4B14-8000-000000000003}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3A7F9C2D-0003-4B14-8000-000000000003}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A7F9C2D-0003-4B14-8000-000000000003}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3A7F9C2D-0003-4B14-8000-000000000003}.Release|Any CPU.Build.0 = Release|Any CPU + {3A7F9C2D-0004-4B14-8000-000000000004}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3A7F9C2D-0004-4B14-8000-000000000004}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A7F9C2D-0004-4B14-8000-000000000004}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3A7F9C2D-0004-4B14-8000-000000000004}.Release|Any CPU.Build.0 = Release|Any CPU + {3A7F9C2D-0005-4B14-8000-000000000005}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3A7F9C2D-0005-4B14-8000-000000000005}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A7F9C2D-0005-4B14-8000-000000000005}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3A7F9C2D-0005-4B14-8000-000000000005}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {8F9CF461-4CE3-D031-4A63-B35AEFA6F4DC} = {A1B2C3D4-1111-4000-8000-000000000001} + {C511A356-BF7C-4503-7C07-8009962B2198} = {A1B2C3D4-2222-4000-8000-000000000002} + {B76A4EC2-E7D5-729B-4128-75D08442893C} = {A1B2C3D4-1111-4000-8000-000000000001} + {18B1D87A-FDAF-D377-002B-D97DDE4B9CC4} = {A1B2C3D4-1111-4000-8000-000000000001} + {3A7F9C2D-0002-4B14-8000-000000000002} = {A1B2C3D4-2222-4000-8000-000000000002} + {3A7F9C2D-0003-4B14-8000-000000000003} = {A1B2C3D4-2222-4000-8000-000000000002} + {3A7F9C2D-0004-4B14-8000-000000000004} = {A1B2C3D4-1111-4000-8000-000000000001} + {3A7F9C2D-0005-4B14-8000-000000000005} = {A1B2C3D4-2222-4000-8000-000000000002} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {C73D6134-2D97-4E17-A6AD-BDE4D28FDBC5} + EndGlobalSection +EndGlobal diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..8b85301 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,17 @@ +# Security Policy + +## Supported Versions + +RMC-BestFit 2.0 is in public source migration. Security reports for the current public branch and current public releases are welcome. + +## Reporting a Vulnerability + +Please do not open a public issue for security vulnerabilities. Report security concerns through official U.S. Army Corps of Engineers Risk Management Center channels and include: + +- Affected version or commit +- Description of the issue +- Steps to reproduce, if possible +- Potential impact +- Any suggested mitigation + +The maintainers will review reports as resources permit and coordinate fixes through the appropriate release process. \ No newline at end of file diff --git a/codemeta.json b/codemeta.json new file mode 100644 index 0000000..f2287b1 --- /dev/null +++ b/codemeta.json @@ -0,0 +1,67 @@ +{ + "@context": "https://doi.org/10.5063/schema/codemeta-2.0", + "@type": "SoftwareSourceCode", + "name": "RMC-BestFit: Bayesian Estimation and Fitting Software for Flood Frequency Analysis", + "alternateName": "RMC-BestFit", + "description": "A free and open-source .NET codebase for Bayesian estimation, distribution fitting, and frequency analysis in flood risk and water resources engineering. The first public package target is the portable RMC.BestFit model library.", + "version": "2.0.0", + "dateModified": "2026-07-17", + "license": "https://spdx.org/licenses/0BSD", + "codeRepository": "https://github.com/USACE-RMC/RMC-BestFit", + "issueTracker": "https://github.com/USACE-RMC/RMC-BestFit/issues", + "programmingLanguage": { + "@type": "ComputerLanguage", + "name": "C#", + "url": "https://learn.microsoft.com/en-us/dotnet/csharp/" + }, + "runtimePlatform": ".NET 10.0", + "operatingSystem": "Windows, Linux, macOS for the model library; Windows for WPF UI projects", + "keywords": [ + "Bayesian inference", + "flood frequency analysis", + "hydrology", + "probability distributions", + "MCMC", + "risk assessment", + "water resources engineering", + ".NET", + "C#" + ], + "author": [ + { + "@type": "Person", + "givenName": "C. Haden", + "familyName": "Smith", + "@id": "https://orcid.org/0000-0002-4651-9890", + "affiliation": { + "@type": "Organization", + "name": "U.S. Army Corps of Engineers, Risk Management Center, Lakewood, Colorado, USA" + } + }, + { + "@type": "Person", + "givenName": "Woodrow L.", + "familyName": "Fields", + "@id": "https://orcid.org/0009-0008-7454-5552", + "affiliation": { + "@type": "Organization", + "name": "U.S. Army Corps of Engineers, Risk Management Center, Lakewood, Colorado, USA" + } + }, + { + "@type": "Person", + "givenName": "Brian", + "familyName": "Skahill", + "@id": "https://orcid.org/0000-0002-2164-0301", + "affiliation": { + "@type": "Organization", + "name": "Fariborz Maseeh Department of Mathematics and Statistics, Portland State University, Portland, Oregon, USA" + } + } + ], + "funder": { + "@type": "Organization", + "name": "U.S. Army Corps of Engineers" + }, + "developmentStatus": "active" +} \ No newline at end of file diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md new file mode 100644 index 0000000..9153633 --- /dev/null +++ b/docs/PROGRESS.md @@ -0,0 +1,34 @@ +# Progress + +## 2026-07-17 + +- Prepared v2.0.0 release metadata: switched central dependencies to `RMC.Numerics` 2.1.4 and `RMC.Wpf.Framework.*` 1.0.4, removed the local project-reference override file, and synchronized final UI/App/project metadata to `2.0.0`. +- Added official release messaging for the PR body, GitHub Release body, LinkedIn post, and screenshot gallery suggestions in `docs/release-messaging-v2.0.0.md`. +- Updated release-facing README, documentation index, and example index wording from beta/pre-release language to official v2.0.0 release language. +- Added a BestFit-specific release workflow to local `AGENTS.md` and `CLAUDE.md`; both files are ignored by the repository unless force-added intentionally. +- Validated release readiness with `dotnet restore`, Debug and Release builds, all four fast test projects, and `dotnet pack src/RMC.BestFit/RMC.BestFit.csproj -c Release -o packages /p:Version=2.0.0`. +- Inspected `packages/RMC.BestFit.2.0.0.nupkg`: package id/version are `RMC.BestFit`/`2.0.0`, release notes are present, README and LICENSE are included, and the package dependency metadata points to `RMC.Numerics` 2.1.4. +## 2026-07-16 + +- Diagnosed the B17C bootstrap regression: assigning a resampled frame through the public `Bulletin17CDistribution.DataFrame` setter ran `SetDefaultParameters`, which wiped the cloned parent initials, disabled every parameter penalty (silently dropping regional-skew prior propagation), and — when default-parameter derivation threw for threshold-heavy boot frames — emptied the parameter list so every replicate failed and fell back to the parent (uncertainty collapsed to a point mass; the progress bar sat at 1% while retries crawled). +- Fixed GMM `Status` coherence: multi-pass strategies restore `Status = Success` when abandoning a failed refinement pass and returning an earlier valid solution; `Estimate()` resets stale outputs up front. +- Added `Bulletin17CDistribution.CloneWithDataFrame` (XElement round-trip preserves parameters, penalties, and links) and warm-started every bootstrap replicate at the parent fit; aligned the replicate acceptance gate with the parent fit (reject only hard failures and non-finite estimates). +- Redesigned failure handling across the uncertainty samplers: failed or rejected realizations are discarded after retries — never substituted with the parent vector — with abort guards (fewer than two survivors or >50% discards) surfaced through the new `UncertaintyDiagnosticMessage` and the UI warning message. +- Extended `BootstrapDiagnostics` (retained count, transform failures, per-attempt GMM status distribution; backward-compatible XML), made the plain MVN sampler report the same diagnostics, rewrote the report section for discard semantics with retention warnings, and persisted diagnostics with the analysis. +- Fixed sampling-loop progress: first tick after the first completed replicate (`AnalysisProgress.ShouldReportLoopProgress`) and monotone pivot-phase mapping (55/56/44) replacing the backwards jump. +- Hardened WPF dispatch: `B17CAnalysisPropertiesControl.Element_PropertyChanged` now marshals to the dispatcher; the UI wrapper suppresses its own run's mid-flight `ThresholdSeries` recompute notification (narrow guard) so `ClearResults` cannot fire on a worker thread. +- Verified end-to-end with a headless harness on B17C Examples 1 and 4: penalties now survive on all boot clones and previously all-failing threshold-heavy replicates fit successfully; all four unit suites pass with zero warnings under XML-docs-as-errors builds. + +## 2026-07-14 + +- Replaced the `MainProjectNode` local Quick Start Guide PDF action with the online RMC-BestFit User Guide. +- Added a testable connectivity and default-browser launcher that reuses `TimeSeriesDownload.IsConnectedToInternet()`. +- Added deterministic App tests for online, offline, validation, failure propagation, and Help-menu wiring behavior. +- Added main-branch Technical Reference and Example Projects links through the generalized online Help launcher. +- Added active-development note boxes to the technical-reference and example-project indexes. +- Reserved the type-compatible `HelpImage` resource for the User Guide and separated the text-only online resources from application items. +- Replaced the framework's default Terms and Conditions document with the verbatim 0BSD license and optional Zenodo citation guidance using a copyable, clickable concept-DOI URL. +- Corrected the `YeoJohnsonLink.FitLambda` XML reference to use the Numerics method's `IList` signature. +- Added a README Documentation callout identifying the RMC-BestFit 2.0 documentation and verification materials as under active development. +- Validated the affected App projects with zero warnings and all fast Core, UI, and App tests passing. +- The required repository XML validation script is absent; equivalent scoped XML-as-errors builds passed for all affected projects. diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..81407bf --- /dev/null +++ b/docs/api.md @@ -0,0 +1,160 @@ +# RMC-BestFit REST API + MCP Server + +`src/RMC.BestFit.Api` hosts a headless REST API and an MCP (Model Context Protocol) server over +the RMC-BestFit model library (`RMC.BestFit.dll`), enabling programmatic and agentic AI +flood-frequency workflows: download USGS data, build input data, fit distributions with Bayesian +MCMC / Bulletin 17C / rating curves, and retrieve results as JSON. + +## Running + +```bash +dotnet run --project src/RMC.BestFit.Api # http://localhost:5210 (Development) +``` + +- OpenAPI document (Development): `GET /openapi/v1.json` +- Health: `GET /health`, `GET /health/detailed`; service info: `GET /api/info` +- Configuration (`appsettings.json`, section `Api`): `MaxResources` (default 500), + `MaxConcurrentRuns` (default 2), `MaxIterations` (default 500,000) + +## Concepts + +- **Stateful resource store.** Creation endpoints store resources in memory keyed by GUID; later + calls reference the ids. Resources are immutable after creation; analyses clone their inputs at + creation, so deleting or replacing upstream resources never corrupts an existing fit. State is + lost on restart (by design for the proof of concept). +- **Response contract.** Every response carries `success`, `errorMessage`, `validationErrors`, + `validationWarnings`, `computationTimeMs`, `timestamp`, `nonFiniteFindings`. JSON is camelCase + with enums as camelCase strings; NaN serializes as the named literal `"NaN"` (missing data); + ±Infinity is rejected server-side before serialization. +- **Status codes.** 200/201 success; 400 validation or argument errors; 404 unknown id or no USGS + data; 409 store at capacity or analysis already running; 499 client cancelled; 502/503 USGS + upstream failures; 500 run failures. Exception: workflow endpoints report step failures in-body + (`success=false`, `failedStep`) with HTTP 200, preserving the ids of already-created resources. +- **Probability convention.** All probability ordinates are annual exceedance probabilities + (AEP), strictly between 0 and 1 (0.01 = 100-year event). Client-supplied ordinates are + de-duplicated and sorted ascending automatically. +- **Distribution specs and engineering judgment.** Wherever a request supplies a distribution as + an input — uncertain-observation measurement errors, `parameterPriors`, `quantilePriors` — it is + a `{ type, parameters }` spec with parameters in the type's canonical order. + `GET api/metadata/distributions` lists the parameter names/order per type (also the names that + priors and Bulletin 17C penalties are matched against — the trailing short-form marker like + "(γ)" may be omitted); `GET api/metadata/enums` lists the accepted spec types + (`priorDistributions`). Supplying any parameter prior switches that analysis off its default + flat priors; unnamed parameters keep flat priors. Bulletin 17C uses Gaussian *penalties* + (`{ parameterName | aep, mean, mse, useLog/useLog10 }`) instead of priors, and reports a + warning (never silently) when its input data contains uncertain observations, which its + Expected Moments Algorithm ignores. +- **Runs are synchronous.** `POST .../run` returns when the estimation finishes (seconds to about + a minute for MCMC). Client disconnect cancels the run; the analysis stays rerunnable. + +## Endpoints + +### Time series — `api/timeseries` + +| Endpoint | Purpose | +|---|---| +| `POST api/timeseries/usgs` | Download from USGS water services: `dailyDischarge`, `dailyStage`, `instantaneousDischarge`, `instantaneousStage`, `peakDischarge`, `peakStage`, `measuredDischarge`, `measuredStage` | +| `POST api/timeseries/manual` | Build from explicit `{dateTime, value}` points (NaN = missing) | +| `GET api/timeseries` / `GET {id}?includePoints&offset&limit` / `DELETE {id}` | List / detail with paged points / delete | + +### Input data — `api/inputdata` + +| Endpoint | Purpose | +|---|---| +| `POST api/inputdata/manual` | Explicit observations: exact (systematic record), uncertain (per-observation measurement-error distributions, e.g. paleoflood estimates), interval-censored, perception thresholds; plotting parameter, low-outlier threshold, lambda | +| `POST api/inputdata/block-max` | Block maxima from a stored time series (`timeBlock=waterYear` default; smoothing for n-day flows) | +| `POST api/inputdata/peaks-over-threshold` | Independent POT events from a stored series (`threshold`, `minStepsBetweenPeaks`, optional `lambda` override) | +| `POST api/inputdata/usgs-peaks` | USGS annual peak-flow file directly (no intermediate time series) | +| `GET api/inputdata` / `GET {id}?includeData` / `GET {id}/summary-statistics` / `DELETE {id}` | List / detail with observations and plotting positions / sample statistics / delete | + +### Analyses — `api/analyses/{univariate|bulletin17c|ratingcurve|mixture|pointprocess|competingrisks|composite|distributionfitting|bivariate|coincidentfrequency|timeseries}` + +All kinds share the verb set: `POST` (create), `POST {id}/run`, `GET` (list), `GET {id}`, +`GET {id}/results`, `GET {id}/validate`, `DELETE {id}`. Lookups are kind-guarded (a univariate id +404s on the Bulletin 17C routes). + +**Live component references (composite, bivariate, coincidentfrequency).** Analyses that consume +OTHER analyses hold LIVE references to them — the one deliberate exception to the clone-at-create +invariant, because components may not be estimated yet when the consumer is created. This applies +to a composite's components, a bivariate's two marginal analyses, and a coincident frequency +analysis's bivariate (plus, transitively, its marginals). Consequences: every referenced analysis +must be RUN before the consumer runs (validate reports which still require estimation); while the +consumer runs it holds each referenced analysis's run lock, so any of them mid-run makes the +consumer run report 409 (and vice versa); re-running a referenced analysis intentionally +refreshes the consumer's next run; deleting one from the store leaves the consumer functional +(the live reference keeps the fitted object alive) — the recorded ids are provenance only. + +| Kind | Create request highlights | Results | +|---|---|---| +| `univariate` | `inputDataId`, `distribution` (15 supported; default `logPearsonTypeIII`), `probabilityOrdinates?`, `bayesianOptions?` (sampler, iterations, chains, seed, CI width, point estimator), `parameterPriors?`, `quantilePriors?` + `useSingleQuantile?` | Frequency curve (mode/mean/CI), posterior parameter summaries with R-hat/ESS, AIC/BIC/DIC/WAIC/LOOIC/RMSE, convergence warnings | +| `bulletin17c` | `inputDataId`, `distribution` (6 supported), `uncertaintyMethod?` (default model `linkedMultivariateNormal`), `probabilityOrdinates?`, `parameterPenalties?` (regional skew), `quantilePenalties?` | Frequency curve with confidence intervals, sampled parameter summaries, AIC/BIC/DIC/RMSE/ERL, GMM/uncertainty timing | +| `ratingcurve` | `stageTimeSeriesId`, `dischargeTimeSeriesId` (date-aligned; ≥10 common dates), `numberOfSegments` 1-3, optional stage grid (`minStage`/`maxStage`/`stageBins`), `bayesianOptions?`, `parameterPriors?` | Stage-discharge curve over the stage grid (mode/mean/CI), fitted parameters, posterior summaries | +| `mixture` | `inputDataId`, `distributions` (1-3 components, all 15 supported), `isZeroInflated?`, `probabilityOrdinates?`, `bayesianOptions?`, `parameterPriors?`, `quantilePriors?` | Frequency curve (mode/mean/CI), posterior summaries for component parameters and weights, AIC/BIC/DIC/WAIC/LOOIC/RMSE | +| `pointprocess` | `inputDataId` (POT resource; its threshold seeds the model), `isSeasonal?` + `timeBlock?`/`startMonth?`, `threshold?`/`totalYears?` overrides, `probabilityOrdinates?`, `bayesianOptions?`, `parameterPriors?`, `quantilePriors?` — GEV-only by construction (no distribution field) | Annual-exceedance frequency curve (mode/mean/CI), posterior summaries, AIC/BIC/DIC/WAIC/LOOIC/RMSE | +| `competingrisks` | `inputDataId`, `distributions` (1-3 components, one per competing process), `probabilityOrdinates?`, `bayesianOptions?`, `parameterPriors?`, `quantilePriors?` | Frequency curve (mode/mean/CI), posterior summaries, AIC/BIC/DIC/WAIC/LOOIC/RMSE | +| `composite` | `components` (`[{analysisId, weight?}]`, live references; kinds univariate/bulletin17c/mixture/pointprocess/competingrisks), `compositeType` (`competingRisks` default \| `mixture` \| `modelAverage`), `averageMethod?` (DIC/WAIC/LOOIC need MCMC components), `dependency?`, `isMaximum?`, `probabilityOrdinates?`, `credibleIntervalWidth?`, `pointEstimator?` — no MCMC of its own | Frequency curve (mode/mean/CI), `composite` block with per-component weights (model-average weights are a first-class output), AIC/BIC/DIC/RMSE | +| `distributionfitting` | `inputDataId`, `distributions?` (candidate subset; default all 15) — parallel MLE screen, seconds, no MCMC | Ranked `fits` (AIC ascending, failures last): parameters, AIC/BIC/RMSE, `fitSucceeded`, `errorMessage` | +| `bivariate` | `marginalXAnalysisId`, `marginalYAnalysisId` (live references; kinds univariate/bulletin17c/mixture/pointprocess; data pair by shared time index, ≥10 overlapping non-outlier exacts), `copulaType` (7 families; default `normal`), `estimationMethod?` (`inferenceFromMargins` default \| `pseudoLikelihood`), `xyOrdinates` (`[{x, y}]` joint-exceedance grid), `bayesianOptions?`, `parameterPriors?` (copula params) | Joint exceedance P(X>x AND Y>y) per grid point (mode/mean/CI), copula posterior summaries with R-hat/ESS, AIC/BIC/DIC/WAIC/LOOIC/RMSE | +| `coincidentfrequency` | `bivariateAnalysisId` (live reference; must be run first), `xValues`/`yValues` (strictly ascending), `bivariateResponse` (2-D surface Z[x][y], strictly increasing both axes), `numberOfBins` 5-1000 (default 50), `credibleIntervalWidth?`, `pointEstimator?` — no MCMC of its own; univariate-marginal posterior chains are pulled in automatically when available (`marginalX/YChainUsed` flags) | Response frequency curve: AEP per response-magnitude bin (mode/mean/CI) over `zValues` | +| `timeseries` | `timeSeriesId`, `modelType` (`ar` \| `ma` \| `arima` \| `arimax`), family-specific orders (`order` for ar/ma; `pOrder`/`dOrder`/`qOrder` for arima/arimax; `xOrder` + `covariateTimeSeriesIds` + `trendType` + `includeSeasonality` + `covariateExtension` for arimax — fields for other families are 400s, never silently ignored), `includeIntercept?`, `transformType?` (lambdas auto-fitted), `trainingTimeSteps?`, `forecastingTimeSteps?` 0-100, `bayesianOptions?`, `parameterPriors?` | Fitted-plus-forecast curve aligned by time index (mode/mean/CI), parameter posteriors with R-hat/ESS, AIC/BIC/DIC/WAIC/LOOIC/RMSE | + +### Workflows (one-shot) — `api/workflows` + +| Endpoint | Steps | +|---|---| +| `POST api/workflows/usgs-peak-frequency` | USGS peaks → input data → univariate MCMC → results | +| `POST api/workflows/usgs-daily-block-max-frequency` | USGS daily → time series → block maxima → univariate MCMC → results | +| `POST api/workflows/usgs-bulletin17c` | USGS peaks → input data → Bulletin 17C → results | +| `POST api/workflows/usgs-rating-curve` | USGS measured stage + discharge → rating curve MCMC → results | + +Responses include every created resource id (`timeSeriesId`, `inputDataId`, `analysisId`, ...) +plus the full results. On a step failure: `success=false`, `failedStep`, and the ids of completed +steps so the workflow can be resumed manually through the granular endpoints. + +### Discovery — `api/metadata`, `api/resources` + +`GET api/metadata/distributions` (which distributions each analysis kind supports), +`GET api/metadata/enums` (every accepted enum string), `GET api/metadata/defaults` (default AEP +ordinates and server limits), `GET api/resources` (cross-cutting id overview). + +## MCP server + +The same host serves MCP over the streamable HTTP transport at **`/mcp`** (stateless mode — all +state lives in the app-singleton resource store, so ids remain valid across MCP sessions and the +REST/MCP boundary). Tools call the same service layer as the controllers and return the same JSON +DTOs. + +Connect from an MCP client: + +```bash +claude mcp add --transport http bestfit http://localhost:5210/mcp +``` + +Tools (26): `get_metadata`, `list_resources`, `delete_resource`, `usgs_download_timeseries`, +`create_manual_timeseries`, `get_timeseries`, `create_inputdata_usgs_peaks`, +`create_inputdata_block_max`, `create_inputdata_pot`, `create_inputdata_manual`, `get_inputdata`, +`create_univariate_analysis`, `create_bulletin17c_analysis`, `create_ratingcurve_analysis`, +`create_mixture_analysis`, `create_pointprocess_analysis`, `create_competingrisks_analysis`, +`create_composite_analysis`, `create_distributionfitting_analysis`, +`create_bivariate_analysis`, `create_coincidentfrequency_analysis`, `create_timeseries_analysis`, +`run_analysis`, `get_analysis_results`, `validate_analysis`, plus the four +`run_usgs_*_workflow` one-shots. + +Typical agent chains: + +``` +get_metadata → create_inputdata_usgs_peaks(site) → create_bulletin17c_analysis(inputDataId) → run_analysis(analysisId) +usgs_download_timeseries(site, dailyDischarge) → create_inputdata_block_max(timeSeriesId) → create_univariate_analysis(...) → run_analysis(...) +usgs_download_timeseries(site, measuredStage) + (site, measuredDischarge) → create_ratingcurve_analysis(...) → run_analysis(...) +run_usgs_bulletin17c_workflow(site) # everything in one call +``` + +## Endpoint map status + +Every analysis endpoint from the original Phase-4 scope is implemented: mixture, point process, +competing risks, composite, bivariate, coincident frequency, time series, and distribution +fitting, plus the Bayesian input features (uncertain data, parameter priors, quantile priors, +Bulletin 17C penalties). + +Deferred features: nonstationary trend models + chronology results, an async job pattern +(jobId + polling) if synchronous runs outgrow client timeouts, authentication. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..d60b998 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,98 @@ +# Getting Started + +[Back to Index](index.md) | [Next: Models Overview ->](technical-reference/models/overview.md) + +This guide shows the minimum namespaces and workflows needed to use `RMC.BestFit.dll` from a .NET application. + +## Installation + +Reference the BestFit model library and its Numerics dependency from your application: + +```xml + +``` + +BestFit uses Numerics for probability distributions, optimization, MCMC, and matrix operations. + +## Required Namespaces + +```cs +using Numerics.Distributions; +using RMC.BestFit; +using RMC.BestFit.Analyses; +using RMC.BestFit.Diagnostics; +using RMC.BestFit.Estimation; +using RMC.BestFit.Models; +``` + +`RMC.BestFit` contains observation and series types such as `ExactSeries`, while `RMC.BestFit.Models` contains `DataFrame` and model classes. + +## Create Input Data + +```cs +using RMC.BestFit; +using RMC.BestFit.Models; + +var dataFrame = new DataFrame +{ + ExactSeries = new ExactSeries(new[] { 22.0, 27.4, 31.8, 29.1, 35.7 }) +}; +``` + +For censored or uncertain records, use `UncertainSeries`, `IntervalSeries`, and `ThresholdSeries`. + +## Fit One Distribution With MLE + +```cs +using Numerics.Distributions; +using RMC.BestFit; +using RMC.BestFit.Estimation; +using RMC.BestFit.Models; + +var dataFrame = new DataFrame +{ + ExactSeries = new ExactSeries(new[] { 22.0, 27.4, 31.8, 29.1, 35.7 }) +}; + +var model = new UnivariateDistribution(dataFrame, UnivariateDistributionType.Normal); +var mle = new MaximumLikelihood(model, OptimizationMethod.DifferentialEvolution); + +if (mle.Estimate()) +{ + model.SetParameterValues(mle.BestParameterSet.Values); + Console.WriteLine($"Log-likelihood: {mle.MaximumLogLikelihood:F3}"); +} +``` + +## Run Bayesian Analysis + +```cs +using Numerics.Distributions; +using RMC.BestFit; +using RMC.BestFit.Analyses; +using RMC.BestFit.Models; + +var dataFrame = new DataFrame +{ + ExactSeries = new ExactSeries(new[] { 22.0, 27.4, 31.8, 29.1, 35.7 }) +}; + +var model = new UnivariateDistribution(dataFrame, UnivariateDistributionType.LogPearsonTypeIII); +var analysis = new UnivariateAnalysis(model); + +analysis.BayesianAnalysis.Iterations = 3000; +analysis.BayesianAnalysis.WarmupIterations = 1500; + +await analysis.RunAsync(); + +Console.WriteLine($"DIC: {analysis.BayesianAnalysis.DIC:F2}"); +Console.WriteLine($"WAIC: {analysis.BayesianAnalysis.WAIC:F2}"); +``` + +## Next Steps + +Read [Models Overview](technical-reference/models/overview.md) for the model contract, then [Input Data Frame](technical-reference/data-frame/index.md) for flood-frequency data types. + +--- + +[Back to Index](index.md) | [Next: Models Overview ->](technical-reference/models/overview.md) diff --git a/docs/images/rmc-bestfit-gui.png b/docs/images/rmc-bestfit-gui.png new file mode 100644 index 0000000..9b391d5 Binary files /dev/null and b/docs/images/rmc-bestfit-gui.png differ diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..a505bde --- /dev/null +++ b/docs/index.md @@ -0,0 +1,167 @@ +# RMC-BestFit Library Documentation + +> [!NOTE] +> This technical reference accompanies RMC-BestFit 2.0.0 and will continue to expand as the public API, examples, and verification materials grow. + +## Overview + +***RMC-BestFit*** is a Bayesian-first statistical analysis framework for flood frequency studies, developed by the U.S. Army Corps of Engineers Risk Management Center. The model library supports life-safety flood risk assessments, hydrologic frequency analysis, rating curves, time series, bivariate frequency analysis, and spatial extremes. + +The documentation is organized to mirror the Numerics library: a short getting-started path, ordered technical-reference chapters, page-to-page navigation, IEEE-style numeric references, and C# examples that track the public API. + +## Documentation Structure + +| Document | Description | +|----------|-------------| +| [Getting Started](getting-started.md) | Installation, namespaces, and first workflows | +| [Models Overview](technical-reference/models/overview.md) | `IModel`, parameters, priors, custom models | +| [Input Data Frame](technical-reference/data-frame/index.md) | Exact, uncertain, interval, and threshold data | +| [Distribution API](technical-reference/distributions/index.md) | Distribution-family map and coverage index | +| [Univariate Distributions](technical-reference/distributions/univariate.md) | All 15 supported univariate distribution models | +| [Mixture Models](technical-reference/distributions/mixture.md) | Weighted flood-population mixtures | +| [Competing Risks](technical-reference/distributions/competing-risks.md) | Maximum/minimum of multiple flood processes | +| [Point Process Models](technical-reference/distributions/point-process.md) | Peaks-over-threshold modeling | +| [Composite Distributions](technical-reference/distributions/composite.md) | Competing risks, mixtures, and model averaging | +| [Model Estimation](technical-reference/estimation/index.md) | MLE, MAP, GMM, Bayesian MCMC, information criteria | +| [Diagnostics](technical-reference/estimation/diagnostics.md) | Influence, leverage, prior influence, predictive checks | +| [Analyses Overview](technical-reference/analysis/overview.md) | Analysis workflow and shared interfaces | +| [Distribution Fitting](technical-reference/analysis/distribution-fitting.md) | Automated MLE fitting and ranking | +| [Univariate Analysis](technical-reference/analysis/univariate.md) | Bayesian frequency analysis for one distribution | +| [Composite Analysis](technical-reference/analysis/composite.md) | `CompositeAnalysis` and `WeightedUnivariateAnalysis` | +| [Bivariate Analysis](technical-reference/analysis/bivariate.md) | Copula-based joint distributions | +| [Coincident Frequency](technical-reference/analysis/coincident-frequency.md) | Response-surface frequency analysis | +| [Rating Curves](technical-reference/analysis/rating-curve.md) | Stage-discharge analysis | +| [Time Series](technical-reference/analysis/time-series.md) | AR, MA, ARIMA, and ARIMAX | +| [Spatial Extremes](technical-reference/spatial/spatial-extremes.md) | Spatial GEV and regional frequency analysis | +| [Trend and Link Functions](technical-reference/support/trend-and-link-functions.md) | Nonstationary parameter functions and link space | +| [References](references.md) | Consolidated bibliography | + +## Quick Start + +### Bayesian Univariate Analysis + +```cs +using Numerics.Distributions; +using RMC.BestFit; +using RMC.BestFit.Analyses; +using RMC.BestFit.Models; + +double[] annualPeaks = +{ + 42000, 51700, 38900, 61200, 46800, 55300, 44100, 67300 +}; + +var dataFrame = new DataFrame +{ + ExactSeries = new ExactSeries(annualPeaks) +}; + +var model = new UnivariateDistribution( + dataFrame, + UnivariateDistributionType.GeneralizedExtremeValue); + +var analysis = new UnivariateAnalysis(model); +analysis.BayesianAnalysis.Iterations = 3000; +analysis.BayesianAnalysis.WarmupIterations = 1500; + +await analysis.RunAsync(); + +var results = analysis.BayesianAnalysis.Results; +if (results is not null) +{ + for (int i = 0; i < model.Parameters.Count; i++) + { + var stats = results.ParameterResults[i].SummaryStatistics; + Console.WriteLine($"{model.Parameters[i].Name}: {stats.Mean:F3}"); + } +} +``` + +### Fast MLE Distribution Screening + +```cs +using RMC.BestFit; +using RMC.BestFit.Analyses; +using RMC.BestFit.Models; + +var dataFrame = new DataFrame +{ + ExactSeries = new ExactSeries(new[] { 12.0, 15.4, 18.2, 21.0, 25.7, 30.1 }) +}; + +var fitting = new FittingAnalysis(dataFrame); +await fitting.RunAsync(); + +var ranked = fitting.FittedDistributions + .Where(candidate => candidate.FitSucceeded) + .OrderBy(candidate => candidate.AIC); + +foreach (var candidate in ranked.Take(5)) +{ + Console.WriteLine($"{candidate.Distribution?.Type}: AIC = {candidate.AIC:F2}"); +} +``` + +## API Coverage Policy + +The documentation targets roughly 90% coverage of the public `RMC.BestFit.dll` API. Coverage means public types and important public members are documented in concept pages, API tables, or examples. Trivial DTO properties may be grouped, but model, estimation, analysis, diagnostic, and serialization workflows must have an explicit documented usage path. + +External references provide scientific context; the production source code controls documented API behavior. + +## Supplemental Public API Inventory + +The following support types are covered as part of the 90% API map. They are usually consumed through the higher-level model, estimation, analysis, or diagnostic pages rather than as standalone chapters. + +| API | Documentation Context | +|-----|-----------------------| +| `BatchAnalysisOptions`, `BatchAnalysisResult`, `BatchAnalysisRunner` | Batch orchestration utilities for running multiple `IAnalysis` instances | +| `BootstrapDiagnostics` | Bootstrap retry, rejection, timing, and failure-rate diagnostics | +| `Bulletin17CDistribution`, `UncertaintyMethod`, `CohnConfidenceIntervalResult` | Bulletin 17C distribution and uncertainty-support API | +| `CachedMultivariateNormal` | Spatial GEV and Gaussian-copula performance support | +| `CorrelationFunctionType`, `ICorrelationModel` | Spatial correlation model selection and common contract | +| `DataComponent`, `DataComponentType`, `PriorComponent` | Observation/prior component labeling for WAIC, LOO-CV, and diagnostics | +| `DataSeries` | Shared base type for exact, uncertain, interval, and threshold data series | +| `GMMEstimationStrategy`, `GMMIdentificationStatus`, `IGMMModel` | Generalized Method of Moments model and result-state support | +| `ISimulatable`, `IUnivariateAnalysis` | Shared simulation and univariate-analysis contracts | +| `MRLPoint`, `MeanResidualLifeResult`, `StabilityPoint`, `ParameterStabilityResult` | Threshold diagnostic outputs for point-process model selection | +| `ASinHLink`, `CenteredLink`, `LogASinHLink`, `LogSESLink`, `BestFitLinkFunctionFactory` | Link-function implementations and XML factory support | +| `ObservationLeverage`, `PriorComponentLeverage`, `PriorComponentSummary` | Diagnostics output records nested in leverage and prior-influence results | +| `ParameterPenalty`, `PriorComponentType`, `ParetoKCategory`, `PointEstimateType` | Prior, influence, and Bayesian result classification support | +| `SpatialGEVCrossValidationResults`, `SpatialGEVSiteResults`, `SpatialGEVUncertaintyMethod` | Spatial GEV site, uncertainty, and cross-validation result types | +| `SubscriptFormatter` | UI/report-friendly parameter subscript formatting helper | +| `UnivariateDistributionModelBase` | Shared base class for univariate, mixture, competing-risk, and point-process models | + +## Namespaces + +| Namespace | Purpose | +|-----------|---------| +| `RMC.BestFit` | Data-series and observation types | +| `RMC.BestFit.Models` | Models, data frame, parameters, trends, rating curves, time series, spatial types | +| `RMC.BestFit.Analyses` | Analysis workflows and result orchestration | +| `RMC.BestFit.Estimation` | MLE, MAP, GMM, Bayesian MCMC | +| `RMC.BestFit.Diagnostics` | Influence, leverage, and predictive diagnostics | +| `Numerics.Distributions` | Distribution implementations, priors, copulas, and distribution enums | + +## Architecture + +```text +Numerics.dll + | +RMC.BestFit.dll + | +RMC.BestFit.UI.dll + | +RMC-BestFit.exe +``` + +## Key References + +[1] Interagency Advisory Committee on Water Data, *Guidelines for Determining Flood Flow Frequency, Bulletin 17C*, U.S. Geological Survey, 2019. + +[2] C. J. F. ter Braak and J. A. Vrugt, "Differential Evolution Markov Chain with snooker updater and fewer chains," *Statistics and Computing*, vol. 18, no. 4, pp. 435-446, 2008. + +[3] A. Vehtari, A. Gelman, and J. Gabry, "Practical Bayesian model evaluation using leave-one-out cross-validation and WAIC," *Statistics and Computing*, vol. 27, no. 5, pp. 1413-1432, 2017. + +## License + +RMC-BestFit is released under the Zero-Clause BSD (0BSD) license. See [LICENSE](../LICENSE) for the full text. diff --git a/docs/references.md b/docs/references.md new file mode 100644 index 0000000..aac63a6 --- /dev/null +++ b/docs/references.md @@ -0,0 +1,45 @@ +# References + +This bibliography consolidates the references cited by the BestFit API documentation. Page-local reference sections use the same IEEE-style numeric format. + +## Flood Frequency and Hydrology + +[1] Interagency Advisory Committee on Water Data, *Guidelines for Determining Flood Flow Frequency, Bulletin 17C*, U.S. Geological Survey, 2019. + +[2] J. R. M. Hosking and J. R. Wallis, *Regional Frequency Analysis: An Approach Based on L-Moments*. Cambridge, U.K.: Cambridge University Press, 1997. + +[3] V. T. Chow, D. R. Maidment, and L. W. Mays, *Applied Hydrology*. New York, NY, USA: McGraw-Hill, 1988. + +## Bayesian Estimation and MCMC + +[4] A. Gelman, J. B. Carlin, H. S. Stern, D. B. Dunson, A. Vehtari, and D. B. Rubin, *Bayesian Data Analysis*, 3rd ed. Boca Raton, FL, USA: CRC Press, 2013. + +[5] C. J. F. ter Braak and J. A. Vrugt, "Differential Evolution Markov Chain with snooker updater and fewer chains," *Statistics and Computing*, vol. 18, no. 4, pp. 435-446, 2008. + +[6] A. Vehtari, A. Gelman, and J. Gabry, "Practical Bayesian model evaluation using leave-one-out cross-validation and WAIC," *Statistics and Computing*, vol. 27, no. 5, pp. 1413-1432, 2017. + +## Extreme Values and Model Averaging + +[7] S. Coles, *An Introduction to Statistical Modeling of Extreme Values*. London, U.K.: Springer, 2001. + +[8] K. P. Burnham and D. R. Anderson, *Model Selection and Multimodel Inference*, 2nd ed. New York, NY, USA: Springer, 2002. + +[9] G. McLachlan and D. Peel, *Finite Mixture Models*. New York, NY, USA: Wiley, 2000. + +## Copulas and Bivariate Analysis + +[10] R. B. Nelsen, *An Introduction to Copulas*, 2nd ed. New York, NY, USA: Springer, 2006. + +[11] H. Joe, *Multivariate Models and Dependence Concepts*. London, U.K.: Chapman and Hall, 1997. + +## Rating Curves + +[12] S. E. Rantz et al., *Measurement and Computation of Streamflow: Volume 2. Computation of Discharge*, U.S. Geological Survey Water-Supply Paper 2175, 1982. + +[13] J. Le Coz, B. Renard, L. Bonnifait, F. Branger, and R. Le Boursicaud, "Combining hydraulic knowledge and uncertain gaugings in the estimation of hydrometric rating curves: A Bayesian approach," *Journal of Hydrology*, vol. 509, pp. 573-587, 2014. + +## Diagnostics + +[14] R. D. Cook, "Detection of influential observation in linear regression," *Technometrics*, vol. 19, no. 1, pp. 15-18, 1977. + +[15] B. C. Wei, Y. Q. Hu, and W. K. Fung, "Generalized leverage and its applications," *Scandinavian Journal of Statistics*, vol. 25, no. 1, pp. 25-36, 1998. diff --git a/docs/technical-reference/analysis/bivariate.md b/docs/technical-reference/analysis/bivariate.md new file mode 100644 index 0000000..5db6914 --- /dev/null +++ b/docs/technical-reference/analysis/bivariate.md @@ -0,0 +1,61 @@ +# Bivariate Analysis + +[<- Previous: Composite Analysis](composite.md) | [Back to Index](../../index.md) | [Next: Coincident Frequency ->](coincident-frequency.md) + +`BivariateAnalysis` fits a `BivariateDistribution` consisting of two univariate marginals and a copula. It is used for joint frequency, conditional frequency, and as the upstream input to coincident frequency analysis. + +## Public API + +| API | Purpose | +|-----|---------| +| `BivariateDistribution(IUnivariateModel, IUnivariateModel, CopulaType)` | Creates the joint model | +| `BivariateDistribution.CreateCopula(...)` | Creates supported Numerics copulas | +| `BivariateDistribution.DataLogLikelihood(...)` | Joint likelihood | +| `BivariateDistribution.PointwiseDataLogLikelihood(...)` | WAIC/LOO-CV support | +| `BivariateDistribution.GenerateRandomValues(...)` | Joint simulation | +| `BivariateAnalysis(BivariateDistribution)` | Analysis workflow | +| `BivariateAnalysis.BayesianAnalysis` | MCMC settings and results | +| `BivariateAnalysis.AnalysisResults` | Joint-frequency uncertainty results | +| `BivariateAnalysis.CreateFrequencyAnalysisResultsAsync()` | Reprocesses frequency outputs | + +## Usage Pattern + +```cs +using Numerics.Distributions; +using Numerics.Distributions.Copulas; +using RMC.BestFit; +using RMC.BestFit.Analyses; +using RMC.BestFit.Models; + +var xData = new DataFrame { ExactSeries = new ExactSeries(new[] { 10.0, 12.0, 14.0, 16.0 }) }; +var yData = new DataFrame { ExactSeries = new ExactSeries(new[] { 5.0, 7.0, 9.0, 11.0 }) }; + +var marginalX = new UnivariateDistribution(xData, UnivariateDistributionType.Normal); +var marginalY = new UnivariateDistribution(yData, UnivariateDistributionType.Gumbel); + +var model = new BivariateDistribution(marginalX, marginalY, CopulaType.Normal); +var analysis = new BivariateAnalysis(model); + +if (analysis.Validate().IsValid) +{ + await analysis.RunAsync(); +} +``` + +## Relationship To Coincident Frequency + +`BivariateAnalysis` estimates the joint probability model for variables `X` and `Y`. [Coincident Frequency](coincident-frequency.md) combines that fitted joint model with a response surface `Z = f(X, Y)`. + +## Source-Verified Likelihood + +`BivariateDistribution.DataLogLikelihood(...)` estimates only the copula parameters. The marginal distributions are supplied by the two `IUnivariateModel` inputs. In pseudo-likelihood mode, BestFit evaluates the copula log density directly on pseudo-uniform sample pairs. In inference-from-margins mode, it transforms each raw pair through the marginal CDFs and then evaluates the copula log density. Non-finite or exception-producing likelihood evaluations return `double.NegativeInfinity`. + +`PointwiseDataLogLikelihood(...)` follows the same path one pair at a time for WAIC, LOO-CV, and influence diagnostics. + +## Implementation Sources + +Primary source paths: `src/RMC.BestFit/Models/BivariateDistribution/BivariateDistribution.cs`, `src/RMC.BestFit/Analyses/Bivariate/BivariateAnalysis.cs`, and `src/RMC.BestFit/Analyses/Bivariate/CoincidentFrequencyAnalysis.cs`. + +--- + +[<- Previous: Composite Analysis](composite.md) | [Back to Index](../../index.md) | [Next: Coincident Frequency ->](coincident-frequency.md) diff --git a/docs/technical-reference/analysis/coincident-frequency.md b/docs/technical-reference/analysis/coincident-frequency.md new file mode 100644 index 0000000..7a19a35 --- /dev/null +++ b/docs/technical-reference/analysis/coincident-frequency.md @@ -0,0 +1,89 @@ +# Coincident Frequency Analysis + +[<- Previous: Bivariate Analysis](bivariate.md) | [Back to Index](../../index.md) | [Next: Rating Curve ->](rating-curve.md) + +`CoincidentFrequencyAnalysis` computes a response-frequency curve from a fitted `BivariateAnalysis` and an externally supplied response surface `Z = f(X, Y)`. + +## Public API + +| Member | Purpose | +|--------|---------| +| `CoincidentFrequencyAnalysis()` | Empty analysis | +| `CoincidentFrequencyAnalysis(BivariateAnalysis, double[], double[], double[,])` | Creates an analysis from a fitted bivariate model and response grid | +| `BivariateAnalysis` | Upstream fitted bivariate model | +| `XValues` | Primary ordinates; length equals response rows | +| `YValues` | Secondary ordinates; length equals response columns | +| `BivariateResponse` | `Z[i,j]` response surface | +| `NumberOfBins` | Number of output `Z` bins | +| `MarginalXChain` | Optional posterior samples for X marginal | +| `MarginalYChain` | Optional posterior samples for Y marginal | +| `RunAsync(...)` | Computes response-frequency uncertainty | +| `SetZOutputValues(...)` | Overrides output bins | +| `ToXElement()` | Serializes configuration and output | + +## Source-Verified Algorithm + +The analysis integrates over columns of the response surface using the fitted bivariate copula. For each output level `z` and each `Y` interval, BestFit interpolates the `X` value that produces `z`, evaluates a two-point copula CDF difference, and sums the contributions. + +The implementation first forms `Y` bin edges as midpoints between adjacent `YValues`, with negative infinity at the first edge and positive infinity at the last edge. In copula space, this means `v_0 = 0`, `v_N = 1`, and each interior edge is `F_Y((y_{j-1}+y_j)/2)`. + +```math +F_Z(z) = \sum_j \left[ +C(u^*_{z,j}, v_{j+1}) - +C(u^*_{z,j}, v_j) +\right] +``` + +The `u^*_{z,j}` term is found by interpolating linearly in `(response, zeta)` space, where `zeta = Normal.StandardInverseCDF(F_X(x))`. When `z` falls outside a response column, BestFit linearly extrapolates from the nearest segment. Boundary identities `C(u,0)=0` and `C(u,1)=u` are used to avoid passing exact 0 or 1 into copula methods that internally apply normal quantiles. + +The annual exceedance probability is `1 - F_Z(z)`. Posterior uncertainty loops over copula posterior draws from the fitted `BivariateAnalysis`. Optional `MarginalXChain` and `MarginalYChain` posterior samples vary the marginals by matched draw index; if marginal chains are absent, the point-estimate marginals are used. + +## Usage Pattern + +```cs +using RMC.BestFit.Analyses; + +double[] xValues = { 10.0, 20.0, 30.0 }; +double[] yValues = { 1.0, 2.0, 3.0 }; +double[,] response = +{ + { 11.0, 12.0, 13.0 }, + { 21.0, 22.0, 23.0 }, + { 31.0, 32.0, 33.0 } +}; + +var coincident = new CoincidentFrequencyAnalysis( + bivariateAnalysis, + xValues, + yValues, + response); + +coincident.NumberOfBins = 50; + +if (coincident.Validate().IsValid) +{ + await coincident.RunAsync(); +} +``` + +## Input Requirements + +| Input | Requirement | +|-------|-------------| +| `BivariateAnalysis` | Estimated before running coincident analysis | +| `XValues` | Strictly ascending | +| `YValues` | Strictly ascending | +| `BivariateResponse` | Dimensions match X/Y arrays and increase along both axes | +| Posterior chains | Optional; if absent, point-estimate marginals are used | + +## Implementation Sources + +Primary source paths: `src/RMC.BestFit/Analyses/Bivariate/CoincidentFrequencyAnalysis.cs`, `src/RMC.BestFit/Analyses/Bivariate/BivariateAnalysis.cs`, and `src/RMC.BestFit/Models/BivariateDistribution/BivariateDistribution.cs`. + +## References + +[1] R. B. Nelsen, *An Introduction to Copulas*, 2nd ed. New York, NY, USA: Springer, 2006. + +--- + +[<- Previous: Bivariate Analysis](bivariate.md) | [Back to Index](../../index.md) | [Next: Rating Curve ->](rating-curve.md) diff --git a/docs/technical-reference/analysis/composite.md b/docs/technical-reference/analysis/composite.md new file mode 100644 index 0000000..caafb18 --- /dev/null +++ b/docs/technical-reference/analysis/composite.md @@ -0,0 +1,74 @@ +# Composite Analysis + +[<- Previous: Univariate Analysis](univariate.md) | [Back to Index](../../index.md) | [Next: Bivariate Analysis ->](bivariate.md) + +`CompositeAnalysis` combines estimated univariate analyses. It is the analysis-layer API for competing risks, mixtures, and model averaging. + +## Public API + +| Member | Purpose | +|--------|---------| +| `CompositeAnalysis()` | Empty composite | +| `CompositeAnalysis(IEnumerable)` | Composite from weighted analyses | +| `Analyses` | `ObservableCollection` | +| `CompositeDistributionType` | `CompetingRisks`, `Mixture`, or `ModelAverage` | +| `ModelAverageMethod` | AIC, BIC, DIC, WAIC, LOOIC, Equal, or RMSE | +| `Dependency` | Dependency assumption used in probability composition | +| `IsMaximum` | Max/min flag for competing-risks composition | +| `RunAsync(...)` | Computes composite uncertainty results | +| `ToXElement()` | Serializes configuration | + +## Three Composition Modes + +| Mode | Select With | Weight Meaning | +|------|-------------|----------------| +| Competing risks | `CompositeType.CompetingRisks` | Process contribution to max/min outcome | +| Mixture | `CompositeType.Mixture` | Latent population probability | +| Model averaging | `CompositeType.ModelAverage` | Information-criterion or equal model weight | + +## Example: WAIC Model Averaging + +```cs +using RMC.BestFit.Analyses; + +var composite = new CompositeAnalysis(new[] +{ + new WeightedUnivariateAnalysis(logPearsonAnalysis, 0.0), + new WeightedUnivariateAnalysis(gevAnalysis, 0.0) +}); + +composite.CompositeDistributionType = CompositeType.ModelAverage; +composite.ModelAverageMethod = AverageMethod.WAIC; + +if (composite.Validate().IsValid) +{ + await composite.RunAsync(); +} +``` + +For model averaging, the analysis estimates weights from the selected criterion. For mixture and competing-risks workflows, manually supplied weights must be meaningful for the process being modeled. + +## Source-Verified Behavior + +`CompositeAnalysis.RunAsync(...)` requires every child `IUnivariateAnalysis` to already be estimated and to have non-null `AnalysisResults`. It then recomputes model-average weights when needed and builds a realization array whose length is the minimum posterior-output length across all child analyses. + +For `CompositeType.CompetingRisks`, each realization is a `Numerics.Distributions.CompetingRisks` distribution formed from the child realization at the same index. For `CompositeType.Mixture` and `CompositeType.ModelAverage`, each realization is a `Numerics.Distributions.Mixture` with the current child weights. The frequency results are created by `BootstrapAnalysis.Estimate(...)` over the composite realization array. + +`AverageMethod.DIC`, `AverageMethod.WAIC`, and `AverageMethod.LOOIC` are rejected when any child is a `Bulletin17CAnalysis`, because B17C is fit by GMM and does not define those MCMC information criteria. + +## Failure Modes + +| Condition | Result | +|-----------|--------| +| A child analysis is not estimated | `Validate()` returns invalid | +| A child is itself `CompositeAnalysis` | `WeightedUnivariateAnalysis` rejects it | +| Information criterion is missing | Model-average weights cannot be estimated for that method | +| Weights are invalid for a mixture | Validation reports the configuration issue | + +## Implementation Sources + +Primary source paths: `src/RMC.BestFit/Analyses/Univariate/CompositeAnalysis.cs`, `src/RMC.BestFit/Analyses/Univariate/WeightedUnivariateAnalysis.cs`, and `src/RMC.BestFit/Analyses/Univariate/BootstrapAnalysis.cs`. + +--- + +[<- Previous: Univariate Analysis](univariate.md) | [Back to Index](../../index.md) | [Next: Bivariate Analysis ->](bivariate.md) diff --git a/docs/technical-reference/analysis/distribution-fitting.md b/docs/technical-reference/analysis/distribution-fitting.md new file mode 100644 index 0000000..c4500c5 --- /dev/null +++ b/docs/technical-reference/analysis/distribution-fitting.md @@ -0,0 +1,515 @@ +# Distribution Fitting Analysis + +[<- Previous: Analyses Overview](overview.md) | [Back to Index](../../index.md) | [Next: Univariate Analysis ->](univariate.md) + +The **Distribution Fitting Analysis** (`FittingAnalysis`) is a powerful tool that automatically fits all 15 supported univariate distributions to a dataset, ranks them by goodness-of-fit criteria, and provides a comparative summary for model selection. This is often the first step in flood frequency analysis — quickly surveying which distribution families best describe the data before conducting detailed Bayesian analysis on selected candidates. + +## Why Automated Fitting Matters + +Consider a hydrologist analyzing 50 years of annual peak flows at a new site. Which distribution should be used? + +- **Log-Pearson Type III** is the Bulletin 17C standard in the United States +- **Generalized Extreme Value (GEV)** is widely used internationally +- **Generalized Logistic** is the UK Flood Estimation Handbook standard +- Regional guidance might suggest other options + +Rather than fitting each distribution manually, `FittingAnalysis` fits all 15 in parallel and produces a ranked comparison. This reveals: + +1. Which distributions fit the data well +2. Which produce similar vs. different estimates +3. Whether the data have unusual features (heavy tails, bimodality) + +## Mathematical Background + +### Maximum Likelihood Fitting + +For each distribution, `FittingAnalysis` finds the parameters that maximize the log-likelihood: + +```math +\hat{\theta} = \arg\max_{\theta} \ell(\theta) = \arg\max_{\theta} \sum_{i=1}^{n} \log f(y_i | \theta) +``` + +where $f(y | \theta)$ is the probability density function parameterized by $\theta$. + +### Model Comparison Criteria + +To compare models with different numbers of parameters, we use information criteria that penalize complexity: + +#### Akaike Information Criterion (AIC) + +```math +\text{AIC} = -2\ell(\hat{\theta}) + 2k +``` + +where $k$ is the number of parameters. AIC estimates the expected Kullback-Leibler divergence from the true distribution. **Lower AIC is better.** + +#### Bayesian Information Criterion (BIC) + +```math +\text{BIC} = -2\ell(\hat{\theta}) + k \log(n) +``` + +BIC penalizes complexity more heavily than AIC for $n > 7$. It's consistent — selecting the true model as $n \to \infty$ if it's among the candidates. + +#### Root Mean Square Error (RMSE) + +RMSE measures the average deviation between observed and predicted quantiles at the plotting positions: + +```math +\text{RMSE} = \sqrt{\frac{1}{n}\sum_{i=1}^{n}\left(y_i - F^{-1}(p_i | \hat{\theta})\right)^2} +``` + +where $p_i$ is the plotting position for observation $i$ and $F^{-1}$ is the inverse CDF (quantile function). + +### Supported Distributions + +`FittingAnalysis` fits all 15 distributions in the ***RMC-BestFit*** library: + +| Distribution | Parameters | Typical Use | +|--------------|------------|-------------| +| Exponential | 2 | Peaks-over-threshold excesses | +| Gamma | 3 | Precipitation, positive-valued data | +| **GEV** | 3 | Annual maxima (international standard) | +| Generalized Logistic | 3 | UK FEH standard | +| Generalized Normal | 3 | Symmetric with flexible kurtosis | +| Generalized Pareto | 3 | Threshold excesses | +| Gumbel | 2 | Annual maxima (light tail) | +| Kappa Four | 4 | Very flexible, research use | +| Ln-Normal | 2 | Right-skewed data | +| Logistic | 2 | Growth curves | +| Log-Normal | 2 | Multiplicative processes | +| **Log-Pearson Type III** | 3 | Bulletin 17C standard (US) | +| Normal | 2 | Symmetric data, transformed flows | +| Pearson Type III | 3 | Skewed data | +| Weibull | 3 | Minima, wind speeds | + +--- + +## Using FittingAnalysis + +### Basic Usage + +```cs +using RMC.BestFit.Analyses; +using RMC.BestFit.Models; + +// Create DataFrame with flood data +var df = new DataFrame(); +var annualPeaks = new double[] { + 45000, 52000, 38000, 61000, 49000, 55000, 42000, 67000, 39000, 48000, + 51000, 36000, 58000, 44000, 53000, 47000, 62000, 41000, 50000, 37000, + 54000, 46000, 59000, 43000, 56000, 40000, 63000, 35000, 57000, 45000 +}; +df.ExactSeries = new ExactSeries(annualPeaks); + +// Create fitting analysis +var analysis = new FittingAnalysis(df); + +// Run analysis (fits all 15 distributions in parallel) +await analysis.RunAsync(); + +// Display ranked results +Console.WriteLine("Distribution Fitting Results (ranked by AIC):"); +Console.WriteLine($"{"Rank",-6} {"Distribution",-25} {"AIC",12} {"BIC",12} {"RMSE",12}"); +Console.WriteLine(new string('-', 70)); + +var ranked = analysis.FittedDistributions + .Where(fd => fd.FitSucceeded) + .OrderBy(fd => fd.AIC) + .ToList(); + +for (int i = 0; i < ranked.Count; i++) +{ + var fd = ranked[i]; + Console.WriteLine($"{i + 1,-6} {fd.Distribution?.Type,-25} {fd.AIC,12:F2} {fd.BIC,12:F2} {fd.RMSE,12:F1}"); +} +``` + +### Output Example + +``` +Distribution Fitting Results (ranked by AIC): +Rank Distribution AIC BIC RMSE +---------------------------------------------------------------------- +1 LogPearsonTypeIII 598.23 603.45 1245.3 +2 GeneralizedExtremeValue 598.87 604.09 1312.7 +3 PearsonTypeIII 599.12 604.34 1287.4 +4 GeneralizedLogistic 600.45 605.67 1356.8 +5 GeneralizedNormal 601.23 606.45 1298.6 +6 LogNormal 602.56 606.04 1423.1 +7 LnNormal 602.56 606.04 1423.1 +8 Gumbel 604.89 608.37 1567.2 +9 Normal 615.34 618.82 2145.8 +... +``` + +### Accessing Fitted Parameters + +```cs +// Get the best-fitting distribution +var best = analysis.FittedDistributions + .Where(fd => fd.FitSucceeded) + .OrderBy(fd => fd.AIC) + .First(); +var bestDistribution = best.Distribution + ?? throw new InvalidOperationException("Best fit has no distribution."); + +Console.WriteLine($"\nBest distribution: {bestDistribution.Type}"); +Console.WriteLine("Parameters:"); + +foreach (var param in bestDistribution.Parameters) +{ + Console.WriteLine($" {param.Name}: {param.Value:F4}"); +} +``` + +### Computing Return Levels + +```cs +// Get return levels from the best-fitting distribution +var returnPeriods = new int[] { 2, 5, 10, 25, 50, 100, 200, 500 }; + +Console.WriteLine("\nReturn Level Estimates:"); +Console.WriteLine($"{"Return Period",-15} {"Return Level",15}"); +Console.WriteLine(new string('-', 32)); + +foreach (int T in returnPeriods) +{ + double p = 1 - 1.0 / T; // Exceedance probability + double rl = bestDistribution.InverseCDF(p); + Console.WriteLine($"{T,-15} {rl,15:F0}"); +} +``` + +--- + +## The FittedDistribution Class + +Each fitted distribution is represented by a `FittedDistribution` object: + +```cs +public class FittedDistribution +{ + /// + /// The fitted univariate distribution with estimated parameters. + /// + public UnivariateDistributionBase? Distribution { get; } + + /// + /// Whether MLE converged successfully. + /// + public bool FitSucceeded { get; } + + /// + /// Akaike Information Criterion. + /// + public double AIC { get; } + + /// + /// Bayesian Information Criterion. + /// + public double BIC { get; } + + /// + /// Root Mean Square Error at plotting positions. + /// + public double RMSE { get; } +} +``` + +--- + +## Configuring Probability Ordinates + +By default, `FittingAnalysis` computes results at standard probability levels. You can customize these: + +```cs +// Clear default ordinates and add custom ones +analysis.ProbabilityOrdinates.Clear(); +analysis.ProbabilityOrdinates.AddRange(new double[] { + 0.99, 0.98, 0.96, 0.90, 0.80, 0.50, 0.20, 0.10, 0.04, 0.02, 0.01, 0.005, 0.002 +}); + +await analysis.RunAsync(); +``` + +--- + +## Interpreting Results + +### AIC Differences + +The magnitude of AIC differences matters: + +| ΔAIC from Best | Interpretation | +|----------------|----------------| +| 0-2 | Substantial support, essentially equivalent | +| 2-4 | Some support, consider both models | +| 4-7 | Considerably less support | +| > 10 | Essentially no support | + +If multiple distributions have ΔAIC < 2, consider model averaging or conducting detailed Bayesian analysis on all of them. + +### When Results Disagree + +Sometimes AIC and BIC select different models: +- **AIC** tends to select more complex models (lower penalty) +- **BIC** tends to select simpler models (higher penalty for complexity) + +If they disagree substantially, the data may not strongly favor any particular model. Consider: +1. Using model averaging (weighted by AIC weights) +2. Conducting sensitivity analysis with multiple distributions +3. Using physical reasoning to select + +### Warning Signs + +Watch for these issues in the fitting results: + +| Issue | Possible Cause | +|-------|----------------| +| Many distributions fail to converge | Unusual data, outliers, wrong units | +| Very different AIC among similar distributions | Boundary effects, numerical issues | +| Shape parameter at bound | Distribution may not be appropriate | +| Extremely low RMSE for complex model | Possible overfitting | + +--- + +## Working with Different Data Types + +`FittingAnalysis` works with any DataFrame, including those with uncertain, interval, and threshold data: + +```cs +var df = new DataFrame(); + +// Systematic record +df.ExactSeries = new ExactSeries(systematicPeaks); + +// Historical flood with uncertainty +df.UncertainSeries.Add(new UncertainData(1889, new Normal(85000, 10000))); + +// Perception threshold +var threshold = new ThresholdData(1800, 1900, 50000); +threshold.NumberBelow = 100; +df.ThresholdSeries.Add(threshold); + +// Fitting analysis handles all data types correctly +var analysis = new FittingAnalysis(df); +await analysis.RunAsync(); +``` + +The likelihood for each distribution automatically incorporates all data types as described in the [Input Data Frame](../data-frame/index.md) chapter. + +--- + +## Parallel Execution + +`FittingAnalysis` fits all 15 distributions in parallel for performance. On a typical workstation, the entire analysis completes in seconds: + +```cs +var stopwatch = System.Diagnostics.Stopwatch.StartNew(); +await analysis.RunAsync(); +stopwatch.Stop(); + +Console.WriteLine($"Fitted 15 distributions in {stopwatch.ElapsedMilliseconds} ms"); +``` + +Typical timing: 500-2000 ms for 30-100 observations. + +--- + +## XML Serialization + +Save and restore fitting analysis results: + +```cs +// Save results +var xElement = analysis.ToXElement(); +xElement.Save("fitting_results.xml"); + +// Restore (requires original DataFrame) +var loadedXml = XElement.Load("fitting_results.xml"); +var restoredAnalysis = new FittingAnalysis(df, loadedXml); + +// Access restored results +foreach (var fd in restoredAnalysis.FittedDistributions) +{ + Console.WriteLine($"{fd.Distribution?.Type}: AIC = {fd.AIC:F2}"); +} +``` + +--- + +## Best Practices + +### 1. Start with FittingAnalysis + +Before detailed Bayesian analysis, run `FittingAnalysis` to: +- Survey which distributions fit well +- Identify data issues (outliers, wrong units) +- Guide distribution selection + +### 2. Don't Blindly Trust AIC + +AIC identifies the best-fitting model among candidates, but: +- The true distribution may not be among the 15 candidates +- Physical reasoning should inform selection +- Regulatory requirements may mandate specific distributions + +### 3. Check Tail Behavior + +Different distributions can fit the bulk of the data similarly but differ dramatically in the tails. Compare 100-year and 500-year return levels: + +```cs +Console.WriteLine("\n100-year and 500-year Return Levels:"); +Console.WriteLine($"{"Distribution",-25} {"RL-100",12} {"RL-500",12}"); + +foreach (var fd in ranked.Take(5)) +{ + if (fd.Distribution is null) + { + continue; + } + + double rl100 = fd.Distribution.InverseCDF(0.99); + double rl500 = fd.Distribution.InverseCDF(0.998); + Console.WriteLine($"{fd.Distribution.Type,-25} {rl100,12:F0} {rl500,12:F0}"); +} +``` + +### 4. Follow Up with Bayesian Analysis + +`FittingAnalysis` provides point estimates only. For uncertainty quantification, follow up with `UnivariateAnalysis`: + +```cs +// Select best distribution type from fitting analysis +var selectedDistribution = ranked.First().Distribution + ?? throw new InvalidOperationException("Selected fit has no distribution."); +var bestType = selectedDistribution.Type; + +// Create full model with same distribution type +var model = new UnivariateDistribution(df, bestType); + +// Run Bayesian analysis for full uncertainty quantification +var bayesianAnalysis = new UnivariateAnalysis(model); +bayesianAnalysis.BayesianAnalysis.Iterations = 10000; +bayesianAnalysis.BayesianAnalysis.WarmupIterations = 5000; +await bayesianAnalysis.RunAsync(); + +// Now have full posterior distributions +var results = bayesianAnalysis.BayesianAnalysis.Results; +``` + +--- + +## API Reference + +### FittingAnalysis Class + +```cs +public class FittingAnalysis : AnalysisBase, IProbabilityOrdinates +{ + // Constructors + public FittingAnalysis(DataFrame dataFrame); + public FittingAnalysis(DataFrame dataFrame, XElement xElement); + + // Properties + public DataFrame DataFrame { get; } + public ProbabilityOrdinates ProbabilityOrdinates { get; } + public List DistributionList { get; } + public List FittedDistributions { get; } + public bool IsEstimated { get; } + + // Methods + public override Task RunAsync(SafeProgressReporter? progressReporter = null); + public override (bool IsValid, List ValidationMessages) Validate(); + public void ClearResults(); + public XElement ToXElement(); +} +``` + +--- + +## Example: Complete Workflow + +```cs +using RMC.BestFit.Analyses; +using RMC.BestFit.Models; +using System.Xml.Linq; + +// Step 1: Load data +var df = new DataFrame(); +df.ExactSeries = new ExactSeries(LoadAnnualPeaks("station_12345.csv")); + +// Step 2: Run fitting analysis +var fitting = new FittingAnalysis(df); +await fitting.RunAsync(); + +// Step 3: Display top 5 distributions +var top5 = fitting.FittedDistributions + .Where(fd => fd.FitSucceeded) + .OrderBy(fd => fd.AIC) + .Take(5) + .ToList(); + +Console.WriteLine("Top 5 Distributions by AIC:\n"); +foreach (var fd in top5) +{ + if (fd.Distribution is null) + { + continue; + } + + Console.WriteLine($"{fd.Distribution.Type}:"); + Console.WriteLine($" AIC: {fd.AIC:F2}, BIC: {fd.BIC:F2}"); + Console.WriteLine($" 100-yr: {fd.Distribution.InverseCDF(0.99):F0} cfs\n"); +} + +// Step 4: Select and run detailed Bayesian analysis +var topDistribution = top5.First().Distribution + ?? throw new InvalidOperationException("Selected fit has no distribution."); +var selectedType = topDistribution.Type; +var model = new UnivariateDistribution(df, selectedType); +var bayesian = new UnivariateAnalysis(model); +bayesian.BayesianAnalysis.Iterations = 10000; +await bayesian.RunAsync(); + +// Step 5: Report results with uncertainty +Console.WriteLine($"\n{selectedType} Bayesian Results:"); +var results = bayesian.BayesianAnalysis.Results; + +for (int i = 0; i < model.Parameters.Count; i++) +{ + var stats = results.ParameterResults[i].SummaryStatistics; + Console.WriteLine($" {model.Parameters[i].Name}: {stats.Mean:F3} ± {stats.StandardDeviation:F3}"); +} + +// Step 6: Save results +fitting.ToXElement().Save("fitting_results.xml"); +bayesian.ToXElement().Save("bayesian_results.xml"); +``` + +--- + +## Implementation Sources + +Primary source paths: `src/RMC.BestFit/Analyses/DistributionFitting/FittingAnalysis.cs`, `src/RMC.BestFit/Analyses/DistributionFitting/FittedDistribution.cs`, `src/RMC.BestFit/Estimation/MaximumLikelihood.cs`, and `src/RMC.BestFit/Models/UnivariateDistribution/UnivariateDistribution.cs`. + +--- + +## References + +[1] +Akaike, H. (1974). "A new look at the statistical model identification." *IEEE Transactions on Automatic Control*, 19(6), 716-723. + +[2] +Schwarz, G. (1978). "Estimating the dimension of a model." *Annals of Statistics*, 6(2), 461-464. + +[3] +Hosking, J.R.M. and Wallis, J.R. (1997). *Regional Frequency Analysis: An Approach Based on L-Moments*. Cambridge University Press. + +[4] +England, J.F., Cohn, T.A., Faber, B.A., et al. (2019). *Guidelines for Determining Flood Flow Frequency: Bulletin 17C*. U.S. Geological Survey Techniques and Methods, Book 4, Chapter B5. + +--- + +[<- Previous: Analyses Overview](overview.md) | [Back to Index](../../index.md) | [Next: Univariate Analysis ->](univariate.md) diff --git a/docs/technical-reference/analysis/overview.md b/docs/technical-reference/analysis/overview.md new file mode 100644 index 0000000..8a149a0 --- /dev/null +++ b/docs/technical-reference/analysis/overview.md @@ -0,0 +1,467 @@ +# Analyses Overview + +[<- Previous: Diagnostics](../estimation/diagnostics.md) | [Back to Index](../../index.md) | [Next: Distribution Fitting ->](distribution-fitting.md) + +An **Analysis** in ***RMC-BestFit*** coordinates the estimation of a model and manages the results. While models define the mathematical relationship between parameters and data, analyses handle the workflow: configuring estimation settings, running MCMC or MLE, storing results, computing derived quantities, and providing event notifications for GUI integration. + +This chapter introduces the analysis architecture, surveys the pre-built analyses available in the library, and demonstrates how to create custom analyses for specialized applications. + +## Why Analyses Matter + +Consider fitting a GEV distribution to flood data. You could manually: +1. Create the model +2. Configure the Bayesian sampler +3. Run MCMC +4. Extract posterior summaries +5. Compute return level estimates +6. Generate uncertainty bands + +Or you could use `UnivariateAnalysis`, which handles all of this with sensible defaults: + +```cs +var analysis = new UnivariateAnalysis(model); +await analysis.RunAsync(); + +// Results are ready +var rl100 = analysis.GetReturnLevel(100); // 100-year return level with uncertainty +``` + +Analyses encapsulate best practices and provide consistent interfaces across different model types. + +## The Analysis Architecture + +### The `IAnalysis` Interface + +All analyses implement the `IAnalysis` interface: + +```cs +public interface IAnalysis +{ + /// + /// Gets or sets whether the analysis has been estimated. + /// + bool IsEstimated { get; set; } + + /// + /// Validates the analysis configuration. + /// + (bool IsValid, List ValidationMessages) Validate(); + + /// + /// Runs the analysis asynchronously. + /// + Task RunAsync(SafeProgressReporter? progressReporter = null); + + /// + /// Cancels a running analysis. + /// + void CancelAnalysis(); + + /// + /// Clears the analysis results. + /// + void ClearResults(); + + /// + /// Event raised before analysis starts (allows cancellation). + /// + event EventHandler? AnalysisStarting; + + /// + /// Event raised when analysis completes. + /// + event EventHandler? AnalysisCompleted; +} +``` + +### The `IBayesianAnalysis` Interface + +Analyses that support Bayesian MCMC implement additional functionality: + +```cs +public interface IBayesianAnalysis : IAnalysis +{ + /// + /// Gets the Bayesian analysis configuration and results. + /// + BayesianAnalysis BayesianAnalysis { get; } +} +``` + +### The `IProbabilityOrdinates` Interface + +Many analyses produce results at specific probability levels: + +```cs +public interface IProbabilityOrdinates +{ + /// + /// Gets the collection of probability ordinates for computing return levels. + /// + ProbabilityOrdinates ProbabilityOrdinates { get; } +} +``` + +--- + +## Pre-Built Analyses + +***RMC-BestFit*** provides analyses for all major model types: + +### Univariate Distribution Analyses + +| Analysis | Model | Description | +|----------|-------|-------------| +| `FittingAnalysis` | Multiple | Fits all 15 distributions, ranks by AIC/BIC | +| `UnivariateAnalysis` | `UnivariateDistribution` | Full Bayesian analysis of single distribution | +| `MixtureAnalysis` | `MixtureModel` | Two-population mixture models | +| `CompetingRiskAnalysis` | `CompetingRisksModel` | Annual max of multiple processes | +| `PointProcessAnalysis` | `PointProcessModel` | Peaks-over-threshold | +| `Bulletin17CAnalysis` | `LogPearsonTypeIII` | Bulletin 17C-compliant analysis | +| `CompositeAnalysis` | Multiple estimated univariate analyses | Competing risks, mixtures, and model averaging | + +### Time Series Analyses + +| Analysis | Model | Description | +|----------|-------|-------------| +| `ARAnalysis` | `AutoRegressive` | Autoregressive models | +| `MAAnalysis` | `MovingAverage` | Moving average models | +| `ARIMAAnalysis` | `ARIMA` | Integrated ARMA models | +| `ARIMAXAnalysis` | `ARIMAX` | ARIMA with covariates | + +### Other Analyses + +| Analysis | Model | Description | +|----------|-------|-------------| +| `RatingCurveAnalysis` | `RatingCurve` | Stage-discharge relationships | +| `BivariateAnalysis` | `BivariateDistribution` | Joint distribution of two variables | +| `CoincidentFrequencyAnalysis` | Fitted `BivariateAnalysis` + response surface | Coincident response-frequency curve | +| `SpatialGEVAnalysis` | `SpatialGEV` | Regional frequency analysis | + +--- + +## Analysis Workflow + +### Basic Workflow + +```cs +using RMC.BestFit.Analyses; +using RMC.BestFit.Models; + +// 1. Create model +var df = new DataFrame(); +df.ExactSeries = new ExactSeries(annualPeaks); +var model = new UnivariateDistribution(df, UnivariateDistributionType.GeneralizedExtremeValue); + +// 2. Create analysis +var analysis = new UnivariateAnalysis(model); + +// 3. Configure (optional - defaults are usually good) +analysis.BayesianAnalysis.Iterations = 10000; +analysis.BayesianAnalysis.WarmupIterations = 5000; + +// 4. Run +await analysis.RunAsync(); + +// 5. Check results +if (analysis.IsEstimated) +{ + // Access posterior summaries + var results = analysis.BayesianAnalysis.Results; + // ... use results +} +``` + +### With Progress Reporting + +For GUI applications, use `SafeProgressReporter`: + +```cs +using RMC.BestFit.Support; + +var progressReporter = new SafeProgressReporter(); + +// Subscribe to progress updates +progressReporter.ProgressChanged += (sender, progress) => +{ + Console.WriteLine($"Progress: {progress:P0}"); +}; + +await analysis.RunAsync(progressReporter); +``` + +### With Cancellation + +```cs +// Start analysis in background +var analysisTask = analysis.RunAsync(); + +// Later, if user requests cancellation: +analysis.CancelAnalysis(); + +// Wait for task to complete (it will be cancelled) +try +{ + await analysisTask; +} +catch (OperationCanceledException) +{ + Console.WriteLine("Analysis was cancelled."); +} +``` + +### With Event Handlers + +```cs +// Before analysis starts (can cancel) +analysis.AnalysisStarting += (sender, args) => +{ + Console.WriteLine("Analysis starting..."); + // args.Cancel = true; // Set to cancel before starting +}; + +// After analysis completes +analysis.AnalysisCompleted += (sender, args) => +{ + if (args.Succeeded) + { + Console.WriteLine("Analysis completed successfully."); + } + else if (args.Cancelled) + { + Console.WriteLine("Analysis was cancelled."); + } + else + { + Console.WriteLine($"Analysis failed: {args.Error?.Message}"); + } +}; + +await analysis.RunAsync(); +``` + +--- + +## Computing Return Levels + +Most distribution analyses support computing return levels with uncertainty: + +```cs +// After running analysis +if (analysis.IsEstimated) +{ + // Get MAP (Maximum A Posteriori) return level + var mapParams = analysis.BayesianAnalysis.Results.MAP.Values; + model.SetParameterValues(mapParams); + double rl100_map = model.Distribution.InverseCDF(1 - 1.0/100); + + Console.WriteLine($"100-year return level (MAP): {rl100_map:F0}"); + + // Get posterior distribution of return levels + var posteriorSamples = analysis.BayesianAnalysis.Results.Output; + var rl100_samples = new double[posteriorSamples.Count]; + + for (int i = 0; i < posteriorSamples.Count; i++) + { + model.SetParameterValues(posteriorSamples[i].Values); + rl100_samples[i] = model.Distribution.InverseCDF(1 - 1.0/100); + } + + // Compute posterior summary + Array.Sort(rl100_samples); + double rl100_median = rl100_samples[rl100_samples.Length / 2]; + double rl100_lower = rl100_samples[(int)(0.025 * rl100_samples.Length)]; + double rl100_upper = rl100_samples[(int)(0.975 * rl100_samples.Length)]; + + Console.WriteLine($"100-year return level (median): {rl100_median:F0}"); + Console.WriteLine($"95% CI: [{rl100_lower:F0}, {rl100_upper:F0}]"); +} +``` + +--- + +## XML Serialization + +Analyses can be saved and restored: + +```cs +// Save analysis configuration and results +var xElement = analysis.ToXElement(); +xElement.Save("analysis_results.xml"); + +// Restore (requires the original model/data) +var loadedXml = XElement.Load("analysis_results.xml"); +var restoredAnalysis = new UnivariateAnalysis(model, loadedXml); +``` + +--- + +## Creating Custom Analyses + +When the pre-built analyses don't meet your needs, you can create custom analyses by inheriting from `AnalysisBase`: + +```cs +using RMC.BestFit.Analyses; +using RMC.BestFit.Estimation; + +public class MyCustomAnalysis : AnalysisBase, IBayesianAnalysis +{ + private readonly MyCustomModel _model; + private BayesianAnalysis _bayesianAnalysis; + + public MyCustomAnalysis(MyCustomModel model) + { + _model = model ?? throw new ArgumentNullException(nameof(model)); + _bayesianAnalysis = new BayesianAnalysis(model); + } + + public BayesianAnalysis BayesianAnalysis => _bayesianAnalysis; + + public override (bool IsValid, List ValidationMessages) Validate() + { + var messages = new List(); + bool isValid = true; + + // Add custom validation logic + if (_model.SomeProperty < 0) + { + messages.Add("SomeProperty must be non-negative."); + isValid = false; + } + + return (isValid, messages); + } + + public override async Task RunAsync(SafeProgressReporter? progressReporter = null) + { + var validation = Validate(); + if (!validation.IsValid) + throw new InvalidOperationException("Analysis is not valid."); + + var previewArgs = new CancelEventArgs(); + OnAnalysisStarting(previewArgs); + if (previewArgs.Cancel) + { + OnAnalysisCompleted(new AnalysisRunCompletedEventArgs(true, false, null)); + return; + } + + try + { + // Run Bayesian analysis + await _bayesianAnalysis.RunAsync(progressReporter); + + IsEstimated = true; + OnAnalysisCompleted(new AnalysisRunCompletedEventArgs(false, true, null)); + } + catch (Exception ex) + { + OnAnalysisCompleted(new AnalysisRunCompletedEventArgs(false, false, ex)); + throw; + } + } +} +``` + +--- + +## Analysis Class Reference + +### UnivariateAnalysis Properties + +| Property | Type | Description | +|----------|------|-------------| +| `UnivariateDistribution` | `UnivariateDistribution` | The distribution model | +| `BayesianAnalysis` | `BayesianAnalysis` | MCMC configuration and results | +| `ProbabilityOrdinates` | `ProbabilityOrdinates` | Probability levels for output | +| `IsEstimated` | `bool` | Whether analysis has completed | + +### RatingCurveAnalysis Properties + +| Property | Type | Description | +|----------|------|-------------| +| `RatingCurve` | `RatingCurve` | The rating curve model | +| `BayesianAnalysis` | `BayesianAnalysis` | MCMC configuration and results | +| `NumberOfSegments` | `int` | 1, 2, or 3 segment model | + +### TimeSeriesAnalysis Properties (AR, MA, ARIMA, ARIMAX) + +| Property | Type | Description | +|----------|------|-------------| +| `Model` | varies | The time series model | +| `BayesianAnalysis` | `BayesianAnalysis` | MCMC configuration and results | +| `ForecastHorizon` | `int` | Number of periods to forecast | + +--- + +## Best Practices + +### Validation + +Always validate before running: + +```cs +var validation = analysis.Validate(); +if (!validation.IsValid) +{ + foreach (var msg in validation.ValidationMessages) + { + Console.WriteLine($"Error: {msg}"); + } + return; +} +``` + +### MCMC Settings + +For routine analyses: +- `Iterations`: 10,000 +- `WarmupIterations`: 5,000 + +For complex models (mixture, spatial): +- `Iterations`: 50,000 +- `WarmupIterations`: 25,000 + +### Checking Convergence + +After running, always check R-hat and ESS: + +```cs +var results = analysis.BayesianAnalysis.Results; +bool converged = true; + +for (int i = 0; i < model.Parameters.Count; i++) +{ + var stats = results.ParameterResults[i].SummaryStatistics; + if (stats.Rhat > 1.1 || stats.ESS < 400) + { + Console.WriteLine($"Warning: {model.Parameters[i].Name} may not have converged."); + converged = false; + } +} + +if (!converged) +{ + Console.WriteLine("Consider increasing iterations."); +} +``` + +--- + +## Implementation Sources + +Primary source paths: `src/RMC.BestFit/Analyses/Support`, `src/RMC.BestFit/Analyses/Univariate`, `src/RMC.BestFit/Analyses/Bivariate`, `src/RMC.BestFit/Analyses/DistributionFitting`, `src/RMC.BestFit/Analyses/RatingCurve`, `src/RMC.BestFit/Analyses/TimeSeries`, and `src/RMC.BestFit/Analyses/SpatialExtremes`. + +--- + +## References + +[1] +Gelman, A., Carlin, J.B., Stern, H.S., Dunson, D.B., Vehtari, A., and Rubin, D.B. (2013). *Bayesian Data Analysis*, Third Edition. CRC Press. + +[2] +ter Braak, C.J.F. and Vrugt, J.A. (2008). "Differential Evolution Markov Chain with snooker updater and fewer chains." *Statistics and Computing*, 18(4), 435-446. + +--- + +[<- Previous: Diagnostics](../estimation/diagnostics.md) | [Back to Index](../../index.md) | [Next: Distribution Fitting ->](distribution-fitting.md) diff --git a/docs/technical-reference/analysis/rating-curve.md b/docs/technical-reference/analysis/rating-curve.md new file mode 100644 index 0000000..1e7bc65 --- /dev/null +++ b/docs/technical-reference/analysis/rating-curve.md @@ -0,0 +1,869 @@ +# Rating Curve Analysis + +[<- Previous: Coincident Frequency](coincident-frequency.md) | [Back to Index](../../index.md) | [Next: Time Series ->](time-series.md) + +A rating curve describes the relationship between river stage (water level) and discharge (flow rate) at a gauging station. This relationship is fundamental to hydrology — while continuous water level measurements are relatively easy to obtain using pressure transducers or float gauges, direct discharge measurements are expensive, time-consuming, and can only be made periodically. The rating curve bridges this gap, allowing hydrologists to convert years of continuous stage records into flow records for flood frequency analysis, water resources planning, and real-time flood forecasting. + +This chapter introduces the theory behind stage-discharge relationships, describes the mathematical models implemented in ***RMC-BestFit***, and demonstrates how to fit rating curves using both Maximum Likelihood and Bayesian methods with full uncertainty quantification. + +## Why Rating Curves Matter + +Consider a typical USGS stream gauging station. A technician might visit the site 10-20 times per year to make direct discharge measurements using an acoustic Doppler current profiler (ADCP) or traditional current meter. Meanwhile, a pressure transducer records water level every 15 minutes — that's 35,000 stage readings per year, but only 10-20 paired with measured discharge. + +The rating curve transforms those sparse discharge measurements into a continuous flow record. The quality of flood frequency analysis, water supply forecasting, and dam safety assessments depends critically on the accuracy of this transformation — and on honest uncertainty quantification about what we don't know. + +## Physical Background + +### The Hydraulics of Open-Channel Flow + +At a stable river cross-section, the relationship between water depth and flow rate is governed by fundamental hydraulic principles. For uniform, steady flow in an open channel, the Manning equation describes the relationship: + +```math +Q = \frac{1}{n} \cdot A \cdot R^{2/3} \cdot S^{1/2} +``` + +where: +- $Q$ is discharge (volume per unit time, e.g., cubic feet per second or cubic meters per second) +- $n$ is Manning's roughness coefficient (dimensionless, typically 0.02-0.15 for natural channels) +- $A$ is the cross-sectional area of flow (perpendicular to flow direction) +- $R$ is the hydraulic radius (area divided by wetted perimeter) +- $S$ is the energy gradient (approximately equal to bed slope for uniform flow) + +For a channel with a fixed cross-sectional shape, both the flow area $A$ and hydraulic radius $R$ are functions of the water depth $d$. If we measure water level relative to some datum — the stage $h$ — then we can write: + +```math +A = f_1(h - \xi) +``` +```math +R = f_2(h - \xi) +``` + +where $\xi$ is the stage at which discharge equals zero (the "zero-flow stage" or "point of zero flow"). The functions $f_1$ and $f_2$ depend on channel geometry. + +For many natural channels, these geometric relationships can be approximated by power functions, leading to the classic power-law rating curve: + +```math +Q = \alpha \cdot (h - \xi)^{\beta} +``` + +This deceptively simple equation captures the essential physics: discharge increases as a power of the effective depth $(h - \xi)$, with the coefficient $\alpha$ encoding channel properties (roughness, slope, width) and the exponent $\beta$ reflecting how the flow area and hydraulic radius change with depth. + +### Understanding the Parameters + +Each parameter in the rating curve equation has physical meaning that helps guide model fitting and interpretation: + +**Main-channel Zero-Flow Stage ($h_1$)**: The stage reading at which the main-channel power law evaluates to zero — essentially the elevation of the lowest point of the main hydraulic control relative to the gauge datum. ISO 1100-2:2010 §5.2.5 refers to this as the **effective gauge height of zero flow**. For a gauge with its zero set at the low-flow control (riffle or weir) thalweg, $h_1 \approx 0$; for gauges referenced to an arbitrary datum (common with pressure transducers) $h_1$ might be several feet. + +**Activation Stages ($h_2, h_3$)**: Under BaRatin addition mode, each additional control (overbank floodplain, secondary terrace, etc.) activates at a threshold stage $h_k$ and contributes $\alpha_k (h - h_k)^{\beta_k}$ on top of the already-active controls. The activation stage plays the dual role of *threshold* and *offset*. The same naming convention is used for all controls ($h_1, h_2, h_3$) — the main-channel zero-flow stage is simply the "b" offset of control 1. + +**Coefficient ($\alpha$)**: This parameter combines the effects of channel roughness, bed slope, and cross-sectional geometry. Larger values indicate a more hydraulically efficient channel — smooth banks, steep gradient, or wide cross-section. The units of $\alpha$ depend on the units of $h$ and $Q$, making physical interpretation of its magnitude difficult without knowing the unit system. + +**Exponent ($\beta$)**: The exponent reflects how rapidly the conveyance capacity increases with stage. For a wide rectangular channel where width dominates, $\beta \approx 5/3 \approx 1.67$. For a channel where increasing stage primarily increases depth (narrow, steep banks), $\beta$ can exceed 2.5. Typical values for natural channels: + +| Channel Type | Typical $\beta$ | Physical Interpretation | +|--------------|-----------------|------------------------| +| Wide, shallow floodplain | 1.3 - 1.5 | Small depth increase → large area increase | +| Typical natural channel | 1.5 - 2.0 | Balanced depth and width response | +| Deep, narrow channel | 2.0 - 2.5 | Depth dominates width | +| Steep mountain stream | 2.5 - 3.0 | Confined geometry with high gradient | + +### Why Channels Change Behavior + +Real rivers don't maintain the same geometry across all flow conditions. A typical river has distinct zones: + +1. **Low flow**: Water is confined to the main channel thalweg. The wetted perimeter is relatively large compared to flow area, creating high friction losses. + +2. **Bankfull flow**: The main channel is full but flow hasn't spilled onto the floodplain. This is often the most hydraulically efficient condition. + +3. **Overbank flow**: Water has spilled onto the floodplain. The flow area increases dramatically, but so does friction from vegetation and irregular terrain. The effective slope may decrease as water takes longer paths across the floodplain. + +These transitions create distinct "segments" in the rating curve, each with different parameters. The ***RMC-BestFit*** library supports up to three segments to capture these physical changes. + +## Mathematical Formulation + +### Single-Segment Model + +The basic rating curve model relates stage $h$ to discharge $Q$ through a power law: + +```math +Q = \alpha \cdot (h - \xi)^{\beta} +``` + +However, discharge measurements are never perfect. Measurement uncertainty, turbulence, unsteady flow, and channel changes all introduce variability. Following standard practice in hydrology, we model this uncertainty in logarithmic space, which is equivalent to assuming multiplicative errors: + +```math +\log_{10}(Q_i) = \log_{10}(\alpha) + \beta \cdot \log_{10}(h_i - \xi) + \varepsilon_i +``` + +where $\varepsilon_i \sim N(0, \sigma^2)$ represents measurement error in log-space. + +The log-space error model has important advantages: +- It ensures predicted discharge remains positive +- It accounts for heteroscedasticity — larger flows have larger absolute uncertainty, but similar relative uncertainty +- It produces residuals that are typically more normally distributed than linear-space residuals + +The single-segment model has **four parameters**: $[h_1, \log_{10}(\alpha_1), \beta_1, \sigma]$. + +Note that we parameterize the coefficient as $\log_{10}(\alpha_1)$ rather than $\alpha_1$ directly. This improves numerical stability during optimization (since $\alpha$ varies over many orders of magnitude) and produces more symmetric posterior distributions. + +### Multi-Segment Model — BaRatin Addition Mode + +For compound channels, the main channel continues to carry flow above bankfull while additional hydraulic controls (overbank floodplain, secondary terraces, etc.) activate and contribute on top of the main-channel flow. ***RMC-BestFit*** models this with the BaRatin matrix-of-controls framework (Le Coz et al. 2014 [[5]](#5)) in **addition mode**: each additional control activates at its own threshold stage $h_k$ and adds its own power-law contribution. + +The authoritative BaRatin master equation, as implemented in the [reference Fortran engine](https://github.com/BaRatin-tools/BaRatin/blob/main/src/RatingCurve_tools.f90), is: + +```math +Q(h) = \sum_{r=1}^{N_{\text{segment}}} \mathbb{1}_{[\kappa_r;\,\kappa_{r+1}]}(h) \,\cdot\, \sum_{j=1}^{N_{\text{control}}} M(r,j)\, a_j\, (h - b_j)^{c_j} +``` + +where $M(r, j)$ is a lower-triangular binary matrix specifying which controls are active in each stage segment, and $b_j$ is derived from BaRatin's continuity condition. Different choices of $M$ encode different physical configurations: + +- **Addition mode** (each row has the 1s of the previous row plus a new 1 — e.g. 2-segment $M = \begin{pmatrix}1&0\\1&1\end{pmatrix}$): controls *accumulate*. A new control activates on top of already-active ones. Continuity collapses to $b_j = \kappa_j$. Appropriate for main-channel + overbank-floodplain (the flood-frequency use case). +- **Succession mode** (each row has exactly one 1 — e.g. 2-segment $M = I_2$): one control *replaces* another. Continuity is enforced by a root-find on $b_j$. Appropriate for drowning weirs / stage-fall-discharge sites. + +***RMC-BestFit*** defaults to the addition-mode matrix $M = \begin{pmatrix}1&0&0\\1&1&0\\1&1&1\end{pmatrix}$ (for up to three controls). Succession-mode and mixed matrices are deferred to a future release. + +> **Physics of compound channels.** For a main channel + wide-rectangular overbank floodplain, Manning's equation gives $Q_{main}(h) = \alpha_1 (h - h_1)^{\beta_1}$ as a continuing contribution above bankfull, and $Q_{ob}(h) = (1.49/n_{ob})\,W_{ob}\,S^{1/2}\,(h - h_{bf})^{5/3}$ as a separate Manning-derived power law in overbank depth. Total discharge is their sum. Wickert et al. (2024/2025) [[11]](#11) describe this "double-Manning" approach explicitly for rating-curve development. + +#### Two-Segment Addition-Mode Model + +```math +Q(h) = \alpha_1(h - h_1)^{\beta_1} + \alpha_2(h - h_2)^{\beta_2}\,\mathbb{1}\{h > h_2\} +``` + +Below $h_2$ only the main-channel power law is active. At $h = h_2$ the overbank term is zero, so the rating curve is continuous by construction. Above $h_2$, both controls contribute. + +The two-segment model has **seven free parameters**: $[h_1, \log_{10}(\alpha_1), \beta_1, h_2, \log_{10}(\alpha_2), \beta_2, \sigma]$. Note that $h_2$ serves a dual role: it is both the *activation stage* of the second control and the *offset* $b_2$ of its power-law term (an automatic consequence of the BaRatin addition-mode continuity derivation). + +#### Three-Segment Addition-Mode Model + +```math +Q(h) = \alpha_1(h - h_1)^{\beta_1} + \alpha_2(h - h_2)^{\beta_2}\,\mathbb{1}\{h > h_2\} + \alpha_3(h - h_3)^{\beta_3}\,\mathbb{1}\{h > h_3\} +``` + +Three hydraulic controls successively activate as stage rises (e.g., main channel, then inner floodplain, then outer floodplain). Each contribution is zero at its own activation stage, so the rating curve is C⁰ continuous at both $h_2$ and $h_3$. + +The three-segment model has **ten free parameters**: $[h_1, \log_{10}(\alpha_1), \beta_1, h_2, \log_{10}(\alpha_2), \beta_2, h_3, \log_{10}(\alpha_3), \beta_3, \sigma]$. + +With ten parameters, the three-segment model requires substantial data — ideally 100+ observations distributed across all three stage regimes — and careful attention to convergence diagnostics. + +#### Why Addition Mode Is Well-Identified Under Flat Priors + +A key virtue of the addition-mode form is that **parameters are statistically separable** even under flat uniform priors: + +1. **Below $h_2$** only control 1 is active, so the data in that stage range pin $(h_1, \alpha_1, \beta_1)$ via a standard single-segment log-linear regression. +2. **Above $h_2$** the residual discharge $Q_{\text{obs}}(h) - \alpha_1(h - h_1)^{\beta_1}$ (with the first-control parameters already determined) is itself a simple power law in $(h - h_2)$, which pins $(\alpha_2, \beta_2, h_2)$ without interaction with the first-control parameters. + +This decoupling avoids the identifiability ridge that arises when a single replacement power law is forced to represent the physical sum of two simultaneous controls. In that case — the succession-mode form applied to compound-channel data — no set of $(\alpha, \xi, \beta)$ can exactly match the true $\alpha_1(h-h_1)^{\beta_1} + \alpha_2(h-h_2)^{\beta_2}$, and the likelihood surface becomes a multi-parameter ridge of approximately-equal-error fits. + +#### Hand-Computable Coefficients from Manning's Equation + +For a well-documented cross-section, the overbank coefficient $\alpha_2$ is **hand-computable** from geometry and Manning's roughness. Example: a 20-ft-wide rectangular overbank at $n_{ob} = 0.035$, $S = 0.05$: + +```math +\alpha_{ob} = \frac{1.49}{n_{ob}}\,W_{ob}\,S^{1/2} = \frac{1.49}{0.035}\cdot 20 \cdot \sqrt{0.05} \approx 190 +``` +```math +\beta_{ob} = \tfrac{5}{3} \approx 1.667 +``` + +Fitted $\alpha_2, \beta_2$ that drift substantially from these physical values on synthetic data are a direct signal of model misspecification — not estimator failure. + +### Standards and Reference Implementations + +- **BaRatin reference Fortran engine** ([github.com/BaRatin-tools/BaRatin](https://github.com/BaRatin-tools/BaRatin)). The master-equation implementation and continuity derivation used by ***RMC-BestFit*** match `ApplyRC_General` (lines 464–478), `RC_General_Continuity` (lines 308–326), and `RC_General_CheckControlMatrix` (lines 191–259) of [`src/RatingCurve_tools.f90`](https://github.com/BaRatin-tools/BaRatin/blob/main/src/RatingCurve_tools.f90). The addition-mode default is the lower-triangular-all-ones matrix valid under those rules. +- **Le Coz et al. (2014) — BaRatin** [[5]](#5) publishes the matrix-of-controls framework and documents both succession and addition configurations. ***RMC-BestFit*** currently exposes only the addition-mode default; a user-facing matrix editor is a planned enhancement. +- **Wickert et al. (2024/2025)** [[11]](#11) ("A double-Manning approach to compute robust rating curves and hydraulic geometries") explicitly adds floodplain flow to main-channel flow using two Manning-derived power laws. +- **ISO 1100-2:2010 §5.2.5** [[3]](#3) instructs that multi-control rating curves be continuous across control transitions and that each control be fit against its own effective gauge height of zero flow. The addition-mode form satisfies both requirements: each control has its own offset ($h_1$ for control 1, $h_k$ for $k \geq 2$), and the curve is $C^0$ at every transition by construction. +- **USGS Kennedy (1984)** [[4]](#4); **Rantz et al. (1982)** [[2]](#2): foundational USGS references for stage-discharge rating methodology. +- **Reitan & Petersen-Øverleir (2009)** [[6]](#6) and **Kiang et al. (2018)** [[9]](#9) present piecewise-succession Bayesian formulations and comparisons; the succession mode is appropriate for sites where a low-flow control is drowned out by a rising channel control, not for overbank transitions. + +### The Likelihood Function + +Given $n$ paired observations $(h_i, Q_i)$ and parameters $\theta$ (see §Single-Segment / Two-Segment / Three-Segment Model above for the free-parameter layout), the log-likelihood is: + +```math +\ell(\theta) = -\frac{n}{2}\log(2\pi) - n \cdot \log(\sigma) - \frac{1}{2\sigma^2} \sum_{i=1}^{n} r_i^2 +``` + +where the residuals are computed in log-space: + +```math +r_i = \log_{10}(Q_i) - \log_{10}[\hat{Q}(h_i; \theta)] +``` + +and $\hat{Q}(h_i; \theta)$ is the predicted discharge. Under the addition-mode model, $\hat{Q}(h; \theta) = \alpha_1(h - h_1)^{\beta_1} + \sum_{k \geq 2} \alpha_k(h - h_k)^{\beta_k}\,\mathbb{1}\{h > h_k\}$. + +For the model to be valid we require $h_i > h_1$ (every observed stage above the main-channel zero-flow stage) so that the first term is well-defined, and $h_1 < h_2 < h_3$ so that each successive control activates strictly above its predecessor. Activation-guarded indicator functions ensure each subsequent term contributes zero when $h \leq h_k$; ordering violations cause the likelihood to return $-\infty$: + +```math +\ell(\theta) = -\infty \quad \text{if any}\ (h_i - h_1) \leq 0\ \text{or}\ h_1 \geq h_2 \ \text{or}\ h_2 \geq h_3 +``` + +These constraints are enforced automatically during optimization and MCMC sampling. + +### Interpreting the Error Parameter $\sigma$ + +The parameter $\sigma$ represents the standard deviation of residuals in log₁₀-space. To convert this to approximate percentage error in discharge: + +```math +\text{CV} \approx \ln(10) \cdot \sigma \approx 2.303 \cdot \sigma +``` + +| Log-space $\sigma$ | Approximate CV | Interpretation | +|-------------------|----------------|----------------| +| 0.02 | 5% | Excellent measurements, stable channel | +| 0.05 | 12% | Good field conditions | +| 0.10 | 23% | Moderate uncertainty | +| 0.15 | 35% | High uncertainty, variable channel | +| 0.20 | 46% | Very high uncertainty | + +Values of $\sigma > 0.15$ often indicate either measurement problems or non-stationarity (the channel is changing over time). + +## Stage / Discharge Alignment + +The two input time series supplied to the rating curve — stage $h_i$ and discharge $Q_i$ — are **not required to match index-for-index or to have the same length**. Real-world gauge records (USGS, Water Data for the Nation) routinely return stage and discharge series whose date ranges overlap only partially. ***RMC-BestFit*** inner-joins the two series by `DateTime` index and fits the rating curve to the **common-date pairs only**: + +```math +\mathcal{D} = \bigl\{ (h_i, Q_i) \;\big|\; \text{date}_i \in \text{Stage.Dates} \cap \text{Discharge.Dates} \bigr\} +``` + +Observations whose date appears in only one series are silently dropped from the fit and from every derived diagnostic (residuals, fitted values, RMSE, AIC / BIC / DIC, posterior-predictive plots). + +Two soft safeguards protect the user from unintentionally fitting a tiny or ill-matched subset: + +- **Hard error** (`RCA-ERR-010`): calibration requires at least **10 common dates**. Below this threshold the analysis is marked invalid and the fit refuses to run. +- **Soft warning** (`RCA-WRN-011`): when the two series differ in length by more than **10 %**, a warning is raised advising the user to verify the selected series are correct. The fit still proceeds on the inner-join — this is purely a sanity check for cases where, for example, an entire discharge record is accidentally paired with an unrelated long stage record. + +## Parameter Estimation + +### Maximum Likelihood Estimation + +Maximum Likelihood Estimation (MLE) finds the parameter values that maximize the log-likelihood — equivalently, the parameters under which the observed data are most probable: + +```math +\hat{\theta}_{\text{MLE}} = \arg\max_{\theta} \ell(\theta) +``` + +For rating curves, the likelihood surface can have multiple local optima and ridges, especially for multi-segment models. The `MultilevelSingleLinkage` global optimizer is recommended: + +```cs +using RMC.BestFit.Models; +using RMC.BestFit.Estimation; +using Numerics.Mathematics.Optimization; + +// Create the rating curve model +var model = new RatingCurve(stageTimeSeries, dischargeTimeSeries, numberOfSegments: 1); + +// Fit using global optimization +var mle = new MaximumLikelihood(model, OptimizationMethod.MultilevelSingleLinkage); +mle.Estimate(); + +// Check convergence and extract parameters +if (mle.IsEstimated) +{ + model.SetParameterValues(mle.BestParameterSet.Values); + + Console.WriteLine($"Zero-flow stage (ξ): {model.Parameters[0].Value:F3}"); + Console.WriteLine($"Log₁₀(α): {model.Parameters[1].Value:F3}"); + Console.WriteLine($"Exponent (β): {model.Parameters[2].Value:F3}"); + Console.WriteLine($"Log-space error (σ): {model.Parameters[3].Value:F4}"); + Console.WriteLine($"Log-likelihood: {mle.BestParameterSet.Fitness:F2}"); +} +``` + +**Advantages of MLE:** +- Fast computation (typically seconds) +- Produces point estimates suitable for operational use +- Good starting point for MCMC + +**Limitations of MLE:** +- No uncertainty quantification on parameters +- Can find local optima instead of global optimum +- Standard errors (when available) assume normality that may not hold + +### Bayesian MCMC Estimation + +Bayesian estimation provides full uncertainty quantification by characterizing the posterior distribution of parameters given the data: + +```math +\pi(\theta | \text{data}) \propto \mathcal{L}(\text{data} | \theta) \cdot \pi(\theta) +``` + +The ***RMC-BestFit*** library uses the **DEMCzs** sampler (Differential Evolution MCMC with snooker update) [[1]](#1), which is self-tuning and robust to multimodal posteriors. + +```cs +using RMC.BestFit.Models; +using RMC.BestFit.Analyses; + +// Create the rating curve model +var model = new RatingCurve(stageTimeSeries, dischargeTimeSeries, numberOfSegments: 1); + +// Configure and run Bayesian analysis +var analysis = new RatingCurveAnalysis(model); +analysis.BayesianAnalysis.Iterations = 10000; +analysis.BayesianAnalysis.WarmupIterations = 5000; + +await analysis.RunAsync(); + +// Access posterior summaries +var results = analysis.BayesianAnalysis.Results; +Console.WriteLine("Parameter posterior summaries:"); +Console.WriteLine($"{"Parameter",-25} {"Mean",10} {"Std Dev",10} {"95% CI Lower",12} {"95% CI Upper",12} {"R-hat",8}"); +Console.WriteLine(new string('-', 80)); + +for (int i = 0; i < model.Parameters.Count; i++) +{ + var stats = results.ParameterResults[i].SummaryStatistics; + Console.WriteLine($"{model.Parameters[i].Name,-25} {stats.Mean,10:F4} {stats.StandardDeviation,10:F4} " + + $"{stats.LowerCI,12:F4} {stats.UpperCI,12:F4} {stats.Rhat,8:F3}"); +} +``` + +**Advantages of Bayesian MCMC:** +- Full posterior distribution, not just point estimates +- Proper uncertainty propagation to derived quantities (e.g., the 100-year discharge) +- Incorporates prior information when available +- Provides convergence diagnostics (R-hat, ESS) + +**Considerations:** +- Slower than MLE (minutes instead of seconds) +- Requires checking convergence diagnostics +- Prior specification can influence results (though default flat priors are usually appropriate) + +### Prior Distributions + +The ***RMC-BestFit*** library uses weakly informative default priors that place minimal constraints while ensuring computational stability: + +| Parameter | Default Prior | Rationale | +|-----------|--------------|-----------| +| $h_1$ (main-channel zero-flow stage) | $\text{Uniform}(\min(h) - \text{range}(h),\ \min(h) + 0.1\cdot\text{range}(h))$ | Must be below minimum observed stage | +| $\log_{10}(\alpha_k)$ (per-control coefficients, all $k$) | $\text{Uniform}(-10, 10)$ | Broad enough for very large rivers while remaining finite | +| $\beta_k$ (per-control exponents) | $\text{Uniform}(0, 5.0)$ | Allows near-flat log-space responses and typical channel exponents | +| $h_2$ (second-control activation stage) | $\text{Uniform}(\min(h)+0.2\cdot\text{range}(h),\ \min(h)+0.7\cdot\text{range}(h))$ | Keeps the activation stage inside the data range, well away from the extremes | +| $h_3$ (third-control activation stage) | $\text{Uniform}(\min(h)+0.5\cdot\text{range}(h),\ \min(h)+\text{range}(h))$ | Sits above $h_2$ and within the data range | +| $\sigma$ (error scale) | $\text{Uniform}(\epsilon,\ 3\cdot\text{std}(\log_{10}Q))$ + optional Jeffreys | Small positive to reasonable upper bound | + +Default prior calibration uses date-aligned stage-discharge pairs when possible, because unpaired observations do not enter the likelihood. Non-finite stages and non-positive or non-finite discharges are ignored. If the observed stage range or log-discharge standard deviation collapses, ***RMC-BestFit*** applies finite positive fallback bounds so the default uniform priors remain valid during model setup. + +Under BaRatin addition mode $h_k$ serves a dual role — it is both the *activation stage* and the *offset* of control $k$'s power-law term — because the continuity derivation collapses to $b_k = \kappa_k$ when a control persists across all upper segments. No separate $\xi_2, \xi_3$ priors are needed. + +When `UseJeffreysRuleForScale = true` (the default), the error scale receives Jeffreys' prior: + +```math +\pi(\sigma) \propto \frac{1}{\sigma} +``` + +This non-informative prior is appropriate for scale parameters and produces posteriors that are invariant to rescaling of the data. + +## Practical Examples + +### Example 1: Simple Mountain Stream + +A mountain stream has a relatively uniform channel geometry — steep, rocky bed with little overbank area. A single-segment model is appropriate. + +```cs +using RMC.BestFit.Models; +using RMC.BestFit.Analyses; +using Numerics.Data; + +// Load stage and discharge time series (typically from CSV or database) +var stageTS = new TimeSeries(TimeInterval.Irregular, stageData); +var dischargeTS = new TimeSeries(TimeInterval.Irregular, dischargeData); + +// Create single-segment rating curve +var model = new RatingCurve(stageTS, dischargeTS, numberOfSegments: 1); + +// Run Bayesian analysis +var analysis = new RatingCurveAnalysis(model); +analysis.BayesianAnalysis.Iterations = 10000; +analysis.BayesianAnalysis.WarmupIterations = 5000; +await analysis.RunAsync(); + +// Generate rating table with uncertainty bands +var mapParams = analysis.BayesianAnalysis.Results.MAP.Values; +var table = model.GenerateRatingTable(mapParams, minStage: 0.5, maxStage: 8.0, numPoints: 50); + +Console.WriteLine("Stage (ft) Discharge (cfs)"); +Console.WriteLine("---------- ---------------"); +for (int i = 0; i < table.GetLength(0); i++) +{ + Console.WriteLine($"{table[i, 0],10:F2} {table[i, 1],15:F1}"); +} +``` + +For a steep mountain stream, expect $\beta \approx 2.0-2.8$. + +### Example 2: River with Floodplain (Bankfull Transition) + +A lowland river has a distinct main channel and floodplain. Below bankfull stage, flow is confined to the main channel. Above bankfull, the main channel continues carrying flow *and* the floodplain activates — both controls contribute. Under BaRatin addition mode the two contributions add. + +```cs +// Create two-segment rating curve for a compound channel +// Q(h) = α₁(h − h₁)^β₁ + α₂(h − h₂)^β₂·𝟙{h > h₂} +var model = new RatingCurve(stageTS, dischargeTS, numberOfSegments: 2); + +var analysis = new RatingCurveAnalysis(model); +analysis.BayesianAnalysis.Iterations = 15000; // More iterations for 7 parameters +analysis.BayesianAnalysis.WarmupIterations = 7500; +await analysis.RunAsync(); + +// The activation stage h₂ corresponds to bankfull +var results = analysis.BayesianAnalysis.Results; +var h2Stats = results.ParameterResults[3].SummaryStatistics; // h₂ is parameter index 3 + +Console.WriteLine($"Estimated bankfull stage: {h2Stats.Mean:F2} ± {h2Stats.StandardDeviation:F2} ft"); +Console.WriteLine($"95% CI: [{h2Stats.LowerCI:F2}, {h2Stats.UpperCI:F2}] ft"); + +// Main-channel exponent β₁ vs. overbank exponent β₂ +// Parameter layout: [h₁, log₁₀α₁, β₁, h₂, log₁₀α₂, β₂, σ] (indices 0..6) +var beta1Stats = results.ParameterResults[2].SummaryStatistics; +var beta2Stats = results.ParameterResults[5].SummaryStatistics; + +Console.WriteLine($"Main-channel exponent (β₁): {beta1Stats.Mean:F2}"); +Console.WriteLine($"Overbank exponent (β₂): {beta2Stats.Mean:F2}"); +``` + +Typical physical values: +- $\beta_1 \approx 1.8-2.5$ (main channel — depth-dominated conveyance) +- $\beta_2 \approx 5/3 \approx 1.667$ (wide rectangular overbank — Manning exact) +- $\alpha_2$ is hand-computable from the floodplain width $W_{ob}$, roughness $n_{ob}$, and slope $S$ via $\alpha_2 = (1.49/n_{ob}) \cdot W_{ob} \cdot S^{1/2}$; MAP values far from the hand-computed floodplain coefficient indicate model-specification problems + +### Example 3: Coastal Plain River (Wide, Shallow) + +Coastal plain rivers are often very wide relative to their depth, with extensive low-gradient floodplains. The exponent is typically low because small increases in stage produce large increases in flow area. + +```cs +// Single-segment model for wide, shallow channel +var model = new RatingCurve(stageTS, dischargeTS, numberOfSegments: 1); + +// For very flat channels, the zero-flow stage may be poorly identified +// Consider providing an informative prior if gauge datum is known +model.Parameters[0].PriorDistribution = new Numerics.Distributions.Uniform(-0.5, 0.5); + +var analysis = new RatingCurveAnalysis(model); +await analysis.RunAsync(); + +var beta = analysis.BayesianAnalysis.Results.ParameterResults[2].SummaryStatistics.Mean; +Console.WriteLine($"Exponent: {beta:F2}"); // Expect 1.3-1.6 +``` + +### Example 4: Complex Cross-Section (Three Segments) + +Some sites have multiple hydraulic controls: a low-flow control (weir, riffle, or rock outcrop), main channel control, and floodplain control. This requires a three-segment model. + +```cs +// Three-segment model requires substantial data (recommend n > 100) +var model = new RatingCurve(stageTS, dischargeTS, numberOfSegments: 3); + +// Increase iterations for 10-parameter model +var analysis = new RatingCurveAnalysis(model); +analysis.BayesianAnalysis.Iterations = 25000; +analysis.BayesianAnalysis.WarmupIterations = 12500; +await analysis.RunAsync(); + +// Check convergence carefully +var results = analysis.BayesianAnalysis.Results; +bool allConverged = true; +for (int i = 0; i < model.Parameters.Count; i++) +{ + var rhat = results.ParameterResults[i].SummaryStatistics.Rhat; + var ess = results.ParameterResults[i].SummaryStatistics.ESS; + + if (rhat > 1.1 || ess < 100) + { + Console.WriteLine($"Warning: {model.Parameters[i].Name} may not have converged (R-hat={rhat:F3}, ESS={ess:F0})"); + allConverged = false; + } +} + +if (allConverged) + Console.WriteLine("All parameters converged successfully."); +``` + +**Caution:** Three-segment models require data distributed across all three stage regimes to identify all ten parameters. If discharge measurements are clustered below the overbank activation or in a narrow range, the third control's parameters will be weakly identified and posteriors will be wide regardless of the BaRatin addition-mode parameterization. Consider whether the physical situation truly requires three controls, or whether a two-segment model with wider uncertainty might be more appropriate for the available data. + +## Parameter Identifiability + +### The ξ-α-β Trade-off + +Within a single segment, the power-law rating curve parameters $\xi$, $\alpha$, and $\beta$ are not fully independent — the relation + +```math +Q = \alpha \cdot (h - \xi)^{\beta} +``` + +can be approximately reproduced over a limited stage range by different combinations of the three parameters, creating a ridge in the likelihood surface. Shifting $\xi$ upward and compensating with changes in $\alpha$ and $\beta$ gives nearly identical discharge predictions when the dynamic range of $(h - \xi)$ is narrow. + +This ridge is most pronounced when: + +1. **Data doesn't extend to low flows**: Without observations near the true zero-flow stage, the data cannot constrain $\xi$. +2. **Limited stage range**: A narrow range of observed stages provides little leverage to distinguish between parameter combinations. +3. **High measurement error**: Noise masks the subtle differences between competing parameter sets. + +> **Why the BaRatin addition mode helps.** The $(\xi, \beta)$ ridge is a classical weak-identification signature of power-law regression when both the location and the exponent must be fit jointly. Under the addition-mode formulation used by ***RMC-BestFit***, only the main-channel location $h_1$ is a free parameter — the activation stages $h_k$ for $k \geq 2$ serve as both activation thresholds and "offsets" of each control's power-law term, which decouples them from the main-channel regression. Below $h_2$ the data pin $(h_1, \alpha_1, \beta_1)$ via ordinary log-log regression; above $h_2$ the residual discharge is a clean power law in $(h - h_2)$ that pins $(\alpha_2, \beta_2)$ independently. The segment-1 location $h_1$ is still fit and can still suffer from weak identification when low-flow data are sparse or narrow — the guidance in this section primarily applies to $h_1$. + +### Diagnosing Identifiability Problems + +Signs of identifiability issues include: + +- **High posterior correlation**: Check correlation between $\xi$, $\alpha$, and $\beta$ in MCMC output +- **Wide credible intervals**: Especially on $\xi$ and $\beta$ +- **Slow MCMC mixing**: Low effective sample size despite many iterations +- **Sensitivity to priors**: Results change substantially with different prior specifications + +```cs +// Check parameter correlations after MCMC +var output = analysis.BayesianAnalysis.Results.Output; + +// Extract posterior samples for correlation analysis +var xiSamples = output.Select(p => p.Values[0]).ToArray(); +var logAlphaSamples = output.Select(p => p.Values[1]).ToArray(); +var betaSamples = output.Select(p => p.Values[2]).ToArray(); + +// Compute correlation (using Numerics.Data.Statistics) +double corr_xi_beta = Statistics.Correlation(xiSamples, betaSamples); +Console.WriteLine($"Correlation(ξ, β): {corr_xi_beta:F3}"); + +if (Math.Abs(corr_xi_beta) > 0.9) + Console.WriteLine("Warning: High correlation between ξ and β suggests identifiability issues."); +``` + +### Improving Identifiability + +Several strategies can improve parameter identifiability: + +**1. Include low-flow measurements**: The most effective solution is to have discharge measurements at stages close to the true zero-flow stage. + +```cs +// Data with good low-flow coverage +var minObservedStage = stageData.Min(); +var trueXi = 0.5; // Known or estimated zero-flow stage + +if (minObservedStage - trueXi > 0.5) + Console.WriteLine("Warning: Minimum observed stage is far from zero-flow stage. ξ may be poorly identified."); +``` + +**2. Use informative priors**: If the gauge datum is known from surveys, specify an informative prior on $\xi$. + +```cs +// If gauge zero is at the channel thalweg, ξ should be near 0 +model.Parameters[0].PriorDistribution = new Numerics.Distributions.Normal(0.0, 0.2); +``` + +**3. Fix $\xi$ if known**: If the zero-flow stage has been surveyed or is known from channel geometry, fix it rather than estimating. + +```cs +// Fix zero-flow stage at surveyed value +model.Parameters[0].Value = 0.3; +model.Parameters[0].IsFixed = true; +``` + +**4. Use physical constraints**: Ensure the prior on $\beta$ reflects physical reality (typically 1.0-3.5 for natural channels). + +## Working with Results + +### Generating Rating Tables + +A rating table provides stage-discharge pairs for operational use: + +```cs +var analysis = new RatingCurveAnalysis(model); +await analysis.RunAsync(); + +// Use MAP (Maximum A Posteriori) parameters for best point estimate +var mapParams = analysis.BayesianAnalysis.Results.MAP.Values; + +// Generate rating table +double[,] table = model.GenerateRatingTable( + parameters: mapParams, + minStage: 0.0, + maxStage: 15.0, + numPoints: 100); + +// Export to CSV +using (var writer = new StreamWriter("rating_table.csv")) +{ + writer.WriteLine("Stage,Discharge"); + for (int i = 0; i < table.GetLength(0); i++) + { + writer.WriteLine($"{table[i, 0]:F3},{table[i, 1]:F2}"); + } +} +``` + +### Uncertainty Bands on the Rating Curve + +To visualize uncertainty, generate predictions from multiple posterior samples: + +```cs +var posteriorSamples = analysis.BayesianAnalysis.Results.Output; +var stages = Enumerable.Range(0, 100).Select(i => 0.5 + i * 0.15).ToArray(); + +// Store predictions for each stage and posterior sample +var predictions = new double[stages.Length, posteriorSamples.Count]; + +for (int j = 0; j < posteriorSamples.Count; j++) +{ + var params = posteriorSamples[j].Values; + for (int i = 0; i < stages.Length; i++) + { + predictions[i, j] = model.Predict(params, stages[i]); + } +} + +// Compute percentiles at each stage +Console.WriteLine("Stage Q_median Q_2.5% Q_97.5%"); +for (int i = 0; i < stages.Length; i++) +{ + var row = Enumerable.Range(0, posteriorSamples.Count).Select(j => predictions[i, j]).OrderBy(x => x).ToArray(); + double median = row[row.Length / 2]; + double lower = row[(int)(row.Length * 0.025)]; + double upper = row[(int)(row.Length * 0.975)]; + + Console.WriteLine($"{stages[i]:F2} {median:F1} {lower:F1} {upper:F1}"); +} +``` + +### Checking Model Fit + +Examine residuals to assess model adequacy: + +```cs +var mapParams = analysis.BayesianAnalysis.Results.MAP.Values; +var residuals = model.Residuals(mapParams); + +// Basic statistics +double meanResid = residuals.Average(); +double stdResid = Math.Sqrt(residuals.Select(r => r * r).Average() - meanResid * meanResid); + +Console.WriteLine($"Mean residual: {meanResid:F4} (should be near 0)"); +Console.WriteLine($"Std dev of residuals: {stdResid:F4} (should be near σ = {mapParams.Last():F4})"); + +// Check for patterns +// - Residuals vs. fitted values: should show no trend +// - Residuals vs. time: should show no autocorrelation (if temporal order known) +// - Q-Q plot: should be approximately linear +``` + +### Synthetic Data Generation + +For simulation studies or testing, generate synthetic data from a specified rating curve: + +```cs +// Define true parameters +double[] trueParams = new double[] +{ + 0.5, // ξ: zero-flow stage + Math.Log10(10), // log₁₀(α): coefficient + 2.0, // β: exponent + 0.05 // σ: measurement error +}; + +// Create model and set parameters +var model = new RatingCurve(); +model.SetParameterValues(trueParams); + +// Generate synthetic data +var (stageTS, dischargeTS) = model.GenerateSyntheticData( + sampleSize: 200, + minStage: 1.0, + maxStage: 10.0, + seed: 12345); + +Console.WriteLine($"Generated {stageTS.Count} stage-discharge pairs"); +Console.WriteLine($"Stage range: {stageTS.Min(s => s.Value):F2} to {stageTS.Max(s => s.Value):F2}"); +Console.WriteLine($"Discharge range: {dischargeTS.Min(s => s.Value):F1} to {dischargeTS.Max(s => s.Value):F1}"); +``` + +## Model Selection + +### Choosing the Number of Segments + +The number of segments should be guided by physical knowledge of the channel: + +| Situation | Recommended Segments | +|-----------|---------------------| +| Uniform channel geometry across observed range | 1 | +| Clear bankfull transition (channel to floodplain) | 2 | +| Multiple distinct controls (weir + channel + floodplain) | 3 | +| Uncertain | Start with 1, add segments if residuals show patterns | + +### Comparing Models with Information Criteria + +When physical reasoning is ambiguous, compare models using information criteria: + +```cs +// Fit models with 1, 2, and 3 segments +var modelResults = new List<(int Segments, double DIC, double WAIC)>(); + +foreach (int nSegments in new[] { 1, 2, 3 }) +{ + var m = new RatingCurve(stageTS, dischargeTS, numberOfSegments: nSegments); + var a = new RatingCurveAnalysis(m); + a.BayesianAnalysis.Iterations = 15000; + a.BayesianAnalysis.WarmupIterations = 7500; + await a.RunAsync(); + + var dic = a.BayesianAnalysis.DIC; + var waic = a.BayesianAnalysis.WAIC; + + modelResults.Add((nSegments, dic, waic)); + Console.WriteLine($"{nSegments} segment(s): DIC = {dic:F1}, WAIC = {waic:F1}"); +} + +// Lower values indicate better fit (penalized for complexity) +var best = modelResults.OrderBy(r => r.DIC).First(); +Console.WriteLine($"\nBest model by DIC: {best.Segments} segment(s)"); +``` + +**Interpretation:** +- **DIC (Deviance Information Criterion)**: Balances fit and complexity. Differences > 10 are considered strong evidence. +- **WAIC (Widely Applicable Information Criterion)**: More robust than DIC, especially for small samples. + +## Assumptions and Limitations + +### Model Assumptions + +The ***RMC-BestFit*** rating curve model assumes: + +1. **Power-law relationship**: The stage-discharge relationship follows $Q = \alpha(h-\xi)^\beta$ (possibly segmented) + +2. **Log-normal errors**: Measurement uncertainty is multiplicative and approximately log-normal + +3. **Independence**: Observations are statistically independent (no unmodeled temporal correlation) + +4. **Stationarity**: The rating curve is stable over the observation period (no channel scour, vegetation growth, or datum shifts) + +5. **Steady flow**: Each observation represents steady, uniform flow conditions + +### Known Limitations + +1. **Maximum 3 segments**: More complex geometries require custom models + +2. **No loop rating**: Rising and falling limb hysteresis is not modeled + +3. **No unsteady flow correction**: Rapid stage changes violate steady-flow assumptions + +4. **No shift analysis**: Rating shifts from channel changes must be handled externally + +5. **Minimum 10 observations**: Fewer observations provide insufficient information for reliable estimation + +### When the Model May Be Inappropriate + +Consider alternative approaches when: + +- **Channel is highly unstable**: Sand-bed rivers with frequent morphological changes +- **Strong unsteady effects**: Tidal influence, backwater from downstream, or very rapid stage changes +- **Loop rating present**: Consistent hysteresis between rising and falling limbs +- **Ice effects**: Ice jams or frazil ice affect the stage-discharge relationship + +## Class Reference + +### RatingCurve Class + +**Namespace:** `RMC.BestFit.Models` + +**Constructors:** + +```cs +// Default constructor (single segment) +RatingCurve() + +// Constructor with data +RatingCurve(TimeSeries stageData, TimeSeries dischargeData, int numberOfSegments = 1) + +// Constructor from XML +RatingCurve(TimeSeries stageData, TimeSeries dischargeData, XElement xElement) +``` + +**Key Properties:** + +| Property | Type | Description | +|----------|------|-------------| +| `StageData` | `TimeSeries` | Stage (water level) observations | +| `DischargeData` | `TimeSeries` | Discharge (flow) observations | +| `NumberOfSegments` | `int` | Number of power law segments (1-3) | +| `UseJeffreysRuleForScale` | `bool` | Use Jeffreys prior on σ (default: true) | +| `UseDefaultFlatPriors` | `bool` | Use default flat priors (default: true) | +| `Parameters` | `List` | Model parameters with priors | +| `NumberOfParameters` | `int` | Total parameter count | + +**Key Methods:** + +| Method | Returns | Description | +|--------|---------|-------------| +| `Predict(double[] params, double stage)` | `double` | Predict discharge at given stage | +| `Residuals(double[] params)` | `double[]` | Log-space residuals | +| `FittedValues(double[] params)` | `double[]` | Predicted discharge values | +| `GenerateRatingTable(...)` | `double[,]` | Stage-discharge lookup table | +| `GenerateSyntheticData(...)` | `(TimeSeries, TimeSeries)` | Synthetic stage-discharge data | +| `GenerateRandomValues(int sampleSize, int seed = -1)` | `double[]` | Random discharge samples | +| `DataLogLikelihood(double[] params)` | `double` | Log-likelihood of data | +| `Validate()` | `(bool, List)` | Validation check | +| `Clone()` | `object` | Deep copy of model | + +### RatingCurveAnalysis Class + +**Namespace:** `RMC.BestFit.Analyses` + +**Constructor:** + +```cs +RatingCurveAnalysis(RatingCurve ratingCurve) +``` + +**Key Properties:** + +| Property | Type | Description | +|----------|------|-------------| +| `RatingCurve` | `RatingCurve` | The rating curve model | +| `BayesianAnalysis` | `BayesianAnalysis` | MCMC configuration and results | +| `IsEstimated` | `bool` | Whether estimation completed | + +**Key Methods:** + +| Method | Returns | Description | +|--------|---------|-------------| +| `RunAsync()` | `Task` | Run Bayesian MCMC estimation | +| `CancelAnalysis()` | `void` | Cancel running analysis | + +--- + +## Implementation Sources + +Primary source paths: `src/RMC.BestFit/Models/RatingCurve/RatingCurve.cs`, `src/RMC.BestFit/Analyses/RatingCurve/RatingCurveAnalysis.cs`, `src/RMC.BestFit/Estimation/BayesianAnalysis.cs`, and `src/RMC.BestFit/Estimation/MaximumLikelihood.cs`. + +--- + +## References + +[1] C. J. F. ter Braak and J. A. Vrugt, "Differential Evolution Markov Chain with snooker updater and fewer chains," *Statistics and Computing*, vol. 18, no. 4, pp. 435-446, 2008. + +[2] S. E. Rantz et al., *Measurement and computation of streamflow: Volume 2. Computation of discharge*, USGS Water-Supply Paper 2175, 1982. + +[3] ISO 1100-2:2010, *Hydrometry — Measurement of liquid flow in open channels — Part 2: Determination of the stage-discharge relation*, International Organization for Standardization, 2010. + +[4] E. J. Kennedy, *Discharge ratings at gaging stations*, USGS Techniques of Water-Resources Investigations, Book 3, Chapter A10, 1984. + +[5] J. Le Coz, B. Renard, L. Bonnifait, F. Branger, and R. Le Boursicaud, "Combining hydraulic knowledge and uncertain gaugings in the estimation of hydrometric rating curves: A Bayesian approach," *Journal of Hydrology*, vol. 509, pp. 573-587, 2014. + +[6] T. Reitan and A. Petersen-Øverleir, "Bayesian methods for estimating multi-segment discharge rating curves," *Stochastic Environmental Research and Risk Assessment*, vol. 23, no. 5, pp. 627-642, 2009. + +[7] World Meteorological Organization, *Manual on Stream Gauging*, WMO-No. 1044, 2010. + +[8] V. T. Chow, *Open-Channel Hydraulics*, McGraw-Hill, 1959. + +[9] J. E. Kiang, C. Gazoorian, H. McMillan, G. Coxon, J. Le Coz, I. K. Westerberg, A. Belleville, D. Sevrez, A. E. Sikorska, A. Petersen-Øverleir, T. Reitan, J. Freer, B. Renard, V. Mansanarez, and R. Mason, "A Comparison of Methods for Streamflow Uncertainty Estimation," *Water Resources Research*, vol. 54, no. 10, pp. 7149-7176, 2018. + +[10] V. Mansanarez, J. Le Coz, B. Renard, M. Lang, G. Pierrefeu, and P. Vauchel, "Rapid stage-discharge rating curve assessment using hydraulic modeling in an uncertainty framework," *Water Resources Research*, vol. 55, no. 4, 2019. + +[11] A. D. Wickert, G. H. C. Ng, and C. P. Bolduc, "A double-Manning approach to compute robust rating curves and hydraulic geometries," *EGUsphere preprint*, 2024/2025. + +[12] BaRatin-tools, *BaRatin reference Fortran computational engine*, GitHub repository [github.com/BaRatin-tools/BaRatin](https://github.com/BaRatin-tools/BaRatin). Master-equation implementation and continuity derivation in `src/RatingCurve_tools.f90`. + +--- + +[<- Previous: Coincident Frequency](coincident-frequency.md) | [Back to Index](../../index.md) | [Next: Time Series ->](time-series.md) + +*Last updated: 2026-04-21 — switched default multi-segment form to BaRatin matrix-of-controls addition mode (Le Coz et al. 2014) following line-by-line verification against the BaRatin reference Fortran source.* diff --git a/docs/technical-reference/analysis/time-series.md b/docs/technical-reference/analysis/time-series.md new file mode 100644 index 0000000..2d97075 --- /dev/null +++ b/docs/technical-reference/analysis/time-series.md @@ -0,0 +1,1277 @@ +# Time Series Analysis + +[<- Previous: Rating Curve](rating-curve.md) | [Back to Index](../../index.md) | [Next: Spatial Extremes ->](../spatial/spatial-extremes.md) + +Time series analysis is the statistical study of data points collected sequentially over time. Unlike cross-sectional data where observations are assumed independent, time series data exhibit temporal dependence — today's observation is related to yesterday's, and tomorrow's will be related to today's. Understanding and modeling this dependence is essential for forecasting future values and understanding the dynamics of the system being studied. + +This chapter introduces the theory and practice of time series modeling in ***RMC-BestFit***, covering autoregressive (AR), moving average (MA), and integrated (ARIMA/ARIMAX) models. We emphasize both the mathematical foundations and practical implementation for readers new to time series methods. + +## Why Time Series Analysis Matters + +Consider a water resources engineer tasked with forecasting next month's streamflow for reservoir operations. Simply using the long-term average ignores valuable information: if this month's flow is unusually high, next month's flow is likely to also be above average (rivers don't jump randomly between extremes). Time series models capture this persistence, producing better forecasts than naive methods. + +Time series analysis is fundamental to: + +- **Streamflow forecasting**: Predict future flows for reservoir operations, water supply planning +- **Climate projections**: Model temperature, precipitation, and drought indices +- **Demand forecasting**: Predict water demand, electricity load, or economic variables +- **Quality control**: Detect anomalies in sensor data through residual monitoring +- **Understanding dynamics**: Quantify how quickly systems respond to perturbations + +The ***RMC.BestFit*** library provides a comprehensive suite of time series models with both Maximum Likelihood (MLE) and Bayesian MCMC estimation, enabling full uncertainty quantification for forecasts. + +## Core Concepts + +Before diving into specific models, let's establish key concepts that underpin all time series analysis. + +### Stationarity + +A time series is **stationary** if its statistical properties (mean, variance, autocorrelation structure) don't change over time. Most classical time series models assume stationarity because: + +1. Non-stationary series have time-varying parameters, making estimation ill-defined +2. Forecasts from non-stationary models can diverge to infinity +3. Standard statistical tests assume stationarity + +**Weak stationarity** (the practical definition) requires: +- Constant mean: $E[Y_t] = \mu$ for all $t$ +- Constant variance: $\text{Var}(Y_t) = \sigma^2$ for all $t$ +- Autocovariance depends only on lag: $\text{Cov}(Y_t, Y_{t+h}) = \gamma(h)$ for all $t$ + +Many real-world series are non-stationary due to trends or seasonality. The ARIMA framework handles this by differencing the series to achieve stationarity before modeling. + +### Autocorrelation + +**Autocorrelation** measures the correlation between a time series and lagged versions of itself. The autocorrelation function (ACF) at lag $h$ is: + +```math +\rho(h) = \frac{\text{Cov}(Y_t, Y_{t+h})}{\text{Var}(Y_t)} = \frac{\gamma(h)}{\gamma(0)} +``` + +The ACF reveals the memory structure of the series: +- **Fast decay**: Short-memory process (AR models with small coefficients) +- **Slow decay**: Long-memory or near unit-root process +- **Sharp cutoff**: MA structure (ACF drops to zero after lag $q$) +- **Sinusoidal pattern**: Seasonal or cyclical component + +The **partial autocorrelation function (PACF)** measures the correlation between $Y_t$ and $Y_{t+h}$ after removing the linear effect of intermediate lags $Y_{t+1}, \ldots, Y_{t+h-1}$. The PACF is crucial for identifying AR order: +- AR(p) process: PACF cuts off after lag $p$ +- MA(q) process: PACF decays gradually + +### White Noise + +**White noise** is a sequence of uncorrelated random variables with constant mean and variance: + +```math +\varepsilon_t \stackrel{iid}{\sim} N(0, \sigma^2) +``` + +White noise has: +- $\rho(0) = 1$ (correlation with itself) +- $\rho(h) = 0$ for $h \neq 0$ (no correlation at any lag) + +The goal of time series modeling is to transform the observed series into white noise residuals. If residuals still show autocorrelation, the model is missing structure. + +### The Backshift Operator + +The **backshift (lag) operator** $L$ simplifies notation: + +```math +L Y_t = Y_{t-1}, \quad L^2 Y_t = Y_{t-2}, \quad L^k Y_t = Y_{t-k} +``` + +The **difference operator** is: + +```math +\Delta Y_t = Y_t - Y_{t-1} = (1 - L) Y_t +``` + +Higher-order differences: + +```math +\Delta^2 Y_t = \Delta(\Delta Y_t) = Y_t - 2Y_{t-1} + Y_{t-2} = (1-L)^2 Y_t +``` + +Using these operators, model equations become compact polynomial expressions that reveal the mathematical structure. + +--- + +## Model Overview + +The ***RMC.BestFit*** library implements four primary time series model classes: + +| Model | Class | Parameters | When to Use | +|-------|-------|------------|-------------| +| AR(p) | `AutoRegressive` | $p + 2$ | Gradual ACF decay, sharp PACF cutoff | +| MA(q) | `MovingAverage` | $q + 2$ | Sharp ACF cutoff, gradual PACF decay | +| ARIMA(p,d,q) | `ARIMA` | $p + q + 2$ | Non-stationary series needing differencing | +| ARIMAX(p,d,q,b) | `ARIMAX` | $p + q + K + 2+$ | External predictors available | + +Each model class has a corresponding analysis class for Bayesian MCMC estimation: +- `ARAnalysis` +- `MAAnalysis` +- `ARIMAAnalysis` +- `ARIMAXAnalysis` + +--- + +## Autoregressive Model — AR(p) + +The autoregressive model is the workhorse of time series analysis. It expresses the current value as a linear combination of past values plus random noise — essentially a regression of the series on its own lagged values. + +### Intuition + +Imagine predicting tomorrow's river flow. If today's flow is high, tomorrow's is likely high too (persistence). If yesterday's was also high, that provides additional information. An AR model formalizes this: predict today using a weighted combination of recent past values. + +The "order" $p$ specifies how many past values to include. An AR(1) uses only yesterday; AR(2) uses yesterday and the day before; and so on. + +### Mathematical Formulation + +The AR(p) model is defined as [[1]](#1): + +```math +Y_t = \mu + \sum_{i=1}^{p} \phi_i (Y_{t-i} - \mu) + \varepsilon_t +``` + +where: +- $Y_t$ is the observation at time $t$ +- $\mu$ is the process mean (intercept) +- $\phi_1, \phi_2, \ldots, \phi_p$ are the AR coefficients +- $\varepsilon_t \stackrel{iid}{\sim} N(0, \sigma^2)$ is white noise + +An equivalent formulation that's often more intuitive: + +```math +Y_t = c + \phi_1 Y_{t-1} + \phi_2 Y_{t-2} + \ldots + \phi_p Y_{t-p} + \varepsilon_t +``` + +where $c = \mu(1 - \phi_1 - \phi_2 - \ldots - \phi_p)$ is a constant term. + +Using the backshift operator: + +```math +\Phi(L)(Y_t - \mu) = \varepsilon_t +``` + +where $\Phi(L) = 1 - \phi_1 L - \phi_2 L^2 - \ldots - \phi_p L^p$ is the **AR polynomial**. + +### Parameters + +For an AR(p) model with intercept: +- $\mu$: Process mean (intercept) +- $\phi_1, \phi_2, \ldots, \phi_p$: AR coefficients +- $\sigma$: Error standard deviation + +**Total parameters:** $p + 2$ (with intercept) or $p + 1$ (without intercept) + +### Stationarity Conditions + +For a stationary AR process, all roots of the characteristic polynomial must lie **outside** the unit circle [[1]](#1): + +```math +\Phi(z) = 1 - \phi_1 z - \phi_2 z^2 - \ldots - \phi_p z^p = 0 +``` + +This ensures the process doesn't explode to infinity. + +**Simplified conditions for low orders:** +- **AR(1)**: $|\phi_1| < 1$ +- **AR(2)**: $\phi_1 + \phi_2 < 1$, $\phi_2 - \phi_1 < 1$, $|\phi_2| < 1$ + +A sufficient (but not necessary) general condition: $\sum_{i=1}^{p} |\phi_i| < 1$ + +**Interpretation of coefficients:** +- $\phi_1 > 0$: Positive persistence (high values followed by high values) +- $\phi_1 < 0$: Oscillating behavior (high values followed by low values) +- $\phi_1 \approx 1$: Near unit root, very persistent (slow mean reversion) +- $\phi_1 \approx 0$: Little dependence on immediate past + +### ACF and PACF Patterns + +The AR(p) process has distinctive correlation patterns: + +| Statistic | Pattern | +|-----------|---------| +| ACF | Exponential decay (or damped sinusoid for complex roots) | +| PACF | Cuts off sharply after lag $p$ | + +This PACF cutoff is the key diagnostic for identifying AR order. + +### Likelihood Function + +The conditional log-likelihood, conditioning on the first $p$ observations, is [[2]](#2): + +```math +\ell(\theta) = -\frac{n-p}{2}\log(2\pi) - (n-p)\log(\sigma) - \frac{1}{2\sigma^2}\sum_{t=p+1}^{n} \varepsilon_t^2 +``` + +where the residuals are: + +```math +\varepsilon_t = Y_t - \mu - \sum_{i=1}^{p} \phi_i (Y_{t-i} - \mu) +``` + +### Implementation Example + +Let's fit an AR(2) model to monthly streamflow data: + +```cs +using Numerics.Data; +using RMC.BestFit.Models; +using RMC.BestFit.Analyses; +using RMC.BestFit.Estimation; + +// Create time series from monthly streamflow data +var startDate = new DateTime(1990, 1, 1); +var ts = new TimeSeries(TimeInterval.OneMonth, startDate, monthlyFlows); + +// Create AR(2) model +var model = new AutoRegressive(ts, order: 2, includeIntercept: true) +{ + UseDefaultTrainingSteps = false, // Use all data for training + UseJeffreysRuleForScale = true // Non-informative prior on sigma +}; +model.TrainingTimeSteps = ts.Count; + +// Perform MLE estimation using MaximumLikelihood class +var mle = new MaximumLikelihood(model); +mle.Estimate(); + +// Display estimated parameters (access via Parameters list) +if (mle.IsEstimated) +{ + model.SetParameterValues(mle.BestParameterSet.Values); + + // Parameter indices for AR(2) with intercept: [0]=μ, [1]=φ₁, [2]=φ₂, [3]=σ + Console.WriteLine($"Mean (μ): {model.Parameters[0].Value:F2}"); + Console.WriteLine($"AR(1) (φ₁): {model.Parameters[1].Value:F4}"); + Console.WriteLine($"AR(2) (φ₂): {model.Parameters[2].Value:F4}"); + Console.WriteLine($"Sigma (σ): {model.Parameters[3].Value:F4}"); +} +``` + +For Bayesian estimation with uncertainty quantification and forecasting: + +```cs +// Create analysis object +var analysis = new ARAnalysis(model); +analysis.BayesianAnalysis.Iterations = 10000; +analysis.BayesianAnalysis.WarmupIterations = 5000; +analysis.ForecastingTimeSteps = 12; // Forecast 12 months ahead + +// Run MCMC +await analysis.RunAsync(); + +// Access posterior summaries +var results = analysis.BayesianAnalysis.Results; +Console.WriteLine("\nPosterior Summary:"); +Console.WriteLine($"{"Parameter",-12} {"Mean",10} {"Std Dev",10} {"2.5%",10} {"97.5%",10}"); + +for (int i = 0; i < model.Parameters.Count; i++) +{ + var stats = results.ParameterResults[i].SummaryStatistics; + Console.WriteLine($"{model.Parameters[i].Name,-12} {stats.Mean,10:F4} {stats.StandardDeviation,10:F4} " + + $"{stats.LowerCI,10:F4} {stats.UpperCI,10:F4}"); +} + +// Access forecasts with uncertainty +var forecasts = analysis.AnalysisResults; +Console.WriteLine("\nForecasts:"); +Console.WriteLine($"{"Month",-8} {"Forecast",12} {"Lower 95%",12} {"Upper 95%",12}"); + +for (int h = 0; h < 12; h++) +{ + var mode = forecasts.ModeCurve[h]; + var lower = forecasts.LowerCurve[h]; + var upper = forecasts.UpperCurve[h]; + Console.WriteLine($"{h + 1,-8} {mode.Value,12:F1} {lower.Value,12:F1} {upper.Value,12:F1}"); +} +``` + +### Practical Considerations + +**Choosing the order $p$:** +1. Plot the PACF — it should cut off after lag $p$ +2. Start with low orders (1-3) for most applications +3. Use information criteria (AIC, BIC) to compare models +4. Prefer parsimony — simpler models often forecast better + +**Common issues:** +- **Near unit root** ($\phi_1 \approx 1$): Consider differencing instead +- **Seasonal patterns**: PACF may show spikes at seasonal lags; consider seasonal terms or Fourier basis +- **Outliers**: Can inflate variance estimates; consider robust methods or data cleaning + +--- + +## Moving Average Model — MA(q) + +The moving average model expresses the current value as a linear combination of current and past random shocks (error terms). While AR models capture persistence through lagged observations, MA models capture persistence through the lingering effects of past innovations. + +### Intuition + +Imagine a river where upstream rainfall events cause flow perturbations that take several days to pass the gauge. Today's flow depends on today's rainfall plus echoes of the past few days' rainfall. An MA model captures this: the current value depends on the current shock plus weighted past shocks. + +The "order" $q$ specifies how many past shocks influence the current value. An MA(1) uses only today's and yesterday's shocks; MA(2) adds the day before; and so on. + +### Mathematical Formulation + +The MA(q) model is defined as [[1]](#1): + +```math +Y_t = \mu + \varepsilon_t + \sum_{j=1}^{q} \theta_j \varepsilon_{t-j} +``` + +where: +- $Y_t$ is the observation at time $t$ +- $\mu$ is the process mean +- $\theta_1, \theta_2, \ldots, \theta_q$ are the MA coefficients +- $\varepsilon_t \stackrel{iid}{\sim} N(0, \sigma^2)$ is white noise + +Using the backshift operator: + +```math +Y_t - \mu = \Theta(L) \varepsilon_t +``` + +where $\Theta(L) = 1 + \theta_1 L + \theta_2 L^2 + \ldots + \theta_q L^q$ is the **MA polynomial**. + +### Parameters + +For an MA(q) model with intercept: +- $\mu$: Process mean +- $\theta_1, \theta_2, \ldots, \theta_q$: MA coefficients +- $\sigma$: Error standard deviation + +**Total parameters:** $q + 2$ (with intercept) or $q + 1$ (without) + +### Invertibility Conditions + +For an **invertible** MA process, all roots of the MA polynomial must lie outside the unit circle [[1]](#1): + +```math +\Theta(z) = 1 + \theta_1 z + \theta_2 z^2 + \ldots + \theta_q z^q = 0 +``` + +**Simplified conditions:** +- **MA(1)**: $|\theta_1| < 1$ + +Invertibility ensures: +1. A unique model representation (non-invertible models have equivalent invertible forms) +2. The MA process can be written as an infinite-order AR process +3. Past shocks can be recovered from observed data + +### ACF and PACF Patterns + +The MA(q) process has distinctive correlation patterns: + +| Statistic | Pattern | +|-----------|---------| +| ACF | Cuts off sharply after lag $q$ | +| PACF | Exponential decay (or damped sinusoid) | + +This ACF cutoff is the key diagnostic for identifying MA order — it's the opposite pattern from AR. + +### Likelihood Function + +The exact likelihood for MA models requires iterative computation of residuals. Given parameters, residuals are computed recursively: + +```math +\varepsilon_t = Y_t - \mu - \sum_{j=1}^{\min(t-1, q)} \theta_j \varepsilon_{t-j} +``` + +with initialization $\varepsilon_0 = \varepsilon_{-1} = \ldots = 0$. + +The conditional log-likelihood is: + +```math +\ell(\theta) = -\frac{n}{2}\log(2\pi) - n\log(\sigma) - \frac{1}{2\sigma^2}\sum_{t=1}^{n} \varepsilon_t^2 +``` + +### Forecasting Properties + +MA(q) forecasts converge to the mean $\mu$ after $q$ steps ahead: + +- **Short horizon** ($h \leq q$): Forecast uses recent shocks + +```math +\hat{Y}_{n+h|n} = \mu + \sum_{j=h}^{q} \theta_j \varepsilon_{n+h-j} +``` + +- **Long horizon** ($h > q$): Forecast equals the mean + +```math +\hat{Y}_{n+h|n} = \mu +``` + +This makes MA models suitable for **transient shocks** — perturbations that affect the system for exactly $q$ periods then disappear. + +### Implementation Example + +```cs +using Numerics.Data; +using RMC.BestFit.Models; +using RMC.BestFit.Analyses; +using RMC.BestFit.Estimation; + +// Create MA(2) model +var model = new MovingAverage(ts, order: 2, includeIntercept: true) +{ + UseDefaultTrainingSteps = false, + UseJeffreysRuleForScale = true +}; +model.TrainingTimeSteps = ts.Count; + +// MLE estimation using MaximumLikelihood class +var mle = new MaximumLikelihood(model); +mle.Estimate(); + +if (mle.IsEstimated) +{ + model.SetParameterValues(mle.BestParameterSet.Values); + + // Parameter indices for MA(2) with intercept: [0]=μ, [1]=θ₁, [2]=θ₂, [3]=σ + Console.WriteLine($"Mean (μ): {model.Parameters[0].Value:F2}"); + Console.WriteLine($"MA(1) (θ₁): {model.Parameters[1].Value:F4}"); + Console.WriteLine($"MA(2) (θ₂): {model.Parameters[2].Value:F4}"); + Console.WriteLine($"Sigma (σ): {model.Parameters[3].Value:F4}"); +} + +// Bayesian estimation +var analysis = new MAAnalysis(model); +analysis.BayesianAnalysis.Iterations = 10000; +analysis.BayesianAnalysis.WarmupIterations = 5000; +await analysis.RunAsync(); +``` + +--- + +## ARIMA Model — ARIMA(p,d,q) + +ARIMA (AutoRegressive Integrated Moving Average) combines differencing with ARMA modeling to handle non-stationary series. Many real-world series exhibit trends or stochastic wandering that violates stationarity — ARIMA handles this by differencing the series before applying AR and MA components [[1]](#1). + +### Intuition + +Consider a stock price that wanders randomly over time (a "random walk"). The price level is non-stationary, but the daily *changes* (differences) might be stationary noise. ARIMA works by: +1. Differencing the series $d$ times to achieve stationarity +2. Fitting an ARMA(p,q) model to the differenced series +3. Integrating forecasts back to the original scale + +The "I" in ARIMA stands for "Integrated" — the opposite of differencing. + +### Mathematical Formulation + +The ARIMA(p,d,q) model is defined as: + +```math +\Phi(L)(1-L)^d (Y_t - \mu) = \Theta(L) \varepsilon_t +``` + +where: +- $(1-L)^d$ is the differencing operator applied $d$ times +- $\Phi(L)$ is the AR polynomial +- $\Theta(L)$ is the MA polynomial + +Let $W_t = \Delta^d Y_t = (1-L)^d Y_t$ be the differenced series. Then: + +```math +W_t = \mu + \sum_{i=1}^{p} \phi_i (W_{t-i} - \mu) + \varepsilon_t + \sum_{j=1}^{q} \theta_j \varepsilon_{t-j} +``` + +### Differencing + +The differencing operation transforms the series: + +- **First difference** ($d=1$): $\Delta Y_t = Y_t - Y_{t-1}$ — removes linear trend +- **Second difference** ($d=2$): $\Delta^2 Y_t = \Delta(\Delta Y_t) = Y_t - 2Y_{t-1} + Y_{t-2}$ — removes quadratic trend + +**Effect on series length:** After $d$ differences, $n$ observations become $n-d$ observations. + +### When to Difference + +Differencing is appropriate when the series shows: +- **Trending behavior**: Systematic upward or downward movement +- **Unit root**: ACF decays very slowly (series is very persistent) +- **Non-constant mean**: Mean appears to change over time + +**Warning:** Over-differencing can introduce artificial patterns. If the differenced series shows negative autocorrelation at lag 1, you may have over-differenced. + +### Parameters + +For an ARIMA(p,d,q) model: +- $\mu$: Mean of the differenced series (drift term if $d > 0$) +- $\phi_1, \ldots, \phi_p$: AR coefficients +- $\theta_1, \ldots, \theta_q$: MA coefficients +- $\sigma$: Error standard deviation + +**Total parameters:** $p + q + 2$ (with intercept) + +### Likelihood + +Computed on the differenced series $W_t = \Delta^d Y_t$ using the ARMA likelihood: + +```math +\ell(\theta) = -\frac{n-d-m}{2}\log(2\pi) - (n-d-m)\log(\sigma) - \frac{1}{2\sigma^2}\sum_{t=m+1}^{n-d} \varepsilon_t^2 +``` + +where $m = \max(p, q)$ accounts for conditioning. + +### Implementation Example + +Fitting an ARIMA(1,1,1) model (differenced AR(1) with MA(1) errors): + +```cs +using Numerics.Data; +using RMC.BestFit.Models; +using RMC.BestFit.Analyses; +using RMC.BestFit.Estimation; + +// Create ARIMA(1,1,1) model +var model = new ARIMA(ts) +{ + IncludeIntercept = true, + AROrderP = 1, + DiffOrderD = 1, + MAOrderQ = 1, + UseDefaultTrainingSteps = false +}; +model.TrainingTimeSteps = ts.Count; + +// MLE estimation using MaximumLikelihood class +var mle = new MaximumLikelihood(model); +mle.Estimate(); + +if (mle.IsEstimated) +{ + model.SetParameterValues(mle.BestParameterSet.Values); + + // Parameter indices for ARIMA(1,1,1) with intercept: [0]=μ, [1]=φ₁, [2]=θ₁, [3]=σ + Console.WriteLine($"Drift (μ): {model.Parameters[0].Value:F4}"); + Console.WriteLine($"AR(1) (φ₁): {model.Parameters[1].Value:F4}"); + Console.WriteLine($"MA(1) (θ₁): {model.Parameters[2].Value:F4}"); + Console.WriteLine($"Sigma (σ): {model.Parameters[3].Value:F4}"); +} + +// Bayesian estimation with forecasting +var analysis = new ARIMAAnalysis(model); +analysis.BayesianAnalysis.Iterations = 10000; +analysis.BayesianAnalysis.WarmupIterations = 5000; +analysis.ForecastingTimeSteps = 24; // 24-month forecast + +await analysis.RunAsync(); +``` + +### Special Cases + +| Model | ARIMA Notation | Description | +|-------|---------------|-------------| +| White noise | ARIMA(0,0,0) | No structure, just noise | +| Random walk | ARIMA(0,1,0) | $Y_t = Y_{t-1} + \varepsilon_t$ | +| Random walk with drift | ARIMA(0,1,0) + intercept | $Y_t = c + Y_{t-1} + \varepsilon_t$ | +| AR(p) | ARIMA(p,0,0) | Autoregressive only | +| MA(q) | ARIMA(0,0,q) | Moving average only | +| ARMA(p,q) | ARIMA(p,0,q) | Mixed, no differencing | + +--- + +## ARIMAX Model — ARIMAX(p,d,q,b) + +ARIMAX extends ARIMA by incorporating **exogenous (external) predictor variables**. When external factors influence the response — climate indices affecting streamflow, economic indicators affecting demand, upstream flows affecting downstream gauges — ARIMAX captures both the internal dynamics and external effects [[3]](#3). + +### Intuition + +Predicting reservoir inflow using only past inflows ignores valuable information: upstream precipitation, snowpack, temperature. ARIMAX includes these external predictors while still modeling the temporal dynamics of inflow itself. + +The model separates two sources of predictability: +1. **Internal dynamics**: How the series depends on its own past (AR/MA terms) +2. **External effects**: How external predictors influence the series (regression terms) + +### Mathematical Formulation + +The ARIMAX model is: + +```math +Y_t = \mu + \gamma(t) + \psi(t) + \sum_{k=1}^{K} \beta_k X_{k,t-b} + \sum_{i=1}^{p} \phi_i (Y_{t-i} - \mu) + \varepsilon_t + \sum_{j=1}^{q} \theta_j \varepsilon_{t-j} +``` + +where: +- $\gamma(t)$ is an optional deterministic trend +- $\psi(t)$ is an optional seasonal component (Fourier series) +- $X_{k,t-b}$ is the $k$-th exogenous variable at lag $b$ +- $\beta_k$ is the regression coefficient for covariate $k$ + +### Trend Components + +***RMC-BestFit*** supports polynomial trend functions: + +| Trend Type | Formula | Parameters | +|------------|---------|------------| +| None | $\gamma(t) = 0$ | 0 | +| Linear | $\gamma(t) = \gamma_1 t$ | 1 | +| Quadratic | $\gamma(t) = \gamma_1 t + \gamma_2 t^2$ | 2 | +| Cubic | $\gamma(t) = \gamma_1 t + \gamma_2 t^2 + \gamma_3 t^3$ | 3 | + +**Warning:** Including both differencing ($d > 0$) and trend terms is usually over-specified. Choose one or the other. + +### Seasonality + +When `IncludeSeasonality = true`, a Fourier basis captures periodic patterns: + +```math +\psi(t) = \psi_1 \sin\left(\frac{2\pi t}{S}\right) + \psi_2 \cos\left(\frac{2\pi t}{S}\right) +``` + +where $S$ is the seasonal period inferred from data frequency: +- Monthly data: $S = 12$ +- Quarterly data: $S = 4$ +- Daily data: $S = 365$ + +This captures seasonal patterns without requiring seasonal differencing or multiplicative seasonal ARIMA. + +### Exogenous Variables + +Covariates enter with an optional lag $b$: +- $b = 0$: **Contemporaneous** effect — $X_t$ affects $Y_t$ +- $b > 0$: **Lagged** effect — $X_{t-b}$ affects $Y_t$ + +**Covariate extension for forecasting:** + +When forecasting beyond available covariate data, ***RMC-BestFit*** offers: +1. `None`: No extension (throws exception if insufficient data) +2. `BlockBootstrap`: Resamples blocks of covariate data, preserving temporal autocorrelation +3. `KNN`: K-nearest neighbors imputation, preserving local covariate structure + +### Implementation Example + +Modeling streamflow with precipitation as a covariate: + +```cs +using Numerics.Data; +using RMC.BestFit.Models; +using RMC.BestFit.Analyses; +using RMC.BestFit.Estimation; + +// Time series of streamflow (response) and precipitation (covariate) +var flowTS = new TimeSeries(TimeInterval.OneMonth, startDate, monthlyFlows); +var precipTS = new TimeSeries(TimeInterval.OneMonth, startDate, monthlyPrecip); + +// Create ARIMAX model +var model = new ARIMAX(flowTS) +{ + IncludeIntercept = true, + AROrderP = 1, + DiffOrderD = 0, + MAOrderQ = 0, + XOrderB = 0, // Contemporaneous effect + IncludeSeasonality = true, // Capture seasonal patterns + UseDefaultTrainingSteps = false +}; +model.TrainingTimeSteps = flowTS.Count; + +// Add covariate(s) +model.SetCovariates(new List { precipTS }); + +// MLE estimation using MaximumLikelihood class +var mle = new MaximumLikelihood(model); +mle.Estimate(); + +if (mle.IsEstimated) +{ + model.SetParameterValues(mle.BestParameterSet.Values); + + // Parameter order depends on model configuration + // For ARIMAX with intercept, AR(1), seasonality, and 1 covariate: + // Parameters are ordered: intercept, AR coefficients, seasonal terms, covariate betas, sigma + Console.WriteLine("Estimated parameters:"); + for (int i = 0; i < model.Parameters.Count; i++) + { + Console.WriteLine($" {model.Parameters[i].Name}: {model.Parameters[i].Value:F4}"); + } +} + +// Bayesian estimation +var analysis = new ARIMAXAnalysis(model); +analysis.BayesianAnalysis.Iterations = 10000; +analysis.BayesianAnalysis.WarmupIterations = 5000; +analysis.ForecastingTimeSteps = 12; +analysis.ARIMAX.CovariateExtension = ARIMAX.CovariateExtensionMethod.BlockBootstrap; + +await analysis.RunAsync(); +``` + +--- + +## Bayesian Estimation + +All time series models in ***RMC-BestFit*** support Bayesian MCMC estimation through their corresponding analysis classes. Bayesian estimation provides complete uncertainty quantification — not just point estimates, but full probability distributions for parameters and forecasts [[4]](#4). + +### Why Bayesian? + +MLE gives you the "best" parameter values, but doesn't tell you how uncertain those estimates are. Bayesian estimation provides: + +1. **Parameter uncertainty**: Full posterior distributions, not just point estimates +2. **Forecast uncertainty**: Prediction intervals that account for parameter uncertainty +3. **Probabilistic inference**: Answer questions like "What's the probability AR(1) > 0.5?" +4. **Prior incorporation**: Include expert knowledge when appropriate + +### MCMC Sampler + +The default sampler is **DEMCzs** (Differential Evolution MCMC with snooker update) [[5]](#5), which is: +- **Self-tuning**: No manual proposal distribution tuning required +- **Robust**: Handles multimodal posteriors and correlated parameters +- **Parallelizable**: Multiple chains run simultaneously + +### Posterior Distribution + +The posterior distribution combines the likelihood with prior information: + +```math +\pi(\theta | Y) \propto \mathcal{L}(Y | \theta) \cdot \pi(\theta) +``` + +where: +- $\mathcal{L}(Y | \theta)$ is the likelihood function +- $\pi(\theta)$ is the prior (product of individual parameter priors) + +### Prior Distributions + +***RMC-BestFit*** uses weakly informative default priors: + +| Parameter | Default Prior | Rationale | +|-----------|--------------|-----------| +| $\mu$ | $\text{Uniform}(L_\mu, U_\mu)$ | Bounds from data range | +| $\phi_i$ | $\text{Uniform}(-2, 2)$ | Allows non-stationary exploration | +| $\theta_j$ | $\text{Uniform}(-2, 2)$ | Allows non-invertible exploration | +| $\sigma$ | $\text{Uniform}(\epsilon, U_\sigma)$ | Positive scale parameter | + +**Jeffreys' prior for scale:** + +When `UseJeffreysRuleForScale = true`, the scale parameter receives the non-informative Jeffreys' prior: + +```math +\pi(\sigma) \propto \frac{1}{\sigma} +``` + +This adds $-\log(\sigma)$ to the log-posterior, producing posteriors invariant to rescaling. + +### Convergence Diagnostics + +Always check MCMC convergence before using results [[4]](#4): + +```cs +var results = analysis.BayesianAnalysis.Results; + +Console.WriteLine($"{"Parameter",-12} {"R-hat",8} {"ESS",8} {"Converged?",12}"); +for (int i = 0; i < model.Parameters.Count; i++) +{ + var stats = results.ParameterResults[i].SummaryStatistics; + bool converged = stats.Rhat < 1.1 && stats.ESS > 100; + + Console.WriteLine($"{model.Parameters[i].Name,-12} {stats.Rhat,8:F3} {stats.ESS,8:F0} {(converged ? "Yes" : "NO"),12}"); +} +``` + +**Diagnostic thresholds:** +- **R-hat** (Gelman-Rubin): Should be < 1.1 (preferably < 1.05) +- **ESS** (Effective Sample Size): Should be > 100 for reliable inference + +**If convergence fails:** +1. Increase `Iterations` and `WarmupIterations` +2. Check for model misspecification +3. Consider reparameterization +4. Examine trace plots for pathological behavior + +--- + +## Model Diagnostics + +Fitting a model is only the beginning. Proper diagnostics verify that the model adequately captures the data structure. + +### Residual Analysis + +If the model is correctly specified, residuals should be white noise: + +```cs +var mapParams = analysis.BayesianAnalysis.Results.MAP.Values; +var residuals = model.Residuals(mapParams); + +// Check residual statistics +double mean = residuals.Average(); +double variance = residuals.Select(r => r * r).Average() - mean * mean; + +// Get sigma from the last parameter (always the scale parameter) +double sigma = mapParams.Last(); + +Console.WriteLine($"Residual mean: {mean:F4} (should be ≈ 0)"); +Console.WriteLine($"Residual std: {Math.Sqrt(variance):F4} (should be ≈ σ = {sigma:F4})"); +``` + +### ACF of Residuals + +Residuals should show no significant autocorrelation: + +```cs +// Compute residual autocorrelations +var residualAcf = Statistics.AutoCorrelation(residuals, maxLag: 20); + +Console.WriteLine("Lag ACF"); +for (int lag = 1; lag <= 20; lag++) +{ + double bound = 1.96 / Math.Sqrt(residuals.Length); // 95% confidence bound + string sig = Math.Abs(residualAcf[lag]) > bound ? " *" : ""; + Console.WriteLine($"{lag,3} {residualAcf[lag],7:F3}{sig}"); +} +``` + +Significant autocorrelation (marked with `*`) suggests the model is missing structure. Consider: +- Increasing AR or MA order +- Adding seasonal terms +- Checking for outliers or structural breaks + +### Ljung-Box Test + +The Ljung-Box test formally tests for residual autocorrelation: + +```math +Q = n(n+2) \sum_{h=1}^{H} \frac{\hat{\rho}(h)^2}{n-h} +``` + +Under the null hypothesis of no autocorrelation, $Q \sim \chi^2_{H-p-q}$. + +```cs +// Ljung-Box test (using Numerics.Data.Statistics) +int H = 20; // Number of lags to test +double Q = Statistics.LjungBoxStatistic(residuals, H); +int df = H - model.Order; // Degrees of freedom +double pValue = 1 - new ChiSquared(df).CDF(Q); + +Console.WriteLine($"Ljung-Box Q({H}): {Q:F2}"); +Console.WriteLine($"p-value: {pValue:F4}"); +Console.WriteLine(pValue < 0.05 ? "Warning: Significant residual autocorrelation" : "OK: No significant autocorrelation"); +``` + +### Normality Check + +Residuals should be approximately normally distributed: + +```cs +// Jarque-Bera test for normality +double skewness = Statistics.Skewness(residuals); +double kurtosis = Statistics.Kurtosis(residuals); // Excess kurtosis +double jb = (residuals.Length / 6.0) * (skewness * skewness + kurtosis * kurtosis / 4.0); + +Console.WriteLine($"Skewness: {skewness:F3} (should be ≈ 0)"); +Console.WriteLine($"Excess kurtosis: {kurtosis:F3} (should be ≈ 0)"); +Console.WriteLine($"Jarque-Bera: {jb:F2}"); +``` + +Non-normality may indicate: +- Outliers (high kurtosis) +- Asymmetric shocks (non-zero skewness) +- Need for transformation (log, Box-Cox) + +--- + +## Data Transformations + +Many hydrologic variables (streamflow, precipitation, concentrations) are positively skewed with variance that increases with the mean. Transformations can stabilize variance and improve normality. + +### Available Transformations + +| Transform | Formula | Inverse | Use Case | +|-----------|---------|---------|----------| +| None | $Y_t^* = Y_t$ | $Y_t = Y_t^*$ | Data already Gaussian | +| Logarithmic | $Y_t^* = \log(Y_t)$ | $Y_t = \exp(Y_t^*)$ | Positive, right-skewed data | +| Box-Cox | $Y_t^* = \frac{Y_t^\lambda - 1}{\lambda}$ | $Y_t = (\lambda Y_t^* + 1)^{1/\lambda}$ | General power transform | + +### Logarithmic Transformation + +The most common transformation for hydrologic data: + +```cs +var model = new AutoRegressive(ts, order: 2) +{ + TransformType = RMC.BestFit.Models.Transform.Logarithmic +}; +``` + +**Interpretation:** The model is fit in log-space, so AR coefficients describe multiplicative dynamics. A forecast of $\hat{Y}^*_{t+1}$ in log-space corresponds to $\exp(\hat{Y}^*_{t+1})$ in original units. + +**Warning:** Requires all values to be positive. Add a small constant if zeros are present. + +### Box-Cox Transformation + +The Box-Cox family includes log ($\lambda = 0$) and square root ($\lambda = 0.5$) as special cases: + +```math +Y_t^* = \begin{cases} +\frac{Y_t^\lambda - 1}{\lambda} & \lambda \neq 0 \\ +\log(Y_t) & \lambda = 0 +\end{cases} +``` + +```cs +var model = new AutoRegressive(ts, order: 2) +{ + TransformType = RMC.BestFit.Models.Transform.BoxCox +}; +model.SetTransformParameters(lambda1: 0.5); // Square root transform +``` + +### Jacobian Adjustment + +When using transformations, the likelihood must include the log-Jacobian for proper inference. For Box-Cox: + +```math +\log J = \sum_{t=1}^{n} (\lambda - 1) \log|Y_t| +``` + +***RMC-BestFit*** handles this automatically. + +--- + +## Model Selection + +Choosing the right model orders (p, d, q) is both art and science. Here's a systematic approach. + +### The Box-Jenkins Methodology + +The classic approach [[1]](#1): + +1. **Identification**: Use ACF/PACF patterns to guess orders +2. **Estimation**: Fit the model +3. **Diagnostics**: Check residuals +4. **Refinement**: Adjust orders if diagnostics fail + +### ACF/PACF Patterns + +| Pattern | Suggested Model | +|---------|-----------------| +| ACF: exponential decay; PACF: cuts off at $p$ | AR(p) | +| ACF: cuts off at $q$; PACF: exponential decay | MA(q) | +| ACF: exponential decay; PACF: exponential decay | ARMA(p,q) | +| ACF: very slow decay | Differencing needed (ARIMA) | + +### Information Criteria + +Compare models using penalized likelihood: + +- **AIC**: $-2\ell(\hat{\theta}) + 2k$ — favors predictive accuracy +- **BIC**: $-2\ell(\hat{\theta}) + k\log(n)$ — stronger penalty, favors parsimony + +Lower values are better. BIC typically selects simpler models than AIC. + +```cs +// Compare AR(1), AR(2), AR(3) +var results = new List<(int p, double AIC, double BIC)>(); + +foreach (int p in new[] { 1, 2, 3 }) +{ + var model = new AutoRegressive(ts, order: p, includeIntercept: true) + { + UseDefaultTrainingSteps = false + }; + model.TrainingTimeSteps = ts.Count; + model.Estimate(); + + int k = p + 2; // Number of parameters + int n = ts.Count - p; // Effective sample size + double logLik = model.DataLogLikelihood(model.Parameters.Select(x => x.Value).ToArray()); + + double aic = -2 * logLik + 2 * k; + double bic = -2 * logLik + k * Math.Log(n); + + results.Add((p, aic, bic)); + Console.WriteLine($"AR({p}): AIC = {aic:F1}, BIC = {bic:F1}"); +} + +var bestAIC = results.OrderBy(r => r.AIC).First(); +var bestBIC = results.OrderBy(r => r.BIC).First(); +Console.WriteLine($"\nBest by AIC: AR({bestAIC.p})"); +Console.WriteLine($"Best by BIC: AR({bestBIC.p})"); +``` + +### Practical Guidelines + +| Scenario | Recommendation | +|----------|---------------| +| Stationary series, gradual ACF decay | Start with AR(1) or AR(2) | +| Stationary series, sharp ACF cutoff | Try MA(1) or MA(2) | +| Trending series | Difference first (ARIMA with d=1) | +| Seasonal patterns | Use ARIMAX with `IncludeSeasonality = true` | +| External predictors available | Use ARIMAX | +| Uncertain about order | Prefer parsimony — simpler models often forecast better | + +--- + +## Practical Examples + +### Example 1: Monthly Streamflow Forecasting + +Forecast monthly streamflow using historical data: + +```cs +using Numerics.Data; +using RMC.BestFit.Models; +using RMC.BestFit.Analyses; +using RMC.BestFit.Estimation; + +// Load historical monthly streamflow (log-transformed for variance stabilization) +var ts = new TimeSeries(TimeInterval.OneMonth, new DateTime(1980, 1, 1), logMonthlyFlows); + +// Fit AR(2) model — streamflow often shows strong lag-1 persistence +var model = new AutoRegressive(ts, order: 2, includeIntercept: true) +{ + UseDefaultTrainingSteps = false, + UseJeffreysRuleForScale = true +}; +model.TrainingTimeSteps = ts.Count; + +// Bayesian estimation with 12-month forecast +var analysis = new ARAnalysis(model); +analysis.BayesianAnalysis.Iterations = 15000; +analysis.BayesianAnalysis.WarmupIterations = 7500; +analysis.ForecastingTimeSteps = 12; + +await analysis.RunAsync(); + +// Check convergence +var results = analysis.BayesianAnalysis.Results; +bool converged = results.ParameterResults.All(p => p.SummaryStatistics.Rhat < 1.1); +Console.WriteLine($"Converged: {converged}"); + +// Display forecasts (transform back from log scale) +Console.WriteLine("\n12-Month Forecast (original units):"); +Console.WriteLine($"{"Month",-8} {"Median",10} {"5th %ile",10} {"95th %ile",10}"); + +var forecasts = analysis.AnalysisResults; +for (int h = 0; h < 12; h++) +{ + double median = Math.Exp(forecasts.ModeCurve[h].Value); + double lower = Math.Exp(forecasts.LowerCurve[h].Value); + double upper = Math.Exp(forecasts.UpperCurve[h].Value); + + Console.WriteLine($"{h + 1,-8} {median,10:F0} {lower,10:F0} {upper,10:F0}"); +} +``` + +### Example 2: ARIMAX with Climate Index + +Predict streamflow using ENSO (El Niño Southern Oscillation) as a covariate: + +```cs +// Monthly streamflow and ENSO index (Niño 3.4) +var flowTS = new TimeSeries(TimeInterval.OneMonth, startDate, monthlyFlows); +var ensoTS = new TimeSeries(TimeInterval.OneMonth, startDate, nino34Index); + +// ARIMAX: AR(1) + ENSO effect + seasonality +var model = new ARIMAX(flowTS) +{ + IncludeIntercept = true, + AROrderP = 1, + MAOrderQ = 0, + DiffOrderD = 0, + XOrderB = 1, // ENSO affects flow with 1-month lag + IncludeSeasonality = true, + UseDefaultTrainingSteps = false +}; +model.TrainingTimeSteps = flowTS.Count; +model.SetCovariates(new List { ensoTS }); + +// MLE estimation +var mle = new MaximumLikelihood(model); +mle.Estimate(); + +if (mle.IsEstimated) +{ + model.SetParameterValues(mle.BestParameterSet.Values); + + // Find the beta coefficient for ENSO in the parameters list + // Parameter order: intercept, AR coeffs, seasonal terms, beta coeffs, sigma + Console.WriteLine("Estimated parameters:"); + for (int i = 0; i < model.Parameters.Count; i++) + { + if (model.Parameters[i].Name.Contains("Beta")) + { + Console.WriteLine($"ENSO effect ({model.Parameters[i].Name}): {model.Parameters[i].Value:F3}"); + Console.WriteLine($"Interpretation: A 1-unit increase in Niño 3.4 is associated with"); + Console.WriteLine($" a {model.Parameters[i].Value:F1} unit change in streamflow"); + } + } +} + +// Bayesian estimation for uncertainty +var analysis = new ARIMAXAnalysis(model); +analysis.BayesianAnalysis.Iterations = 15000; +analysis.BayesianAnalysis.WarmupIterations = 7500; +await analysis.RunAsync(); + +// ENSO effect uncertainty +var ensoStats = analysis.BayesianAnalysis.Results.ParameterResults + .First(p => p.Name?.Contains("Beta") ?? false).SummaryStatistics; +Console.WriteLine($"ENSO effect: {ensoStats.Mean:F3} (95% CI: [{ensoStats.LowerCI:F3}, {ensoStats.UpperCI:F3}])"); +``` + +### Example 3: Detecting Non-Stationarity + +Test whether a series needs differencing: + +```cs +// Plot ACF to check for slow decay +var acf = Statistics.AutoCorrelation(data, maxLag: 20); + +Console.WriteLine("Lag ACF (slow decay suggests non-stationarity)"); +for (int lag = 1; lag <= 10; lag++) +{ + Console.WriteLine($"{lag,3} {acf[lag],7:F3}"); +} + +// If ACF decays very slowly, try differencing +var diffData = new double[data.Length - 1]; +for (int t = 0; t < diffData.Length; t++) +{ + diffData[t] = data[t + 1] - data[t]; +} + +var diffAcf = Statistics.AutoCorrelation(diffData, maxLag: 20); + +Console.WriteLine("\nAfter differencing:"); +Console.WriteLine("Lag ACF"); +for (int lag = 1; lag <= 10; lag++) +{ + Console.WriteLine($"{lag,3} {diffAcf[lag],7:F3}"); +} + +// Fit ARIMA(1,1,0) if differencing helped +var model = new ARIMA(ts) +{ + AROrderP = 1, + DiffOrderD = 1, + MAOrderQ = 0 +}; +``` + +--- + +## Training Configuration + +By default, ***RMC-BestFit*** uses 80% of data for training, reserving 20% for out-of-sample validation. To use all data (matching R's `arima()` behavior): + +```cs +model.UseDefaultTrainingSteps = false; +model.TrainingTimeSteps = timeSeries.Count; +``` + +**When to use each approach:** +- **Default (80/20 split)**: Good for model validation, prevents overfitting +- **All data**: Use when validating against other software or when sample size is limited + +--- + +## Validation Against R + +All time series models have been validated against R's `arima()` function using the Box-Jenkins airline passenger dataset [[1]](#1). + +### Test Data + +The **airline passenger dataset** contains 144 monthly observations (1949-1960) of international airline passengers. It's log-transformed for variance stabilization and exhibits both trend and seasonality. + +### Validation Results + +| Model | Parameter | R Value | RMC-BestFit | Within Tolerance? | +|-------|-----------|---------|-------------|-------------------| +| AR(1) | φ₁ | 0.9646 | ≈0.96 | Yes (10%) | +| AR(1) | μ | 5.5392 | ≈5.54 | Yes (10%) | +| MA(1) | θ₁ | 0.4018 | ≈0.40 | Yes (10%) | +| ARIMA(1,1,1) | φ₁ | 0.8822 | ≈0.88 | Yes (10%) | + +Bayesian estimates use 15% tolerance to account for MCMC variability. + +--- + +## Assumptions and Limitations + +### Model Assumptions + +1. **Gaussian errors**: $\varepsilon_t \sim N(0, \sigma^2)$ +2. **Constant variance**: Homoscedasticity over time +3. **Linear dynamics**: Current value is a linear function of past values/shocks +4. **No structural breaks**: Parameters are constant over time + +### Violations and Remedies + +| Violation | Symptom | Remedy | +|-----------|---------|--------| +| Non-normality | Heavy tails, skewness | Transformation (log, Box-Cox) | +| Heteroscedasticity | Variance changes with level | Transformation or GARCH models | +| Nonlinearity | Residual patterns | Threshold models, neural nets | +| Structural break | Sudden parameter change | Split sample, regime-switching | + +### Limitations + +1. **Maximum order constraints**: $p, q \leq 10$; $d \leq 3$ +2. **Minimum data requirements**: At least 10 observations +3. **No seasonal ARIMA**: Use Fourier basis via ARIMAX instead +4. **No automatic model selection**: User specifies orders (use AIC/BIC for guidance) +5. **Linear models only**: Nonlinear dynamics require other approaches + +--- + +## Class Reference + +### Model Classes (`RMC.BestFit.Models`) + +| Class | Key Properties | Key Methods | +|-------|----------------|-------------| +| `AutoRegressive` | `Order`, `TimeSeries`, `TransformType`, `TrainingTimeSteps`, `Parameters` | `Residuals()`, `DataLogLikelihood()`, `SetParameterValues()` | +| `MovingAverage` | `Order`, `TimeSeries`, `TransformType`, `TrainingTimeSteps`, `Parameters` | `Residuals()`, `DataLogLikelihood()`, `SetParameterValues()` | +| `ARIMA` | `AROrderP`, `DiffOrderD`, `MAOrderQ`, `IncludeIntercept`, `Parameters` | `Residuals()`, `DataLogLikelihood()`, `SetParameterValues()` | +| `ARIMAX` | `AROrderP`, `DiffOrderD`, `MAOrderQ`, `XOrderB`, `IncludeSeasonality`, `CovariateExtension` | `SetCovariates()`, `Residuals()`, `DataLogLikelihood()` | + +**Note:** MLE estimation is performed using the `MaximumLikelihood` class: +```cs +var mle = new MaximumLikelihood(model); +mle.Estimate(); +if (mle.IsEstimated) + model.SetParameterValues(mle.BestParameterSet.Values); +``` + +### Analysis Classes (`RMC.BestFit.Analyses`) + +| Class | Key Properties | Key Methods | +|-------|----------------|-------------| +| `ARAnalysis` | `AutoRegressive`, `BayesianAnalysis`, `AnalysisResults`, `ForecastingTimeSteps` | `RunAsync()`, `CancelAnalysis()`, `Validate()` | +| `MAAnalysis` | `MovingAverage`, `BayesianAnalysis`, `AnalysisResults`, `ForecastingTimeSteps` | `RunAsync()`, `CancelAnalysis()`, `Validate()` | +| `ARIMAAnalysis` | `ARIMA`, `BayesianAnalysis`, `AnalysisResults`, `ForecastingTimeSteps` | `RunAsync()`, `CancelAnalysis()`, `Validate()` | +| `ARIMAXAnalysis` | `ARIMAX`, `BayesianAnalysis`, `AnalysisResults`, `ForecastingTimeSteps`, `CovariateExtensionMethod` | `RunAsync()`, `CancelAnalysis()`, `Validate()` | + +--- + +## Implementation Sources + +Primary source paths: `src/RMC.BestFit/Models/TimeSeries`, `src/RMC.BestFit/Analyses/TimeSeries`, `src/RMC.BestFit/Estimation/BayesianAnalysis.cs`, and `src/RMC.BestFit/Estimation/MaximumLikelihood.cs`. + +--- + +## References + +[1] G. E. P. Box, G. M. Jenkins, G. C. Reinsel, and G. M. Ljung, *Time Series Analysis: Forecasting and Control*, 5th ed., Hoboken, NJ: Wiley, 2015. + +[2] P. J. Brockwell and R. A. Davis, *Introduction to Time Series and Forecasting*, 3rd ed., New York: Springer, 2016. + +[3] J. D. Hamilton, *Time Series Analysis*, Princeton, NJ: Princeton University Press, 1994. + +[4] A. Gelman, J. B. Carlin, H. S. Stern, D. B. Dunson, A. Vehtari, and D. B. Rubin, *Bayesian Data Analysis*, 3rd ed., Boca Raton, FL: Chapman and Hall/CRC, 2013. + +[5] C. J. F. ter Braak and J. A. Vrugt, "Differential Evolution Markov Chain with snooker updater and fewer chains," *Statistics and Computing*, vol. 18, no. 4, pp. 435-446, 2008. + +[6] R. Prado and M. West, *Time Series: Modeling, Computation, and Inference*, Boca Raton, FL: Chapman and Hall/CRC, 2010. + +[7] R. J. Hyndman and Y. Khandakar, "Automatic time series forecasting: The forecast package for R," *Journal of Statistical Software*, vol. 27, no. 3, pp. 1-22, 2008. + +[8] C. Chatfield, *The Analysis of Time Series: An Introduction*, 6th ed., Boca Raton, FL: Chapman and Hall/CRC, 2004. + +--- + +[<- Previous: Rating Curve](rating-curve.md) | [Back to Index](../../index.md) | [Next: Spatial Extremes ->](../spatial/spatial-extremes.md) + +*Last updated: 2026-02-01* +*RMC-BestFit v2.0* diff --git a/docs/technical-reference/analysis/univariate.md b/docs/technical-reference/analysis/univariate.md new file mode 100644 index 0000000..e493485 --- /dev/null +++ b/docs/technical-reference/analysis/univariate.md @@ -0,0 +1,76 @@ +# Univariate Analysis + +[<- Previous: Distribution Fitting](distribution-fitting.md) | [Back to Index](../../index.md) | [Next: Composite Analysis ->](composite.md) + +`UnivariateAnalysis` runs Bayesian frequency analysis for a single `UnivariateDistribution`. It owns sampler settings, probability ordinates, posterior results, and derived frequency results. + +## Public API + +| Member | Purpose | +|--------|---------| +| `UnivariateAnalysis(UnivariateDistribution)` | Creates an analysis for a model | +| `BayesianAnalysis` | MCMC settings, posterior samples, DIC, WAIC, LOOIC | +| `ProbabilityOrdinates` | Annual exceedance probabilities used for outputs | +| `AnalysisResults` | Frequency-analysis uncertainty results | +| `ChronologyAnalysisResults` | Nonstationary chronology results | +| `RunAsync(...)` | Runs MCMC and post-processing | +| `CreateFrequencyAnalysisResultsAsync()` | Recomputes frequency results from posterior output | +| `Validate()` | Checks model and analysis state | +| `ToXElement()` | Serializes settings and result metadata | + +## Workflow + +```cs +using Numerics.Distributions; +using RMC.BestFit; +using RMC.BestFit.Analyses; +using RMC.BestFit.Models; + +var dataFrame = new DataFrame +{ + ExactSeries = new ExactSeries(new[] { 1010.0, 1230.0, 1560.0, 1780.0 }) +}; + +var model = new UnivariateDistribution(dataFrame, UnivariateDistributionType.LogPearsonTypeIII); +var analysis = new UnivariateAnalysis(model); + +analysis.BayesianAnalysis.Iterations = 3000; +analysis.BayesianAnalysis.WarmupIterations = 1500; + +if (analysis.Validate().IsValid) +{ + await analysis.RunAsync(); +} +``` + +## Result Access + +```cs +var results = analysis.BayesianAnalysis.Results; +if (results is not null) +{ + var map = results.MAP.Values; + model.SetParameterValues(map); + + double onePercentAepQuantile = model.Distribution.InverseCDF(0.99); + Console.WriteLine(onePercentAepQuantile); +} +``` + +## Related Analyses + +| Analysis | Use | +|----------|-----| +| `Bulletin17CAnalysis` | Specialized Bulletin 17C workflow | +| `MixtureAnalysis` | Latent-population mixture model | +| `CompetingRiskAnalysis` | Multiple process max/min model | +| `PointProcessAnalysis` | Peaks-over-threshold model | +| `CompositeAnalysis` | Composite or model-averaged distribution | + +## Implementation Sources + +Primary source paths: `src/RMC.BestFit/Analyses/Univariate/UnivariateAnalysis.cs`, `src/RMC.BestFit/Analyses/Univariate/UncertaintyAnalysisResults.cs`, `src/RMC.BestFit/Models/UnivariateDistribution/UnivariateDistribution.cs`, and `src/RMC.BestFit/Estimation/BayesianAnalysis.cs`. + +--- + +[<- Previous: Distribution Fitting](distribution-fitting.md) | [Back to Index](../../index.md) | [Next: Composite Analysis ->](composite.md) diff --git a/docs/technical-reference/data-frame/index.md b/docs/technical-reference/data-frame/index.md new file mode 100644 index 0000000..70637d3 --- /dev/null +++ b/docs/technical-reference/data-frame/index.md @@ -0,0 +1,601 @@ +# Input Data Frame + +[<- Previous: Models Overview](../models/overview.md) | [Back to Index](../../index.md) | [Next: Distribution API ->](../distributions/index.md) + +The **DataFrame** is the fundamental data structure in ***RMC-BestFit*** for storing and organizing observations for statistical analysis. Unlike simple arrays of numbers, the DataFrame supports the complex data types encountered in real-world flood frequency analysis: exact observations, uncertain measurements with error distributions, interval-censored data from paleoflood studies, and perception thresholds for historical floods. + +This chapter provides comprehensive coverage of the DataFrame structure, each data type it supports, and how these data types are used in flood frequency analysis. + +## Why Data Types Matter + +Consider analyzing flood risk at a dam site. Your data might include: + +1. **50 years of systematic records** from a streamflow gauge — exact annual maxima +2. **A 1927 flood** estimated from high-water marks — uncertain, with a measurement error distribution +3. **Paleoflood deposits** indicating floods between 80,000 and 120,000 cfs within the last 500 years — interval-censored +4. **Newspaper accounts** noting no floods exceeded the 1894 bridge deck elevation (35,000 cfs capacity) between 1850-1920 — perception threshold + +Each observation type contributes different information to the analysis. ***RMC-BestFit's*** DataFrame handles all of these seamlessly, and the likelihood functions automatically incorporate each data type correctly. + +## The DataFrame Structure + +### Overview + +A DataFrame contains four collections, one for each data type: + +```cs +public class DataFrame : INotifyPropertyChanged +{ + /// + /// Exact observations (point values with known magnitude). + /// + public ExactSeries ExactSeries { get; set; } + + /// + /// Uncertain observations (values with measurement error distributions). + /// + public UncertainSeries UncertainSeries { get; set; } + + /// + /// Interval-censored observations (bounded ranges). + /// + public IntervalSeries IntervalSeries { get; set; } + + /// + /// Threshold data (perception thresholds with exceedance counts). + /// + public ThresholdSeries ThresholdSeries { get; set; } + + /// + /// The plotting position parameter. Default is 0.0 (Weibull). + /// + public double PlottingParameter { get; set; } + + /// + /// The number of events per time unit (typically 1 for annual maxima). + /// + public double Lambda { get; } +} +``` + +### Creating a DataFrame + +```cs +using RMC.BestFit.Models; +using Numerics.Distributions; + +// Create an empty DataFrame +var df = new DataFrame(); + +// Add exact observations +var annualPeaks = new double[] { 45000, 52000, 38000, 61000, 49000, /* ... */ }; +df.ExactSeries = new ExactSeries(annualPeaks); + +// Add uncertain observation (historical flood with measurement error) +df.UncertainSeries.Add(new UncertainData(1889, new Normal(85000, 10000))); + +// Add interval-censored observation (paleoflood) +df.IntervalSeries.Add(new IntervalData(1500, 60000, 80000, 100000)); + +// Add perception threshold +var threshold = new ThresholdData(1850, 1920, 40000); +threshold.NumberBelow = 70; // Years below threshold +df.ThresholdSeries.Add(threshold); +``` + +--- + +## Exact Data + +### Definition + +**Exact data** are observations where the magnitude is known with negligible measurement error. These form the foundation of most flood frequency analyses. + +### Mathematical Treatment + +Each exact observation $y_i$ contributes directly to the likelihood: + +```math +\mathcal{L}_{\text{exact}}(\theta) = \prod_{i=1}^{n} f(y_i | \theta) +``` + +where $f(y | \theta)$ is the probability density function of the assumed distribution. + +### The ExactData Class + +```cs +public class ExactData : Data +{ + /// + /// The time index (e.g., year). + /// + public int Index { get; set; } + + /// + /// The observed value. + /// + public double Value { get; set; } + + /// + /// The plotting position (probability) for frequency plots. + /// + public double PlottingPosition { get; set; } + + /// + /// Whether this observation is flagged as a low outlier. + /// + public bool IsLowOutlier { get; set; } +} +``` + +### Creating Exact Data + +```cs +// From array of values (indexes automatically assigned 0, 1, 2, ...) +df.ExactSeries = new ExactSeries(new double[] { 45000, 52000, 38000, 61000 }); + +// With explicit indexes (e.g., water years) +df.ExactSeries.Add(new ExactData(1970, 45000)); +df.ExactSeries.Add(new ExactData(1971, 52000)); +df.ExactSeries.Add(new ExactData(1972, 38000)); +df.ExactSeries.Add(new ExactData(1973, 61000)); + +// With explicit plotting position (rare) +df.ExactSeries.Add(new ExactData(1970, 45000, plottingPosition: 0.5, isLowOutlier: false)); +``` + +### Use Cases + +| Application | Description | +|-------------|-------------| +| Annual peak flows | Primary systematic record from gauging station | +| Monthly/daily maxima | Sub-annual frequency analysis | +| Precipitation depths | Point rainfall observations | +| Wind speeds | Annual maximum gusts | + +--- + +## Uncertain Data + +### Definition + +**Uncertain data** are observations with significant measurement error that should be propagated into the analysis. Each observation is represented by a probability distribution rather than a point value. + +### Mathematical Treatment + +For uncertain observations, we integrate over the measurement error distribution: + +```math +\mathcal{L}_{\text{uncertain}}(\theta) = \prod_{j=1}^{m} \int f(y | \theta) \cdot g_j(y) \, dy +``` + +where $g_j(y)$ is the measurement error distribution for observation $j$. + +In practice, this integral is approximated numerically or via Monte Carlo integration during MCMC sampling. + +### The UncertainData Class + +```cs +public class UncertainData : Data +{ + /// + /// The time index (e.g., year). + /// + public int Index { get; set; } + + /// + /// The central (most likely) value. + /// + public double Value { get; set; } + + /// + /// The measurement error distribution. + /// + public IUnivariateDistribution Distribution { get; set; } +} +``` + +### Supported Error Distributions + +Any distribution from the Numerics library can represent measurement error: + +| Distribution | Use Case | +|--------------|----------| +| `Normal` | Symmetric error, known standard deviation | +| `LogNormal` | Positive-only error, multiplicative uncertainty | +| `Triangular` | Bounded error with most likely value | +| `Uniform` | Bounded error, no preference within range | +| `TruncatedNormal` | Bounded symmetric error | + +### Creating Uncertain Data + +```cs +using Numerics.Distributions; + +// Normal measurement error: mean 85,000, SD 10,000 +df.UncertainSeries.Add(new UncertainData(1889, new Normal(85000, 10000))); + +// Log-normal error: geometric mean 75,000, multiplicative SD factor 1.2 +df.UncertainSeries.Add(new UncertainData(1913, new LogNormal(Math.Log(75000), Math.Log(1.2)))); + +// Triangular error: minimum 70,000, most likely 90,000, maximum 110,000 +df.UncertainSeries.Add(new UncertainData(1927, new Triangular(70000, 90000, 110000))); + +// Uniform error: anywhere between 50,000 and 70,000 equally likely +df.UncertainSeries.Add(new UncertainData(1862, new Uniform(50000, 70000))); +``` + +### Use Cases + +| Application | Description | +|-------------|-------------| +| Historical floods | High-water marks with indirect discharge estimates | +| Paleoflood peaks | Geologic evidence with dating and magnitude uncertainty | +| Reconstructed flows | Tree-ring or other proxy-based estimates | +| Regional estimates | Transferred data with uncertainty | + +--- + +## Interval Data + +### Definition + +**Interval-censored data** are observations where only the range is known — the true value lies somewhere between a lower and upper bound. This is common in paleoflood hydrology where geologic evidence constrains flood magnitude but doesn't provide exact values. + +### Mathematical Treatment + +The likelihood contribution from an interval-censored observation is the probability mass within the interval: + +```math +\mathcal{L}_{\text{interval}}(\theta) = \prod_{k=1}^{p} \left[ F(y_k^U | \theta) - F(y_k^L | \theta) \right] +``` + +where $F$ is the cumulative distribution function, $y_k^L$ is the lower bound, and $y_k^U$ is the upper bound. + +### The IntervalData Class + +```cs +public class IntervalData : Data +{ + /// + /// The time index (e.g., year or period identifier). + /// + public int Index { get; set; } + + /// + /// The lower bound of the interval. + /// + public double LowerValue { get; set; } + + /// + /// The most likely value (central estimate). + /// + public double Value { get; set; } + + /// + /// The upper bound of the interval. + /// + public double UpperValue { get; set; } +} +``` + +### Creating Interval Data + +```cs +// Paleoflood: between 80,000 and 120,000 cfs, most likely around 100,000 +// Index 1500 indicates approximate year (can also be arbitrary identifier) +df.IntervalSeries.Add(new IntervalData(1500, 80000, 100000, 120000)); + +// Another paleoflood event +df.IntervalSeries.Add(new IntervalData(1200, 90000, 110000, 130000)); +``` + +### Use Cases + +| Application | Description | +|-------------|-------------| +| Slackwater deposits | Floods that deposited sediment at known elevations | +| Scour indicators | Channel erosion suggesting minimum flood magnitude | +| Botanical evidence | Flood-scarred trees indicating stage range | +| Archaeological evidence | Flood damage to structures of known elevation | + +--- + +## Threshold Data + +### Definition + +**Threshold data** encode information about floods that did or did not exceed a perception threshold during a period of observation. This is crucial for extending records beyond the systematic gauging period using historical information. + +### Mathematical Treatment + +Threshold data contribute binomial-like terms to the likelihood. If $k$ floods exceeded threshold $y_T$ during a period of $n$ years: + +```math +\mathcal{L}_{\text{threshold}}(\theta) = \binom{n}{k} \left[ 1 - F(y_T | \theta) \right]^k \cdot \left[ F(y_T | \theta) \right]^{n-k} +``` + +The threshold also adjusts the conditional distribution of non-exceedances (floods that occurred but stayed below the threshold). + +### The ThresholdData Class + +```cs +public class ThresholdData : Data +{ + /// + /// The start year of the perception period. + /// + public int StartYear { get; set; } + + /// + /// The end year of the perception period. + /// + public int EndYear { get; set; } + + /// + /// The perception threshold value. + /// + public double Value { get; set; } + + /// + /// Effective number of events below the threshold after overlap processing. + /// + public int NumberBelow { get; internal set; } + + /// + /// User-supplied exceedance count; reads return the current effective count. + /// + public int NumberAbove { get; set; } +} +``` + +`NumberAbove` is the only count supplied by callers. Internally, the data frame retains that +source count separately and recomputes effective `NumberAbove` and `NumberBelow` values from it +whenever explicit, interval, uncertain, or threshold data change. Reprocessing is idempotent: +repeated calls with unchanged input produce the same counts. XML continues to store the source +count in the existing `NumberAbove` attribute, and effective counts are rebuilt when a data frame +is restored. + +### Creating Threshold Data + +```cs +// Historical period 1850-1920: threshold was 40,000 cfs (e.g., bridge deck elevation) +// We know no floods exceeded this threshold during these 71 inclusive years. +var threshold = new ThresholdData(1850, 1920, 40000) +{ + NumberAbove = 0 +}; +df.ThresholdSeries.Add(threshold); + +// Another period with two floods known to have exceeded the threshold. +var threshold2 = new ThresholdData(1800, 1850, 50000) +{ + NumberAbove = 2 +}; +df.ThresholdSeries.Add(threshold2); +``` + +### Use Cases + +| Application | Description | +|-------------|-------------| +| Historical accounts | Newspaper records of "highest flood since..." | +| Infrastructure evidence | Bridges, buildings that survived/were damaged | +| Paleostage indicators | Geologic evidence of non-exceedance bounds | +| Perception thresholds | Bulletin 17C systematic threshold analysis | + +--- + +## Plotting Positions + +### Theory + +Plotting positions assign empirical probabilities to ranked observations for display on frequency plots. For observation ranked $i$ out of $n$ (from largest to smallest), the general formula is: + +```math +p_i = \frac{i - a}{n + 1 - 2a} +``` + +where $a$ is the plotting position parameter. + +### Common Plotting Position Formulas + +| Name | Parameter $a$ | Formula | Recommended For | +|------|---------------|---------|-----------------| +| Weibull | 0.0 | $i/(n+1)$ | General use, unbiased for uniform | +| Hazen | 0.5 | $(i-0.5)/n$ | Quick approximation | +| Cunnane | 0.40 | $(i-0.4)/(n+0.2)$ | GEV, LP3 distributions | +| Gringorten | 0.44 | $(i-0.44)/(n+0.12)$ | Gumbel distribution | +| Blom | 0.375 | $(i-0.375)/(n+0.25)$ | Normal distribution | + +### Setting the Plotting Parameter + +```cs +// Create DataFrame +var df = new DataFrame(); +df.ExactSeries = new ExactSeries(annualPeaks); + +// Set plotting parameter (default is 0.0 for Weibull) +df.PlottingParameter = 0.0; // Weibull +// df.PlottingParameter = 0.44; // Gringorten +// df.PlottingParameter = 0.40; // Cunnane + +// Plotting positions are calculated automatically when the series changes +// Access via individual data items +foreach (var item in df.ExactSeries) +{ + Console.WriteLine($"Value: {item.Value}, Plotting Position: {item.PlottingPosition:F4}"); +} +``` + +### Recommendation + +Use **Weibull (a = 0.0)** as the default. It's the most commonly used formula in hydrology and makes no distributional assumptions about the data. + +--- + +## Combining Data Types + +### The Full Likelihood + +When a DataFrame contains multiple data types, the total likelihood is the product of contributions from each type: + +```math +\mathcal{L}(\theta) = \mathcal{L}_{\text{exact}}(\theta) \cdot \mathcal{L}_{\text{uncertain}}(\theta) \cdot \mathcal{L}_{\text{interval}}(\theta) \cdot \mathcal{L}_{\text{threshold}}(\theta) +``` + +Or in log-space: + +```math +\ell(\theta) = \ell_{\text{exact}}(\theta) + \ell_{\text{uncertain}}(\theta) + \ell_{\text{interval}}(\theta) + \ell_{\text{threshold}}(\theta) +``` + +***RMC-BestFit*** handles this automatically — simply populate the appropriate series and run the analysis. + +### Example: Complete Flood Frequency Dataset + +```cs +using RMC.BestFit.Models; +using Numerics.Distributions; + +var df = new DataFrame(); + +// 1. Systematic record (50 years of exact annual peaks) +var systematicPeaks = new double[] { + 45000, 52000, 38000, 61000, 49000, 55000, 42000, 67000, 39000, 48000, + 51000, 36000, 58000, 44000, 53000, 47000, 62000, 41000, 50000, 37000, + 54000, 46000, 59000, 43000, 56000, 40000, 63000, 35000, 57000, 45000, + 50000, 42000, 58000, 39000, 52000, 47000, 61000, 44000, 55000, 38000, + 49000, 53000, 36000, 60000, 46000, 54000, 41000, 57000, 43000, 48000 +}; +df.ExactSeries = new ExactSeries(systematicPeaks); + +// 2. Historical flood (1889) with uncertain magnitude +df.UncertainSeries.Add(new UncertainData(1889, new Normal(85000, 12000))); + +// 3. Paleoflood deposits indicating two ancient floods +df.IntervalSeries.Add(new IntervalData(1500, 70000, 90000, 110000)); +df.IntervalSeries.Add(new IntervalData(1200, 80000, 100000, 120000)); + +// 4. Historical perception threshold (no floods > 80,000 cfs from 1800-1889) +var threshold = new ThresholdData(1800, 1889, 80000); +threshold.NumberBelow = 89; +threshold.NumberAbove = 1; // The 1889 flood itself +df.ThresholdSeries.Add(threshold); + +// 5. Non-exceedance bound from paleostage (no floods > 150,000 cfs in last 500 years) +var paleoBound = new ThresholdData(1520, 2020, 150000); +paleoBound.NumberBelow = 500; +paleoBound.NumberAbove = 0; +df.ThresholdSeries.Add(paleoBound); + +Console.WriteLine($"Dataset summary:"); +Console.WriteLine($" Exact observations: {df.ExactSeries.Count}"); +Console.WriteLine($" Uncertain observations: {df.UncertainSeries.Count}"); +Console.WriteLine($" Interval observations: {df.IntervalSeries.Count}"); +Console.WriteLine($" Threshold periods: {df.ThresholdSeries.Count}"); +Console.WriteLine($" Total record length: {df.TotalRecordLength()} years"); +``` + +--- + +## XML Serialization + +DataFrames can be saved and loaded via XML for persistence: + +```cs +using System.Xml.Linq; + +// Save to XML +var xElement = df.ToXElement(); +xElement.Save("flood_data.xml"); + +// Load from XML +var loadedXml = XElement.Load("flood_data.xml"); +var loadedDf = new DataFrame(loadedXml); +``` + +--- + +## Validation + +Before running an analysis, validate the DataFrame: + +```cs +var validation = df.Validate(); +if (!validation.IsValid) +{ + Console.WriteLine("Validation errors:"); + foreach (var message in validation.ValidationMessages) + { + Console.WriteLine($" - {message}"); + } +} +else +{ + Console.WriteLine("DataFrame is valid."); +} +``` + +Common validation issues: +- Duplicate indexes across series +- Invalid values (NaN, Infinity) +- Inconsistent threshold periods +- Index out of valid range + +--- + +## Best Practices + +### Data Quality + +1. **Review outliers**: Use exploratory plots before analysis +2. **Document data sources**: Track provenance of each observation +3. **Quantify uncertainty honestly**: Don't understate measurement error +4. **Check temporal consistency**: Ensure indexes don't overlap incorrectly + +### Choosing Data Types + +| Question | Data Type | +|----------|-----------| +| Do you know the exact value? | ExactData | +| Is there significant measurement error? | UncertainData | +| Do you only know a range? | IntervalData | +| Do you know floods did/didn't exceed a level? | ThresholdData | + +### Sample Size Considerations + +| Model Complexity | Minimum Recommended Sample | +|------------------|---------------------------| +| 2-parameter (Normal) | 15-20 observations | +| 3-parameter (GEV, LP3) | 25-30 observations | +| Mixture models | 50+ observations | +| Regional models | 100+ site-years | + +Historical and paleoflood data can effectively increase sample size for extreme quantiles, even if they don't add precision at lower return periods. + +--- + +## Implementation Sources + +Primary source paths: `src/RMC.BestFit/Models/DataFrame/DataFrame.cs`, `src/RMC.BestFit/Models/DataFrame/DataTypes`, `src/RMC.BestFit/Models/DataFrame/DataCollections`, and `src/RMC.BestFit/Models/DataFrame/ThresholdDiagnostics.cs`. + +--- + +## References + +[1] +Stedinger, J.R. and Cohn, T.A. (1986). "Flood frequency analysis with historical and paleoflood information." *Water Resources Research*, 22(5), 785-793. + +[2] +O'Connell, D.R.H., Ostenaa, D.A., Levish, D.R., and Klinger, R.E. (2002). "Bayesian flood frequency analysis with paleohydrologic bound data." *Water Resources Research*, 38(5), 1058. + +[3] +England, J.F., Cohn, T.A., Faber, B.A., et al. (2019). *Guidelines for Determining Flood Flow Frequency: Bulletin 17C*. U.S. Geological Survey Techniques and Methods, Book 4, Chapter B5. + +[4] +Hosking, J.R.M. and Wallis, J.R. (1997). *Regional Frequency Analysis: An Approach Based on L-Moments*. Cambridge University Press. + +--- + +[<- Previous: Models Overview](../models/overview.md) | [Back to Index](../../index.md) | [Next: Distribution API ->](../distributions/index.md) diff --git a/docs/technical-reference/distributions/competing-risks.md b/docs/technical-reference/distributions/competing-risks.md new file mode 100644 index 0000000..01d780c --- /dev/null +++ b/docs/technical-reference/distributions/competing-risks.md @@ -0,0 +1,72 @@ +# Competing Risks + +[<- Previous: Mixture Models](mixture.md) | [Back to Index](../../index.md) | [Next: Point Process Models ->](point-process.md) + +`CompetingRisksModel` represents an annual outcome formed by the maximum or minimum of multiple processes. For flood frequency, the maximum form is most common: annual maximum flow is the largest event generated by any process in that year. + +## Mathematical Form + +For independent component variables `X_1, ..., X_K`, the CDF of their maximum is + +```math +P(\max(X_1,\ldots,X_K) \le x) = \prod_{k=1}^{K} F_k(x) +``` + +For a minimum, + +```math +P(\min(X_1,\ldots,X_K) \le x) = +1 - \prod_{k=1}^{K} (1 - F_k(x)) +``` + +BestFit evaluates this through `Numerics.Distributions.CompetingRisks`. The `MinimumOfRandomVariables` setting selects the minimum formulation; otherwise the maximum formulation is used. The dependency setting is passed through to the Numerics competing-risks distribution. + +The data likelihood uses the same BestFit data-frame decomposition as a univariate distribution, but every density, interval, and CDF call is evaluated on the configured competing-risks distribution. `PointwiseDataLogLikelihood(...)` mirrors that path for information criteria and diagnostics. + +## Public API + +| API | Purpose | +|-----|---------| +| `CompetingRisksModel` | Model-layer max/min process distribution | +| `CompetingRiskAnalysis` | Analysis workflow for Bayesian estimation | +| `DataLogLikelihood(...)` | Likelihood for the observed annual outcome | +| `GenerateRandomValues(...)` | Simulates annual outcomes from component processes | +| `SetParameterValues(...)` | Applies component parameter values | + +## Usage Pattern + +```cs +using RMC.BestFit.Analyses; +using RMC.BestFit.Models; + +var model = new CompetingRisksModel(); +var analysis = new CompetingRiskAnalysis(model); + +analysis.BayesianAnalysis.Iterations = 5000; +analysis.BayesianAnalysis.WarmupIterations = 2500; + +if (analysis.Validate().IsValid) +{ + await analysis.RunAsync(); +} +``` + +## Choosing Between Mixture And Competing Risks + +Use `MixtureModel` when each observation belongs to one latent population. Use `CompetingRisksModel` when multiple processes may occur and the recorded annual value is the maximum or minimum across them. + +## Implementation Notes + +The current implementation supports one to three component distributions. Parameter priors are evaluated for the component parameters, Jeffreys scale priors are applied per component scale parameter when enabled, and quantile priors are evaluated on the composite competing-risks quantile. + +## Implementation Sources + +Primary source paths: `src/RMC.BestFit/Models/UnivariateDistribution/CompetingRisksModel.cs`, `src/RMC.BestFit/Analyses/Univariate/CompetingRiskAnalysis.cs`, and `src/RMC.BestFit/Models/UnivariateDistribution/UnivariateDistributionModelBase.cs`. + +## References + +[1] V. T. Chow, D. R. Maidment, and L. W. Mays, *Applied Hydrology*. New York, NY, USA: McGraw-Hill, 1988. + +--- + +[<- Previous: Mixture Models](mixture.md) | [Back to Index](../../index.md) | [Next: Point Process Models ->](point-process.md) diff --git a/docs/technical-reference/distributions/composite.md b/docs/technical-reference/distributions/composite.md new file mode 100644 index 0000000..99c2cbd --- /dev/null +++ b/docs/technical-reference/distributions/composite.md @@ -0,0 +1,84 @@ +# Composite Distributions + +[<- Previous: Point Process Models](point-process.md) | [Back to Index](../../index.md) | [Next: Model Estimation ->](../estimation/index.md) + +Composite distributions combine multiple univariate analyses into a single distribution used for frequency results. BestFit exposes this through `CompositeAnalysis`, `WeightedUnivariateAnalysis`, `CompositeType`, and `AverageMethod`. + +## Composite Types + +| `CompositeType` | Meaning | Typical Use | +|-----------------|---------|-------------| +| `CompetingRisks` | Combines processes as a maximum or minimum | Rainfall vs. snowmelt annual maxima | +| `Mixture` | Weighted mixture of populations | Mixed flood populations | +| `ModelAverage` | Weighted average across candidate models | Model-form uncertainty | + +## Model Averaging Methods + +| `AverageMethod` | Weight Source | +|-----------------|---------------| +| `AIC` | Akaike Information Criterion from MLE fitting | +| `BIC` | Bayesian Information Criterion from MLE fitting | +| `DIC` | Bayesian deviance information criterion | +| `WAIC` | Watanabe-Akaike information criterion | +| `LOOIC` | PSIS leave-one-out information criterion | +| `Equal` | Equal component weights | +| `RMSE` | Plotting-position root mean square error | + +For `AIC`, `BIC`, `DIC`, `WAIC`, and `LOOIC`, `CompositeAnalysis.EstimateModelWeights()` gathers one criterion value from each successfully estimated child and passes the valid criterion vector to `GoodnessOfFit.AICWeights(...)`. For `RMSE`, it calls `GoodnessOfFit.RMSEWeights(...)`. For `Equal`, it assigns `1 / Analyses.Count` to every child. Unestimated children receive zero weight and are not allowed through `RunAsync(...)`. + +## Public API + +| Member | Purpose | +|--------|---------| +| `CompositeAnalysis()` | Creates an empty composite | +| `CompositeAnalysis(IEnumerable)` | Creates a composite from estimated child analyses | +| `Analyses` | Weighted child analyses | +| `CompositeDistributionType` | Selects competing risks, mixture, or model averaging | +| `ModelAverageMethod` | Selects the information criterion or weighting method | +| `Dependency` | Dependency assumption from Numerics probability helpers | +| `IsMaximum` | Max/min selection for competing risks | +| `ProbabilityOrdinates` | Output frequencies | +| `BayesianAnalysis` | Presentation and posterior-propagation settings | + +## Usage Pattern + +```cs +using RMC.BestFit.Analyses; + +var components = new[] +{ + new WeightedUnivariateAnalysis(firstAnalysis, 0.50), + new WeightedUnivariateAnalysis(secondAnalysis, 0.50) +}; + +var composite = new CompositeAnalysis(components) +{ + CompositeDistributionType = CompositeType.ModelAverage, + ModelAverageMethod = AverageMethod.WAIC +}; + +if (composite.Validate().IsValid) +{ + await composite.RunAsync(); +} +``` + +## Constraints + +`WeightedUnivariateAnalysis` rejects another `CompositeAnalysis` as a child. Composite-of-composite nesting is intentionally unsupported to avoid circular references and ambiguous weighting semantics. + +For mixture and model-average output, BestFit constructs a `Numerics.Distributions.Mixture` from the child point-estimate or posterior-realization distributions. If the supplied weights sum to less than one, the resulting mixture is marked zero-inflated and the residual mass is stored in `ZeroWeight`. For competing risks, BestFit constructs `Numerics.Distributions.CompetingRisks`, passes through `Dependency`, and sets `MinimumOfRandomVariables = !IsMaximum`. + +## Implementation Sources + +Primary source paths: `src/RMC.BestFit/Analyses/Univariate/CompositeAnalysis.cs`, `src/RMC.BestFit/Analyses/Univariate/WeightedUnivariateAnalysis.cs`, and `src/RMC.BestFit/Analyses/Univariate/UncertaintyAnalysisResults.cs`. + +## References + +[1] K. P. Burnham and D. R. Anderson, *Model Selection and Multimodel Inference*, 2nd ed. New York, NY, USA: Springer, 2002. + +[2] A. Vehtari, A. Gelman, and J. Gabry, "Practical Bayesian model evaluation using leave-one-out cross-validation and WAIC," *Statistics and Computing*, vol. 27, no. 5, pp. 1413-1432, 2017. + +--- + +[<- Previous: Point Process Models](point-process.md) | [Back to Index](../../index.md) | [Next: Model Estimation ->](../estimation/index.md) diff --git a/docs/technical-reference/distributions/index.md b/docs/technical-reference/distributions/index.md new file mode 100644 index 0000000..93ef56e --- /dev/null +++ b/docs/technical-reference/distributions/index.md @@ -0,0 +1,64 @@ +# Distribution API + +[<- Previous: Input Data Frame](../data-frame/index.md) | [Back to Index](../../index.md) | [Next: Univariate Distributions ->](univariate.md) + +BestFit distribution models wrap Numerics probability distributions with hydrologic likelihoods, priors, censoring support, posterior propagation, and analysis workflows. + +## Coverage Map + +| Page | Public API Covered | +|------|--------------------| +| [Univariate Distributions](univariate.md) | `UnivariateDistribution`, `UnivariateDistributionType`, quantile priors | +| [Mixture Models](mixture.md) | `MixtureModel`, `MixtureAnalysis` | +| [Competing Risks](competing-risks.md) | `CompetingRisksModel`, `CompetingRiskAnalysis` | +| [Point Process Models](point-process.md) | `PointProcessModel`, `PointProcessAnalysis` | +| [Composite Distributions](composite.md) | `CompositeAnalysis`, `CompositeType`, `AverageMethod`, `WeightedUnivariateAnalysis` | + +## Supported Univariate Distribution Types + +| Distribution | Enum Value | Typical Use | +|--------------|------------|-------------| +| Exponential | `Exponential` | Waiting times, threshold excess simplification | +| Gamma | `GammaDistribution` | Positive skewed variables | +| Generalized Extreme Value | `GeneralizedExtremeValue` | Annual maxima | +| Generalized Logistic | `GeneralizedLogistic` | Flexible hydrologic frequency analysis | +| Generalized Normal | `GeneralizedNormal` | Flexible symmetric and skewed data | +| Generalized Pareto | `GeneralizedPareto` | Peaks-over-threshold | +| Gumbel | `Gumbel` | Light-tailed annual maxima | +| Kappa Four | `KappaFour` | Flexible four-parameter frequency model | +| Ln-Normal | `LnNormal` | Natural-log transformed normal model | +| Logistic | `Logistic` | Symmetric distribution with heavier tails than Normal | +| Log-Normal | `LogNormal` | Positive multiplicative processes | +| Log-Pearson Type III | `LogPearsonTypeIII` | Bulletin 17C-style flood frequency | +| Normal | `Normal` | Symmetric or transformed data | +| Pearson Type III | `PearsonTypeIII` | Skewed data | +| Weibull | `Weibull` | Positive data and minima/extremes | + +## Common Construction Pattern + +```cs +using Numerics.Distributions; +using RMC.BestFit; +using RMC.BestFit.Models; + +var dataFrame = new DataFrame +{ + ExactSeries = new ExactSeries(new[] { 10.2, 12.7, 15.9, 18.4 }) +}; + +var model = new UnivariateDistribution( + dataFrame, + UnivariateDistributionType.GeneralizedExtremeValue); +``` + +## Organization Rule + +Use single-distribution pages for a fitted physical process. Use composite pages when multiple processes, populations, candidate models, or uncertainty in model choice are part of the analysis. + +## Implementation Sources + +Primary source paths: `src/RMC.BestFit/Models/UnivariateDistribution`, `src/RMC.BestFit/Analyses/Univariate`, and `src/RMC.BestFit/Models/BivariateDistribution`. + +--- + +[<- Previous: Input Data Frame](../data-frame/index.md) | [Back to Index](../../index.md) | [Next: Univariate Distributions ->](univariate.md) diff --git a/docs/technical-reference/distributions/mixture.md b/docs/technical-reference/distributions/mixture.md new file mode 100644 index 0000000..50f6435 --- /dev/null +++ b/docs/technical-reference/distributions/mixture.md @@ -0,0 +1,73 @@ +# Mixture Models + +[<- Previous: Univariate Distributions](univariate.md) | [Back to Index](../../index.md) | [Next: Competing Risks ->](competing-risks.md) + +`MixtureModel` represents a weighted mixture of flood-generating populations. It is appropriate when observations may arise from different physical populations, such as rainfall floods and snowmelt floods. + +## Mathematical Form + +For component distributions `F_k` with weights `w_k`, + +```math +F(x) = \sum_{k=1}^{K} w_k F_k(x), \quad \sum_{k=1}^{K} w_k = 1 +``` + +The corresponding density is + +```math +f(x) = \sum_{k=1}^{K} w_k f_k(x) +``` + +BestFit evaluates this through `Numerics.Distributions.Mixture`. The model parameter vector contains component distribution parameters plus mixture weights; `SetParameterValues(...)` pushes those values into the Numerics mixture before likelihood evaluation. + +For exact data, the contribution is the log density of the mixture at the observed value. Uncertain, interval, and threshold observations follow the same BestFit data-type semantics as `UnivariateDistribution`, but the density and CDF calls are made on the configured mixture distribution. `PointwiseDataLogLikelihood(...)` preserves that same decomposition for WAIC, LOO-CV, and diagnostics. + +## Public API + +| API | Purpose | +|-----|---------| +| `MixtureModel` | Model-layer mixture distribution | +| `MixtureAnalysis` | Bayesian workflow for the mixture model | +| `Parameters` | Component parameters plus mixture weights | +| `DataLogLikelihood(...)` | Mixture likelihood for BestFit data types | +| `GenerateRandomValues(...)` | Simulates from the weighted mixture | +| `SetParameterValues(...)` | Updates component parameters and weights | + +## Usage Pattern + +```cs +using RMC.BestFit.Analyses; +using RMC.BestFit.Models; + +var model = new MixtureModel(); +var analysis = new MixtureAnalysis(model); + +analysis.BayesianAnalysis.Iterations = 5000; +analysis.BayesianAnalysis.WarmupIterations = 2500; + +var validation = analysis.Validate(); +if (validation.IsValid) +{ + await analysis.RunAsync(); +} +``` + +## Interpretation + +Mixture weights represent the probability that a future event belongs to each latent population. They do not represent the probability of simultaneous processes; use [Competing Risks](competing-risks.md) for annual maxima formed from multiple concurrent processes. + +## Implementation Notes + +The current implementation supports one to three component distributions. Parameter priors are evaluated component-by-component. When Jeffreys scale priors are enabled, BestFit applies the scale-parameter penalty for each component distribution that exposes a scale parameter. Quantile priors are evaluated on the mixture quantile rather than on individual component quantiles. + +## Implementation Sources + +Primary source paths: `src/RMC.BestFit/Models/UnivariateDistribution/MixtureModel.cs`, `src/RMC.BestFit/Analyses/Univariate/MixtureAnalysis.cs`, and `src/RMC.BestFit/Models/UnivariateDistribution/UnivariateDistributionModelBase.cs`. + +## References + +[1] G. McLachlan and D. Peel, *Finite Mixture Models*. New York, NY, USA: Wiley, 2000. + +--- + +[<- Previous: Univariate Distributions](univariate.md) | [Back to Index](../../index.md) | [Next: Competing Risks ->](competing-risks.md) diff --git a/docs/technical-reference/distributions/point-process.md b/docs/technical-reference/distributions/point-process.md new file mode 100644 index 0000000..e4c7246 --- /dev/null +++ b/docs/technical-reference/distributions/point-process.md @@ -0,0 +1,79 @@ +# Point Process Models + +[<- Previous: Competing Risks](competing-risks.md) | [Back to Index](../../index.md) | [Next: Composite Distributions ->](composite.md) + +`PointProcessModel` supports peaks-over-threshold frequency analysis. Instead of modeling annual maxima, it models threshold exceedances and their occurrence rate. + +## Mathematical Form + +BestFit implements the nonhomogeneous extreme-value point-process likelihood using GEV-compatible location, scale, and shape parameters, not a standalone generalized-Pareto excess model. Let `u` be the threshold, `Ny` the number of observation years, and let the fitted GEV parameters be location $\mu$, scale $\sigma>0$, and Coles shape $\xi$. For an exceedance $x_i > u$: + +```math +z_i = 1 + \xi\frac{x_i-\mu}{\sigma}, \qquad z_i > 0 +``` + +For $\xi \ne 0$, BestFit uses: + +```math +\log L = +\sum_i \left[-\log \sigma - \left(1+\frac{1}{\xi}\right)\log z_i\right] +- N_y \left(1+\xi\frac{u-\mu}{\sigma}\right)^{-1/\xi} +``` + +For $|\xi| < 10^{-4}$, the implementation switches to the Gumbel limit: + +```math +\log L = +\sum_i \left[-\log\sigma-\frac{x_i-\mu}{\sigma}\right] +- N_y\exp\left[-\frac{u-\mu}{\sigma}\right]. +``` + +Numerics stores the GEV shape with Hosking's `Kappa`; BestFit converts it to the Coles sign convention internally by using $\xi=-\kappa$. + +## Public API + +| API | Purpose | +|-----|---------| +| `PointProcessModel` | Peaks-over-threshold model | +| `PointProcessAnalysis` | Bayesian analysis workflow | +| `DataLogLikelihood(...)` | Exceedance likelihood | +| `PointwiseDataLogLikelihood(...)` | WAIC/LOO-CV support | +| `GenerateRandomValues(...)` | Simulates threshold exceedance behavior | + +## Usage Pattern + +```cs +using RMC.BestFit.Analyses; +using RMC.BestFit.Models; + +var model = new PointProcessModel(); +var analysis = new PointProcessAnalysis(model); + +analysis.BayesianAnalysis.Iterations = 5000; +analysis.BayesianAnalysis.WarmupIterations = 2500; + +if (analysis.Validate().IsValid) +{ + await analysis.RunAsync(); +} +``` + +## Threshold Diagnostics + +Before fitting a point-process model, inspect threshold stability with `ThresholdDiagnostics`, `MeanResidualLifeResult`, and `ParameterStabilityResult` from the data-frame API. + +## Seasonal Option + +The seasonal implementation uses two GEV components and two day-of-year change points. BestFit validates `1 <= k1 < k2 <= 366`, assigns observations outside `[k1,k2)` to season 1 and observations inside `[k1,k2)` to season 2, and scales the Poisson rate term by each season's fraction of the year. + +## Implementation Sources + +Primary source paths: `src/RMC.BestFit/Models/UnivariateDistribution/PointProcessModel.cs`, `src/RMC.BestFit/Analyses/Univariate/PointProcessAnalysis.cs`, and `src/RMC.BestFit/Models/DataFrame/ThresholdDiagnostics.cs`. + +## References + +[1] S. Coles, *An Introduction to Statistical Modeling of Extreme Values*. London, U.K.: Springer, 2001. + +--- + +[<- Previous: Competing Risks](competing-risks.md) | [Back to Index](../../index.md) | [Next: Composite Distributions ->](composite.md) diff --git a/docs/technical-reference/distributions/univariate.md b/docs/technical-reference/distributions/univariate.md new file mode 100644 index 0000000..a7bc70e --- /dev/null +++ b/docs/technical-reference/distributions/univariate.md @@ -0,0 +1,94 @@ +# Univariate Distributions + +[<- Previous: Distribution API](index.md) | [Back to Index](../../index.md) | [Next: Mixture Models ->](mixture.md) + +`UnivariateDistribution` is the primary model for fitting one probability distribution to a `DataFrame`. It supports exact, uncertain, interval, and threshold observations through `DataLogLikelihood`, prior terms through `PriorLogLikelihood`, and pointwise likelihoods for WAIC/LOO-CV diagnostics. + +## Public API + +| Member | Purpose | +|--------|---------| +| `UnivariateDistribution()` | Creates a default Log-Pearson Type III model | +| `UnivariateDistribution(DataFrame, UnivariateDistributionBase)` | Wraps an existing Numerics distribution | +| `UnivariateDistribution(DataFrame, UnivariateDistributionType)` | Creates a distribution by enum | +| `DataFrame` | Input observations | +| `Distribution` | Underlying Numerics distribution | +| `Parameters` | Estimation parameters, bounds, priors, and fixed flags | +| `UseDefaultFlatPriors` | Applies broad uniform priors | +| `UseJeffreysRuleForScale` | Uses Jeffreys-style scale priors | +| `EnableQuantilePriors` | Adds engineering-judgment quantile prior penalties | +| `IsNonstationary` | Enables parameter trend models | +| `SetParameterValues(...)` | Pushes parameter values into the model and distribution | +| `GenerateRandomValues(...)` | Simulates from the fitted model | +| `Validate()` | Returns validation status and messages | + +## Likelihood Decomposition + +```math +\log p(\theta | y) = +\log p(y | \theta) + \log p(\theta) +``` + +`DataLogLikelihood` computes the first term and `PriorLogLikelihood` computes the second. `LogLikelihood` returns their sum. + +The source implementation evaluates the data term by observation type. Exact observations contribute `Distribution.LogLikelihood(value)` except low-outlier exact observations, which are converted to left-censored likelihood at `DataFrame.LowOutlierThreshold`. Uncertain observations integrate the product of the measurement-error density and the fitted distribution density over the central probability mass of the uncertainty distribution. Interval observations use `LogLikelihood_Intervals(lower, upper)`. Threshold records add below-threshold and above-threshold count contributions. + +For nonstationary models, `SetParameterValues(...)` updates trend-model coefficients and evaluates each trend at the data index before evaluating the distribution likelihood. `PointwiseDataLogLikelihood(...)` mirrors the same component decomposition for WAIC, LOO-CV, and influence diagnostics. + +## Example: MLE Then Bayesian Analysis + +```cs +using Numerics.Distributions; +using RMC.BestFit; +using RMC.BestFit.Analyses; +using RMC.BestFit.Estimation; +using RMC.BestFit.Models; + +var dataFrame = new DataFrame +{ + ExactSeries = new ExactSeries(new[] { 1100.0, 1250.0, 1430.0, 1710.0, 1620.0 }) +}; + +var model = new UnivariateDistribution( + dataFrame, + UnivariateDistributionType.GeneralizedExtremeValue); + +var mle = new MaximumLikelihood(model, OptimizationMethod.DifferentialEvolution); +if (mle.Estimate()) +{ + model.SetParameterValues(mle.BestParameterSet.Values); +} + +var analysis = new UnivariateAnalysis(model); +analysis.BayesianAnalysis.Iterations = 3000; +analysis.BayesianAnalysis.WarmupIterations = 1500; +await analysis.RunAsync(); +``` + +## Quantile Priors + +Quantile priors add prior penalties on derived quantiles rather than raw distribution parameters. They are useful when engineering judgment is naturally stated as a flow estimate at an annual exceedance probability. + +| Type | Public API | +|------|------------| +| `QuantilePrior` | Stores probability, expected quantile, uncertainty, and enabled state | +| `IQuantilePriors` | Interface for models exposing quantile priors | +| `QuantilePenalty` | Prior component used during likelihood evaluation | + +## Nonstationary Models + +When `IsNonstationary` is enabled, distribution parameters can be represented by trend functions. Trend and link functions are documented in [Trend and Link Functions](../support/trend-and-link-functions.md). + +## Implementation Sources + +Primary source paths: `src/RMC.BestFit/Models/UnivariateDistribution/UnivariateDistribution.cs`, `src/RMC.BestFit/Models/UnivariateDistribution/UnivariateDistributionModelBase.cs`, `src/RMC.BestFit/Models/UnivariateDistribution/QuantilePrior.cs`, `src/RMC.BestFit/Models/Support/ModelParameter.cs`, and `src/RMC.BestFit/Models/TrendFunctions`. + +## References + +[1] S. Coles, *An Introduction to Statistical Modeling of Extreme Values*. London, U.K.: Springer, 2001. + +[2] Interagency Advisory Committee on Water Data, *Guidelines for Determining Flood Flow Frequency, Bulletin 17C*, U.S. Geological Survey, 2019. + +--- + +[<- Previous: Distribution API](index.md) | [Back to Index](../../index.md) | [Next: Mixture Models ->](mixture.md) diff --git a/docs/technical-reference/estimation/diagnostics.md b/docs/technical-reference/estimation/diagnostics.md new file mode 100644 index 0000000..740b3e6 --- /dev/null +++ b/docs/technical-reference/estimation/diagnostics.md @@ -0,0 +1,117 @@ +# Diagnostics + +[<- Previous: Model Estimation](index.md) | [Back to Index](../../index.md) | [Next: Analyses Overview ->](../analysis/overview.md) + +BestFit diagnostics identify observations, priors, model assumptions, and fitted results that deserve engineering review. They are exposed through `RMC.BestFit.Diagnostics` and through helper methods on estimation classes. + +## Diagnostic Coverage + +| API | Purpose | +|-----|---------| +| `InfluenceDiagnostics` | PSIS-LOO influence summaries and Pareto-k classification | +| `LeverageDiagnostics` | MAP leverage decomposition into fit and variance influence | +| `PriorInfluenceDiagnostics` | Prior-component contribution and prior-to-data ratios | +| `PosteriorPredictiveCheck` | Simulates replicated datasets from posterior draws | +| `PriorPredictiveCheck` | Simulates datasets from priors before fitting | +| `PredictiveCheckResults` | Common posterior predictive p-values | +| `PredictiveSummary` | Quantile summaries for generated predictive datasets | +| `MaximumLikelihood.GetObservationInfluence()` | Data-only influence at MLE | +| `MaximumAPosteriori.GetObservationInfluence()` | Posterior influence at MAP | +| `BayesianAnalysis.ComputeInfluenceDiagnostics()` | PSIS-LOO influence from MCMC results | +| `BayesianAnalysis.ComputeLeverageDiagnostics()` | Leverage at MCMC MAP | +| `BayesianAnalysis.ComputePriorInfluenceDiagnostics()` | Prior influence from posterior samples | + +## PSIS-LOO Influence + +`InfluenceDiagnostics` stores one `ObservationInfluence` per observation or data component. Pareto `k` values classify the reliability of the PSIS approximation. + +| Category | Rule | Interpretation | +|----------|------|----------------| +| Good | `k < 0.5` | Stable approximation | +| Ok | `0.5 <= k < 0.7` | Monitor | +| Bad | `0.7 <= k < 1.0` | Influential observation | +| Very Bad | `k >= 1.0` | PSIS approximation may be unreliable | + +```cs +using RMC.BestFit.Diagnostics; + +InfluenceDiagnostics diagnostics = analysis.BayesianAnalysis.ComputeInfluenceDiagnostics(); + +foreach (var observation in diagnostics.GetProblematicObservations(0.7)) +{ + Console.WriteLine($"{observation.Index}: k = {observation.ParetoK:F3}"); +} +``` + +## Leverage Diagnostics + +`LeverageDiagnostics` decomposes each observation and prior component at the MAP estimate. + +```math +\text{Total Leverage} = +\text{Fit Influence} + \text{Variance Influence} +``` + +Fit influence is Cook's-distance-like movement in the MAP estimate. Variance influence measures how much the component contributes to posterior precision. + +```cs +using RMC.BestFit.Diagnostics; + +LeverageDiagnostics leverage = analysis.BayesianAnalysis.ComputeLeverageDiagnostics(); + +Console.WriteLine($"Total leverage: {leverage.TotalLeverage:F3}"); +Console.WriteLine($"Observation leverage: {leverage.TotalObservationLeverage:F3}"); +Console.WriteLine($"Prior leverage: {leverage.TotalPriorLeverage:F3}"); +``` + +## Prior Influence + +`PriorInfluenceDiagnostics` summarizes prior components by type and magnitude. It is useful when quantile priors or strong parameter priors may dominate limited data. + +```cs +using RMC.BestFit.Diagnostics; + +PriorInfluenceDiagnostics priors = + analysis.BayesianAnalysis.ComputePriorInfluenceDiagnostics(thinEvery: 10); + +foreach (var component in priors.GetMostConstrainingComponents(topN: 5)) +{ + Console.WriteLine($"{component.Name}: {component.MeanLogLikelihood:F3}"); +} +``` + +## Predictive Checks + +Posterior predictive checks simulate new data from posterior draws; prior predictive checks simulate from priors before fitting. + +```cs +using RMC.BestFit.Diagnostics; + +var posteriorCheck = new PosteriorPredictiveCheck(model, analysis.BayesianAnalysis.Results!); +PredictiveCheckResults pValues = posteriorCheck.ComputeCommonPValues(numberOfReplicates: 100); + +if (pValues.HasPotentialMisfit()) +{ + Console.WriteLine("At least one predictive p-value is near the tail."); +} +``` + +## Serialization + +`InfluenceDiagnostics`, `LeverageDiagnostics`, and `PriorInfluenceDiagnostics` support XML serialization through `ToXElement()` constructors or methods. Persist diagnostics with the analysis result when review reproducibility matters. + +## Implementation Sources + +Primary source paths: `src/RMC.BestFit/Diagnostics/InfluenceDiagnostics.cs`, `src/RMC.BestFit/Diagnostics/LeverageDiagnostics.cs`, `src/RMC.BestFit/Diagnostics/PriorInfluenceDiagnostics.cs`, `src/RMC.BestFit/Diagnostics/PosteriorPredictiveCheck.cs`, `src/RMC.BestFit/Diagnostics/PriorPredictiveCheck.cs`, and `src/RMC.BestFit/Estimation/BayesianAnalysis.cs`. + +## References + +[1] A. Vehtari, A. Gelman, and J. Gabry, "Practical Bayesian model evaluation using leave-one-out cross-validation and WAIC," *Statistics and Computing*, vol. 27, no. 5, pp. 1413-1432, 2017. + +[2] R. D. Cook, "Detection of influential observation in linear regression," *Technometrics*, vol. 19, no. 1, pp. 15-18, 1977. + +[3] B. C. Wei, Y. Q. Hu, and W. K. Fung, "Generalized leverage and its applications," *Scandinavian Journal of Statistics*, vol. 25, no. 1, pp. 25-36, 1998. + +--- + +[<- Previous: Model Estimation](index.md) | [Back to Index](../../index.md) | [Next: Analyses Overview ->](../analysis/overview.md) diff --git a/docs/technical-reference/estimation/index.md b/docs/technical-reference/estimation/index.md new file mode 100644 index 0000000..e963a0b --- /dev/null +++ b/docs/technical-reference/estimation/index.md @@ -0,0 +1,547 @@ +# Model Estimation + +[<- Previous: Composite Distributions](../distributions/composite.md) | [Back to Index](../../index.md) | [Next: Diagnostics ->](diagnostics.md) + +Parameter estimation is the process of finding the values that make a model best explain observed data. ***RMC-BestFit*** is **Bayesian estimation software first and foremost**, providing full uncertainty quantification through Markov Chain Monte Carlo (MCMC) sampling. It also supports Maximum Likelihood Estimation (MLE) for point estimates and the Generalized Method of Moments (GMM) for specific applications. + +This chapter provides in-depth coverage of all estimation methods available in ***RMC-BestFit***, including the mathematical foundations, practical implementation, convergence diagnostics, and model comparison techniques. + +## Why Estimation Method Matters + +Consider fitting a Generalized Extreme Value (GEV) distribution to 50 years of annual peak flows. Different estimation methods give different information: + +| Method | Output | Use Case | +|--------|--------|----------| +| **MLE** | Point estimates, standard errors | Quick analysis, operational forecasting | +| **Bayesian MCMC** | Full posterior distributions | Uncertainty quantification, decision-making under uncertainty | +| **GMM** | Point estimates, moment conditions | Robust to distributional assumptions | +| **MAP** | Posterior mode | Regularized point estimate with prior information | + +For life-safety applications like dam and levee risk assessment, **Bayesian MCMC is the recommended approach** because it properly propagates parameter uncertainty to design quantiles and risk estimates. + +--- + +## Maximum Likelihood Estimation (MLE) + +### Theoretical Foundation + +Maximum Likelihood Estimation finds the parameter values $\hat{\theta}$ that maximize the probability of observing the data: + +```math +\hat{\theta}_{\text{MLE}} = \arg\max_{\theta} \mathcal{L}(\theta | \mathbf{y}) = \arg\max_{\theta} \prod_{i=1}^{n} f(y_i | \theta) +``` + +Equivalently, we maximize the log-likelihood: + +```math +\hat{\theta}_{\text{MLE}} = \arg\max_{\theta} \ell(\theta) = \arg\max_{\theta} \sum_{i=1}^{n} \log f(y_i | \theta) +``` + +### Properties of MLE + +Under regularity conditions, MLE has desirable asymptotic properties: + +1. **Consistency**: $\hat{\theta}_{\text{MLE}} \xrightarrow{p} \theta_0$ as $n \to \infty$ +2. **Asymptotic Normality**: $\sqrt{n}(\hat{\theta}_{\text{MLE}} - \theta_0) \xrightarrow{d} N(0, I^{-1}(\theta_0))$ +3. **Efficiency**: Achieves the Cramér-Rao lower bound asymptotically + +where $I(\theta)$ is the Fisher Information matrix: + +```math +I(\theta) = -E\left[\frac{\partial^2 \ell(\theta)}{\partial \theta \partial \theta^T}\right] +``` + +### Implementation in RMC-BestFit + +The `MaximumLikelihood` class provides MLE for any model implementing `IModel`: + +```cs +using RMC.BestFit.Estimation; +using RMC.BestFit.Models; +using Numerics.Mathematics.Optimization; + +// Create a model (e.g., GEV distribution) +var df = new DataFrame(); +df.ExactSeries = new ExactSeries(annualPeakFlows); +var model = new UnivariateDistribution(df, UnivariateDistributionType.GeneralizedExtremeValue); + +// Configure MLE +var mle = new MaximumLikelihood(model); + +// Choose optimization method +mle.OptimizerMethod = OptimizationMethod.NelderMead; // Local optimizer (fast) +// mle.OptimizerMethod = OptimizationMethod.MultilevelSingleLinkage; // Global optimizer (robust) +// mle.OptimizerMethod = OptimizationMethod.DifferentialEvolution; // Global optimizer (very robust) + +// Run estimation +mle.Estimate(); + +// Check results +if (mle.IsEstimated) +{ + Console.WriteLine("MLE Results:"); + Console.WriteLine($" Log-likelihood: {mle.BestParameterSet.Fitness:F2}"); + + for (int i = 0; i < model.Parameters.Count; i++) + { + Console.WriteLine($" {model.Parameters[i].Name}: {mle.BestParameterSet.Values[i]:F4}"); + } + + // Apply estimates to model + model.SetParameterValues(mle.BestParameterSet.Values); +} +``` + +### Optimization Methods + +***RMC-BestFit*** provides several optimization algorithms through the Numerics library: + +| Method | Type | Speed | Robustness | Recommended For | +|--------|------|-------|------------|-----------------| +| `NelderMead` | Local | Fast | Low | Well-behaved likelihoods | +| `BFGS` | Local | Fast | Medium | Smooth likelihoods | +| `Powell` | Local | Medium | Medium | Non-differentiable objectives | +| `DifferentialEvolution` | Global | Slow | High | Multimodal likelihoods | +| `MultilevelSingleLinkage` | Global | Medium | High | General use | +| `ParticleSwarm` | Global | Medium | Medium | High-dimensional problems | + +**Recommendation**: Use `MultilevelSingleLinkage` for most applications. It balances computational cost with robustness to local optima. + +### Handling Multiple Local Optima + +Many hydrologic models have multimodal likelihoods. For example, rating curves with the power-law form $Q = \alpha(h - \xi)^\beta$ have parameter trade-offs: different combinations of $(\xi, \alpha, \beta)$ can produce similar fits. + +To increase confidence in finding the global optimum: + +```cs +// Run multiple optimizations from different starting points +var bestFitness = double.NegativeInfinity; +ParameterSet bestParams = default; + +for (int trial = 0; trial < 10; trial++) +{ + // Random starting point within bounds + var random = new Random(trial); + var startValues = new double[model.Parameters.Count]; + for (int i = 0; i < model.Parameters.Count; i++) + { + var p = model.Parameters[i]; + startValues[i] = p.LowerBound + random.NextDouble() * (p.UpperBound - p.LowerBound); + } + for (int i = 0; i < model.Parameters.Count; i++) + { + model.Parameters[i].Value = startValues[i]; + } + + var mle = new MaximumLikelihood(model, OptimizationMethod.NelderMead); + mle.Estimate(); + + if (mle.IsEstimated && mle.BestParameterSet.Fitness > bestFitness) + { + bestFitness = mle.BestParameterSet.Fitness; + bestParams = mle.BestParameterSet; + } +} + +Console.WriteLine($"Best log-likelihood across trials: {bestFitness:F2}"); +``` + +--- + +## Bayesian MCMC Estimation + +### Theoretical Foundation + +Bayesian inference treats parameters as random variables with probability distributions. We start with a **prior distribution** $\pi(\theta)$ encoding our beliefs before seeing data, then update to a **posterior distribution** after observing data $\mathbf{y}$: + +```math +\pi(\theta | \mathbf{y}) = \frac{\mathcal{L}(\mathbf{y} | \theta) \cdot \pi(\theta)}{\int \mathcal{L}(\mathbf{y} | \theta) \cdot \pi(\theta) \, d\theta} +``` + +This is **Bayes' theorem**. The denominator (marginal likelihood or evidence) is typically intractable, but MCMC methods sample from the posterior without computing it. + +### Why Bayesian? + +Bayesian estimation offers several advantages for hydrologic applications: + +1. **Full Uncertainty Quantification**: Get probability distributions, not just point estimates +2. **Natural Framework for Prediction**: Predictive distributions integrate over parameter uncertainty +3. **Incorporation of Prior Information**: Engineering judgment can inform priors +4. **Coherent Decision-Making**: Posterior distributions support risk-based decisions +5. **Flexible Model Comparison**: WAIC, LOO-CV, and Bayes factors + +### The DEMCzs Sampler + +***RMC-BestFit*** uses the **Differential Evolution Markov Chain with snooker update (DEMCzs)** algorithm [[1]](#1) as its primary MCMC sampler. DEMCzs is particularly well-suited for: + +- **High-dimensional problems**: Efficient even with 10+ parameters +- **Correlated parameters**: Adapts proposal scale and direction automatically +- **Multimodal posteriors**: Multiple chains can explore different modes + +The algorithm maintains $N$ chains (typically $N = 3$) that evolve in parallel. Each chain proposes new states using differences between other chains: + +```math +\theta^* = \theta^{(i)} + \gamma \cdot (\theta^{(r_1)} - \theta^{(r_2)}) + \varepsilon +``` + +where $r_1, r_2$ are randomly selected chains and $\gamma$ is a scaling factor. + +### Implementation in RMC-BestFit + +The `BayesianAnalysis` class provides MCMC estimation: + +```cs +using RMC.BestFit.Estimation; +using RMC.BestFit.Models; + +// Create and configure model +var df = new DataFrame(); +df.ExactSeries = new ExactSeries(annualPeakFlows); +var model = new UnivariateDistribution(df, UnivariateDistributionType.GeneralizedExtremeValue); + +// Configure Bayesian analysis +var bayesian = new BayesianAnalysis(model); + +// Sampling parameters +bayesian.Type = BayesianAnalysis.SamplerType.DEMCzs; // Default sampler +bayesian.Iterations = 10000; // Total iterations after warmup +bayesian.WarmupIterations = 5000; // Burn-in period (discarded) +bayesian.ThinningInterval = 1; // Keep every nth sample (1 = no thinning) + +// Run MCMC +await bayesian.RunAsync(); + +// Access results +var results = bayesian.Results; + +Console.WriteLine("Posterior Summary:"); +Console.WriteLine($"{"Parameter",-20} {"Mean",10} {"SD",10} {"2.5%",10} {"97.5%",10} {"R-hat",8} {"ESS",8}"); +Console.WriteLine(new string('-', 78)); + +for (int i = 0; i < model.Parameters.Count; i++) +{ + var stats = results.ParameterResults[i].SummaryStatistics; + Console.WriteLine($"{model.Parameters[i].Name,-20} {stats.Mean,10:F3} {stats.StandardDeviation,10:F3} " + + $"{stats.LowerCI,10:F3} {stats.UpperCI,10:F3} {stats.Rhat,8:F3} {stats.ESS,8:F0}"); +} +``` + +### Prior Distributions + +Prior distributions encode beliefs about parameters before seeing data. ***RMC-BestFit*** supports several approaches: + +#### Flat (Uniform) Priors + +Non-informative within bounds: + +```cs +model.Parameters[0].PriorDistribution = new Uniform(lowerBound, upperBound); +``` + +#### Weakly Informative Priors + +Regularize extreme values without strong assumptions: + +```cs +// Normal prior centered at expected value with wide standard deviation +model.Parameters[0].PriorDistribution = new Normal(expectedValue, 10 * expectedSD); +``` + +#### Informative Priors + +Incorporate engineering knowledge: + +```cs +// Prior based on regional analysis or expert judgment +model.Parameters[0].PriorDistribution = new Normal(regionalEstimate, regionalUncertainty); +``` + +#### Jeffreys' Prior for Scale Parameters + +For scale parameters (standard deviation, etc.), Jeffreys' prior is non-informative: + +```math +\pi(\sigma) \propto \frac{1}{\sigma} +``` + +This is the default when `UseJeffreysRuleForScale = true`: + +```cs +bayesian.UseJeffreysRuleForScale = true; // Default +``` + +### Quantile Priors + +A unique feature of ***RMC-BestFit*** is support for **quantile priors** — incorporating expert judgment about specific return levels: + +```cs +// Add prior information: "The 100-year flood is probably between 50,000 and 80,000 cfs" +var qPrior = new QuantilePrior(); +qPrior.ReturnPeriod = 100; +qPrior.Distribution = new Normal(65000, 7500); // Mean 65,000, SD 7,500 + +model.QuantilePriors.Add(qPrior); +``` + +Quantile priors are particularly valuable when: +- Historical or paleoflood information suggests a range for extreme quantiles +- Regional studies provide guidance on expected flood magnitudes +- Engineering judgment bounds the plausible range of design values + +### MCMC Diagnostics + +Successful MCMC requires verifying that chains have converged to the target distribution. + +#### R-hat (Potential Scale Reduction Factor) + +R-hat compares within-chain and between-chain variance [[2]](#2): + +```math +\hat{R} = \sqrt{\frac{\hat{\text{Var}}(\theta | \mathbf{y})}{W}} +``` + +where $W$ is the within-chain variance. **Rule of thumb**: $\hat{R} < 1.1$ indicates convergence. + +```cs +var rhat = results.ParameterResults[i].SummaryStatistics.Rhat; +if (rhat > 1.1) +{ + Console.WriteLine($"Warning: {model.Parameters[i].Name} has R-hat = {rhat:F3}. Consider more iterations."); +} +``` + +#### Effective Sample Size (ESS) + +ESS measures the number of independent samples accounting for autocorrelation: + +```math +\text{ESS} = \frac{nm}{1 + 2\sum_{k=1}^{\infty} \rho_k} +``` + +where $\rho_k$ is the lag-$k$ autocorrelation. **Rule of thumb**: ESS > 400 for reliable posterior summaries. + +```cs +var ess = results.ParameterResults[i].SummaryStatistics.ESS; +if (ess < 400) +{ + Console.WriteLine($"Warning: {model.Parameters[i].Name} has ESS = {ess:F0}. Consider more iterations or thinning."); +} +``` + +#### Visual Diagnostics + +Trace plots and density plots help diagnose convergence: + +```cs +// Access raw samples for plotting +var samples = results.Output; // List + +// Extract parameter values for custom visualization +var parameterSamples = new double[samples.Count]; +for (int j = 0; j < samples.Count; j++) +{ + parameterSamples[j] = samples[j].Values[parameterIndex]; +} + +// Use your preferred plotting library (e.g., ScottPlot, OxyPlot) +``` + +--- + +## Maximum A Posteriori (MAP) Estimation + +MAP estimation finds the mode of the posterior distribution: + +```math +\hat{\theta}_{\text{MAP}} = \arg\max_{\theta} \pi(\theta | \mathbf{y}) = \arg\max_{\theta} \left[ \ell(\theta) + \log \pi(\theta) \right] +``` + +MAP is a regularized version of MLE — the prior acts as a penalty on extreme parameter values. + +```cs +using RMC.BestFit.Estimation; + +var map = new MaximumAPosteriori(model); +map.Estimate(); + +if (map.IsEstimated) +{ + Console.WriteLine("MAP Estimates:"); + for (int i = 0; i < model.Parameters.Count; i++) + { + Console.WriteLine($" {model.Parameters[i].Name}: {map.BestParameterSet.Values[i]:F4}"); + } +} +``` + +**When to use MAP**: +- Quick point estimate with prior regularization +- Starting point for MCMC +- When full posterior isn't needed + +--- + +## Generalized Method of Moments (GMM) + +GMM estimates parameters by matching sample moments to theoretical moments: + +```math +\hat{\theta}_{\text{GMM}} = \arg\min_{\theta} g(\theta)^T W g(\theta) +``` + +where $g(\theta)$ is a vector of moment conditions and $W$ is a weighting matrix. + +```cs +using RMC.BestFit.Estimation; + +var gmm = new GeneralizedMethodOfMoments(model) +{ + EstimationStrategy = GeneralizedMethodOfMoments.GMMEstimationStrategy.Iterative, + MaxGMMIterations = 100 +}; + +if (gmm.Estimate()) +{ + Console.WriteLine("GMM Estimates:"); + for (int i = 0; i < model.Parameters.Count; i++) + { + Console.WriteLine($" {model.Parameters[i].Name}: {gmm.BestParameterSet.Values[i]:F4}"); + } + + Console.WriteLine($"Optimization passes: {gmm.GMMIterations}"); + Console.WriteLine($"Confirmed converged: {gmm.ConvergedWithinTolerance}"); +} +``` + +For iterative GMM, `GMMIterations` counts attempted optimization passes from 1 through +`MaxGMMIterations` and never exceeds the configured limit. `ConvergedWithinTolerance` is true +only when an iterative comparison pass satisfies the absolute parameter-distance or relative +objective-change criterion, including convergence on the final permitted pass. It remains false +for iteration exhaustion, optimizer failure, one-step and two-step strategies, and a one-pass run +that has no comparison. The confirmed state is persisted in GMM XML; legacy XML without the +attribute restores conservatively as not confirmed converged. + +**Advantages of GMM**: +- Robust to distributional misspecification +- Only requires moment assumptions, not full likelihood +- Efficient two-step estimator available + +**Note**: RMC-BestFit uses GMM only for specific applications. **MLE or Bayesian MCMC are preferred** for most analyses. + +--- + +## Model Comparison + +After fitting multiple models, we need criteria to select among them. + +### Information Criteria + +#### Akaike Information Criterion (AIC) + +```math +\text{AIC} = -2\ell(\hat{\theta}) + 2k +``` + +where $k$ is the number of parameters. Lower AIC is better. + +#### Bayesian Information Criterion (BIC) + +```math +\text{BIC} = -2\ell(\hat{\theta}) + k \log(n) +``` + +BIC penalizes complexity more heavily than AIC for $n > 7$. + +```cs +// Compute AIC/BIC after MLE +double logLik = mle.BestParameterSet.Fitness; +int k = model.Parameters.Count(p => !p.IsFixed); +int n = df.ExactSeries.Count; + +double aic = -2 * logLik + 2 * k; +double bic = -2 * logLik + k * Math.Log(n); + +Console.WriteLine($"AIC: {aic:F2}"); +Console.WriteLine($"BIC: {bic:F2}"); +``` + +### Deviance Information Criterion (DIC) + +DIC is a Bayesian alternative to AIC: + +```math +\text{DIC} = \bar{D} + p_D +``` + +where $\bar{D}$ is the posterior mean deviance and $p_D$ is the effective number of parameters. + +### WAIC and LOO-CV + +For fully Bayesian model comparison, ***RMC-BestFit*** supports: + +- **WAIC** (Widely Applicable Information Criterion): Uses pointwise predictive densities +- **LOO-CV** (Leave-One-Out Cross-Validation) with PSIS (Pareto-Smoothed Importance Sampling) + +These are computed from MCMC output using the `PointwiseDataLogLikelihood` method. + +--- + +## Practical Recommendations + +### Choosing an Estimation Method + +| Situation | Recommended Method | +|-----------|-------------------| +| Quick exploratory analysis | MLE | +| Uncertainty in design values matters | Bayesian MCMC | +| Prior information available | Bayesian MCMC | +| Very large dataset (n > 10,000) | MLE, then Bayesian if needed | +| Model selection among many candidates | MLE with AIC/BIC, then Bayesian for finalist | +| Life-safety decision | Bayesian MCMC (mandatory) | + +### MCMC Settings + +| Parameter | Typical Value | Guidance | +|-----------|---------------|----------| +| `Iterations` | 10,000-50,000 | More for complex models | +| `WarmupIterations` | 50% of Iterations | Allow chains to find high-probability region | +| `ThinningInterval` | 1-10 | Increase if storage is limited | +| Chains | 3+ | Minimum 3 for R-hat computation | + +### Troubleshooting Convergence + +| Problem | Symptom | Solution | +|---------|---------|----------| +| Slow mixing | High autocorrelation, low ESS | More iterations, reparameterize | +| Non-convergence | R-hat > 1.1 | Longer warmup, better initialization | +| Multimodality | Chains stuck in different regions | Use DEMCzs, more chains | +| Numerical issues | NaN in likelihood | Check parameter bounds, transforms | + +--- + +## Implementation Sources + +Primary source paths: `src/RMC.BestFit/Estimation/BayesianAnalysis.cs`, `src/RMC.BestFit/Estimation/MaximumLikelihood.cs`, `src/RMC.BestFit/Estimation/MaximumAPosteriori.cs`, `src/RMC.BestFit/Estimation/GeneralizedMethodOfMoments.cs`, `src/RMC.BestFit/Estimation/NumericalDiff.cs`, and `src/RMC.BestFit/Models/Support/IModel.cs`. + +--- + +## References + +[1] +ter Braak, C.J.F. and Vrugt, J.A. (2008). "Differential Evolution Markov Chain with snooker updater and fewer chains." *Statistics and Computing*, 18(4), 435-446. + +[2] +Gelman, A. and Rubin, D.B. (1992). "Inference from iterative simulation using multiple sequences." *Statistical Science*, 7(4), 457-472. + +[3] +Vehtari, A., Gelman, A., and Gabry, J. (2017). "Practical Bayesian model evaluation using leave-one-out cross-validation and WAIC." *Statistics and Computing*, 27(5), 1413-1432. + +[4] +Spiegelhalter, D.J., Best, N.G., Carlin, B.P., and van der Linde, A. (2002). "Bayesian measures of model complexity and fit." *Journal of the Royal Statistical Society: Series B*, 64(4), 583-639. + +[5] +Jeffreys, H. (1946). "An invariant form for the prior probability in estimation problems." *Proceedings of the Royal Society A*, 186, 453-461. + +--- + +[<- Previous: Composite Distributions](../distributions/composite.md) | [Back to Index](../../index.md) | [Next: Diagnostics ->](diagnostics.md) diff --git a/docs/technical-reference/estimation/influence-diagnostics.md b/docs/technical-reference/estimation/influence-diagnostics.md new file mode 100644 index 0000000..5e819b9 --- /dev/null +++ b/docs/technical-reference/estimation/influence-diagnostics.md @@ -0,0 +1,157 @@ +# Influence Diagnostics + +[<- Previous: Diagnostics](diagnostics.md) | [Back to Index](../../index.md) | [Next: Analyses Overview ->](../analysis/overview.md) + +BestFit exposes influence diagnostics for Bayesian MCMC, maximum likelihood, maximum a posteriori estimation, and generalized method of moments. The key implementation rule is that diagnostics are computed from the model's actual pointwise likelihood methods, so exact observations, uncertain observations, interval data, and threshold data enter the diagnostics through the same likelihood decomposition used during estimation. + +## Diagnostic Map + +| Estimation Method | Diagnostics | +|-------------------|-------------| +| Bayesian MCMC | PSIS-LOO influence, Pareto `k`, MAP leverage decomposition, prior influence | +| Maximum Likelihood | Cook's distance, DFBETAS-like observation influence | +| Maximum A Posteriori | Cook's distance, DFBETAS-like observation influence, MAP leverage decomposition | +| Generalized Method of Moments | Cook's distance, observation influence, influence diagnostics | + +## MAP Leverage + +At the MAP estimate $\hat{\boldsymbol{\theta}}$, BestFit computes the posterior observed information from the negative Hessian of `Model.LogLikelihood`: + +$$\mathbf{J}_{\text{post}} = -\nabla^2 \left[\log p(\mathbf{y}\mid\boldsymbol{\theta}) + \log p(\boldsymbol{\theta})\right]_{\boldsymbol{\theta}=\hat{\boldsymbol{\theta}}} \tag{1}$$ + +For observation $i$, the score vector is obtained by central differences on `Model.PointwiseDataLogLikelihood`: + +$$\mathbf{g}_i = \nabla_{\boldsymbol{\theta}}\log p(y_i\mid\boldsymbol{\theta})\big|_{\hat{\boldsymbol{\theta}}}. \tag{2}$$ + +The fit-influence component is Cook's-distance-like: + +$$D_i = \frac{1}{p}\mathbf{g}_i^\top\mathbf{J}_{\text{post}}^{-1}\mathbf{g}_i. \tag{3}$$ + +The variance-influence component uses the diagonal finite-difference information contribution $\mathbf{J}_i$ for the observation: + +$$V_i = \frac{1}{p}\left|\operatorname{tr}\left(\mathbf{J}_{\text{post}}^{-1}\mathbf{J}_i\right)\right|. \tag{4}$$ + +The displayed observation leverage is: + +$$\ell_i = D_i + V_i. \tag{5}$$ + +Prior components use the same fit-influence quadratic form with scores from `Model.PointwisePriorLogLikelihood`. For prior variance influence, BestFit removes one prior component from the posterior log likelihood, recomputes the local covariance, and reports the generalized-variance change. `LeverageDiagnostics` sums all observation and prior leverages and reports percentages of that computed total; it logs a debug warning if the total differs from the parameter count $p$ by more than 50 percent, but it does not rescale the values to force equality. + +## Numerical Differentiation + +The Hessian and score calculations route through `NumericalDiff` where possible. The initial step for parameter $j$ is: + +$$h_j = \max\!\left(10^{-4}(|\theta_j| + 1),\;10^{-8}\right). \tag{6}$$ + +Flat finite-difference directions are escalated by a factor of 4 up to $10^{-2}$. This is important for hydrologic distributions with near-zero shape or skew parameters, where too-small perturbations can numerically land on the same approximation branch. + +Diagonal Hessian entries use the three-point central formula: + +$$H_{jj} = \frac{f(\boldsymbol{\theta}+h_j\mathbf{e}_j)-2f(\boldsymbol{\theta})+f(\boldsymbol{\theta}-h_j\mathbf{e}_j)}{h_j^2}. \tag{7}$$ + +Off-diagonal entries use the four-point cross-partial formula: + +$$H_{jk} = \frac{f(\boldsymbol{\theta}+h_j\mathbf{e}_j+h_k\mathbf{e}_k)-f(\boldsymbol{\theta}+h_j\mathbf{e}_j-h_k\mathbf{e}_k)-f(\boldsymbol{\theta}-h_j\mathbf{e}_j+h_k\mathbf{e}_k)+f(\boldsymbol{\theta}-h_j\mathbf{e}_j-h_k\mathbf{e}_k)}{4h_jh_k}. \tag{8}$$ + +If direct inversion of $\mathbf{J}_{\text{post}}$ fails, `LeverageDiagnostics` applies `MatrixRegularization.MakeSymmetricPositiveDefinite` and retries the inversion. + +## PSIS-LOO Influence + +For posterior draw $s$ and data component $i$, BestFit forms raw log importance weights: + +$$\log r_{is} = -\log p(y_i\mid\boldsymbol{\theta}^{(s)}). \tag{9}$$ + +Weights are shifted by their maximum log weight to avoid overflow. For each observation, the number of upper-tail weights used in Pareto smoothing is: + +$$M = \min\!\left(\left\lfloor S/5 \right\rfloor,\left\lfloor 3\sqrt{S} \right\rfloor\right), \qquad 3 \le M \le S-1. \tag{10}$$ + +BestFit fits the upper tail with `Numerics.Distributions.GeneralizedPareto.MLE`. Numerics uses Hosking's shape parameter $\kappa$, whose sign is opposite the PSIS convention, so the reported diagnostic is: + +$$\hat{k}_{\text{PSIS}} = -\hat{\kappa}_{\text{Numerics}}. \tag{11}$$ + +If the GPD MLE throws, the implementation falls back to a moment estimate from the tail weights. The final $k$ is clamped to $[-0.5, 1.5]$; smoothing is applied only when $-0.5 < k < 1$. Tail weights are replaced by GPD expected order-statistic quantiles: + +$$Q(p_j)=\frac{\hat{\sigma}}{\hat{k}}\left[(1-p_j)^{-\hat{k}}-1\right],\qquad p_j=\frac{j+0.5}{M}. \tag{12}$$ + +When $|\hat{k}|<10^{-8}$, BestFit uses the exponential limit: + +$$Q(p_j)=-\hat{\sigma}\log(1-p_j). \tag{13}$$ + +The pointwise expected log predictive density is: + +$$\widehat{\operatorname{elpd}}_{\text{loo},i} = \log \sum_{s=1}^{S} w_{is}p(y_i\mid\boldsymbol{\theta}^{(s)}), \tag{14}$$ + +where $w_{is}$ are the smoothed and normalized importance weights. + +## Aggregate LOO Statistics + +`BayesianAnalysis` stores: + +$$\operatorname{LOOIC} = -2\sum_i \widehat{\operatorname{elpd}}_{\text{loo},i}, \tag{15}$$ + +$$p_{\text{loo}} = \sum_i \operatorname{lppd}_i - \sum_i \widehat{\operatorname{elpd}}_{\text{loo},i}, \tag{16}$$ + +$$\operatorname{SE}(\operatorname{LOOIC}) = 2\sqrt{n\,\operatorname{Var}(\widehat{\operatorname{elpd}}_{\text{loo},i})}. \tag{17}$$ + +The variance in (17) is the sample variance across pointwise ELPD values with denominator $n-1$. + +## Pareto K Categories + +| Category | Rule | Interpretation | +|----------|------|----------------| +| Good | $k < 0.5$ | PSIS approximation is stable | +| OK | $0.5 \le k < 0.7$ | Moderate influence; monitor | +| Bad | $0.7 \le k < 1.0$ | High influence; exact LOO may be needed | +| Very Bad | $k \ge 1.0$ | Observation dominates the importance weights | + +## MLE And MAP Influence + +`MaximumLikelihood.GetCooksDistance()` computes equation (3) using the data-only Hessian from `Model.DataLogLikelihood`. `MaximumAPosteriori.GetCooksDistance()` uses the full posterior Hessian from `Model.LogLikelihood`. + +Both `GetObservationInfluence()` methods return DFBETAS-like parameter influence: + +$$\operatorname{DFBETAS}_{ij} = \frac{(\mathbf{J}^{-1}\mathbf{g}_i)_j}{\operatorname{SE}_j}, \tag{18}$$ + +where $\operatorname{SE}_j$ is the square root of the $j$th diagonal element of the covariance approximation. + +## Data Components + +Each observation's pointwise likelihood contribution comes from the model implementation: + +| Type | Contribution | +|------|--------------| +| Exact | $\log f(y_i\mid\boldsymbol{\theta})$ | +| Uncertain | $\log \int f(q\mid\boldsymbol{\theta})g_i(q)\,dq$ evaluated by the model's quadrature routine | +| Interval | $\log [F(b_i\mid\boldsymbol{\theta}) - F(a_i\mid\boldsymbol{\theta})]$ | +| Threshold below | $n_{\text{below}}\log F(c_i\mid\boldsymbol{\theta})$ | +| Threshold above | $n_{\text{above}}\log[1-F(c_i\mid\boldsymbol{\theta})]$ | + +Threshold data contributes one diagnostic row per threshold component with `Count` set to the number of represented years. + +## Implementation Classes + +| Class | Purpose | +|-------|---------| +| `InfluenceDiagnostics` | Container for PSIS-LOO results, Pareto `k`, ELPD-LOO, and observation metadata | +| `LeverageDiagnostics` | Computes and stores MAP leverage decomposition | +| `PriorInfluenceDiagnostics` | Summarizes prior components across thinned posterior samples | +| `BayesianAnalysis` | Entry points: `ComputeInfluenceDiagnostics()`, `ComputeLeverageDiagnostics()`, `ComputePriorInfluenceDiagnostics()` | +| `MaximumLikelihood` | MLE Cook's distance and DFBETAS-like influence | +| `MaximumAPosteriori` | MAP Cook's distance, DFBETAS-like influence, and leverage | +| `GeneralizedMethodOfMoments` | GMM Cook's distance and influence summaries | + +## References + +[1] A. Vehtari, A. Gelman, and J. Gabry, "Practical Bayesian model evaluation using leave-one-out cross-validation and WAIC," *Statistics and Computing*, vol. 27, no. 5, pp. 1413-1432, 2017. + +[2] R. D. Cook, "Detection of influential observation in linear regression," *Technometrics*, vol. 19, no. 1, pp. 15-18, 1977. + +[3] B. C. Wei, Y. Q. Hu, and W. K. Fung, "Generalized leverage and its applications," *Scandinavian Journal of Statistics*, vol. 25, no. 1, pp. 25-36, 1998. + +## Implementation Sources + +Primary source paths: `src/RMC.BestFit/Estimation/BayesianAnalysis.cs`, `src/RMC.BestFit/Estimation/MaximumLikelihood.cs`, `src/RMC.BestFit/Estimation/MaximumAPosteriori.cs`, `src/RMC.BestFit/Estimation/GeneralizedMethodOfMoments.cs`, `src/RMC.BestFit/Estimation/NumericalDiff.cs`, `src/RMC.BestFit/Diagnostics/InfluenceDiagnostics.cs`, `src/RMC.BestFit/Diagnostics/LeverageDiagnostics.cs`, and `src/RMC.BestFit/Diagnostics/PriorInfluenceDiagnostics.cs`. + +--- + +[<- Previous: Diagnostics](diagnostics.md) | [Back to Index](../../index.md) | [Next: Analyses Overview ->](../analysis/overview.md) diff --git a/docs/technical-reference/models/overview.md b/docs/technical-reference/models/overview.md new file mode 100644 index 0000000..b166655 --- /dev/null +++ b/docs/technical-reference/models/overview.md @@ -0,0 +1,428 @@ +# Models Overview + +[<- Previous: Getting Started](../../getting-started.md) | [Back to Index](../../index.md) | [Next: Input Data Frame ->](../data-frame/index.md) + +In ***RMC-BestFit***, a **model** is any mathematical representation of a physical process that can be fitted to data. Models define the relationship between parameters and observations, specify prior distributions for Bayesian inference, and compute likelihood functions that measure how well parameter values explain the data. + +This chapter introduces the model architecture in ***RMC-BestFit***, describes the core interfaces that all models implement, surveys the pre-built models available in the library, and demonstrates how to create custom models for specialized applications. + +## Why Models Matter + +Statistical analysis begins with a model — an assumption about how nature generates the data we observe. A flood frequency analyst might assume annual peak flows follow a Log-Pearson Type III distribution. A rating curve developer assumes discharge is a power function of stage. A regional hydrologist might assume extreme value parameters vary smoothly with elevation and distance from the coast. + +The choice of model determines: +- **What questions we can answer**: A stationary model cannot detect trends; a univariate model cannot capture dependence between variables +- **What parameters we estimate**: Each model has its own set of interpretable parameters +- **How uncertainty propagates**: The model structure determines how parameter uncertainty translates to predictive uncertainty +- **What data are required**: Complex models need more data to estimate reliably + +***RMC-BestFit*** provides a rich library of pre-built models for common hydrologic applications, along with a flexible framework for creating custom models when the built-in options don't fit your needs. + +## The Model Architecture + +### The `IModel` Interface + +All models in ***RMC-BestFit*** implement the `IModel` interface, which defines the contract for Bayesian inference: + +```cs +public interface IModel +{ + /// + /// Gets the list of model parameters with their prior distributions. + /// + List Parameters { get; } + + /// + /// Computes the log-posterior: log-likelihood plus log-prior. + /// + double LogLikelihood(double[] parameters); + + /// + /// Computes only the data log-likelihood (excludes priors). + /// + double DataLogLikelihood(double[] parameters); + + /// + /// Computes only the log-prior. + /// + double PriorLogLikelihood(double[] parameters); + + /// + /// Computes pointwise log-likelihood for each observation. + /// Required for WAIC and LOO-CV diagnostics. + /// + double[] PointwiseDataLogLikelihood(double[] parameters); + + /// + /// Computes pointwise log-prior for each parameter. + /// + double[] PointwisePriorLogLikelihood(double[] parameters); +} +``` + +The distinction between `LogLikelihood` and `DataLogLikelihood` is crucial for Bayesian inference: + +- **`LogLikelihood`** returns $\log p(\text{data} | \theta) + \log \pi(\theta)$ — the unnormalized log-posterior. This is what MCMC samplers maximize. + +- **`DataLogLikelihood`** returns only $\log p(\text{data} | \theta)$ — the likelihood contribution. This is used for computing DIC, WAIC, and other model comparison criteria. + +- **`PriorLogLikelihood`** returns $\log \pi(\theta)$ — the prior contribution. Useful for prior predictive checks. + +### The `ModelParameter` Class + +Each parameter in a model is represented by a `ModelParameter` object that encapsulates: + +```cs +public class ModelParameter : INotifyPropertyChanged +{ + /// + /// The parameter name (for display and serialization). + /// + public string Name { get; set; } + + /// + /// The current parameter value. + /// + public double Value { get; set; } + + /// + /// The prior distribution for Bayesian inference. + /// + public IUnivariateDistribution PriorDistribution { get; set; } + + /// + /// Lower bound for optimization and sampling. + /// + public double LowerBound { get; set; } + + /// + /// Upper bound for optimization and sampling. + /// + public double UpperBound { get; set; } + + /// + /// Whether the parameter is fixed (not estimated). + /// + public bool IsFixed { get; set; } + + /// + /// The parameter transform for MCMC sampling. + /// + public ParameterTransform Transform { get; set; } +} +``` + +**Key features:** + +- **Prior distributions**: Any `IUnivariateDistribution` can serve as a prior — Normal, Uniform, Gamma, etc. +- **Bounds**: Physical constraints (e.g., variance must be positive) are enforced via bounds +- **Fixed parameters**: Set `IsFixed = true` to hold a parameter constant during estimation +- **Transforms**: Parameters can be sampled on transformed scales (log, logit) for better MCMC mixing + +### Parameter Transforms + +MCMC sampling works best when parameters are unbounded and roughly symmetric. The `ParameterTransform` enum provides common transformations: + +| Transform | Forward | Inverse | Use Case | +|-----------|---------|---------|----------| +| `None` | $\theta$ | $\theta$ | Unbounded location parameters | +| `Log` | $\log(\theta)$ | $\exp(\phi)$ | Positive scale parameters | +| `Logit` | $\text{logit}(\theta)$ | $\text{logit}^{-1}(\phi)$ | Parameters bounded in (0,1) | +| `Asinh` | $\text{asinh}(\theta)$ | $\text{sinh}(\phi)$ | Heavy-tailed or near-zero parameters | + +When a transform is specified, MCMC sampling occurs in the transformed space $\phi$, and the Jacobian of the transformation is automatically included in the likelihood. + +--- + +## Pre-Built Models + +***RMC-BestFit*** includes a comprehensive library of models for hydrologic and statistical applications. + +### Univariate Distributions + +The library supports 15 univariate probability distributions commonly used in flood frequency analysis: + +| Distribution | Class | Parameters | Typical Application | +|--------------|-------|------------|---------------------| +| Normal | `Normal` | $\mu, \sigma$ | Symmetric data, transformed flows | +| Log-Normal | `LogNormal` | $\mu, \sigma$ | Right-skewed flows, rainfall | +| Ln-Normal | `LnNormal` | $\mu_{\ln}, \sigma_{\ln}$ | Alternative log-normal parameterization | +| Exponential | `Exponential` | $\xi, \alpha$ | Peaks-over-threshold excesses | +| Gamma | `GammaDistribution` | $\xi, \alpha, \beta$ | Precipitation depths | +| Gumbel | `Gumbel` | $\xi, \alpha$ | Annual maxima (light upper tail) | +| **GEV** | `GeneralizedExtremeValue` | $\xi, \alpha, \kappa$ | Annual maxima (flexible tail) | +| Generalized Logistic | `GeneralizedLogistic` | $\xi, \alpha, \kappa$ | UK flood frequency standard | +| Generalized Normal | `GeneralizedNormal` | $\xi, \alpha, \kappa$ | Symmetric with flexible kurtosis | +| **Generalized Pareto** | `GeneralizedPareto` | $\xi, \alpha, \kappa$ | Peaks-over-threshold | +| Logistic | `Logistic` | $\xi, \alpha$ | Growth curves, bounded data | +| Kappa Four | `KappaFour` | $\xi, \alpha, \kappa, h$ | Very flexible 4-parameter family | +| Pearson Type III | `PearsonTypeIII` | $\mu, \sigma, \gamma$ | General skewed data | +| **Log-Pearson Type III** | `LogPearsonTypeIII` | $\mu, \sigma, \gamma$ | Bulletin 17C standard | +| Weibull | `Weibull` | $\xi, \alpha, \kappa$ | Minima, low flows, wind speeds | + +The distributions in **bold** are most commonly used in flood frequency analysis. + +### Distribution Extensions + +Beyond simple univariate fitting, ***RMC-BestFit*** supports: + +| Model Type | Class | Description | +|------------|-------|-------------| +| **Mixture** | `MixtureModel` | Two-population models (e.g., rain vs. snowmelt floods) | +| **Competing Risks** | `CompetingRisksModel` | Annual maximum of multiple processes | +| **Nonstationary** | `UnivariateDistribution` with trends | Time-varying parameters | +| **Peaks-Over-Threshold** | `PointProcessModel` | Exceedances above threshold | + +### Time Series Models + +For temporal dependence modeling: + +| Model | Class | Order | Description | +|-------|-------|-------|-------------| +| AR(p) | `AutoRegressive` | $p$ | Autoregressive | +| MA(q) | `MovingAverage` | $q$ | Moving average | +| ARIMA(p,d,q) | `ARIMA` | $p, d, q$ | Integrated ARMA | +| ARIMAX | `ARIMAX` | $p, d, q$ + covariates | ARIMA with exogenous variables | + +### Rating Curves + +Stage-discharge relationships: + +| Model | Class | Segments | Parameters | +|-------|-------|----------|------------| +| Power Law | `RatingCurve` | 1 | $\xi, \log_{10}\alpha, \beta, \sigma$ | +| Two-Segment | `RatingCurve` | 2 | + $h_2, \log_{10}\alpha_2, \beta_2$ | +| Three-Segment | `RatingCurve` | 3 | + $h_3, \log_{10}\alpha_3, \beta_3$ | + +### Spatial Models + +For regional frequency analysis: + +| Model | Class | Description | +|-------|-------|-------------| +| Spatial GEV | `SpatialGEV` | GEV with spatially-varying parameters | +| Gaussian Copula | `GaussianCopula` | Spatial dependence structure | + +### Bivariate Distributions + +For joint analysis of two variables: + +| Model | Class | Description | +|-------|-------|-------------| +| Bivariate | `BivariateDistribution` | Copula-based joint distribution | +| Coincident Frequency | `CoincidentFrequencyAnalysis` | Joint exceedance probabilities | + +--- + +## Creating Custom Models + +When the pre-built models don't fit your needs, you can create custom models by implementing the `IModel` interface. + +### Example: Custom Regression Model + +Consider a simple linear regression model: $y_i = \alpha + \beta x_i + \varepsilon_i$ where $\varepsilon_i \sim N(0, \sigma^2)$. + +```cs +using Numerics.Distributions; +using RMC.BestFit; +using RMC.BestFit.Models; + +public class LinearRegressionModel : IModel +{ + private readonly double[] _x; + private readonly double[] _y; + + public LinearRegressionModel(double[] x, double[] y) + { + _x = x ?? throw new ArgumentNullException(nameof(x)); + _y = y ?? throw new ArgumentNullException(nameof(y)); + + if (x.Length != y.Length) + throw new ArgumentException("x and y must have same length"); + + // Initialize parameters with flat priors + Parameters = new List + { + new ModelParameter + { + Name = "α (intercept)", + Value = 0, + LowerBound = -1000, + UpperBound = 1000, + PriorDistribution = new Uniform(-1000, 1000) + }, + new ModelParameter + { + Name = "β (slope)", + Value = 0, + LowerBound = -100, + UpperBound = 100, + PriorDistribution = new Uniform(-100, 100) + }, + new ModelParameter + { + Name = "σ (error SD)", + Value = 1, + LowerBound = 1e-6, + UpperBound = 100, + PriorDistribution = new Uniform(1e-6, 100), + Transform = ParameterTransform.Log // Sample on log scale + } + }; + } + + public List Parameters { get; } + + public double LogLikelihood(double[] parameters) + { + return DataLogLikelihood(parameters) + PriorLogLikelihood(parameters); + } + + public double DataLogLikelihood(double[] parameters) + { + double alpha = parameters[0]; + double beta = parameters[1]; + double sigma = parameters[2]; + + if (sigma <= 0) return double.NegativeInfinity; + + double logLik = 0; + for (int i = 0; i < _x.Length; i++) + { + double predicted = alpha + beta * _x[i]; + double residual = _y[i] - predicted; + logLik += Normal.StandardPDF(residual / sigma) - Math.Log(sigma); + } + return logLik; + } + + public double PriorLogLikelihood(double[] parameters) + { + double logPrior = 0; + for (int i = 0; i < Parameters.Count; i++) + { + if (!Parameters[i].IsFixed) + { + logPrior += Parameters[i].PriorDistribution.LogPDF(parameters[i]); + } + } + return logPrior; + } + + public double[] PointwiseDataLogLikelihood(double[] parameters) + { + double alpha = parameters[0]; + double beta = parameters[1]; + double sigma = parameters[2]; + + var pointwise = new double[_x.Length]; + for (int i = 0; i < _x.Length; i++) + { + double predicted = alpha + beta * _x[i]; + double residual = _y[i] - predicted; + pointwise[i] = Normal.StandardPDF(residual / sigma) - Math.Log(sigma); + } + return pointwise; + } + + public double[] PointwisePriorLogLikelihood(double[] parameters) + { + var pointwise = new double[Parameters.Count]; + for (int i = 0; i < Parameters.Count; i++) + { + pointwise[i] = Parameters[i].IsFixed ? 0 : + Parameters[i].PriorDistribution.LogPDF(parameters[i]); + } + return pointwise; + } +} +``` + +### Using the Custom Model + +Once implemented, custom models work with all ***RMC-BestFit*** estimation methods: + +```cs +// Create model with data +var model = new LinearRegressionModel(xData, yData); + +// Maximum Likelihood Estimation +var mle = new MaximumLikelihood(model); +mle.Estimate(); +Console.WriteLine($"MLE intercept: {mle.BestParameterSet.Values[0]:F3}"); +Console.WriteLine($"MLE slope: {mle.BestParameterSet.Values[1]:F3}"); + +// Bayesian MCMC +var bayesian = new BayesianAnalysis(model); +bayesian.Iterations = 10000; +bayesian.WarmupIterations = 5000; +await bayesian.RunAsync(); + +var results = bayesian.Results; +Console.WriteLine($"Posterior mean intercept: {results.ParameterResults[0].SummaryStatistics.Mean:F3}"); +Console.WriteLine($"Posterior mean slope: {results.ParameterResults[1].SummaryStatistics.Mean:F3}"); +``` + +### Best Practices for Custom Models + +1. **Return `-∞` for invalid parameters**: If parameters violate constraints (e.g., negative variance), return `double.NegativeInfinity` rather than throwing an exception. + +2. **Use logarithmic computation**: When multiplying many probabilities, work in log-space to avoid numerical underflow. + +3. **Guard against numerical issues**: Check for `NaN`, `Inf`, and division by zero. + +4. **Implement pointwise likelihoods**: These are required for WAIC and LOO-CV model comparison. + +5. **Use appropriate transforms**: Scale parameters should typically use `ParameterTransform.Log`. + +6. **Document parameter interpretations**: Clear parameter names help users understand results. + +--- + +## Model Hierarchy + +The following diagram shows the inheritance relationships between model classes in ***RMC-BestFit***: + +``` +IModel +├── ModelBase (abstract) +│ ├── UnivariateDistributionBase +│ │ ├── Normal, LogNormal, GEV, etc. +│ │ ├── MixtureModel +│ │ ├── CompetingRisksModel +│ │ └── PointProcessModel +│ ├── TimeSeriesModelBase +│ │ ├── AutoRegressive +│ │ ├── MovingAverage +│ │ ├── ARMA → ARIMA → ARIMAX +│ │ └── ARMAX +│ ├── RatingCurve +│ ├── SpatialGEV +│ └── BivariateDistribution +``` + +--- + +## Implementation Sources + +Primary source paths: `src/RMC.BestFit/Models/Support/IModel.cs`, `src/RMC.BestFit/Models/Support/ModelBase.cs`, `src/RMC.BestFit/Models/Support/ModelParameter.cs`, `src/RMC.BestFit/Models/UnivariateDistribution`, `src/RMC.BestFit/Models/BivariateDistribution`, `src/RMC.BestFit/Models/RatingCurve`, `src/RMC.BestFit/Models/TimeSeries`, and `src/RMC.BestFit/Models/SpatialExtremes`. + +--- + +## References + +[1] +Hosking, J.R.M. and Wallis, J.R. (1997). *Regional Frequency Analysis: An Approach Based on L-Moments*. Cambridge University Press. + +[2] +Coles, S. (2001). *An Introduction to Statistical Modeling of Extreme Values*. Springer. + +[3] +Gelman, A., Carlin, J.B., Stern, H.S., Dunson, D.B., Vehtari, A., and Rubin, D.B. (2013). *Bayesian Data Analysis*, Third Edition. CRC Press. + +[4] +ter Braak, C.J.F. and Vrugt, J.A. (2008). "Differential Evolution Markov Chain with snooker updater and fewer chains." *Statistics and Computing*, 18(4), 435-446. + +--- + +[<- Previous: Getting Started](../../getting-started.md) | [Back to Index](../../index.md) | [Next: Input Data Frame ->](../data-frame/index.md) diff --git a/docs/technical-reference/spatial/spatial-extremes.md b/docs/technical-reference/spatial/spatial-extremes.md new file mode 100644 index 0000000..2cd9f73 --- /dev/null +++ b/docs/technical-reference/spatial/spatial-extremes.md @@ -0,0 +1,971 @@ +# Spatial Extremes Analysis + +[<- Previous: Time Series](../analysis/time-series.md) | [Back to Index](../../index.md) | [Next: Trend and Link Functions ->](../support/trend-and-link-functions.md) + +Spatial extremes analysis extends classical univariate extreme value theory to multiple locations, enabling regional flood frequency analysis with rigorous uncertainty quantification. This chapter provides a comprehensive introduction to spatial GEV modeling using Bayesian Hierarchical Models (BHM), suitable for readers with foundational knowledge of probability and statistics but limited exposure to spatial statistics or extreme value theory. + +--- + +## Table of Contents + +1. [Introduction and Motivation](#1-introduction-and-motivation) +2. [Foundations: Extreme Value Theory](#2-foundations-extreme-value-theory) +3. [Regional Frequency Analysis](#3-regional-frequency-analysis) +4. [The Bayesian Hierarchical Model Framework](#4-the-bayesian-hierarchical-model-framework) +5. [Spatial Correlation Structures](#5-spatial-correlation-structures) +6. [Gaussian Copula for Inter-Site Dependence](#6-gaussian-copula-for-inter-site-dependence) +7. [Spatial Regression on GEV Parameters](#7-spatial-regression-on-gev-parameters) +8. [Spatially Correlated Regression Errors](#8-spatially-correlated-regression-errors) +9. [Likelihood Formulation](#9-likelihood-formulation) +10. [Bayesian Inference](#10-bayesian-inference) +11. [Model Selection and Validation](#11-model-selection-and-validation) +12. [Implementation in RMC-BestFit](#12-implementation-in-rmc-bestfit) +13. [Assumptions and Limitations](#13-assumptions-and-limitations) +14. [References](#14-references) + +--- + +## 1. Introduction and Motivation + +### The Challenge of Rare Events + +Hydrologic design—whether for dams, levees, bridges, or stormwater systems—requires estimating the magnitude of floods that occur rarely: the 100-year flood, the 500-year flood, or even rarer events. These rare quantiles lie far beyond the range of observed data at most gauging stations. A typical station with 50 years of record provides only modest information about events with return periods exceeding the record length. + +### The Power of Regional Information + +The fundamental insight of regional frequency analysis is that nearby locations experience similar flood-generating mechanisms. A station with 30 years of record gains inferential power by "borrowing strength" from neighboring stations. If 10 stations each have 30 years of data, the region collectively contains 300 station-years of information—far more than any single station possesses. + +### Why Spatial Models? + +Classical regional frequency analysis methods, such as the index flood method [[1]](#1), assume that: +1. All sites share the same standardized distribution (the "growth curve") +2. Sites differ only in a scaling factor (the index flood) +3. Observations at different sites are independent + +These assumptions are often violated in practice: +- Distribution parameters vary smoothly across space +- Nearby sites experience correlated flood events +- Environmental covariates (elevation, drainage area, precipitation) influence flood characteristics + +Spatial GEV models address these limitations by explicitly modeling: +- **Spatial variation** in distribution parameters through regression +- **Spatial dependence** in observations through copulas +- **Residual spatial correlation** through Gaussian processes + +--- + +## 2. Foundations: Extreme Value Theory + +### Block Maxima and the GEV Distribution + +The Generalized Extreme Value (GEV) distribution arises naturally as the limiting distribution of properly rescaled block maxima. If $X_1, X_2, \ldots, X_n$ are independent observations from some parent distribution, and $M_n = \max(X_1, \ldots, X_n)$, then under mild regularity conditions [[2]](#2): + +```math +\frac{M_n - b_n}{a_n} \xrightarrow{d} G +``` + +where $G$ is the GEV distribution with CDF: + +```math +G(x; \xi, \alpha, \kappa) = \exp\left\{-\left[1 + \kappa\left(\frac{x - \xi}{\alpha}\right)\right]^{-1/\kappa}\right\} +``` + +for $1 + \kappa(x - \xi)/\alpha > 0$, where: +- $\xi \in \mathbb{R}$ is the **location** parameter (central tendency) +- $\alpha > 0$ is the **scale** parameter (spread) +- $\kappa \in \mathbb{R}$ is the **shape** parameter (tail behavior) + +### Interpretation of Parameters + +**Location ($\xi$)**: The mode of the distribution occurs near $\xi$. For annual maximum floods, $\xi$ represents a "typical" annual maximum. + +**Scale ($\alpha$)**: Controls the spread of the distribution. Larger $\alpha$ means greater variability in annual maxima. + +**Shape ($\kappa$)**: Determines tail behavior: +- $\kappa < 0$ (Fréchet type): Heavy upper tail, no upper bound. Common for river floods. +- $\kappa = 0$ (Gumbel type): Exponential tail decay. Limit as $\kappa \to 0$. +- $\kappa > 0$ (Weibull type): Bounded upper tail at $\xi - \alpha/\kappa$. + +### Quantile Function + +The $p$-quantile (value exceeded with probability $1-p$) is: + +```math +x_p = \xi + \frac{\alpha}{\kappa}\left[(-\log p)^{-\kappa} - 1\right] +``` + +For the Gumbel case ($\kappa = 0$): + +```math +x_p = \xi - \alpha \log(-\log p) +``` + +**Example**: The 100-year flood corresponds to $p = 0.99$ (or equivalently, 1% annual exceedance probability). + +--- + +## 3. Regional Frequency Analysis + +### The Index Flood Method + +The classical index flood method [[1]](#1) assumes that at each site $j$, the annual maximum $Y_j$ follows: + +```math +Y_j = \mu_j \cdot Z +``` + +where $\mu_j$ is a site-specific index flood (typically the mean or median) and $Z$ is a dimensionless "growth curve" common to all sites in the region. This implies all sites share identical shape and dimensionless scale, differing only by a multiplicative factor. + +### Limitations of Classical RFA + +1. **Homogeneity assumption**: Requires defining "homogeneous regions" where growth curves are identical—a subjective and often unrealistic requirement. + +2. **Independence assumption**: Ignores that nearby sites experience floods simultaneously (e.g., basin-wide storm events). + +3. **No covariate information**: Cannot incorporate physical explanatory variables. + +4. **Two-stage estimation**: First estimates growth curve, then index floods, without proper uncertainty propagation. + +### The Hierarchical Alternative + +Bayesian Hierarchical Models (BHM) address these limitations by: +- Allowing parameters to vary spatially through regression +- Modeling inter-site dependence explicitly +- Estimating all parameters simultaneously with full uncertainty propagation +- Eliminating the need for discrete homogeneous regions + +--- + +## 4. The Bayesian Hierarchical Model Framework + +The spatial GEV model follows the hierarchical framework developed by Renard et al. [[3]](#3), [[4]](#4), consisting of three levels: + +### Level 1: Data Model + +At each site $j \in \{1, \ldots, S\}$ with $n_j$ observations: + +```math +Y_{ij} \mid \theta_j \sim \text{GEV}(\xi_j, \alpha_j, \kappa_j), \quad i = 1, \ldots, n_j +``` + +where $\theta_j = (\xi_j, \alpha_j, \kappa_j)$ are the site-specific GEV parameters. + +**Spatial Dependence**: Within a given year, observations across sites may be correlated (e.g., a regional storm produces high flows at multiple stations simultaneously). This dependence is captured through a Gaussian copula (see [Section 6](#6-gaussian-copula-for-inter-site-dependence)). + +### Level 2: Process Model + +GEV parameters at each site are functions of: +1. **Spatial regression**: Linear functions of covariates (location, elevation, drainage area, etc.) +2. **Spatially correlated errors**: Gaussian process deviations from the regression surface + +For the location parameter with log-link: + +```math +\log(\xi_j) = \beta_0^\xi + \beta_1^\xi X_{1j} + \beta_2^\xi X_{2j} + \varepsilon_j^\xi +``` + +where: +- $\beta_0^\xi$ is the intercept +- $\beta_k^\xi$ are regression coefficients +- $X_{kj}$ are covariate values at site $j$ (e.g., latitude, longitude, elevation) +- $\varepsilon_j^\xi$ is a spatially correlated error term + +The vector of errors $\boldsymbol{\varepsilon}^\xi = (\varepsilon_1^\xi, \ldots, \varepsilon_S^\xi)$ follows: + +```math +\boldsymbol{\varepsilon}^\xi \sim \mathcal{N}(\mathbf{0}, \sigma_\xi^2 \mathbf{R}_\xi) +``` + +where $\mathbf{R}_\xi$ is a correlation matrix determined by a spatial correlation function. + +### Level 3: Prior Model + +Hyperparameters (regression coefficients, error variance, correlation parameters) receive prior distributions: + +```math +\begin{aligned} +\beta_k^\xi &\sim \text{Uniform}(L_\beta, U_\beta) \\ +\sigma_\xi &\sim \text{Uniform}(0, U_\sigma) \text{ or Jeffreys prior} \\ +\text{range} &\sim \text{Uniform}(0, U_r) +\end{aligned} +``` + +### Model Schematic + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ LEVEL 3: PRIORS │ +│ β (regression coefficients) σ (error std dev) r (range) │ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ LEVEL 2: PROCESS MODEL │ +│ │ +│ log(ξⱼ) = β₀ + β₁X₁ⱼ + β₂X₂ⱼ + εⱼ where ε ~ MVN(0, σ²R) │ +│ log(αⱼ) = ... │ +│ κⱼ = ... │ +│ │ +│ Site-specific GEV parameters: θⱼ = (ξⱼ, αⱼ, κⱼ) │ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ LEVEL 1: DATA MODEL │ +│ │ +│ Yᵢⱼ | θⱼ ~ GEV(ξⱼ, αⱼ, κⱼ) with Gaussian copula dependence │ +│ │ +│ Site 1: Y₁₁, Y₁₂, ..., Y₁ₙ₁ │ +│ Site 2: Y₂₁, Y₂₂, ..., Y₂ₙ₂ │ +│ ... │ +│ Site S: Yₛ₁, Yₛ₂, ..., Yₛₙₛ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 5. Spatial Correlation Structures + +Spatial correlation functions describe how similarity between locations decreases with distance. Let $h = \|\mathbf{s}_i - \mathbf{s}_j\|$ denote the Euclidean distance between sites $i$ and $j$. The correlation function $\rho(h)$ must satisfy: +- $\rho(0) = 1$ (perfect correlation at zero distance) +- $0 \leq \rho(h) \leq 1$ for all $h \geq 0$ +- $\rho(h) \to 0$ as $h \to \infty$ (decay to independence) +- Positive definiteness (required for valid covariance matrices) + +### Basic Exponential + +```math +\rho(h) = \exp\left(-\frac{h}{r}\right) +``` + +where $r > 0$ is the **range** (or **scale**) parameter. + +**Properties**: +- Exponential decay with distance +- Practical range (correlation drops to 0.05): approximately $3r$ +- Infinitely divisible (valid for any positive definite operation) +- Non-differentiable at the origin (rough process realizations) + +**When to use**: Default choice; robust and widely applicable. + +### Powered Exponential + +```math +\rho(h) = \exp\left(-\left(\frac{h}{r}\right)^p\right) +``` + +where $r > 0$ is the range and $0 < p \leq 2$ is the **power** parameter. + +**Special cases**: +- $p = 1$: Basic exponential +- $p = 2$: Gaussian (squared exponential) + +**Properties**: +- Higher $p$ gives smoother correlation decay +- Gaussian ($p = 2$) produces infinitely differentiable (smooth) process realizations +- Practical range varies with $p$ + +**When to use**: When process smoothness is important; $p \approx 1.5$ often works well. + +### Spherical + +```math +\rho(h) = \begin{cases} +1 - \frac{3h}{2r} + \frac{h^3}{2r^3} & \text{if } h < r \\ +0 & \text{if } h \geq r +\end{cases} +``` + +**Properties**: +- **Compact support**: Exactly zero correlation beyond range $r$ +- Computational advantage for large networks (sparse correlation matrices) +- Transition from correlation to independence at distance $r$ + +**When to use**: When distant sites are believed truly independent; reduces computational burden. + +### Comparison + +| Property | Basic Exponential | Powered Exponential | Spherical | +|----------|-------------------|---------------------|-----------| +| Parameters | 1 (range) | 2 (range, power) | 1 (range) | +| Smoothness | Rough | Adjustable | Moderate | +| Compact support | No | No | Yes | +| Computation | O(S²) | O(S²) | O(S² sparse) | + +### Implementation in RMC-BestFit + +```cs +// Basic exponential correlation +var basicExp = new BasicExponential(range: 30.0); + +// Powered exponential (p = 1.5) +var poweredExp = new PoweredExponential(range: 30.0, power: 1.5); + +// Spherical correlation +var spherical = new Spherical(range: 50.0); +``` + +--- + +## 6. Gaussian Copula for Inter-Site Dependence + +### The Copula Concept + +When analyzing regional extremes, observations at different sites in the same year are typically not independent—a major storm produces large floods at multiple nearby stations. A **copula** models this dependence structure separately from the marginal distributions [[5]](#5). + +**Sklar's Theorem**: Any multivariate distribution can be decomposed as: + +```math +F(y_1, \ldots, y_S) = C(F_1(y_1), \ldots, F_S(y_S)) +``` + +where $F_j$ are the marginal CDFs and $C: [0,1]^S \to [0,1]$ is the copula function capturing dependence. + +### The Gaussian Copula + +The Gaussian copula is defined through the multivariate normal distribution: + +```math +C(u_1, \ldots, u_S) = \Phi_{\mathbf{R}}(\Phi^{-1}(u_1), \ldots, \Phi^{-1}(u_S)) +``` + +where: +- $\Phi$ is the standard normal CDF +- $\Phi^{-1}$ is the standard normal quantile function +- $\Phi_{\mathbf{R}}$ is the CDF of the multivariate normal with correlation matrix $\mathbf{R}$ + +### Copula Density + +The copula density is: + +```math +c(u_1, \ldots, u_S) = \frac{\phi_{\mathbf{R}}(z_1, \ldots, z_S)}{\prod_{j=1}^S \phi(z_j)} +``` + +where $z_j = \Phi^{-1}(u_j)$ and $\phi_{\mathbf{R}}$ is the multivariate normal PDF. + +The log-copula density is: + +```math +\log c(\mathbf{u}) = -\frac{1}{2}\log|\mathbf{R}| - \frac{1}{2}\mathbf{z}^\top(\mathbf{R}^{-1} - \mathbf{I})\mathbf{z} +``` + +### Constructing the Correlation Matrix + +The correlation matrix $\mathbf{R}$ for the Gaussian copula uses the same spatial correlation functions from [Section 5](#5-spatial-correlation-structures): + +```math +R_{ij} = \rho(h_{ij}) +``` + +where $h_{ij}$ is the distance between sites $i$ and $j$. + +### Joint Likelihood with Copula + +The joint density of observations in year $i$ is: + +```math +f(y_{i1}, \ldots, y_{iS}) = c(F_1(y_{i1}), \ldots, F_S(y_{iS})) \cdot \prod_{j=1}^S f_j(y_{ij}) +``` + +where $f_j$ and $F_j$ are the site-specific GEV density and CDF. + +**Log-likelihood contribution from observation $i$**: + +```math +\ell_i = \log c\left(F_1(y_{i1}), \ldots, F_S(y_{iS})\right) + \sum_{j=1}^S \log f_j(y_{ij}) +``` + +### Why Gaussian Copula? + +**Advantages**: +1. Mathematically tractable (closed-form density) +2. Flexible range of dependence (correlation matrix) +3. Natural spatial interpretation (correlation decays with distance) +4. Efficient computation via Cholesky decomposition + +**Limitations**: +1. Symmetric dependence (cannot model asymmetric tail dependence) +2. Tail independence (joint extremes less likely than heavy-tailed copulas) +3. May underestimate joint flood risk at very high return periods + +For flood frequency analysis, the Gaussian copula is typically adequate because: +- Annual maxima from different sites have moderate dependence +- Primary interest is in marginal quantiles, not joint exceedance probabilities +- More complex copulas (e.g., extreme-value copulas) add parameters without clear practical benefit + +--- + +## 7. Spatial Regression on GEV Parameters + +### Link Functions + +GEV parameters have natural constraints that require appropriate link functions: +- **Location ($\xi$)**: Often positive for flood data, so log-link ensures positivity +- **Scale ($\alpha$)**: Must be positive, so log-link is essential +- **Shape ($\kappa$)**: Typically small ($|\kappa| < 0.5$), so identity link with bounded priors + +### Location Parameter + +With log-link: + +```math +\log(\xi_j) = \beta_0^\xi + \beta_1^\xi X_{1j} + \beta_2^\xi X_{2j} + \cdots + \beta_K^\xi X_{Kj} +``` + +Equivalently: + +```math +\xi_j = \exp\left(\beta_0^\xi + \sum_{k=1}^K \beta_k^\xi X_{kj}\right) +``` + +**Interpretation**: Regression coefficients represent multiplicative effects. A unit increase in covariate $X_k$ multiplies location by $\exp(\beta_k^\xi)$. + +### Scale Parameter + +With log-link: + +```math +\log(\alpha_j) = \beta_0^\alpha + \sum_{k=1}^K \beta_k^\alpha X_{kj} +``` + +This ensures $\alpha_j > 0$ for all covariate values. + +### Shape Parameter + +With identity link: + +```math +\kappa_j = \beta_0^\kappa + \sum_{k=1}^K \beta_k^\kappa X_{kj} +``` + +Shape is kept near zero through bounded priors, typically $|\kappa_j| < 0.5$. + +### Common Covariates + +For spatial flood frequency analysis, useful covariates include: + +| Covariate | Symbol | Effect on Floods | +|-----------|--------|------------------| +| Longitude | $X$ | East-west climate gradients | +| Latitude | $Y$ | North-south climate gradients | +| Elevation | $Z$ | Orographic precipitation effects | +| Drainage area | $A$ | Larger basins → larger floods | +| Mean precipitation | $P$ | Wetter regions → larger floods | +| Basin slope | $S$ | Steeper basins → faster concentration | + +### Example: Location Regression + +Consider a simple model with X and Y coordinates as covariates: + +```math +\log(\xi_j) = \beta_0 + \beta_X \cdot X_j + \beta_Y \cdot Y_j +``` + +For a 100×100 km region with: +- $\beta_0 = 8.987$ (intercept, log-scale) +- $\beta_X = 0.005$ (X coefficient) +- $\beta_Y = 0.008$ (Y coefficient) + +**At corner (0, 0)**: +```math +\xi = \exp(8.987) \approx 8000 +``` + +**At corner (100, 100)**: +```math +\xi = \exp(8.987 + 0.5 + 0.8) = \exp(10.287) \approx 29,500 +``` + +The location parameter increases by a factor of 3.7 across the region due to spatial trends. + +--- + +## 8. Spatially Correlated Regression Errors + +### Motivation + +Even with spatial regression, residual variation remains that is: +1. **Spatially structured**: Nearby sites have similar residuals +2. **Not captured by covariates**: Due to unmeasured local effects + +This variation is modeled as a Gaussian process on the regression residuals. + +### Mathematical Formulation + +For the location parameter: + +```math +\log(\xi_j) = \underbrace{\beta_0^\xi + \sum_{k=1}^K \beta_k^\xi X_{kj}}_{\text{spatial trend}} + \underbrace{\varepsilon_j^\xi}_{\text{spatial error}} +``` + +where the error vector follows: + +```math +\boldsymbol{\varepsilon}^\xi = (\varepsilon_1^\xi, \ldots, \varepsilon_S^\xi)^\top \sim \mathcal{N}(\mathbf{0}, \sigma_\xi^2 \mathbf{R}) +``` + +The covariance matrix is: + +```math +\text{Cov}(\varepsilon_i^\xi, \varepsilon_j^\xi) = \sigma_\xi^2 \rho(h_{ij}) +``` + +### Gaussian Process Prior + +The spatial errors define a **Gaussian process** prior on deviations from the regression surface. This is equivalent to kriging in geostatistics, providing: +- Smooth interpolation between observed sites +- Proper uncertainty quantification +- Shrinkage toward the regression surface where data is sparse + +### Error Parameters + +Each parameter with spatial errors has two hyperparameters: + +| Hyperparameter | Symbol | Interpretation | +|----------------|--------|----------------| +| Error standard deviation | $\sigma$ | Magnitude of spatial deviations | +| Correlation range | $r$ | Distance over which errors remain correlated | + +### The Full Model + +With spatial errors on all parameters: + +```math +\begin{aligned} +\log(\xi_j) &= \beta_0^\xi + \sum_k \beta_k^\xi X_{kj} + \varepsilon_j^\xi, \quad \boldsymbol{\varepsilon}^\xi \sim \mathcal{N}(\mathbf{0}, \sigma_\xi^2 \mathbf{R}_\xi) \\ +\log(\alpha_j) &= \beta_0^\alpha + \sum_k \beta_k^\alpha X_{kj} + \varepsilon_j^\alpha, \quad \boldsymbol{\varepsilon}^\alpha \sim \mathcal{N}(\mathbf{0}, \sigma_\alpha^2 \mathbf{R}_\alpha) \\ +\kappa_j &= \beta_0^\kappa + \sum_k \beta_k^\kappa X_{kj} + \varepsilon_j^\kappa, \quad \boldsymbol{\varepsilon}^\kappa \sim \mathcal{N}(\mathbf{0}, \sigma_\kappa^2 \mathbf{R}_\kappa) +\end{aligned} +``` + +The three error processes $\boldsymbol{\varepsilon}^\xi$, $\boldsymbol{\varepsilon}^\alpha$, $\boldsymbol{\varepsilon}^\kappa$ are assumed independent, though they may share the same correlation function. + +--- + +## 9. Likelihood Formulation + +### Full Log-Likelihood + +The full log-likelihood combines contributions from: +1. **Marginal GEV likelihoods** at each site +2. **Copula likelihood** for inter-site dependence +3. **Gaussian process priors** on spatial errors + +```math +\ell(\theta) = \ell_{\text{data}}(\theta) + \ell_{\text{errors}}(\theta) +``` + +### Data Likelihood + +**Without copula** (conditional independence given parameters): + +```math +\ell_{\text{data}} = \sum_{i=1}^{n} \sum_{j=1}^{S} w_j \cdot \log f_{\text{GEV}}(y_{ij}; \xi_j, \alpha_j, \kappa_j) +``` + +where $w_j$ are optional site weights. + +The GEV log-density is: + +```math +\log f_{\text{GEV}}(y; \xi, \alpha, \kappa) = -\log\alpha - \left(1 + \frac{1}{\kappa}\right)\log\left(1 + \kappa\frac{y-\xi}{\alpha}\right) - \left(1 + \kappa\frac{y-\xi}{\alpha}\right)^{-1/\kappa} +``` + +For $\kappa = 0$ (Gumbel case): + +```math +\log f_{\text{Gumbel}}(y; \xi, \alpha) = -\log\alpha - \frac{y-\xi}{\alpha} - \exp\left(-\frac{y-\xi}{\alpha}\right) +``` + +**With copula**: + +```math +\ell_{\text{data}} = \sum_{i=1}^{n} \left[\log c_{\mathbf{R}}(u_{i1}, \ldots, u_{iS}) + \sum_{j=1}^{S} w_j \cdot \log f_j(y_{ij})\right] +``` + +where $u_{ij} = F_j(y_{ij})$ is the probability integral transform. + +### Spatial Error Prior Likelihood + +The Gaussian process prior on errors contributes: + +```math +\ell_{\text{errors}} = \sum_{\theta \in \{\xi, \alpha, \kappa\}} \mathbb{1}_{\text{errors on } \theta} \cdot \log \phi_S(\boldsymbol{\varepsilon}^\theta; \mathbf{0}, \sigma_\theta^2 \mathbf{R}_\theta) +``` + +where $\phi_S$ is the $S$-dimensional multivariate normal density: + +```math +\log \phi_S(\boldsymbol{\varepsilon}; \mathbf{0}, \boldsymbol{\Sigma}) = -\frac{S}{2}\log(2\pi) - \frac{1}{2}\log|\boldsymbol{\Sigma}| - \frac{1}{2}\boldsymbol{\varepsilon}^\top \boldsymbol{\Sigma}^{-1} \boldsymbol{\varepsilon} +``` + +### Handling Missing Data + +Annual maximum series often have gaps. Missing values are handled naturally: +- Omit missing observations from marginal likelihood +- Use only available observations for copula contributions +- No interpolation or imputation required + +### Numerical Considerations + +1. **Support constraints**: Return $-\infty$ if $1 + \kappa(y-\xi)/\alpha \leq 0$ +2. **Scale positivity**: Return $-\infty$ if $\alpha \leq 0$ +3. **Cholesky stability**: Add small ridge ($10^{-8}$) to correlation matrix diagonal +4. **Overflow protection**: Use log-scale computations throughout + +--- + +## 10. Bayesian Inference + +### Posterior Distribution + +The posterior distribution of all parameters is: + +```math +\pi(\theta \mid \mathbf{Y}) \propto \mathcal{L}(\mathbf{Y} \mid \theta) \cdot \pi(\theta) +``` + +where $\theta$ includes: +- Regression coefficients $\beta$ +- Error hyperparameters $(\sigma, r)$ for each parameter +- Copula correlation parameters +- Site-specific spatial errors $\varepsilon_j$ + +### MCMC Sampling + +RMC-BestFit uses the **DEMCzs** sampler (Differential Evolution Markov Chain with snooker update) [[6]](#6), which offers: +- Self-tuning proposal distributions +- Robust multimodal exploration +- Parallel chain execution +- No manual tuning required + +### Prior Distributions + +**Regression coefficients**: +```math +\beta_k \sim \text{Uniform}(L_\beta, U_\beta) +``` + +Bounds are set wide enough to be non-informative but finite for numerical stability. + +**Error standard deviations**: +```math +\sigma \sim \text{Uniform}(\epsilon, U_\sigma) \quad \text{or} \quad \sigma \sim \text{Jeffreys}(\propto 1/\sigma) +``` + +**Correlation range**: +```math +r \sim \text{Uniform}(\epsilon, U_r) +``` + +where $U_r$ is typically 2-3 times the maximum inter-site distance. + +**Spatial errors**: + +The site-specific errors $\varepsilon_j$ are sampled jointly or as part of Gibbs updates, with the Gaussian process prior providing regularization. + +### Convergence Diagnostics + +| Diagnostic | Target Value | Interpretation | +|------------|--------------|----------------| +| $\hat{R}$ (Gelman-Rubin) | $< 1.1$ | Chains have converged to same distribution | +| ESS (Effective Sample Size) | $> 400$ | Sufficient independent samples | +| Trace plots | No trends, good mixing | Visual confirmation of stationarity | + +### Point Estimates + +Two point estimators are available: + +1. **Posterior Mean**: $\hat{\theta} = E[\theta \mid \mathbf{Y}]$ + - Minimizes squared error loss + - Affected by posterior skewness + +2. **Posterior Mode (MAP)**: $\hat{\theta} = \arg\max_\theta \pi(\theta \mid \mathbf{Y})$ + - Maximum a posteriori estimate + - Coincides with MLE when priors are flat + +--- + +## 11. Model Selection and Validation + +### Information Criteria + +**Deviance Information Criterion (DIC)** [[7]](#7): + +```math +\text{DIC} = \bar{D} + p_D +``` + +where $\bar{D}$ is the posterior mean deviance and $p_D$ is the effective number of parameters. + +**Watanabe-Akaike Information Criterion (WAIC)** [[8]](#8): + +```math +\text{WAIC} = -2 \cdot \text{lppd} + 2 \cdot p_{\text{WAIC}} +``` + +where lppd is the log pointwise predictive density. + +**Lower values indicate better models** (balancing fit and complexity). + +### Leave-One-Out Cross-Validation + +LOO-CV with Pareto-Smoothed Importance Sampling (PSIS-LOO) [[9]](#9) provides: +- Estimate of out-of-sample predictive accuracy +- Diagnostic for influential observations +- No refitting required + +### Posterior Predictive Checks + +1. **Quantile comparison**: Compare observed vs. predicted quantiles at each site +2. **Return level plots**: Visual comparison of fitted curves to data +3. **Residual analysis**: Check for spatial patterns in residuals + +### Model Hierarchy Testing + +Build models of increasing complexity: + +| Model | Copula | Regression | Spatial Errors | +|-------|--------|------------|----------------| +| Baseline | No | None | None | +| + Copula | Yes | None | None | +| + Location regression | Yes | Location | None | +| + Scale regression | Yes | Loc + Scale | None | +| + Location errors | Yes | Loc + Scale | Location | +| Full BHM | Yes | All | All | + +Compare each step using DIC/WAIC to justify added complexity. + +--- + +## 12. Implementation in RMC-BestFit + +### Model Classes + +| Class | Description | +|-------|-------------| +| `SpatialGEV` | Main spatial GEV model implementing the full BHM | +| `GeneralLinearFunction` | Spatial regression for GEV parameters | +| `SpatialRegressionErrors` | Gaussian process for spatially correlated errors | +| `GaussianCopula` | Inter-site dependence modeling | +| `BasicExponential` | Exponential correlation function | +| `PoweredExponential` | Powered exponential correlation function | +| `Spherical` | Spherical correlation function | + +### Basic Usage + +```cs +using RMC.BestFit.Models; +using RMC.BestFit.Models.SpatialExtremes; + +// Prepare data: nObs × nSites matrix +double[,] atSiteData = LoadAtSiteData(); // Annual maxima +double[,] coordinates = LoadCoordinates(); // Site coordinates [S × 2] + +// Create covariate matrix (X and Y coordinates) +int nSites = coordinates.GetLength(0); +var covariates = new double[nSites, 2]; +for (int j = 0; j < nSites; j++) +{ + covariates[j, 0] = coordinates[j, 0]; // X + covariates[j, 1] = coordinates[j, 1]; // Y +} + +// Create GeneralLinearFunction for each parameter +var location = new GeneralLinearFunction("Location", covariates); +var scale = new GeneralLinearFunction("Scale", covariates); +var shape = new GeneralLinearFunction("Shape"); // Intercept only + +// Create spatial GEV model +var model = new SpatialGEV(atSiteData, coordinates, location, scale, shape) +{ + UseLogLinkForLocation = true, + UseLogLinkForScale = true +}; +``` + +### Adding Spatial Errors + +```cs +// Create spatial error models +var locErrors = new SpatialRegressionErrors(coordinates, new BasicExponential(range: 30.0)); +var sclErrors = new SpatialRegressionErrors(coordinates, new BasicExponential(range: 40.0)); + +// Enable spatial errors +model.UseLocationErrors = true; +model.LocationErrors = locErrors; + +model.UseScaleErrors = true; +model.ScaleErrors = sclErrors; + +model.SetDefaultParameters(); +``` + +### Adding Copula Dependence + +```cs +// Create Gaussian copula with exponential correlation +var copula = new GaussianCopula(coordinates, new BasicExponential(range: 30.0)); + +model.UseCopulaDependence = true; +model.SpatialDependence = copula; + +model.SetDefaultParameters(); +``` + +### Running Bayesian Analysis + +```cs +using RMC.BestFit.Analyses; + +var analysis = new SpatialGEVAnalysis(model); + +// Configure MCMC +analysis.BayesianAnalysis.Iterations = 20000; +analysis.BayesianAnalysis.WarmupIterations = 10000; +analysis.BayesianAnalysis.ThinningInterval = 10; +analysis.BayesianAnalysis.NumberOfChains = 4; + +// Run analysis +await analysis.RunAsync(); + +// Access results +var map = analysis.BayesianAnalysis.Results.MAP.Values; +var posteriorMean = analysis.BayesianAnalysis.Results.ParameterResults + .Select(p => p.SummaryStatistics.Mean) + .ToArray(); + +// Get site-specific quantiles +foreach (var siteResult in analysis.SiteResults) +{ + Console.WriteLine($"Site: {siteResult.SiteName}"); + Console.WriteLine($" 100-year flood: {siteResult.Q100:F0}"); + Console.WriteLine($" 500-year flood: {siteResult.Q500:F0}"); +} +``` + +--- + +## 13. Assumptions and Limitations + +### Model Assumptions + +1. **GEV marginals**: Annual maxima follow GEV distributions at each site +2. **Stationarity**: Distribution parameters constant over time (no trends) +3. **Gaussian copula**: Inter-site dependence adequately captured by Gaussian copula +4. **Gaussian process**: Spatial errors follow multivariate normal distribution +5. **Isotropic correlation**: Correlation depends only on distance, not direction +6. **Euclidean distance**: Distance measured as straight-line (may be inappropriate for river networks) + +### Limitations + +1. **Computational complexity**: Full model scales as $O(S^3)$ due to matrix operations +2. **No non-stationarity**: Time trends not currently supported (see univariate models) +3. **Limited copula choice**: Only Gaussian copula; no extreme-value copulas +4. **Isotropy**: Cannot model anisotropic correlation (directional dependence) +5. **Fixed correlation functions**: Cannot estimate correlation function parameters jointly +6. **No prediction at ungauged sites**: Currently supports only gauged site estimation + +### Data Requirements + +| Requirement | Minimum | Recommended | +|-------------|---------|-------------| +| Number of sites | 3 | 10+ | +| Observations per site | 10 | 30+ | +| Total observations | 100 | 500+ | +| Parameter identifiability | 500+ total for full BHM | + +### When to Use Simpler Models + +| Situation | Recommended Model | +|-----------|-------------------| +| Single site | Univariate GEV | +| Few sites (< 5) | Independent univariate models | +| No spatial trend | Homogeneous regional model | +| Short records | Index flood method | +| Interest in joint exceedance | Consider max-stable processes | + +--- + +## 14. References + +[1] J. R. M. Hosking and J. R. Wallis, *Regional Frequency Analysis: An Approach Based on L-Moments*, Cambridge, UK: Cambridge University Press, 1997. + +[2] S. Coles, *An Introduction to Statistical Modeling of Extreme Values*, London, UK: Springer, 2001. + +[3] B. Renard, V. Garreta, and M. Lang, "An application of Bayesian analysis and MCMC methods to the estimation of a regional trend in annual maxima," *Water Resources Research*, vol. 42, W12422, 2006. + +[4] B. Renard, "A Bayesian hierarchical approach to regional frequency analysis," *Water Resources Research*, vol. 47, W11513, 2011. + +[5] B. Renard and M. Lang, "Use of a Gaussian copula for multivariate extreme value analysis: Some case studies in hydrology," *Advances in Water Resources*, vol. 30, no. 4, pp. 897-912, 2007. + +[6] C. J. F. ter Braak and J. A. Vrugt, "Differential Evolution Markov Chain with snooker updater and fewer chains," *Statistics and Computing*, vol. 18, no. 4, pp. 435-446, 2008. + +[7] D. J. Spiegelhalter, N. G. Best, B. P. Carlin, and A. van der Linde, "Bayesian measures of model complexity and fit," *Journal of the Royal Statistical Society: Series B*, vol. 64, no. 4, pp. 583-639, 2002. + +[8] S. Watanabe, "Asymptotic equivalence of Bayes cross validation and widely applicable information criterion in singular learning theory," *Journal of Machine Learning Research*, vol. 11, pp. 3571-3594, 2010. + +[9] A. Vehtari, A. Gelman, and J. Gabry, "Practical Bayesian model evaluation using leave-one-out cross-validation and WAIC," *Statistics and Computing*, vol. 27, no. 5, pp. 1413-1432, 2017. + +[10] D. Cooley, D. Nychka, and P. Naveau, "Bayesian spatial modeling of extreme precipitation return levels," *Journal of the American Statistical Association*, vol. 102, no. 479, pp. 824-840, 2007. + +[11] A. C. Davison, S. A. Padoan, and M. Ribatet, "Statistical modeling of spatial extremes," *Statistical Science*, vol. 27, no. 2, pp. 161-186, 2012. + +[12] M. D. Dettinger, D. R. Cayan, H. F. Diaz, and D. M. Meko, "Large-scale atmospheric forcing of recent trends toward early snowmelt runoff in California," *Journal of Climate*, vol. 11, no. 12, pp. 3064-3078, 1998. + +--- + +## Appendix A: Notation Summary + +| Symbol | Description | +|--------|-------------| +| $Y_{ij}$ | Observation $i$ at site $j$ | +| $S$ | Number of sites | +| $n_j$ | Number of observations at site $j$ | +| $\xi_j$, $\alpha_j$, $\kappa_j$ | GEV parameters at site $j$ | +| $\beta_k$ | Regression coefficient | +| $X_{kj}$ | Covariate $k$ at site $j$ | +| $\varepsilon_j$ | Spatial error at site $j$ | +| $\sigma$ | Spatial error standard deviation | +| $r$ | Correlation range parameter | +| $\rho(h)$ | Correlation function | +| $h_{ij}$ | Distance between sites $i$ and $j$ | +| $\mathbf{R}$ | Correlation matrix | +| $C(\cdot)$ | Copula function | +| $c(\cdot)$ | Copula density | + +## Appendix B: Progressive Test Hierarchy + +RMC-BestFit includes a comprehensive suite of synthetic data generators for validation, building from simple to complex: + +| Level | Test Case | Description | +|-------|-----------|-------------| +| 1 | `GetBasicIdentifiableData` | 500 total obs, constant parameters | +| 2 | `GetHomogeneousGridData` | 100×100 km grid, constant parameters | +| 3a | `GetCopulaDataBasicExponential` | Add copula with exponential correlation | +| 3b | `GetCopulaDataPoweredExponential` | Copula with powered exponential | +| 3c | `GetCopulaDataSpherical` | Copula with spherical correlation | +| 4a | `GetRegressionLocationOnly` | Location varies with X, Y | +| 4b | `GetRegressionScaleOnly` | Scale varies with X, Y | +| 4c | `GetRegressionLocationScale` | Both vary | +| 4d | `GetRegressionLocationScaleShape` | All parameters vary | +| 5a | `GetSpatialErrorsLocationOnly` | Add GP errors to location | +| 5b | `GetSpatialErrorsLocationScale` | GP errors on location + scale | +| 5c | `GetFullBHMData` | Complete model with all components | +| 6 | `GetCopulaWithRegressionData` | Copula + regression combined | + +This hierarchy validates each component independently before combining them, ensuring correct implementation. + +--- + +## Implementation Sources + +Primary source paths: `src/RMC.BestFit/Models/SpatialExtremes/SpatialGEV.cs`, `src/RMC.BestFit/Models/SpatialExtremes/CopulaModels`, `src/RMC.BestFit/Models/SpatialExtremes/SpatialCorrelation`, `src/RMC.BestFit/Analyses/SpatialExtremes/SpatialGEVAnalysis.cs`, and `src/RMC.BestFit/Estimation/BayesianAnalysis.cs`. + +--- + +[<- Previous: Time Series](../analysis/time-series.md) | [Back to Index](../../index.md) | [Next: Trend and Link Functions ->](../support/trend-and-link-functions.md) + +--- + +*Last updated: 2026-02-01* +*RMC-BestFit v2.0* diff --git a/docs/technical-reference/support/trend-and-link-functions.md b/docs/technical-reference/support/trend-and-link-functions.md new file mode 100644 index 0000000..b9d34ce --- /dev/null +++ b/docs/technical-reference/support/trend-and-link-functions.md @@ -0,0 +1,66 @@ +# Trend and Link Functions + +[<- Previous: Spatial Extremes](../spatial/spatial-extremes.md) | [Back to Index](../../index.md) | [Next: References ->](../../references.md) + +Trend and link functions support nonstationary models by mapping covariates into parameter space while respecting parameter constraints. + +## Trend Function API + +| API | Purpose | +|-----|---------| +| `ITrendModel` | Common nonstationary parameter function interface | +| `TrendModelBase` | Shared implementation for trend functions | +| `TrendModelType` | Enum used for serialization and UI selection | +| `ConstantTrend` | Constant parameter | +| `LinearTrend` | Linear covariate effect | +| `QuadraticTrend` | Quadratic covariate effect | +| `CubicTrend` | Cubic covariate effect | +| `ExponentialTrend` | Exponential covariate effect | +| `LogisticTrend` | Logistic transition | +| `PowerTrend` | Power-law covariate effect | +| `ReciprocalTrend` | Reciprocal covariate effect | +| `SinusoidalTrend` | Seasonal/cyclic behavior | +| `StepFunction` | Piecewise step behavior | +| `GeneralLinearFunction` | Multi-covariate linear predictor | + +## Link Function API + +| API | Purpose | +|-----|---------| +| `SESLink` | Supports skewed-error-space style parameter transformations | +| `ASinHLink` | Applies inverse-hyperbolic-sine style transformations | +| `CenteredLink` | Centers a parameter before transforming | +| `LogASinHLink` | Combines log and inverse-hyperbolic-sine transformations | +| `LogSESLink` | Log-scale skewed-error-space transformation | +| `BestFitLinkFunctionFactory` | Restores link functions from XML | +| Model parameter transform settings | Keep scale, probability, and bounded parameters valid | + +## Usage Pattern + +```cs +using RMC.BestFit.Models.TrendFunctions; + +var trend = new LinearTrend(); +trend.Parameters[0].Value = 1.0; +trend.Parameters[1].Value = 0.25; + +double value = trend.Predict(index: 10); + +Console.WriteLine(value); +``` + +## Spatial Regression + +`GeneralLinearFunction` is used by spatial GEV models for covariate regression on location, scale, or shape parameters. Link functions keep transformed parameters in numerically valid ranges. + +## Documentation Rule + +When adding a nonstationary model page, document both the trend-space equation and the inverse transformation used to return to natural parameter space. + +## Implementation Sources + +Primary source paths: `src/RMC.BestFit/Models/TrendFunctions`, `src/RMC.BestFit/Models/LinkFunctions`, `src/RMC.BestFit/Models/UnivariateDistribution/UnivariateDistribution.cs`, and `src/RMC.BestFit/Models/SpatialExtremes/SpatialGEV.cs`. + +--- + +[<- Previous: Spatial Extremes](../spatial/spatial-extremes.md) | [Back to Index](../../index.md) | [Next: References ->](../../references.md) diff --git a/examples/1-time-series-data/1-usgs-download/usgs-download-example.bestfit b/examples/1-time-series-data/1-usgs-download/usgs-download-example.bestfit index 025d71d..b22583e 100644 Binary files a/examples/1-time-series-data/1-usgs-download/usgs-download-example.bestfit and b/examples/1-time-series-data/1-usgs-download/usgs-download-example.bestfit differ diff --git a/examples/1-time-series-data/2-ghcn-download/ghcn-download-example.bestfit b/examples/1-time-series-data/2-ghcn-download/ghcn-download-example.bestfit index ba855e0..5af15ae 100644 Binary files a/examples/1-time-series-data/2-ghcn-download/ghcn-download-example.bestfit and b/examples/1-time-series-data/2-ghcn-download/ghcn-download-example.bestfit differ diff --git a/examples/1-time-series-data/3-chmn-download/chmn-download-example.bestfit b/examples/1-time-series-data/3-chmn-download/chmn-download-example.bestfit index 66864a4..b83a1fd 100644 Binary files a/examples/1-time-series-data/3-chmn-download/chmn-download-example.bestfit and b/examples/1-time-series-data/3-chmn-download/chmn-download-example.bestfit differ diff --git a/examples/1-time-series-data/4-abom-download/abom-download-example.bestfit b/examples/1-time-series-data/4-abom-download/abom-download-example.bestfit index af79fb1..a6d41e6 100644 Binary files a/examples/1-time-series-data/4-abom-download/abom-download-example.bestfit and b/examples/1-time-series-data/4-abom-download/abom-download-example.bestfit differ diff --git a/examples/1-time-series-data/5-hec-dss-import/hec-dss-import-example.bestfit b/examples/1-time-series-data/5-hec-dss-import/hec-dss-import-example.bestfit index 274a8bb..ecd3171 100644 Binary files a/examples/1-time-series-data/5-hec-dss-import/hec-dss-import-example.bestfit and b/examples/1-time-series-data/5-hec-dss-import/hec-dss-import-example.bestfit differ diff --git a/examples/1-time-series-data/6-manual-entry/manual-entry-example.bestfit b/examples/1-time-series-data/6-manual-entry/manual-entry-example.bestfit index e2b6a7d..870115a 100644 Binary files a/examples/1-time-series-data/6-manual-entry/manual-entry-example.bestfit and b/examples/1-time-series-data/6-manual-entry/manual-entry-example.bestfit differ diff --git a/examples/1-time-series-data/README.md b/examples/1-time-series-data/README.md index 2bc0f78..54f0aee 100644 --- a/examples/1-time-series-data/README.md +++ b/examples/1-time-series-data/README.md @@ -38,7 +38,7 @@ RMC-BestFit supports manual data entry, downloads from four web APIs, and import ## Prerequisites -- **RMC-BestFit 2.0** (Beta or later) +- **RMC-BestFit 2.0.0** or later - **Internet connection** required for USGS, GHCN, CHMN, and ABOM download examples - **HEC-DSS example** includes the `.dss` file in the [`5-hec-dss-import/`](5-hec-dss-import/) subfolder -- no internet required - **Manual entry example** includes CSV files in the [`6-manual-entry/`](6-manual-entry/) subfolder for copy-paste -- no internet required diff --git a/examples/2-input-data/1-block-maximum/usgs-block-max-example.bestfit b/examples/2-input-data/1-block-maximum/usgs-block-max-example.bestfit index e7c365e..c475abc 100644 Binary files a/examples/2-input-data/1-block-maximum/usgs-block-max-example.bestfit and b/examples/2-input-data/1-block-maximum/usgs-block-max-example.bestfit differ diff --git a/examples/2-input-data/2-usgs-peak-discharge/usgs-peak-download-example.bestfit b/examples/2-input-data/2-usgs-peak-discharge/usgs-peak-download-example.bestfit index 96982ae..af28f45 100644 Binary files a/examples/2-input-data/2-usgs-peak-discharge/usgs-peak-download-example.bestfit and b/examples/2-input-data/2-usgs-peak-discharge/usgs-peak-download-example.bestfit differ diff --git a/examples/2-input-data/3-peaks-over-threshold/ghcn-peaks-over-threshold-example.bestfit b/examples/2-input-data/3-peaks-over-threshold/ghcn-peaks-over-threshold-example.bestfit index 3afd100..3dfcedd 100644 Binary files a/examples/2-input-data/3-peaks-over-threshold/ghcn-peaks-over-threshold-example.bestfit and b/examples/2-input-data/3-peaks-over-threshold/ghcn-peaks-over-threshold-example.bestfit differ diff --git a/examples/2-input-data/3-peaks-over-threshold/usgs-peaks-over-threshold-example.bestfit b/examples/2-input-data/3-peaks-over-threshold/usgs-peaks-over-threshold-example.bestfit index 98bb948..98f8380 100644 Binary files a/examples/2-input-data/3-peaks-over-threshold/usgs-peaks-over-threshold-example.bestfit and b/examples/2-input-data/3-peaks-over-threshold/usgs-peaks-over-threshold-example.bestfit differ diff --git a/examples/2-input-data/README.md b/examples/2-input-data/README.md index e1d69ce..ad36a61 100644 --- a/examples/2-input-data/README.md +++ b/examples/2-input-data/README.md @@ -44,7 +44,7 @@ The block maximum and POT methods produce exact observations. The USGS peak down ## Prerequisites -- **RMC-BestFit 2.0** (Beta or later) +- **RMC-BestFit 2.0.0** or later - **Internet connection** required for the USGS peak discharge download example and for refreshing time series data in the block maximum and POT examples - All examples include pre-downloaded data -- you can explore the results without an internet connection diff --git a/examples/4-univariate-distribution-analysis/1-univariate-analysis/1-information-expansion/viglione-et-al-2013.bestfit b/examples/4-univariate-distribution-analysis/1-univariate-analysis/1-information-expansion/viglione-et-al-2013.bestfit index 6c6a6ae..c6030a4 100644 Binary files a/examples/4-univariate-distribution-analysis/1-univariate-analysis/1-information-expansion/viglione-et-al-2013.bestfit and b/examples/4-univariate-distribution-analysis/1-univariate-analysis/1-information-expansion/viglione-et-al-2013.bestfit differ diff --git a/examples/4-univariate-distribution-analysis/1-univariate-analysis/2-arr-flike/arr-flike-examples.bestfit b/examples/4-univariate-distribution-analysis/1-univariate-analysis/2-arr-flike/arr-flike-examples.bestfit index 57456ea..90d3874 100644 Binary files a/examples/4-univariate-distribution-analysis/1-univariate-analysis/2-arr-flike/arr-flike-examples.bestfit and b/examples/4-univariate-distribution-analysis/1-univariate-analysis/2-arr-flike/arr-flike-examples.bestfit differ diff --git a/examples/4-univariate-distribution-analysis/1-univariate-analysis/3-bulletin17C-examples/bulletin-17c-bayesian-examples.bestfit b/examples/4-univariate-distribution-analysis/1-univariate-analysis/3-bulletin17C-examples/bulletin-17c-bayesian-examples.bestfit index 60b6f8e..c4ff7db 100644 Binary files a/examples/4-univariate-distribution-analysis/1-univariate-analysis/3-bulletin17C-examples/bulletin-17c-bayesian-examples.bestfit and b/examples/4-univariate-distribution-analysis/1-univariate-analysis/3-bulletin17C-examples/bulletin-17c-bayesian-examples.bestfit differ diff --git a/examples/4-univariate-distribution-analysis/1-univariate-analysis/4-measurement-errors/sinnemahoning-move3-bayesian.bestfit b/examples/4-univariate-distribution-analysis/1-univariate-analysis/4-measurement-errors/sinnemahoning-move3-bayesian.bestfit index af8511e..6e8b197 100644 Binary files a/examples/4-univariate-distribution-analysis/1-univariate-analysis/4-measurement-errors/sinnemahoning-move3-bayesian.bestfit and b/examples/4-univariate-distribution-analysis/1-univariate-analysis/4-measurement-errors/sinnemahoning-move3-bayesian.bestfit differ diff --git a/examples/4-univariate-distribution-analysis/1-univariate-analysis/5-nonstationary-ffa/nsffa-brays-bayou-texas.bestfit b/examples/4-univariate-distribution-analysis/1-univariate-analysis/5-nonstationary-ffa/nsffa-brays-bayou-texas.bestfit index c8feec2..24c6b71 100644 Binary files a/examples/4-univariate-distribution-analysis/1-univariate-analysis/5-nonstationary-ffa/nsffa-brays-bayou-texas.bestfit and b/examples/4-univariate-distribution-analysis/1-univariate-analysis/5-nonstationary-ffa/nsffa-brays-bayou-texas.bestfit differ diff --git a/examples/4-univariate-distribution-analysis/1-univariate-analysis/5-nonstationary-ffa/nsffa-oc-fisher-dam.bestfit b/examples/4-univariate-distribution-analysis/1-univariate-analysis/5-nonstationary-ffa/nsffa-oc-fisher-dam.bestfit index 3fa170a..7e6afd5 100644 Binary files a/examples/4-univariate-distribution-analysis/1-univariate-analysis/5-nonstationary-ffa/nsffa-oc-fisher-dam.bestfit and b/examples/4-univariate-distribution-analysis/1-univariate-analysis/5-nonstationary-ffa/nsffa-oc-fisher-dam.bestfit differ diff --git a/examples/4-univariate-distribution-analysis/1-univariate-analysis/5-nonstationary-ffa/nsffa-synthetic-data.bestfit b/examples/4-univariate-distribution-analysis/1-univariate-analysis/5-nonstationary-ffa/nsffa-synthetic-data.bestfit index 116197c..d743ee0 100644 Binary files a/examples/4-univariate-distribution-analysis/1-univariate-analysis/5-nonstationary-ffa/nsffa-synthetic-data.bestfit and b/examples/4-univariate-distribution-analysis/1-univariate-analysis/5-nonstationary-ffa/nsffa-synthetic-data.bestfit differ diff --git a/examples/4-univariate-distribution-analysis/2-bulletin-17C-analysis/1-bulletin17C-examples/bulletin-17c-examples.bestfit b/examples/4-univariate-distribution-analysis/2-bulletin-17C-analysis/1-bulletin17C-examples/bulletin-17c-examples.bestfit index 14b2484..4512b5f 100644 Binary files a/examples/4-univariate-distribution-analysis/2-bulletin-17C-analysis/1-bulletin17C-examples/bulletin-17c-examples.bestfit and b/examples/4-univariate-distribution-analysis/2-bulletin-17C-analysis/1-bulletin17C-examples/bulletin-17c-examples.bestfit differ diff --git a/examples/4-univariate-distribution-analysis/2-bulletin-17C-analysis/2-information-expansion/Case Study/CI-Coverage.xlsx b/examples/4-univariate-distribution-analysis/2-bulletin-17C-analysis/2-information-expansion/Case Study/CI-Coverage.xlsx new file mode 100644 index 0000000..29f432e Binary files /dev/null and b/examples/4-univariate-distribution-analysis/2-bulletin-17C-analysis/2-information-expansion/Case Study/CI-Coverage.xlsx differ diff --git a/examples/4-univariate-distribution-analysis/2-bulletin-17C-analysis/2-information-expansion/Case Study/IES Appendix E2 Hydrologic Hazard Report.pdf b/examples/4-univariate-distribution-analysis/2-bulletin-17C-analysis/2-information-expansion/Case Study/IES Appendix E2 Hydrologic Hazard Report.pdf new file mode 100644 index 0000000..e0dd6dc Binary files /dev/null and b/examples/4-univariate-distribution-analysis/2-bulletin-17C-analysis/2-information-expansion/Case Study/IES Appendix E2 Hydrologic Hazard Report.pdf differ diff --git a/examples/4-univariate-distribution-analysis/2-bulletin-17C-analysis/2-information-expansion/Case Study/Input-Data.xlsx b/examples/4-univariate-distribution-analysis/2-bulletin-17C-analysis/2-information-expansion/Case Study/Input-Data.xlsx new file mode 100644 index 0000000..4a0033a Binary files /dev/null and b/examples/4-univariate-distribution-analysis/2-bulletin-17C-analysis/2-information-expansion/Case Study/Input-Data.xlsx differ diff --git a/examples/4-univariate-distribution-analysis/2-bulletin-17C-analysis/2-information-expansion/Case Study/blakely-mountain-dam-b17c-metric.bestfit b/examples/4-univariate-distribution-analysis/2-bulletin-17C-analysis/2-information-expansion/Case Study/blakely-mountain-dam-b17c-metric.bestfit new file mode 100644 index 0000000..f72aa9f Binary files /dev/null and b/examples/4-univariate-distribution-analysis/2-bulletin-17C-analysis/2-information-expansion/Case Study/blakely-mountain-dam-b17c-metric.bestfit differ diff --git a/examples/4-univariate-distribution-analysis/2-bulletin-17C-analysis/2-information-expansion/blakely-mountain-dam-b17c.bestfit b/examples/4-univariate-distribution-analysis/2-bulletin-17C-analysis/2-information-expansion/blakely-mountain-dam-b17c.bestfit index ea13463..e9fa5ae 100644 Binary files a/examples/4-univariate-distribution-analysis/2-bulletin-17C-analysis/2-information-expansion/blakely-mountain-dam-b17c.bestfit and b/examples/4-univariate-distribution-analysis/2-bulletin-17C-analysis/2-information-expansion/blakely-mountain-dam-b17c.bestfit differ diff --git a/examples/4-univariate-distribution-analysis/2-bulletin-17C-analysis/3-measurement-errors/sinnemahoning-move3-b17c.bestfit b/examples/4-univariate-distribution-analysis/2-bulletin-17C-analysis/3-measurement-errors/sinnemahoning-move3-b17c.bestfit index 76266f2..b9bab29 100644 Binary files a/examples/4-univariate-distribution-analysis/2-bulletin-17C-analysis/3-measurement-errors/sinnemahoning-move3-b17c.bestfit and b/examples/4-univariate-distribution-analysis/2-bulletin-17C-analysis/3-measurement-errors/sinnemahoning-move3-b17c.bestfit differ diff --git a/examples/4-univariate-distribution-analysis/3-point-process-analysis/point-process-examples.bestfit b/examples/4-univariate-distribution-analysis/3-point-process-analysis/point-process-examples.bestfit index db9ba22..1bb422c 100644 Binary files a/examples/4-univariate-distribution-analysis/3-point-process-analysis/point-process-examples.bestfit and b/examples/4-univariate-distribution-analysis/3-point-process-analysis/point-process-examples.bestfit differ diff --git a/examples/4-univariate-distribution-analysis/4-mixture-analysis/mixture-distribution-examples.bestfit b/examples/4-univariate-distribution-analysis/4-mixture-analysis/mixture-distribution-examples.bestfit index 94fd4f5..80972c0 100644 Binary files a/examples/4-univariate-distribution-analysis/4-mixture-analysis/mixture-distribution-examples.bestfit and b/examples/4-univariate-distribution-analysis/4-mixture-analysis/mixture-distribution-examples.bestfit differ diff --git a/examples/4-univariate-distribution-analysis/5-composite-analysis/mixed-population-examples.bestfit b/examples/4-univariate-distribution-analysis/5-composite-analysis/mixed-population-examples.bestfit index 25a0186..9cd6ea5 100644 Binary files a/examples/4-univariate-distribution-analysis/5-composite-analysis/mixed-population-examples.bestfit and b/examples/4-univariate-distribution-analysis/5-composite-analysis/mixed-population-examples.bestfit differ diff --git a/examples/5-bivariate-distribution-analysis/1-bivariate-distributions/bivariate-distribution-examples.bestfit b/examples/5-bivariate-distribution-analysis/1-bivariate-distributions/bivariate-distribution-examples.bestfit index 66f5b95..b47408a 100644 Binary files a/examples/5-bivariate-distribution-analysis/1-bivariate-distributions/bivariate-distribution-examples.bestfit and b/examples/5-bivariate-distribution-analysis/1-bivariate-distributions/bivariate-distribution-examples.bestfit differ diff --git a/examples/5-bivariate-distribution-analysis/2-coincident-frequency/sum-two-normals.bestfit b/examples/5-bivariate-distribution-analysis/2-coincident-frequency/sum-two-normals.bestfit index 7c88beb..c28a4b4 100644 Binary files a/examples/5-bivariate-distribution-analysis/2-coincident-frequency/sum-two-normals.bestfit and b/examples/5-bivariate-distribution-analysis/2-coincident-frequency/sum-two-normals.bestfit differ diff --git a/examples/5-bivariate-distribution-analysis/2-coincident-frequency/waimea-river-stage-frequency.bestfit b/examples/5-bivariate-distribution-analysis/2-coincident-frequency/waimea-river-stage-frequency.bestfit index 01c7276..89036ff 100644 Binary files a/examples/5-bivariate-distribution-analysis/2-coincident-frequency/waimea-river-stage-frequency.bestfit and b/examples/5-bivariate-distribution-analysis/2-coincident-frequency/waimea-river-stage-frequency.bestfit differ diff --git a/examples/6-rating-curve-analysis/synthetic-rating-curve-examples.bestfit b/examples/6-rating-curve-analysis/synthetic-rating-curve-examples.bestfit index ba10d78..b388713 100644 Binary files a/examples/6-rating-curve-analysis/synthetic-rating-curve-examples.bestfit and b/examples/6-rating-curve-analysis/synthetic-rating-curve-examples.bestfit differ diff --git a/examples/6-rating-curve-analysis/usgs-07024175-mississippi-rating-curve.bestfit b/examples/6-rating-curve-analysis/usgs-07024175-mississippi-rating-curve.bestfit index 9b87e7f..4345f00 100644 Binary files a/examples/6-rating-curve-analysis/usgs-07024175-mississippi-rating-curve.bestfit and b/examples/6-rating-curve-analysis/usgs-07024175-mississippi-rating-curve.bestfit differ diff --git a/examples/7-time-series-analysis/1-synthetic-data-examples/synthetic-time-series-examples.bestfit b/examples/7-time-series-analysis/1-synthetic-data-examples/synthetic-time-series-examples.bestfit index e3ce390..fce2cf8 100644 Binary files a/examples/7-time-series-analysis/1-synthetic-data-examples/synthetic-time-series-examples.bestfit and b/examples/7-time-series-analysis/1-synthetic-data-examples/synthetic-time-series-examples.bestfit differ diff --git a/examples/7-time-series-analysis/2-classic-time-series-examples/classic-time-series-examples.bestfit b/examples/7-time-series-analysis/2-classic-time-series-examples/classic-time-series-examples.bestfit index cc6194c..e082b1b 100644 Binary files a/examples/7-time-series-analysis/2-classic-time-series-examples/classic-time-series-examples.bestfit and b/examples/7-time-series-analysis/2-classic-time-series-examples/classic-time-series-examples.bestfit differ diff --git a/examples/7-time-series-analysis/3-time-series-regression-example/time-series-regression-example.bestfit b/examples/7-time-series-analysis/3-time-series-regression-example/time-series-regression-example.bestfit index 54b9f3c..bff8f9d 100644 Binary files a/examples/7-time-series-analysis/3-time-series-regression-example/time-series-regression-example.bestfit and b/examples/7-time-series-analysis/3-time-series-regression-example/time-series-regression-example.bestfit differ diff --git a/examples/README.md b/examples/README.md index 450eb2f..86ca16a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,5 +1,8 @@ # RMC-BestFit Example Projects +> [!NOTE] +> These example projects and tutorials are published for RMC-BestFit 2.0.0 and will continue to expand with additional screenshots, output tables, and validation notes. + This folder contains tutorial example projects for RMC-BestFit 2.0. Each `.bestfit` file is a self-contained SQLite project that can be opened directly in BestFit; each `.md` file alongside is a step-by-step tutorial guide describing what's inside, how to explore it, and what the expected results look like. ## Chapters diff --git a/index.html b/index.html deleted file mode 100644 index 91ce079..0000000 --- a/index.html +++ /dev/null @@ -1,191 +0,0 @@ - - - - - - - - - - - - RMC-BestFit - - - -
-
-

RMC-BestFit

-
- -
-
-

RMC-BestFit is a state-of-the-art Bayesian estimation and fitting software developed collaboratively by the U.S. Army Corps of Engineers' Risk Management Center (RMC) and Engineer Research and Development Center's Coastal and Hydraulics Laboratory (CHL). Tailored to expedite flood hazard assessments for the Flood Risk Management, Planning, and Dam and Levee Safety communities, the software employs a Bayesian framework to integrate a variety of data sources, including historical records, paleoflood evidence, regional data, rainfall-runoff models, and expert judgment.

-

This intuitive, menu-driven application provides a comprehensive environment for distribution fitting and Bayesian estimation. Its modern graphical interface, combined with robust data input, analysis, and reporting functionalities, enables users to effectively conduct flood frequency analyses and produce high-quality visualizations.

-
- -
-

Version 2.0 Beta Release

-

We are thrilled to announce the release of RMC-BestFit Version 2.0 Beta! This major update brings powerful new features designed to enhance time series analysis and modeling capabilities. Key enhancements include:

-
    -
  • Expanded Data Import: Import time series data directly from USGS and GHCN platforms, streamlining the creation of block maximum or peaks-over-threshold series for comprehensive analysis.
  • -
  • Advanced Hypothesis Testing: Conduct rigorous hypothesis tests on time series data to identify trends, nonstationarity, and other statistical patterns.
  • -
  • Measurement Error Integration: Incorporate measurement error into analyses for enhanced accuracy and reliability in results.
  • -
  • Additional Probability Distributions: Access a wider selection of probability distributions to better fit diverse datasets and hydrological conditions.
  • -
  • Nonstationary Flood Frequency Analysis: Model evolving flood frequency patterns over time to gain valuable insights for risk assessment and management.
  • -
  • Improved Prior Distributions: Set parameter and quantile priors from 10 distribution options. Users can also apply Jeffreys’ Rule for Scale parameters, providing a superior noninformative prior and enabling compatibility with other Bayesian software, such as FLIKE®.
  • -
  • Complex Modeling Techniques: Utilize mixture models, competing risks, and model averaging to address complex hydrological phenomena and improve model performance.
  • -
  • Dependency Modeling with Bivariate Copulas: Capture the dependence structure between hydrological variables for a deeper understanding of your data.
  • -
-

With these powerful enhancements, RMC-BestFit 2.0 significantly broadens its capabilities, equipping users to tackle a wider array of hydrological challenges with greater precision and confidence.

-
- -
-

Get Started Today!

-

Download RMC-BestFit Version 2.0 Beta and example projects at GitHub.

- -
-
- -
- - diff --git a/src/RMC.BestFit.Api.Tests/Configuration/ApiOptionsTests.cs b/src/RMC.BestFit.Api.Tests/Configuration/ApiOptionsTests.cs new file mode 100644 index 0000000..3b60849 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Configuration/ApiOptionsTests.cs @@ -0,0 +1,36 @@ +using RMC.BestFit.Api.Configuration; + +namespace RMC.BestFit.Api.Tests.Configuration +{ + /// + /// Unit tests for defaults and property round-trips. + /// + [TestClass] + public class ApiOptionsTests + { + /// + /// Verifies the documented defaults. + /// + [TestMethod] + public void Defaults_MatchDocumentation() + { + var options = new ApiOptions(); + Assert.AreEqual(500, options.MaxResources); + Assert.AreEqual(2, options.MaxConcurrentRuns); + Assert.AreEqual(500_000, options.MaxIterations); + Assert.AreEqual("Api", ApiOptions.SectionName); + } + + /// + /// Verifies the properties round-trip assigned values. + /// + [TestMethod] + public void Properties_RoundTrip() + { + var options = new ApiOptions { MaxResources = 10, MaxConcurrentRuns = 1, MaxIterations = 1000 }; + Assert.AreEqual(10, options.MaxResources); + Assert.AreEqual(1, options.MaxConcurrentRuns); + Assert.AreEqual(1000, options.MaxIterations); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Controllers/ApiControllerBaseTests.cs b/src/RMC.BestFit.Api.Tests/Controllers/ApiControllerBaseTests.cs new file mode 100644 index 0000000..b47227e --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Controllers/ApiControllerBaseTests.cs @@ -0,0 +1,151 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using RMC.BestFit.Api.Controllers; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Services.Exceptions; + +namespace RMC.BestFit.Api.Tests.Controllers +{ + /// + /// Unit tests for : the exception-to-status-code mapping, the + /// response contract stamping, and the non-finite response audit. + /// + [TestClass] + public class ApiControllerBaseTests + { + /// + /// Minimal concrete controller exposing the protected execute pipeline for testing. + /// + private sealed class TestController : ApiControllerBase + { + /// + /// Runs an operation through the shared pipeline. + /// + /// The response DTO type. + /// The operation under test. + /// The success status code. + /// The action result. + public Task> Run(Func operation, int successStatusCode = 200) + where TResponse : ResponseBase, new() + { + return ExecuteAsync(operation, NullLogger.Instance, "test.operation", successStatusCode); + } + } + + /// + /// Extracts the object result (status + body) from an action result. + /// + /// The response DTO type. + /// The action result returned by the pipeline. + /// The status code and typed response body. + private static (int StatusCode, TResponse Body) Unwrap(ActionResult actionResult) + where TResponse : ResponseBase + { + var objectResult = actionResult.Result as ObjectResult; + Assert.IsNotNull(objectResult, "Expected an ObjectResult."); + Assert.IsNotNull(objectResult.StatusCode, "Expected an explicit status code."); + var body = objectResult.Value as TResponse; + Assert.IsNotNull(body, "Expected a typed response body."); + return (objectResult.StatusCode.Value, body); + } + + /// + /// Verifies the success path stamps success, timing, and timestamp, and honors the + /// requested status code. + /// + [TestMethod] + public async Task Execute_Success_StampsContract() + { + var controller = new TestController(); + var (status, body) = Unwrap(await controller.Run( + () => new DeleteResourceResponse { DeletedId = Guid.NewGuid() }, successStatusCode: 201)); + + Assert.AreEqual(201, status); + Assert.IsTrue(body.Success); + Assert.IsNotNull(body.ComputationTimeMs); + Assert.IsNotNull(body.Timestamp); + Assert.IsNull(body.ErrorMessage); + } + + /// + /// Verifies each typed exception maps to its documented status code. + /// + [TestMethod] + public async Task Execute_ExceptionMapping_MatchesContract() + { + var controller = new TestController(); + + var cases = new (Exception Exception, int ExpectedStatus)[] + { + (new RequestValidationException(new[] { "bad data" }), 400), + (new ArgumentException("bad argument"), 400), + (new ResourceNotFoundException("missing"), 404), + (new UsgsDataNotFoundException("no data"), 404), + (new ResourceConflictException("busy"), 409), + (new OperationCanceledException(), 499), + (new UsgsUnavailableException("upstream", statusCode: 502), 502), + (new UsgsUnavailableException("offline", statusCode: 503), 503), + (new InvalidOperationException("boom"), 500) + }; + + foreach (var (exception, expectedStatus) in cases) + { + var (status, body) = Unwrap(await controller.Run(() => throw exception)); + Assert.AreEqual(expectedStatus, status, $"Exception {exception.GetType().Name} mapped to {status}."); + Assert.IsFalse(body.Success); + Assert.IsNotNull(body.ErrorMessage); + } + } + + /// + /// Verifies validation exceptions carry their individual messages in validationErrors. + /// + [TestMethod] + public async Task Execute_ValidationFailure_CarriesErrorList() + { + var controller = new TestController(); + var (status, body) = Unwrap(await controller.Run( + () => throw new RequestValidationException(new[] { "error one", "error two" }))); + + Assert.AreEqual(400, status); + Assert.IsNotNull(body.ValidationErrors); + Assert.AreEqual(2, body.ValidationErrors.Count); + } + + /// + /// Verifies a response containing ±Infinity is rejected with 500 and the audit findings. + /// + [TestMethod] + public async Task Execute_InfinityInResponse_Returns500WithFindings() + { + var controller = new TestController(); + var (status, body) = Unwrap(await controller.Run(() => new SummaryStatisticsResponse + { + InputDataId = Guid.NewGuid(), + Statistics = new Dictionary { ["bad"] = double.PositiveInfinity } + })); + + Assert.AreEqual(500, status); + Assert.IsFalse(body.Success); + Assert.IsNotNull(body.NonFiniteFindings); + Assert.AreEqual(1, body.NonFiniteFindings.Count); + } + + /// + /// Verifies NaN in a response is tolerated (missing-data marker, not a defect). + /// + [TestMethod] + public async Task Execute_NaNInResponse_Succeeds() + { + var controller = new TestController(); + var (status, body) = Unwrap(await controller.Run(() => new SummaryStatisticsResponse + { + InputDataId = Guid.NewGuid(), + Statistics = new Dictionary { ["skew"] = double.NaN } + })); + + Assert.AreEqual(200, status); + Assert.IsTrue(body.Success); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Controllers/BivariateAnalysisControllerTests.cs b/src/RMC.BestFit.Api.Tests/Controllers/BivariateAnalysisControllerTests.cs new file mode 100644 index 0000000..37180c9 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Controllers/BivariateAnalysisControllerTests.cs @@ -0,0 +1,159 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.Controllers; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Controllers +{ + /// + /// Unit tests for status codes and payloads, using + /// the real service over an in-memory store. Analyses are created and validated but never run. + /// + [TestClass] + public class BivariateAnalysisControllerTests + { + /// + /// The store backing the controller stack. + /// + private InMemoryResourceStore _store = null!; + + /// + /// The service shared by the controller and marginal creation. + /// + private AnalysisService _service = null!; + + /// + /// The controller under test. + /// + private BivariateAnalysisController _controller = null!; + + /// + /// Creates a fresh controller stack before each test. + /// + [TestInitialize] + public void Initialize() + { + _store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + _service = new AnalysisService(_store, Options.Create(new ApiOptions())); + _controller = new BivariateAnalysisController(NullLogger.Instance, _service); + } + + /// + /// Extracts status code and body from an action result. + /// + /// The response DTO type. + /// The action result. + /// The status code and body. + private static (int StatusCode, TResponse Body) Unwrap(ActionResult actionResult) + where TResponse : ResponseBase + { + var objectResult = (ObjectResult)actionResult.Result!; + return (objectResult.StatusCode!.Value, (TResponse)objectResult.Value!); + } + + /// + /// Builds a valid creation request over two stored unrun univariate marginals. + /// + /// The request. + private CreateBivariateAnalysisRequest CreateValidRequest() + { + var input = _store.AddInputData(TestAnalyses.CreateInputDataResource()); + var marginalX = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id }); + var marginalY = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id }); + return new CreateBivariateAnalysisRequest + { + MarginalXAnalysisId = marginalX.Id, + MarginalYAnalysisId = marginalY.Id, + XyOrdinates = new List { new() { X = 300d, Y = 320d } } + }; + } + + /// + /// Verifies create returns 201 with copula metadata and marginal provenance, and 404 for + /// an unknown marginal id. + /// + [TestMethod] + public async Task Create_Returns201_Or404() + { + var request = CreateValidRequest(); + var (status, body) = Unwrap(await _controller.Create(request)); + Assert.AreEqual(201, status); + Assert.AreEqual("bivariate", body.Analysis!.Kind); + Assert.AreEqual("normal", body.Analysis.CopulaType); + Assert.AreEqual("inferenceFromMargins", body.Analysis.CopulaEstimationMethod); + Assert.AreEqual(request.MarginalXAnalysisId, body.Analysis.MarginalXAnalysisId); + Assert.IsFalse(body.Analysis.IsValid, "Unrun marginals must make the bivariate invalid until estimated."); + + request.MarginalYAnalysisId = Guid.NewGuid(); + var (missingStatus, _) = Unwrap(await _controller.Create(request)); + Assert.AreEqual(404, missingStatus); + } + + /// + /// Verifies get/list/validate/results-before-run/delete round-trip with kind guarding. + /// + [TestMethod] + public async Task Get_List_Validate_Delete_Lifecycle() + { + var (_, created) = Unwrap(await _controller.Create(CreateValidRequest())); + var id = created.Analysis!.Id; + + var (getStatus, got) = Unwrap(await _controller.Get(id)); + Assert.AreEqual(200, getStatus); + Assert.AreEqual(id, got.Analysis!.Id); + + var (listStatus, list) = Unwrap(await _controller.List()); + Assert.AreEqual(200, listStatus); + Assert.AreEqual(1, list.Count); + + var validateResult = _controller.Validate(id); + var validateBody = (ValidationResponse)((OkObjectResult)validateResult.Result!).Value!; + Assert.IsFalse(validateBody.IsValid); + Assert.IsTrue(validateBody.Errors.Any(e => e.Contains("marginal")), "Validation must name the unestimated marginals."); + + var (resultsStatus, _) = Unwrap(await _controller.GetResults(id)); + Assert.AreEqual(404, resultsStatus, "Results before any run must be 404."); + + var other = _store.AddAnalysis(TestAnalyses.CreateUnivariateResource()); + var (guardStatus, _) = Unwrap(await _controller.Get(other.Id)); + Assert.AreEqual(404, guardStatus, "A univariate id must 404 on the bivariate routes."); + + var (deleteStatus, deleted) = Unwrap(await _controller.Delete(id)); + Assert.AreEqual(200, deleteStatus); + Assert.AreEqual(id, deleted.DeletedId); + } + + /// + /// Verifies a run returns 409 while a marginal's run lock is held, and 400 once the + /// locks are free (marginals unestimated). + /// + [TestMethod] + public async Task Run_MarginalRunning409_ThenInvalid400() + { + var (_, created) = Unwrap(await _controller.Create(CreateValidRequest())); + var resource = _store.GetAnalysis(created.Analysis!.Id)!; + var marginal = resource.MarginalXResource!; + + Assert.IsTrue(marginal.RunLock.Wait(0)); + try + { + var (conflictStatus, conflictBody) = Unwrap(await _controller.Run(resource.Id, CancellationToken.None)); + Assert.AreEqual(409, conflictStatus); + Assert.IsFalse(conflictBody.Success); + } + finally + { + marginal.RunLock.Release(); + } + + var (invalidStatus, invalidBody) = Unwrap(await _controller.Run(resource.Id, CancellationToken.None)); + Assert.AreEqual(400, invalidStatus); + Assert.IsNotNull(invalidBody.ValidationErrors); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Controllers/Bulletin17CAnalysisControllerTests.cs b/src/RMC.BestFit.Api.Tests/Controllers/Bulletin17CAnalysisControllerTests.cs new file mode 100644 index 0000000..998808a --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Controllers/Bulletin17CAnalysisControllerTests.cs @@ -0,0 +1,117 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Numerics.Distributions; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.Controllers; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Controllers +{ + /// + /// Unit tests for status codes and payloads. + /// Analyses are created and validated but never run. + /// + [TestClass] + public class Bulletin17CAnalysisControllerTests + { + /// + /// The store backing the controller stack. + /// + private InMemoryResourceStore _store = null!; + + /// + /// The controller under test. + /// + private Bulletin17CAnalysisController _controller = null!; + + /// + /// Creates a fresh controller stack before each test. + /// + [TestInitialize] + public void Initialize() + { + _store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + var service = new AnalysisService(_store, Options.Create(new ApiOptions())); + _controller = new Bulletin17CAnalysisController(NullLogger.Instance, service); + } + + /// + /// Extracts status code and body from an action result. + /// + /// The response DTO type. + /// The action result. + /// The status code and body. + private static (int StatusCode, TResponse Body) Unwrap(ActionResult actionResult) + where TResponse : ResponseBase + { + var objectResult = (ObjectResult)actionResult.Result!; + return (objectResult.StatusCode!.Value, (TResponse)objectResult.Value!); + } + + /// + /// Verifies create returns 201 with the uncertainty method, and 400 for a distribution + /// Bulletin 17C does not support. + /// + [TestMethod] + public async Task Create_Returns201_Or400ForUnsupportedDistribution() + { + var input = _store.AddInputData(TestAnalyses.CreateInputDataResource()); + + var (status, body) = Unwrap(await _controller.Create(new CreateBulletin17CAnalysisRequest { InputDataId = input.Id })); + Assert.AreEqual(201, status); + Assert.AreEqual("bulletin17C", body.Analysis!.Kind); + // Omitting uncertaintyMethod keeps the model default (linkedMultivariateNormal). + Assert.AreEqual("linkedMultivariateNormal", body.Analysis.UncertaintyMethod); + + var (badStatus, badBody) = Unwrap(await _controller.Create(new CreateBulletin17CAnalysisRequest + { + InputDataId = input.Id, + Distribution = UnivariateDistributionType.GeneralizedExtremeValue + })); + Assert.AreEqual(400, badStatus); + Assert.IsFalse(badBody.Success); + } + + /// + /// Verifies kind guarding: a univariate analysis id is not visible through the Bulletin 17C routes. + /// + [TestMethod] + public async Task Get_UnivariateId_Returns404() + { + var univariate = _store.AddAnalysis(TestAnalyses.CreateUnivariateResource()); + var (status, _) = Unwrap(await _controller.Get(univariate.Id)); + Assert.AreEqual(404, status); + } + + /// + /// Verifies get/list/validate/results-404/delete round-trip. + /// + [TestMethod] + public async Task Get_List_Validate_Results_Delete_Lifecycle() + { + var input = _store.AddInputData(TestAnalyses.CreateInputDataResource()); + var (_, created) = Unwrap(await _controller.Create(new CreateBulletin17CAnalysisRequest { InputDataId = input.Id })); + var id = created.Analysis!.Id; + + var (getStatus, _) = Unwrap(await _controller.Get(id)); + Assert.AreEqual(200, getStatus); + + var (listStatus, list) = Unwrap(await _controller.List()); + Assert.AreEqual(200, listStatus); + Assert.AreEqual(1, list.Count); + + var validateBody = (ValidationResponse)((OkObjectResult)_controller.Validate(id).Result!).Value!; + Assert.IsTrue(validateBody.IsValid); + + var (resultsStatus, _) = Unwrap(await _controller.GetResults(id)); + Assert.AreEqual(404, resultsStatus); + + var (deleteStatus, _) = Unwrap(await _controller.Delete(id)); + Assert.AreEqual(200, deleteStatus); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Controllers/CoincidentFrequencyAnalysisControllerTests.cs b/src/RMC.BestFit.Api.Tests/Controllers/CoincidentFrequencyAnalysisControllerTests.cs new file mode 100644 index 0000000..4c0d549 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Controllers/CoincidentFrequencyAnalysisControllerTests.cs @@ -0,0 +1,173 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.Controllers; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Controllers +{ + /// + /// Unit tests for status codes and + /// payloads, using the real service over an in-memory store. Analyses are created and + /// validated but never run. + /// + [TestClass] + public class CoincidentFrequencyAnalysisControllerTests + { + /// + /// The store backing the controller stack. + /// + private InMemoryResourceStore _store = null!; + + /// + /// The service shared by the controller and upstream creation. + /// + private AnalysisService _service = null!; + + /// + /// The controller under test. + /// + private CoincidentFrequencyAnalysisController _controller = null!; + + /// + /// Creates a fresh controller stack before each test. + /// + [TestInitialize] + public void Initialize() + { + _store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + _service = new AnalysisService(_store, Options.Create(new ApiOptions())); + _controller = new CoincidentFrequencyAnalysisController(NullLogger.Instance, _service); + } + + /// + /// Extracts status code and body from an action result. + /// + /// The response DTO type. + /// The action result. + /// The status code and body. + private static (int StatusCode, TResponse Body) Unwrap(ActionResult actionResult) + where TResponse : ResponseBase + { + var objectResult = (ObjectResult)actionResult.Result!; + return (objectResult.StatusCode!.Value, (TResponse)objectResult.Value!); + } + + /// + /// Creates a stored bivariate analysis over two unrun univariate marginals. + /// + /// The bivariate resource. + private AnalysisResource CreateStoredBivariate() + { + var input = _store.AddInputData(TestAnalyses.CreateInputDataResource()); + var marginalX = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id }); + var marginalY = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id }); + return _service.CreateBivariate(new CreateBivariateAnalysisRequest + { + MarginalXAnalysisId = marginalX.Id, + MarginalYAnalysisId = marginalY.Id, + XyOrdinates = new List { new() { X = 300d, Y = 320d } } + }); + } + + /// + /// Builds a valid creation request over a stored bivariate analysis. + /// + /// The upstream bivariate id. + /// The request. + private static CreateCoincidentFrequencyAnalysisRequest CreateValidRequest(Guid bivariateId) + { + return new CreateCoincidentFrequencyAnalysisRequest + { + BivariateAnalysisId = bivariateId, + XValues = new List { 100d, 200d, 300d }, + YValues = new List { 50d, 100d, 150d }, + BivariateResponse = new List> + { + new() { 10d, 11d, 12d }, + new() { 13d, 14d, 15d }, + new() { 16d, 17d, 18d } + } + }; + } + + /// + /// Verifies create returns 201 with provenance and bins, 404 for an unknown bivariate id, + /// and 400 for a ragged surface. + /// + [TestMethod] + public async Task Create_Returns201_404_400() + { + var bivariate = CreateStoredBivariate(); + + var (status, body) = Unwrap(await _controller.Create(CreateValidRequest(bivariate.Id))); + Assert.AreEqual(201, status); + Assert.AreEqual("coincidentFrequency", body.Analysis!.Kind); + Assert.AreEqual(bivariate.Id, body.Analysis.BivariateAnalysisId); + Assert.AreEqual(50, body.Analysis.NumberOfBins); + Assert.IsFalse(body.Analysis.IsValid, "An unestimated bivariate must make the analysis invalid."); + + var (missingStatus, _) = Unwrap(await _controller.Create(CreateValidRequest(Guid.NewGuid()))); + Assert.AreEqual(404, missingStatus); + + var ragged = CreateValidRequest(bivariate.Id); + ragged.BivariateResponse[1] = new List { 13d }; + var (raggedStatus, _) = Unwrap(await _controller.Create(ragged)); + Assert.AreEqual(400, raggedStatus); + } + + /// + /// Verifies validate reports the unestimated bivariate run-free, plus get/list/ + /// results-before-run/delete round-trip with kind guarding and the run conflict while + /// the upstream bivariate is locked. + /// + [TestMethod] + public async Task Get_List_Validate_Delete_Lifecycle() + { + var bivariate = CreateStoredBivariate(); + var (_, created) = Unwrap(await _controller.Create(CreateValidRequest(bivariate.Id))); + var id = created.Analysis!.Id; + + var validateResult = _controller.Validate(id); + var validateBody = (ValidationResponse)((OkObjectResult)validateResult.Result!).Value!; + Assert.IsFalse(validateBody.IsValid); + Assert.IsTrue(validateBody.Errors.Any(e => e.Contains("not been estimated")), + "Validation must explain that the bivariate requires estimation."); + + var (getStatus, got) = Unwrap(await _controller.Get(id)); + Assert.AreEqual(200, getStatus); + Assert.AreEqual(id, got.Analysis!.Id); + + var (listStatus, list) = Unwrap(await _controller.List()); + Assert.AreEqual(200, listStatus); + Assert.AreEqual(1, list.Count); + + var (resultsStatus, _) = Unwrap(await _controller.GetResults(id)); + Assert.AreEqual(404, resultsStatus, "Results before any run must be 404."); + + var resource = _store.GetAnalysis(id)!; + Assert.IsTrue(bivariate.RunLock.Wait(0)); + try + { + var (conflictStatus, _) = Unwrap(await _controller.Run(resource.Id, CancellationToken.None)); + Assert.AreEqual(409, conflictStatus, "A busy upstream bivariate must block the run."); + } + finally + { + bivariate.RunLock.Release(); + } + + var other = _store.AddAnalysis(TestAnalyses.CreateUnivariateResource()); + var (guardStatus, _) = Unwrap(await _controller.Get(other.Id)); + Assert.AreEqual(404, guardStatus, "A univariate id must 404 on the coincident frequency routes."); + + var (deleteStatus, deleted) = Unwrap(await _controller.Delete(id)); + Assert.AreEqual(200, deleteStatus); + Assert.AreEqual(id, deleted.DeletedId); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Controllers/CompetingRisksAnalysisControllerTests.cs b/src/RMC.BestFit.Api.Tests/Controllers/CompetingRisksAnalysisControllerTests.cs new file mode 100644 index 0000000..c1a5e61 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Controllers/CompetingRisksAnalysisControllerTests.cs @@ -0,0 +1,156 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Numerics.Distributions; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.Controllers; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Controllers +{ + /// + /// Unit tests for status codes and payloads, + /// using the real service over an in-memory store. Analyses are created and validated but + /// never run. + /// + [TestClass] + public class CompetingRisksAnalysisControllerTests + { + /// + /// The store backing the controller stack. + /// + private InMemoryResourceStore _store = null!; + + /// + /// The controller under test. + /// + private CompetingRisksAnalysisController _controller = null!; + + /// + /// Creates a fresh controller stack before each test. + /// + [TestInitialize] + public void Initialize() + { + _store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + var service = new AnalysisService(_store, Options.Create(new ApiOptions())); + _controller = new CompetingRisksAnalysisController(NullLogger.Instance, service); + } + + /// + /// Extracts status code and body from an action result. + /// + /// The response DTO type. + /// The action result. + /// The status code and body. + private static (int StatusCode, TResponse Body) Unwrap(ActionResult actionResult) + where TResponse : ResponseBase + { + var objectResult = (ObjectResult)actionResult.Result!; + return (objectResult.StatusCode!.Value, (TResponse)objectResult.Value!); + } + + /// + /// Builds a valid two-component creation request over a stored input-data resource. + /// + /// The request. + private CreateCompetingRisksAnalysisRequest CreateValidRequest() + { + var input = _store.AddInputData(TestAnalyses.CreateInputDataResource()); + return new CreateCompetingRisksAnalysisRequest + { + InputDataId = input.Id, + Distributions = new List + { + UnivariateDistributionType.Gumbel, + UnivariateDistributionType.GeneralizedExtremeValue + } + }; + } + + /// + /// Verifies create returns 201 with kind and component metadata, 404 for an unknown + /// input-data id, and 400 for too many components. + /// + [TestMethod] + public async Task Create_Returns201_404_400() + { + var (status, body) = Unwrap(await _controller.Create(CreateValidRequest())); + Assert.AreEqual(201, status); + Assert.AreEqual("competingRisks", body.Analysis!.Kind); + Assert.IsTrue(body.Analysis.IsValid); + Assert.IsNotNull(body.Analysis.ComponentDistributions); + Assert.AreEqual(2, body.Analysis.ComponentDistributions.Count); + + var (missingStatus, _) = Unwrap(await _controller.Create(new CreateCompetingRisksAnalysisRequest + { + InputDataId = Guid.NewGuid(), + Distributions = new List { UnivariateDistributionType.Gumbel } + })); + Assert.AreEqual(404, missingStatus); + + var tooMany = CreateValidRequest(); + tooMany.Distributions = Enumerable.Repeat(UnivariateDistributionType.Gumbel, 4).ToList(); + var (tooManyStatus, _) = Unwrap(await _controller.Create(tooMany)); + Assert.AreEqual(400, tooManyStatus); + } + + /// + /// Verifies get/list/validate/results-before-run/delete round-trip and the kind guard. + /// + [TestMethod] + public async Task Get_List_Validate_Delete_Lifecycle() + { + var (_, created) = Unwrap(await _controller.Create(CreateValidRequest())); + var id = created.Analysis!.Id; + + var (getStatus, got) = Unwrap(await _controller.Get(id)); + Assert.AreEqual(200, getStatus); + Assert.AreEqual(id, got.Analysis!.Id); + + var (listStatus, list) = Unwrap(await _controller.List()); + Assert.AreEqual(200, listStatus); + Assert.AreEqual(1, list.Count); + + var validateResult = _controller.Validate(id); + var validateBody = (ValidationResponse)((OkObjectResult)validateResult.Result!).Value!; + Assert.IsTrue(validateBody.IsValid); + + var (resultsStatus, _) = Unwrap(await _controller.GetResults(id)); + Assert.AreEqual(404, resultsStatus, "Results before any run must be 404."); + + var other = _store.AddAnalysis(TestAnalyses.CreateUnivariateResource()); + var (guardStatus, _) = Unwrap(await _controller.Get(other.Id)); + Assert.AreEqual(404, guardStatus, "A univariate id must 404 on the competing risks routes."); + + var (deleteStatus, deleted) = Unwrap(await _controller.Delete(id)); + Assert.AreEqual(200, deleteStatus); + Assert.AreEqual(id, deleted.DeletedId); + } + + /// + /// Verifies a run request while the run lock is held returns 409. + /// + [TestMethod] + public async Task Run_Conflict409() + { + var (_, created) = Unwrap(await _controller.Create(CreateValidRequest())); + var resource = _store.GetAnalysis(created.Analysis!.Id)!; + + Assert.IsTrue(resource.RunLock.Wait(0)); + try + { + var (conflictStatus, conflictBody) = Unwrap(await _controller.Run(resource.Id, CancellationToken.None)); + Assert.AreEqual(409, conflictStatus); + Assert.IsFalse(conflictBody.Success); + } + finally + { + resource.RunLock.Release(); + } + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Controllers/CompositeAnalysisControllerTests.cs b/src/RMC.BestFit.Api.Tests/Controllers/CompositeAnalysisControllerTests.cs new file mode 100644 index 0000000..68a5a24 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Controllers/CompositeAnalysisControllerTests.cs @@ -0,0 +1,163 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.Controllers; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Controllers +{ + /// + /// Unit tests for status codes and payloads, using + /// the real service over an in-memory store. Analyses are created and validated but never run. + /// + [TestClass] + public class CompositeAnalysisControllerTests + { + /// + /// The store backing the controller stack. + /// + private InMemoryResourceStore _store = null!; + + /// + /// The service shared by the composite controller and component creation. + /// + private AnalysisService _service = null!; + + /// + /// The controller under test. + /// + private CompositeAnalysisController _controller = null!; + + /// + /// Creates a fresh controller stack before each test. + /// + [TestInitialize] + public void Initialize() + { + _store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + _service = new AnalysisService(_store, Options.Create(new ApiOptions())); + _controller = new CompositeAnalysisController(NullLogger.Instance, _service); + } + + /// + /// Extracts status code and body from an action result. + /// + /// The response DTO type. + /// The action result. + /// The status code and body. + private static (int StatusCode, TResponse Body) Unwrap(ActionResult actionResult) + where TResponse : ResponseBase + { + var objectResult = (ObjectResult)actionResult.Result!; + return (objectResult.StatusCode!.Value, (TResponse)objectResult.Value!); + } + + /// + /// Builds a valid two-component creation request over two stored unrun univariate analyses. + /// + /// The request. + private CreateCompositeAnalysisRequest CreateValidRequest() + { + var input = _store.AddInputData(TestAnalyses.CreateInputDataResource()); + var componentA = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id }); + var componentB = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id }); + return new CreateCompositeAnalysisRequest + { + Components = new List + { + new() { AnalysisId = componentA.Id }, + new() { AnalysisId = componentB.Id } + } + }; + } + + /// + /// Verifies create returns 201 with composite metadata and 404 for an unknown component id. + /// + [TestMethod] + public async Task Create_Returns201_Or404() + { + var (status, body) = Unwrap(await _controller.Create(CreateValidRequest())); + Assert.AreEqual(201, status); + Assert.AreEqual("composite", body.Analysis!.Kind); + Assert.AreEqual("competingRisks", body.Analysis.CompositeType); + Assert.IsNotNull(body.Analysis.ComponentAnalysisIds); + Assert.AreEqual(2, body.Analysis.ComponentAnalysisIds.Count); + Assert.IsFalse(body.Analysis.IsValid, "A composite over unrun components must report invalid until they are estimated."); + + var (missingStatus, _) = Unwrap(await _controller.Create(new CreateCompositeAnalysisRequest + { + Components = new List { new() { AnalysisId = Guid.NewGuid() } } + })); + Assert.AreEqual(404, missingStatus); + } + + /// + /// Verifies validate reports the unestimated components without running, and get/list/ + /// results-before-run/delete round-trip with kind guarding. + /// + [TestMethod] + public async Task Get_List_Validate_Delete_Lifecycle() + { + var (_, created) = Unwrap(await _controller.Create(CreateValidRequest())); + var id = created.Analysis!.Id; + + var validateResult = _controller.Validate(id); + var validateBody = (ValidationResponse)((OkObjectResult)validateResult.Result!).Value!; + Assert.IsFalse(validateBody.IsValid); + Assert.IsTrue(validateBody.Errors.Any(e => e.Contains("requires estimation")), + "Validation must explain that components require estimation."); + + var (getStatus, got) = Unwrap(await _controller.Get(id)); + Assert.AreEqual(200, getStatus); + Assert.AreEqual(id, got.Analysis!.Id); + + var (listStatus, list) = Unwrap(await _controller.List()); + Assert.AreEqual(200, listStatus); + Assert.AreEqual(1, list.Count); + + var (resultsStatus, _) = Unwrap(await _controller.GetResults(id)); + Assert.AreEqual(404, resultsStatus, "Results before any run must be 404."); + + var other = _store.AddAnalysis(TestAnalyses.CreateUnivariateResource()); + var (guardStatus, _) = Unwrap(await _controller.Get(other.Id)); + Assert.AreEqual(404, guardStatus, "A univariate id must 404 on the composite routes."); + + var (deleteStatus, deleted) = Unwrap(await _controller.Delete(id)); + Assert.AreEqual(200, deleteStatus); + Assert.AreEqual(id, deleted.DeletedId); + } + + /// + /// Verifies a composite run returns 409 while a component's run lock is held, and 400 + /// (children unestimated) once the locks are free. + /// + [TestMethod] + public async Task Run_ComponentRunning409_ThenInvalid400() + { + var (_, created) = Unwrap(await _controller.Create(CreateValidRequest())); + var composite = _store.GetAnalysis(created.Analysis!.Id)!; + var component = composite.ComponentResources![0]; + + Assert.IsTrue(component.RunLock.Wait(0)); + try + { + var (conflictStatus, conflictBody) = Unwrap(await _controller.Run(composite.Id, CancellationToken.None)); + Assert.AreEqual(409, conflictStatus); + StringAssert.Contains(conflictBody.ErrorMessage!, "component analysis"); + } + finally + { + component.RunLock.Release(); + } + + var (invalidStatus, invalidBody) = Unwrap(await _controller.Run(composite.Id, CancellationToken.None)); + Assert.AreEqual(400, invalidStatus); + Assert.IsNotNull(invalidBody.ValidationErrors); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Controllers/DistributionFittingAnalysisControllerTests.cs b/src/RMC.BestFit.Api.Tests/Controllers/DistributionFittingAnalysisControllerTests.cs new file mode 100644 index 0000000..066e6b2 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Controllers/DistributionFittingAnalysisControllerTests.cs @@ -0,0 +1,130 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Numerics.Distributions; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.Controllers; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Controllers +{ + /// + /// Unit tests for status codes and + /// payloads, using the real service over an in-memory store. Analyses are created and + /// validated but never run (no MLE). + /// + [TestClass] + public class DistributionFittingAnalysisControllerTests + { + /// + /// The store backing the controller stack. + /// + private InMemoryResourceStore _store = null!; + + /// + /// The controller under test. + /// + private DistributionFittingAnalysisController _controller = null!; + + /// + /// Creates a fresh controller stack before each test. + /// + [TestInitialize] + public void Initialize() + { + _store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + var service = new AnalysisService(_store, Options.Create(new ApiOptions())); + _controller = new DistributionFittingAnalysisController(NullLogger.Instance, service); + } + + /// + /// Extracts status code and body from an action result. + /// + /// The response DTO type. + /// The action result. + /// The status code and body. + private static (int StatusCode, TResponse Body) Unwrap(ActionResult actionResult) + where TResponse : ResponseBase + { + var objectResult = (ObjectResult)actionResult.Result!; + return (objectResult.StatusCode!.Value, (TResponse)objectResult.Value!); + } + + /// + /// Verifies create returns 201 (echoing the candidate set), 404 for an unknown input-data + /// id, and 400 for an unsupported candidate. + /// + [TestMethod] + public async Task Create_Returns201_404_400() + { + var input = _store.AddInputData(TestAnalyses.CreateInputDataResource()); + + var (status, body) = Unwrap(await _controller.Create(new CreateDistributionFittingAnalysisRequest { InputDataId = input.Id })); + Assert.AreEqual(201, status); + Assert.AreEqual("distributionFitting", body.Analysis!.Kind); + Assert.IsTrue(body.Analysis.IsValid); + Assert.IsNotNull(body.Analysis.ComponentDistributions); + Assert.AreEqual(15, body.Analysis.ComponentDistributions.Count); + + var (missingStatus, _) = Unwrap(await _controller.Create(new CreateDistributionFittingAnalysisRequest { InputDataId = Guid.NewGuid() })); + Assert.AreEqual(404, missingStatus); + + var (badStatus, _) = Unwrap(await _controller.Create(new CreateDistributionFittingAnalysisRequest + { + InputDataId = input.Id, + Distributions = new List { UnivariateDistributionType.Cauchy } + })); + Assert.AreEqual(400, badStatus); + } + + /// + /// Verifies get/list/validate/results-before-run/delete round-trip with kind guarding + /// and the run-lock conflict. + /// + [TestMethod] + public async Task Get_List_Validate_Delete_Lifecycle() + { + var input = _store.AddInputData(TestAnalyses.CreateInputDataResource()); + var (_, created) = Unwrap(await _controller.Create(new CreateDistributionFittingAnalysisRequest { InputDataId = input.Id })); + var id = created.Analysis!.Id; + + var (getStatus, got) = Unwrap(await _controller.Get(id)); + Assert.AreEqual(200, getStatus); + Assert.AreEqual(id, got.Analysis!.Id); + + var (listStatus, list) = Unwrap(await _controller.List()); + Assert.AreEqual(200, listStatus); + Assert.AreEqual(1, list.Count); + + var validateResult = _controller.Validate(id); + var validateBody = (ValidationResponse)((OkObjectResult)validateResult.Result!).Value!; + Assert.IsTrue(validateBody.IsValid); + + var (resultsStatus, _) = Unwrap(await _controller.GetResults(id)); + Assert.AreEqual(404, resultsStatus, "Results before any run must be 404."); + + var resource = _store.GetAnalysis(id)!; + Assert.IsTrue(resource.RunLock.Wait(0)); + try + { + var (conflictStatus, _) = Unwrap(await _controller.Run(id, CancellationToken.None)); + Assert.AreEqual(409, conflictStatus); + } + finally + { + resource.RunLock.Release(); + } + + var other = _store.AddAnalysis(TestAnalyses.CreateUnivariateResource()); + var (guardStatus, _) = Unwrap(await _controller.Get(other.Id)); + Assert.AreEqual(404, guardStatus, "A univariate id must 404 on the distribution-fitting routes."); + + var (deleteStatus, deleted) = Unwrap(await _controller.Delete(id)); + Assert.AreEqual(200, deleteStatus); + Assert.AreEqual(id, deleted.DeletedId); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Controllers/InputDataControllerTests.cs b/src/RMC.BestFit.Api.Tests/Controllers/InputDataControllerTests.cs new file mode 100644 index 0000000..3f47c63 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Controllers/InputDataControllerTests.cs @@ -0,0 +1,193 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.Controllers; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Controllers +{ + /// + /// Unit tests for status codes and payloads, using the real + /// service over an in-memory store with the USGS seam faked. + /// + [TestClass] + public class InputDataControllerTests + { + /// + /// The store shared by the controller stack. + /// + private InMemoryResourceStore _store = null!; + + /// + /// The faked USGS seam. + /// + private FakeUsgsTimeSeriesService _usgs = null!; + + /// + /// The controller under test. + /// + private InputDataController _controller = null!; + + /// + /// Creates a fresh controller stack before each test. + /// + [TestInitialize] + public void Initialize() + { + _store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + _usgs = new FakeUsgsTimeSeriesService { Result = TestSeries.IrregularPeaks() }; + var service = new InputDataService(_store, _usgs); + _controller = new InputDataController(NullLogger.Instance, service); + } + + /// + /// Extracts status code and body from an action result. + /// + /// The response DTO type. + /// The action result. + /// The status code and body. + private static (int StatusCode, TResponse Body) Unwrap(ActionResult actionResult) + where TResponse : ResponseBase + { + var objectResult = (ObjectResult)actionResult.Result!; + return (objectResult.StatusCode!.Value, (TResponse)objectResult.Value!); + } + + /// + /// Verifies manual create returns 201 and the detail endpoint returns observations. + /// + [TestMethod] + public async Task CreateManual_ThenGetWithData_ReturnsObservations() + { + var (createStatus, created) = Unwrap(await _controller.CreateManual(new CreateManualInputDataRequest + { + ExactData = new List + { + new() { Index = 2000, Value = 100d }, + new() { Index = 2001, Value = 200d } + } + })); + Assert.AreEqual(201, createStatus); + var id = created.InputData!.Id; + + var (getStatus, got) = Unwrap(await _controller.Get(id, includeData: true)); + Assert.AreEqual(200, getStatus); + Assert.IsNotNull(got.ExactData); + Assert.AreEqual(2, got.ExactData.Count); + } + + /// + /// Verifies invalid manual data returns 400 with the model-layer validation messages. + /// + [TestMethod] + public async Task CreateManual_Invalid_Returns400WithErrors() + { + var (status, body) = Unwrap(await _controller.CreateManual(new CreateManualInputDataRequest + { + ExactData = new List { new() { Index = 2000, Value = 100d } }, + ThresholdData = new List + { + new() { StartIndex = 1950, EndIndex = 1900, Value = 500d } + } + })); + Assert.AreEqual(400, status); + Assert.IsNotNull(body.ValidationErrors); + } + + /// + /// Verifies the block-max endpoint links to a stored series and returns 201, and an + /// unknown link returns 404. + /// + [TestMethod] + public async Task CreateBlockMax_LinksSeries_Or404() + { + var series = new TimeSeriesResource(TestSeries.DailyThreeWaterYears()) { Name = "daily", Source = TimeSeriesSource.Manual }; + _store.AddTimeSeries(series); + + var (status, body) = Unwrap(await _controller.CreateBlockMax(new CreateBlockMaxInputDataRequest { TimeSeriesId = series.Id })); + Assert.AreEqual(201, status); + Assert.AreEqual(series.Id, body.InputData!.SourceTimeSeriesId); + Assert.AreEqual(3, body.InputData.ExactCount); + + var (missingStatus, _) = Unwrap(await _controller.CreateBlockMax(new CreateBlockMaxInputDataRequest { TimeSeriesId = Guid.NewGuid() })); + Assert.AreEqual(404, missingStatus); + } + + /// + /// Verifies the POT endpoint returns 201 with the extraction echo. + /// + [TestMethod] + public async Task CreatePeaksOverThreshold_Returns201() + { + var series = new TimeSeriesResource(TestSeries.DailyThreeWaterYears()) { Name = "daily", Source = TimeSeriesSource.Manual }; + _store.AddTimeSeries(series); + + var (status, body) = Unwrap(await _controller.CreatePeaksOverThreshold(new CreatePotInputDataRequest + { + TimeSeriesId = series.Id, + Threshold = 400d + })); + Assert.AreEqual(201, status); + Assert.IsNotNull(body.InputData!.PotOptions); + Assert.AreEqual(400d, body.InputData.PotOptions.Threshold); + } + + /// + /// Verifies the USGS peaks endpoint returns 201 through the faked seam and 400 for + /// non-peak series types. + /// + [TestMethod] + public async Task CreateFromUsgsPeaks_Returns201_Or400() + { + var (status, body) = Unwrap(await _controller.CreateFromUsgsPeaks( + new CreateUsgsPeaksInputDataRequest { SiteNumber = "01646500" }, CancellationToken.None)); + Assert.AreEqual(201, status); + Assert.AreEqual("usgsPeakDischarge", body.InputData!.Method); + + var (badStatus, _) = Unwrap(await _controller.CreateFromUsgsPeaks( + new CreateUsgsPeaksInputDataRequest + { + SiteNumber = "01646500", + SeriesType = Numerics.Data.TimeSeriesDownload.TimeSeriesType.DailyDischarge + }, CancellationToken.None)); + Assert.AreEqual(400, badStatus); + } + + /// + /// Verifies list, summary statistics, and delete round-trip and enforce 404 semantics. + /// + [TestMethod] + public async Task List_SummaryStatistics_Delete_Lifecycle() + { + var (_, created) = Unwrap(await _controller.CreateManual(new CreateManualInputDataRequest + { + ExactData = new List + { + new() { Index = 2000, Value = 100d }, + new() { Index = 2001, Value = 200d }, + new() { Index = 2002, Value = 300d } + } + })); + var id = created.InputData!.Id; + + var (listStatus, list) = Unwrap(await _controller.List()); + Assert.AreEqual(200, listStatus); + Assert.AreEqual(1, list.Count); + + var (statisticsStatus, statistics) = Unwrap(await _controller.GetSummaryStatistics(id)); + Assert.AreEqual(200, statisticsStatus); + Assert.IsTrue(statistics.Statistics.Count > 0); + + var (deleteStatus, deleted) = Unwrap(await _controller.Delete(id)); + Assert.AreEqual(200, deleteStatus); + Assert.AreEqual("inputData", deleted.ResourceType); + + var (missingStatus, _) = Unwrap(await _controller.GetSummaryStatistics(id)); + Assert.AreEqual(404, missingStatus); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Controllers/MetadataControllerTests.cs b/src/RMC.BestFit.Api.Tests/Controllers/MetadataControllerTests.cs new file mode 100644 index 0000000..0d94ce1 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Controllers/MetadataControllerTests.cs @@ -0,0 +1,83 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.Controllers; +using RMC.BestFit.Api.DTOs; + +namespace RMC.BestFit.Api.Tests.Controllers +{ + /// + /// Unit tests for discovery endpoints. + /// + [TestClass] + public class MetadataControllerTests + { + /// + /// The controller under test. + /// + private MetadataController _controller = null!; + + /// + /// Creates a fresh controller before each test. + /// + [TestInitialize] + public void Initialize() + { + _controller = new MetadataController( + NullLogger.Instance, + Options.Create(new ApiOptions { MaxIterations = 1234, MaxConcurrentRuns = 3, MaxResources = 42 })); + } + + /// + /// Extracts status code and body from an action result. + /// + /// The response DTO type. + /// The action result. + /// The status code and body. + private static (int StatusCode, TResponse Body) Unwrap(ActionResult actionResult) + where TResponse : ResponseBase + { + var objectResult = (ObjectResult)actionResult.Result!; + return (objectResult.StatusCode!.Value, (TResponse)objectResult.Value!); + } + + /// + /// Verifies the distributions endpoint returns the supported distribution listing. + /// + [TestMethod] + public async Task GetDistributions_Returns200WithListing() + { + var (status, body) = Unwrap(await _controller.GetDistributions()); + Assert.AreEqual(200, status); + Assert.IsTrue(body.Distributions.Count >= 15); + } + + /// + /// Verifies the enums endpoint returns populated option lists. + /// + [TestMethod] + public async Task GetEnumOptions_Returns200WithOptions() + { + var (status, body) = Unwrap(await _controller.GetEnumOptions()); + Assert.AreEqual(200, status); + Assert.IsTrue(body.Samplers.Count > 0); + Assert.IsTrue(body.UsgsSeriesTypes.Count > 0); + } + + /// + /// Verifies the defaults endpoint reports the default ordinates and the configured limits. + /// + [TestMethod] + public async Task GetDefaults_ReportsOrdinatesAndLimits() + { + var (status, body) = Unwrap(await _controller.GetDefaults()); + Assert.AreEqual(200, status); + Assert.IsTrue(body.ProbabilityOrdinates.Count > 0); + Assert.AreEqual(1234, body.MaxIterations); + Assert.AreEqual(3, body.MaxConcurrentRuns); + Assert.AreEqual(42, body.MaxResources); + Assert.IsTrue(body.Notes.Count > 0); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Controllers/MixtureAnalysisControllerTests.cs b/src/RMC.BestFit.Api.Tests/Controllers/MixtureAnalysisControllerTests.cs new file mode 100644 index 0000000..34b6779 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Controllers/MixtureAnalysisControllerTests.cs @@ -0,0 +1,172 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Numerics.Distributions; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.Controllers; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Controllers +{ + /// + /// Unit tests for status codes and payloads, using + /// the real service over an in-memory store. Analyses are created and validated but never run. + /// + [TestClass] + public class MixtureAnalysisControllerTests + { + /// + /// The store backing the controller stack. + /// + private InMemoryResourceStore _store = null!; + + /// + /// The controller under test. + /// + private MixtureAnalysisController _controller = null!; + + /// + /// Creates a fresh controller stack before each test. + /// + [TestInitialize] + public void Initialize() + { + _store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + var service = new AnalysisService(_store, Options.Create(new ApiOptions())); + _controller = new MixtureAnalysisController(NullLogger.Instance, service); + } + + /// + /// Extracts status code and body from an action result. + /// + /// The response DTO type. + /// The action result. + /// The status code and body. + private static (int StatusCode, TResponse Body) Unwrap(ActionResult actionResult) + where TResponse : ResponseBase + { + var objectResult = (ObjectResult)actionResult.Result!; + return (objectResult.StatusCode!.Value, (TResponse)objectResult.Value!); + } + + /// + /// Builds a valid two-component creation request over a stored input-data resource. + /// + /// The request. + private CreateMixtureAnalysisRequest CreateValidRequest() + { + var input = _store.AddInputData(TestAnalyses.CreateInputDataResource()); + return new CreateMixtureAnalysisRequest + { + InputDataId = input.Id, + Distributions = new List + { + UnivariateDistributionType.Gumbel, + UnivariateDistributionType.LogNormal + } + }; + } + + /// + /// Verifies create returns 201 with kind and component metadata, 404 for an unknown + /// input-data id, and 400 for too many components. + /// + [TestMethod] + public async Task Create_Returns201_404_400() + { + var (status, body) = Unwrap(await _controller.Create(CreateValidRequest())); + Assert.AreEqual(201, status); + Assert.AreEqual("mixture", body.Analysis!.Kind); + Assert.IsTrue(body.Analysis.IsValid); + Assert.IsNotNull(body.Analysis.ComponentDistributions); + Assert.AreEqual(2, body.Analysis.ComponentDistributions.Count); + Assert.IsFalse(body.Analysis.IsZeroInflated!.Value); + + var (missingStatus, _) = Unwrap(await _controller.Create(new CreateMixtureAnalysisRequest + { + InputDataId = Guid.NewGuid(), + Distributions = new List { UnivariateDistributionType.Gumbel } + })); + Assert.AreEqual(404, missingStatus); + + var tooMany = CreateValidRequest(); + tooMany.Distributions = Enumerable.Repeat(UnivariateDistributionType.Gumbel, 4).ToList(); + var (tooManyStatus, _) = Unwrap(await _controller.Create(tooMany)); + Assert.AreEqual(400, tooManyStatus); + } + + /// + /// Verifies get/list/validate/results-before-run/delete round-trip. + /// + [TestMethod] + public async Task Get_List_Validate_Delete_Lifecycle() + { + var (_, created) = Unwrap(await _controller.Create(CreateValidRequest())); + var id = created.Analysis!.Id; + + var (getStatus, got) = Unwrap(await _controller.Get(id)); + Assert.AreEqual(200, getStatus); + Assert.AreEqual(id, got.Analysis!.Id); + + var (listStatus, list) = Unwrap(await _controller.List()); + Assert.AreEqual(200, listStatus); + Assert.AreEqual(1, list.Count); + + var validateResult = _controller.Validate(id); + var validateBody = (ValidationResponse)((OkObjectResult)validateResult.Result!).Value!; + Assert.IsTrue(validateBody.IsValid); + + var (resultsStatus, _) = Unwrap(await _controller.GetResults(id)); + Assert.AreEqual(404, resultsStatus, "Results before any run must be 404."); + + var (deleteStatus, deleted) = Unwrap(await _controller.Delete(id)); + Assert.AreEqual(200, deleteStatus); + Assert.AreEqual(id, deleted.DeletedId); + } + + /// + /// Verifies a run request while the run lock is held returns 409, and an invalid + /// configuration returns 400 with validation errors. + /// + [TestMethod] + public async Task Run_Conflict409_AndInvalid400() + { + var (_, created) = Unwrap(await _controller.Create(CreateValidRequest())); + var resource = _store.GetAnalysis(created.Analysis!.Id)!; + + Assert.IsTrue(resource.RunLock.Wait(0)); + try + { + var (conflictStatus, conflictBody) = Unwrap(await _controller.Run(resource.Id, CancellationToken.None)); + Assert.AreEqual(409, conflictStatus); + Assert.IsFalse(conflictBody.Success); + } + finally + { + resource.RunLock.Release(); + } + + // Corrupt the ordinates directly on the model so the run fails validation fast — no + // estimation starts. + resource.Mixture!.ProbabilityOrdinates.Clear(); + resource.Mixture.ProbabilityOrdinates.AddRange(new[] { 0.5, 0.1 }); + var (invalidStatus, invalidBody) = Unwrap(await _controller.Run(resource.Id, CancellationToken.None)); + Assert.AreEqual(400, invalidStatus); + Assert.IsNotNull(invalidBody.ValidationErrors); + } + + /// + /// Verifies the kind guard: a univariate analysis id 404s on every mixture route. + /// + [TestMethod] + public async Task KindGuard_UnivariateId_Returns404() + { + var other = _store.AddAnalysis(TestAnalyses.CreateUnivariateResource()); + var (getStatus, _) = Unwrap(await _controller.Get(other.Id)); + Assert.AreEqual(404, getStatus); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Controllers/PointProcessAnalysisControllerTests.cs b/src/RMC.BestFit.Api.Tests/Controllers/PointProcessAnalysisControllerTests.cs new file mode 100644 index 0000000..13518b6 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Controllers/PointProcessAnalysisControllerTests.cs @@ -0,0 +1,170 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.Controllers; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Controllers +{ + /// + /// Unit tests for status codes and payloads, + /// using the real service over an in-memory store. Analyses are created and validated but + /// never run. + /// + [TestClass] + public class PointProcessAnalysisControllerTests + { + /// + /// The store backing the controller stack. + /// + private InMemoryResourceStore _store = null!; + + /// + /// The controller under test. + /// + private PointProcessAnalysisController _controller = null!; + + /// + /// Creates a fresh controller stack before each test. + /// + [TestInitialize] + public void Initialize() + { + _store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + var service = new AnalysisService(_store, Options.Create(new ApiOptions())); + _controller = new PointProcessAnalysisController(NullLogger.Instance, service); + } + + /// + /// Extracts status code and body from an action result. + /// + /// The response DTO type. + /// The action result. + /// The status code and body. + private static (int StatusCode, TResponse Body) Unwrap(ActionResult actionResult) + where TResponse : ResponseBase + { + var objectResult = (ObjectResult)actionResult.Result!; + return (objectResult.StatusCode!.Value, (TResponse)objectResult.Value!); + } + + /// + /// Adds a peaks-over-threshold input-data resource carrying its extraction threshold. + /// + /// The stored resource. + private InputDataResource AddPotInputData() + { + return _store.AddInputData(new InputDataResource + { + Name = "pot", + DataFrame = TestAnalyses.CreatePotDataFrame(), + Method = InputDataMethod.PeaksOverThreshold, + Threshold = 400d + }); + } + + /// + /// Verifies create returns 201 with point process metadata (threshold seeded from the POT + /// resource) and 404 for an unknown input-data id. + /// + [TestMethod] + public async Task Create_Returns201_Or404() + { + var input = AddPotInputData(); + + var (status, body) = Unwrap(await _controller.Create(new CreatePointProcessAnalysisRequest { InputDataId = input.Id })); + Assert.AreEqual(201, status); + Assert.AreEqual("pointProcess", body.Analysis!.Kind); + Assert.IsFalse(body.Analysis.IsSeasonal!.Value); + Assert.IsNotNull(body.Analysis.Threshold, "The POT resource's threshold must seed the model."); + Assert.IsNotNull(body.Analysis.Lambda); + + var (missingStatus, _) = Unwrap(await _controller.Create(new CreatePointProcessAnalysisRequest { InputDataId = Guid.NewGuid() })); + Assert.AreEqual(404, missingStatus); + } + + /// + /// Verifies seasonal options without isSeasonal and non-positive record spans are 400s. + /// + [TestMethod] + public async Task Create_InvalidOptions_Return400() + { + var input = AddPotInputData(); + + var (seasonalStatus, _) = Unwrap(await _controller.Create(new CreatePointProcessAnalysisRequest + { + InputDataId = input.Id, + StartMonth = 4 + })); + Assert.AreEqual(400, seasonalStatus, "startMonth without isSeasonal must be rejected, not silently ignored."); + + var (yearsStatus, _) = Unwrap(await _controller.Create(new CreatePointProcessAnalysisRequest + { + InputDataId = input.Id, + TotalYears = 0d + })); + Assert.AreEqual(400, yearsStatus); + } + + /// + /// Verifies get/list/validate/results-before-run/delete round-trip and the kind guard. + /// + [TestMethod] + public async Task Get_List_Validate_Delete_Lifecycle() + { + var input = AddPotInputData(); + var (_, created) = Unwrap(await _controller.Create(new CreatePointProcessAnalysisRequest { InputDataId = input.Id })); + var id = created.Analysis!.Id; + + var (getStatus, got) = Unwrap(await _controller.Get(id)); + Assert.AreEqual(200, getStatus); + Assert.AreEqual(id, got.Analysis!.Id); + + var (listStatus, list) = Unwrap(await _controller.List()); + Assert.AreEqual(200, listStatus); + Assert.AreEqual(1, list.Count); + + var validateResult = _controller.Validate(id); + var validateBody = (ValidationResponse)((OkObjectResult)validateResult.Result!).Value!; + Assert.IsTrue(validateBody.IsValid, string.Join("; ", validateBody.Errors)); + + var (resultsStatus, _) = Unwrap(await _controller.GetResults(id)); + Assert.AreEqual(404, resultsStatus, "Results before any run must be 404."); + + var other = _store.AddAnalysis(TestAnalyses.CreateUnivariateResource()); + var (guardStatus, _) = Unwrap(await _controller.Get(other.Id)); + Assert.AreEqual(404, guardStatus, "A univariate id must 404 on the point process routes."); + + var (deleteStatus, deleted) = Unwrap(await _controller.Delete(id)); + Assert.AreEqual(200, deleteStatus); + Assert.AreEqual(id, deleted.DeletedId); + } + + /// + /// Verifies a run request while the run lock is held returns 409. + /// + [TestMethod] + public async Task Run_Conflict409() + { + var input = AddPotInputData(); + var (_, created) = Unwrap(await _controller.Create(new CreatePointProcessAnalysisRequest { InputDataId = input.Id })); + var resource = _store.GetAnalysis(created.Analysis!.Id)!; + + Assert.IsTrue(resource.RunLock.Wait(0)); + try + { + var (conflictStatus, conflictBody) = Unwrap(await _controller.Run(resource.Id, CancellationToken.None)); + Assert.AreEqual(409, conflictStatus); + Assert.IsFalse(conflictBody.Success); + } + finally + { + resource.RunLock.Release(); + } + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Controllers/RatingCurveAnalysisControllerTests.cs b/src/RMC.BestFit.Api.Tests/Controllers/RatingCurveAnalysisControllerTests.cs new file mode 100644 index 0000000..fe174f6 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Controllers/RatingCurveAnalysisControllerTests.cs @@ -0,0 +1,132 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.Controllers; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Controllers +{ + /// + /// Unit tests for status codes and payloads. + /// Analyses are created and validated but never run. + /// + [TestClass] + public class RatingCurveAnalysisControllerTests + { + /// + /// The store backing the controller stack. + /// + private InMemoryResourceStore _store = null!; + + /// + /// The controller under test. + /// + private RatingCurveAnalysisController _controller = null!; + + /// + /// Creates a fresh controller stack before each test. + /// + [TestInitialize] + public void Initialize() + { + _store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + var service = new AnalysisService(_store, Options.Create(new ApiOptions())); + _controller = new RatingCurveAnalysisController(NullLogger.Instance, service); + } + + /// + /// Extracts status code and body from an action result. + /// + /// The response DTO type. + /// The action result. + /// The status code and body. + private static (int StatusCode, TResponse Body) Unwrap(ActionResult actionResult) + where TResponse : ResponseBase + { + var objectResult = (ObjectResult)actionResult.Result!; + return (objectResult.StatusCode!.Value, (TResponse)objectResult.Value!); + } + + /// + /// Adds a stage/discharge pair to the store. + /// + /// The stored stage and discharge resources. + private (TimeSeriesResource Stage, TimeSeriesResource Discharge) AddPair() + { + var (stage, discharge) = TestAnalyses.CreateStageDischargePair(); + return ( + _store.AddTimeSeries(new TimeSeriesResource(stage) { Name = "stage", Source = TimeSeriesSource.Manual }), + _store.AddTimeSeries(new TimeSeriesResource(discharge) { Name = "discharge", Source = TimeSeriesSource.Manual })); + } + + /// + /// Verifies create returns 201 linked to both series, 404 for a missing series, and 400 + /// for an inconsistent grid. + /// + [TestMethod] + public async Task Create_Returns201_404_Or400() + { + var (stage, discharge) = AddPair(); + + var (status, body) = Unwrap(await _controller.Create(new CreateRatingCurveAnalysisRequest + { + StageTimeSeriesId = stage.Id, + DischargeTimeSeriesId = discharge.Id + })); + Assert.AreEqual(201, status); + Assert.AreEqual("ratingCurve", body.Analysis!.Kind); + Assert.AreEqual(stage.Id, body.Analysis.StageTimeSeriesId); + Assert.IsTrue(body.Analysis.IsValid); + + var (missingStatus, _) = Unwrap(await _controller.Create(new CreateRatingCurveAnalysisRequest + { + StageTimeSeriesId = Guid.NewGuid(), + DischargeTimeSeriesId = discharge.Id + })); + Assert.AreEqual(404, missingStatus); + + var (badStatus, _) = Unwrap(await _controller.Create(new CreateRatingCurveAnalysisRequest + { + StageTimeSeriesId = stage.Id, + DischargeTimeSeriesId = discharge.Id, + MinStage = 5d + })); + Assert.AreEqual(400, badStatus); + } + + /// + /// Verifies get/list/validate/results-404/delete round-trip. + /// + [TestMethod] + public async Task Get_List_Validate_Results_Delete_Lifecycle() + { + var (stage, discharge) = AddPair(); + var (_, created) = Unwrap(await _controller.Create(new CreateRatingCurveAnalysisRequest + { + StageTimeSeriesId = stage.Id, + DischargeTimeSeriesId = discharge.Id + })); + var id = created.Analysis!.Id; + + var (getStatus, _) = Unwrap(await _controller.Get(id)); + Assert.AreEqual(200, getStatus); + + var (listStatus, list) = Unwrap(await _controller.List()); + Assert.AreEqual(200, listStatus); + Assert.AreEqual(1, list.Count); + + var validateBody = (ValidationResponse)((OkObjectResult)_controller.Validate(id).Result!).Value!; + Assert.IsTrue(validateBody.IsValid); + + var (resultsStatus, _) = Unwrap(await _controller.GetResults(id)); + Assert.AreEqual(404, resultsStatus); + + var (deleteStatus, _) = Unwrap(await _controller.Delete(id)); + Assert.AreEqual(200, deleteStatus); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Controllers/ResourcesControllerTests.cs b/src/RMC.BestFit.Api.Tests/Controllers/ResourcesControllerTests.cs new file mode 100644 index 0000000..fa5bec1 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Controllers/ResourcesControllerTests.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.Controllers; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; +using RMC.BestFit.Models; + +namespace RMC.BestFit.Api.Tests.Controllers +{ + /// + /// Unit tests for : the cross-cutting overview. + /// + [TestClass] + public class ResourcesControllerTests + { + /// + /// Verifies the overview reports per-type counts and one summary per resource. + /// + [TestMethod] + public async Task GetOverview_ReportsCountsAndSummaries() + { + var store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + store.AddTimeSeries(new TimeSeriesResource(TestSeries.IrregularPeaks()) { Name = "ts", Source = TimeSeriesSource.Manual }); + store.AddInputData(new InputDataResource { Name = "id", DataFrame = new DataFrame(), Method = InputDataMethod.Manual }); + var controller = new ResourcesController(NullLogger.Instance, store); + + var actionResult = await controller.GetOverview(); + var objectResult = (ObjectResult)actionResult.Result!; + var body = (ResourcesOverviewResponse)objectResult.Value!; + + Assert.AreEqual(200, objectResult.StatusCode); + Assert.AreEqual(1, body.TimeSeriesCount); + Assert.AreEqual(1, body.InputDataCount); + Assert.AreEqual(0, body.AnalysisCount); + Assert.AreEqual(2, body.Resources.Count); + Assert.IsTrue(body.Resources.All(r => r.Detail != null && r.Name != null)); + } + + /// + /// Verifies an empty store yields an empty overview rather than an error. + /// + [TestMethod] + public async Task GetOverview_EmptyStore_ReturnsEmptyListing() + { + var store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + var controller = new ResourcesController(NullLogger.Instance, store); + + var actionResult = await controller.GetOverview(); + var body = (ResourcesOverviewResponse)((ObjectResult)actionResult.Result!).Value!; + + Assert.AreEqual(0, body.Resources.Count); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Controllers/TimeSeriesAnalysisControllerTests.cs b/src/RMC.BestFit.Api.Tests/Controllers/TimeSeriesAnalysisControllerTests.cs new file mode 100644 index 0000000..e162997 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Controllers/TimeSeriesAnalysisControllerTests.cs @@ -0,0 +1,157 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.Controllers; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Controllers +{ + /// + /// Unit tests for status codes and payloads, using + /// the real service over an in-memory store. Analyses are created and validated but never run. + /// + [TestClass] + public class TimeSeriesAnalysisControllerTests + { + /// + /// The store backing the controller stack. + /// + private InMemoryResourceStore _store = null!; + + /// + /// The controller under test. + /// + private TimeSeriesAnalysisController _controller = null!; + + /// + /// Creates a fresh controller stack before each test. + /// + [TestInitialize] + public void Initialize() + { + _store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + var service = new AnalysisService(_store, Options.Create(new ApiOptions())); + _controller = new TimeSeriesAnalysisController(NullLogger.Instance, service); + } + + /// + /// Extracts status code and body from an action result. + /// + /// The response DTO type. + /// The action result. + /// The status code and body. + private static (int StatusCode, TResponse Body) Unwrap(ActionResult actionResult) + where TResponse : ResponseBase + { + var objectResult = (ObjectResult)actionResult.Result!; + return (objectResult.StatusCode!.Value, (TResponse)objectResult.Value!); + } + + /// + /// Adds the synthetic daily series to the store. + /// + /// The stored resource. + private TimeSeriesResource AddDailySeries() + { + return _store.AddTimeSeries(new TimeSeriesResource(TestSeries.DailyThreeWaterYears()) + { + Name = "daily", + Source = TimeSeriesSource.Manual + }); + } + + /// + /// Verifies create returns 201 for each model family, 404 for an unknown series id, and + /// 400 for a field that does not apply to the model type. + /// + [TestMethod] + public async Task Create_Returns201_404_400() + { + var source = AddDailySeries(); + + foreach (var modelType in Enum.GetValues()) + { + var (status, body) = Unwrap(await _controller.Create(new CreateTimeSeriesAnalysisRequest + { + TimeSeriesId = source.Id, + ModelType = modelType + })); + Assert.AreEqual(201, status, $"Create must succeed for {modelType}."); + Assert.AreEqual("timeSeries", body.Analysis!.Kind); + Assert.AreEqual(Api.Helpers.EnumHelper.ToCamelCase(modelType.ToString()), body.Analysis.TimeSeriesModelType); + Assert.IsTrue(body.Analysis.IsValid, $"A default {modelType} configuration must validate."); + } + + var (missingStatus, _) = Unwrap(await _controller.Create(new CreateTimeSeriesAnalysisRequest + { + TimeSeriesId = Guid.NewGuid(), + ModelType = TimeSeriesModelType.Ar + })); + Assert.AreEqual(404, missingStatus); + + var (badFieldStatus, badFieldBody) = Unwrap(await _controller.Create(new CreateTimeSeriesAnalysisRequest + { + TimeSeriesId = source.Id, + ModelType = TimeSeriesModelType.Ma, + TrendType = RMC.BestFit.Models.ARIMAX.Trend.Linear + })); + Assert.AreEqual(400, badFieldStatus); + StringAssert.Contains(badFieldBody.ErrorMessage!, "trendType"); + } + + /// + /// Verifies get/list/validate/results-before-run/delete round-trip with kind guarding in + /// both directions and the run-lock conflict. + /// + [TestMethod] + public async Task Get_List_Validate_Delete_Lifecycle() + { + var source = AddDailySeries(); + var (_, created) = Unwrap(await _controller.Create(new CreateTimeSeriesAnalysisRequest + { + TimeSeriesId = source.Id, + ModelType = TimeSeriesModelType.Arima + })); + var id = created.Analysis!.Id; + + var (getStatus, got) = Unwrap(await _controller.Get(id)); + Assert.AreEqual(200, getStatus); + Assert.AreEqual(id, got.Analysis!.Id); + + var (listStatus, list) = Unwrap(await _controller.List()); + Assert.AreEqual(200, listStatus); + Assert.AreEqual(1, list.Count); + + var validateResult = _controller.Validate(id); + var validateBody = (ValidationResponse)((OkObjectResult)validateResult.Result!).Value!; + Assert.IsTrue(validateBody.IsValid); + + var (resultsStatus, _) = Unwrap(await _controller.GetResults(id)); + Assert.AreEqual(404, resultsStatus, "Results before any run must be 404."); + + var resource = _store.GetAnalysis(id)!; + Assert.IsTrue(resource.RunLock.Wait(0)); + try + { + var (conflictStatus, _) = Unwrap(await _controller.Run(id, CancellationToken.None)); + Assert.AreEqual(409, conflictStatus); + } + finally + { + resource.RunLock.Release(); + } + + var other = _store.AddAnalysis(TestAnalyses.CreateUnivariateResource()); + var (guardStatus, _) = Unwrap(await _controller.Get(other.Id)); + Assert.AreEqual(404, guardStatus, "A univariate id must 404 on the time-series routes."); + + var (deleteStatus, deleted) = Unwrap(await _controller.Delete(id)); + Assert.AreEqual(200, deleteStatus); + Assert.AreEqual(id, deleted.DeletedId); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Controllers/TimeSeriesControllerTests.cs b/src/RMC.BestFit.Api.Tests/Controllers/TimeSeriesControllerTests.cs new file mode 100644 index 0000000..8114206 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Controllers/TimeSeriesControllerTests.cs @@ -0,0 +1,139 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Numerics.Data; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.Controllers; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Services.Exceptions; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Controllers +{ + /// + /// Unit tests for status codes and payloads, using the real + /// service over an in-memory store with the USGS seam faked. + /// + [TestClass] + public class TimeSeriesControllerTests + { + /// + /// The faked USGS seam. + /// + private FakeUsgsTimeSeriesService _usgs = null!; + + /// + /// The controller under test. + /// + private TimeSeriesController _controller = null!; + + /// + /// Creates a fresh controller stack before each test. + /// + [TestInitialize] + public void Initialize() + { + var store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + _usgs = new FakeUsgsTimeSeriesService { Result = TestSeries.DailyThreeWaterYears() }; + var service = new TimeSeriesService(store, _usgs); + _controller = new TimeSeriesController(NullLogger.Instance, service); + } + + /// + /// Extracts status code and body from an action result. + /// + /// The response DTO type. + /// The action result. + /// The status code and body. + private static (int StatusCode, TResponse Body) Unwrap(ActionResult actionResult) + where TResponse : ResponseBase + { + var objectResult = (ObjectResult)actionResult.Result!; + return (objectResult.StatusCode!.Value, (TResponse)objectResult.Value!); + } + + /// + /// Verifies the USGS create endpoint returns 201 with the resource summary. + /// + [TestMethod] + public async Task CreateFromUsgs_Returns201() + { + var (status, body) = Unwrap(await _controller.CreateFromUsgs( + new CreateUsgsTimeSeriesRequest { SiteNumber = "01646500" }, CancellationToken.None)); + + Assert.AreEqual(201, status); + Assert.IsTrue(body.Success); + Assert.IsNotNull(body.TimeSeries); + Assert.AreEqual("01646500", body.TimeSeries.UsgsSiteNumber); + } + + /// + /// Verifies a USGS no-data failure surfaces as 404. + /// + [TestMethod] + public async Task CreateFromUsgs_NoData_Returns404() + { + _usgs.ExceptionToThrow = new UsgsDataNotFoundException("no data"); + var (status, body) = Unwrap(await _controller.CreateFromUsgs( + new CreateUsgsTimeSeriesRequest { SiteNumber = "01646500" }, CancellationToken.None)); + Assert.AreEqual(404, status); + Assert.IsFalse(body.Success); + } + + /// + /// Verifies a USGS upstream failure surfaces with the exception's status code (502). + /// + [TestMethod] + public async Task CreateFromUsgs_Upstream_Returns502() + { + _usgs.ExceptionToThrow = new UsgsUnavailableException("upstream", statusCode: 502); + var (status, _) = Unwrap(await _controller.CreateFromUsgs( + new CreateUsgsTimeSeriesRequest { SiteNumber = "01646500" }, CancellationToken.None)); + Assert.AreEqual(502, status); + } + + /// + /// Verifies manual create returns 201 and get/list/delete round-trip the resource. + /// + [TestMethod] + public async Task Manual_Get_List_Delete_Lifecycle() + { + var (createStatus, created) = Unwrap(await _controller.CreateManual(new CreateManualTimeSeriesRequest + { + TimeInterval = TimeInterval.Irregular, + Points = new List { new() { DateTime = new DateTime(2020, 1, 1), Value = 5d } } + })); + Assert.AreEqual(201, createStatus); + var id = created.TimeSeries!.Id; + + var (getStatus, got) = Unwrap(await _controller.Get(id, includePoints: true)); + Assert.AreEqual(200, getStatus); + Assert.IsNotNull(got.Points); + Assert.AreEqual(5d, got.Points[0].Value); + + var (listStatus, list) = Unwrap(await _controller.List()); + Assert.AreEqual(200, listStatus); + Assert.AreEqual(1, list.Count); + + var (deleteStatus, deleted) = Unwrap(await _controller.Delete(id)); + Assert.AreEqual(200, deleteStatus); + Assert.AreEqual(id, deleted.DeletedId); + Assert.AreEqual("timeSeries", deleted.ResourceType); + } + + /// + /// Verifies unknown ids surface as 404 on get and delete. + /// + [TestMethod] + public async Task Get_And_Delete_UnknownId_Return404() + { + var (getStatus, _) = Unwrap(await _controller.Get(Guid.NewGuid())); + Assert.AreEqual(404, getStatus); + + var (deleteStatus, _) = Unwrap(await _controller.Delete(Guid.NewGuid())); + Assert.AreEqual(404, deleteStatus); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Controllers/UnivariateAnalysisControllerTests.cs b/src/RMC.BestFit.Api.Tests/Controllers/UnivariateAnalysisControllerTests.cs new file mode 100644 index 0000000..d5f6031 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Controllers/UnivariateAnalysisControllerTests.cs @@ -0,0 +1,146 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.Controllers; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Controllers +{ + /// + /// Unit tests for status codes and payloads, using + /// the real service over an in-memory store. Analyses are created and validated but never run. + /// + [TestClass] + public class UnivariateAnalysisControllerTests + { + /// + /// The store backing the controller stack. + /// + private InMemoryResourceStore _store = null!; + + /// + /// The controller under test. + /// + private UnivariateAnalysisController _controller = null!; + + /// + /// Creates a fresh controller stack before each test. + /// + [TestInitialize] + public void Initialize() + { + _store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + var service = new AnalysisService(_store, Options.Create(new ApiOptions())); + _controller = new UnivariateAnalysisController(NullLogger.Instance, service); + } + + /// + /// Extracts status code and body from an action result. + /// + /// The response DTO type. + /// The action result. + /// The status code and body. + private static (int StatusCode, TResponse Body) Unwrap(ActionResult actionResult) + where TResponse : ResponseBase + { + var objectResult = (ObjectResult)actionResult.Result!; + return (objectResult.StatusCode!.Value, (TResponse)objectResult.Value!); + } + + /// + /// Verifies create returns 201 with the analysis summary and 404 for an unknown input-data id. + /// + [TestMethod] + public async Task Create_Returns201_Or404() + { + var input = _store.AddInputData(TestAnalyses.CreateInputDataResource()); + + var (status, body) = Unwrap(await _controller.Create(new CreateUnivariateAnalysisRequest { InputDataId = input.Id })); + Assert.AreEqual(201, status); + Assert.AreEqual("univariate", body.Analysis!.Kind); + Assert.IsTrue(body.Analysis.IsValid); + + var (missingStatus, _) = Unwrap(await _controller.Create(new CreateUnivariateAnalysisRequest { InputDataId = Guid.NewGuid() })); + Assert.AreEqual(404, missingStatus); + } + + /// + /// Verifies get/list/validate/delete round-trip with kind guarding. + /// + [TestMethod] + public async Task Get_List_Validate_Delete_Lifecycle() + { + var input = _store.AddInputData(TestAnalyses.CreateInputDataResource()); + var (_, created) = Unwrap(await _controller.Create(new CreateUnivariateAnalysisRequest { InputDataId = input.Id })); + var id = created.Analysis!.Id; + + var (getStatus, got) = Unwrap(await _controller.Get(id)); + Assert.AreEqual(200, getStatus); + Assert.AreEqual(id, got.Analysis!.Id); + + var (listStatus, list) = Unwrap(await _controller.List()); + Assert.AreEqual(200, listStatus); + Assert.AreEqual(1, list.Count); + + var validateResult = _controller.Validate(id); + var validateBody = (ValidationResponse)((OkObjectResult)validateResult.Result!).Value!; + Assert.IsTrue(validateBody.IsValid); + + var (resultsStatus, _) = Unwrap(await _controller.GetResults(id)); + Assert.AreEqual(404, resultsStatus, "Results before any run must be 404."); + + var (deleteStatus, deleted) = Unwrap(await _controller.Delete(id)); + Assert.AreEqual(200, deleteStatus); + Assert.AreEqual(id, deleted.DeletedId); + } + + /// + /// Verifies a run request while the run lock is held returns 409, and an invalid + /// configuration returns 400 with validation errors. + /// + [TestMethod] + public async Task Run_Conflict409_AndInvalid400() + { + var input = _store.AddInputData(TestAnalyses.CreateInputDataResource()); + var (_, created) = Unwrap(await _controller.Create(new CreateUnivariateAnalysisRequest { InputDataId = input.Id })); + var resource = _store.GetAnalysis(created.Analysis!.Id)!; + + Assert.IsTrue(resource.RunLock.Wait(0)); + try + { + var (conflictStatus, conflictBody) = Unwrap(await _controller.Run(resource.Id, CancellationToken.None)); + Assert.AreEqual(409, conflictStatus); + Assert.IsFalse(conflictBody.Success); + } + finally + { + resource.RunLock.Release(); + } + + // Corrupt the ordinates directly on the model (the service sorts client ordinates) so + // the run fails validation fast — no estimation starts. + resource.Univariate!.ProbabilityOrdinates.Clear(); + resource.Univariate.ProbabilityOrdinates.AddRange(new[] { 0.5, 0.1 }); + var (invalidStatus, invalidBody) = Unwrap(await _controller.Run(resource.Id, CancellationToken.None)); + Assert.AreEqual(400, invalidStatus); + Assert.IsNotNull(invalidBody.ValidationErrors); + } + + /// + /// Verifies the validate endpoint returns 404 semantics for unknown ids. + /// + [TestMethod] + public void Validate_UnknownId_Returns404() + { + var result = _controller.Validate(Guid.NewGuid()); + var notFound = result.Result as NotFoundObjectResult; + Assert.IsNotNull(notFound); + var body = (ValidationResponse)notFound.Value!; + Assert.IsFalse(body.IsValid); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Controllers/WorkflowControllerTests.cs b/src/RMC.BestFit.Api.Tests/Controllers/WorkflowControllerTests.cs new file mode 100644 index 0000000..bface28 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Controllers/WorkflowControllerTests.cs @@ -0,0 +1,99 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.Controllers; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Services.Exceptions; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Controllers +{ + /// + /// Unit tests for : step failures return HTTP 200 with the + /// in-body failure contract (success=false, failedStep, partial ids), and cancellation maps + /// to 499. Success paths are not exercised (they run estimators). + /// + [TestClass] + public class WorkflowControllerTests + { + /// + /// The faked USGS seam. + /// + private FakeUsgsTimeSeriesService _usgs = null!; + + /// + /// The controller under test. + /// + private WorkflowController _controller = null!; + + /// + /// Creates a fresh controller stack before each test. + /// + [TestInitialize] + public void Initialize() + { + var store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + _usgs = new FakeUsgsTimeSeriesService { Result = TestSeries.IrregularPeaks() }; + var timeSeries = new TimeSeriesService(store, _usgs); + var inputData = new InputDataService(store, _usgs); + var analyses = new AnalysisService(store, Options.Create(new ApiOptions())); + var workflows = new WorkflowService(timeSeries, inputData, analyses); + _controller = new WorkflowController(NullLogger.Instance, workflows); + } + + /// + /// Verifies a step failure returns HTTP 200 with the in-body failure contract and the + /// shared timing/timestamp fields stamped by the controller pipeline. + /// + [TestMethod] + public async Task Workflow_StepFailure_Returns200WithInBodyFailure() + { + _usgs.ExceptionToThrow = new UsgsDataNotFoundException("no data"); + var actionResult = await _controller.RunUsgsPeakFrequency( + new UsgsPeakFrequencyWorkflowRequest { SiteNumber = "01646500" }, CancellationToken.None); + var objectResult = (ObjectResult)actionResult.Result!; + var body = (UsgsFrequencyWorkflowResponse)objectResult.Value!; + + Assert.AreEqual(200, objectResult.StatusCode); + Assert.IsFalse(body.Success); + Assert.AreEqual("createInputData", body.FailedStep); + Assert.IsNotNull(body.ErrorMessage); + Assert.IsNotNull(body.Timestamp); + Assert.IsNotNull(body.ComputationTimeMs); + } + + /// + /// Verifies client cancellation maps to HTTP 499 through the controller pipeline. + /// + [TestMethod] + public async Task Workflow_Cancelled_Returns499() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var actionResult = await _controller.RunUsgsBulletin17C( + new UsgsBulletin17CWorkflowRequest { SiteNumber = "01646500" }, cts.Token); + var objectResult = (ObjectResult)actionResult.Result!; + Assert.AreEqual(499, objectResult.StatusCode); + } + + /// + /// Verifies the rating curve workflow surface returns the in-body failure contract as well. + /// + [TestMethod] + public async Task RatingCurveWorkflow_StepFailure_Returns200WithInBodyFailure() + { + _usgs.ExceptionToThrow = new UsgsUnavailableException("offline", statusCode: 503); + var actionResult = await _controller.RunUsgsRatingCurve( + new UsgsRatingCurveWorkflowRequest { SiteNumber = "01646500" }, CancellationToken.None); + var objectResult = (ObjectResult)actionResult.Result!; + var body = (UsgsRatingCurveWorkflowResponse)objectResult.Value!; + + Assert.AreEqual(200, objectResult.StatusCode); + Assert.IsFalse(body.Success); + Assert.AreEqual("downloadStage", body.FailedStep); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/AnalysisListResponseTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/AnalysisListResponseTests.cs new file mode 100644 index 0000000..7831ec9 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/AnalysisListResponseTests.cs @@ -0,0 +1,77 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip including the + /// inherited fields, and camelCase wire names. + /// + [TestClass] + public class AnalysisListResponseTests + { + /// + /// Verifies a new listing defaults to success with a zero count and an empty summary list. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var response = new AnalysisListResponse(); + Assert.IsTrue(response.Success); + Assert.AreEqual(0, response.Count); + Assert.IsNotNull(response.Analyses); + Assert.AreEqual(0, response.Analyses.Count); + } + + /// + /// Verifies every property, including the inherited response fields, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var response = new AnalysisListResponse + { + Count = 1, + Analyses = new List + { + new AnalysisSummaryDto { Id = Guid.NewGuid(), Name = "B17C fit", Kind = "bulletin17C" } + }, + Success = false, + ErrorMessage = "listing incomplete", + ValidationErrors = new List { "e1" }, + ValidationWarnings = new List { "w1" }, + ComputationTimeMs = 4, + Timestamp = "2026-07-01T00:00:00.0000000Z", + NonFiniteFindings = new List { "$.q" } + }; + + var copy = TestJson.Roundtrip(response); + + Assert.AreEqual(1, copy.Count); + Assert.AreEqual(1, copy.Analyses.Count); + Assert.AreEqual(response.Analyses[0].Id, copy.Analyses[0].Id); + Assert.AreEqual("B17C fit", copy.Analyses[0].Name); + Assert.AreEqual("bulletin17C", copy.Analyses[0].Kind); + Assert.IsFalse(copy.Success); + Assert.AreEqual("listing incomplete", copy.ErrorMessage); + CollectionAssert.AreEqual(response.ValidationErrors, copy.ValidationErrors); + CollectionAssert.AreEqual(response.ValidationWarnings, copy.ValidationWarnings); + Assert.AreEqual(4L, copy.ComputationTimeMs); + Assert.AreEqual("2026-07-01T00:00:00.0000000Z", copy.Timestamp); + CollectionAssert.AreEqual(response.NonFiniteFindings, copy.NonFiniteFindings); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new AnalysisListResponse()); + StringAssert.Contains(json, "\"count\""); + StringAssert.Contains(json, "\"analyses\""); + StringAssert.Contains(json, "\"success\""); + Assert.IsFalse(json.Contains("\"errorMessage\""), "Null errorMessage should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/AnalysisResourceResponseTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/AnalysisResourceResponseTests.cs new file mode 100644 index 0000000..85ae963 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/AnalysisResourceResponseTests.cs @@ -0,0 +1,84 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip including + /// the inherited fields, and camelCase wire names. + /// + [TestClass] + public class AnalysisResourceResponseTests + { + /// + /// Verifies a new response defaults to success with no analysis summary. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var response = new AnalysisResourceResponse(); + Assert.IsTrue(response.Success); + Assert.IsNull(response.Analysis); + } + + /// + /// Verifies every property, including the inherited response fields, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var response = new AnalysisResourceResponse + { + Analysis = new AnalysisSummaryDto + { + Id = Guid.NewGuid(), + Name = "LP3 fit", + Kind = "univariate", + State = "created", + IsValid = true + }, + Success = false, + ErrorMessage = "analysis stale", + ValidationErrors = new List { "e1" }, + ValidationWarnings = new List { "w1" }, + ComputationTimeMs = 7, + Timestamp = "2026-07-01T00:00:00.0000000Z", + NonFiniteFindings = new List { "$.q" } + }; + + var copy = TestJson.Roundtrip(response); + + Assert.IsNotNull(copy.Analysis); + Assert.AreEqual(response.Analysis.Id, copy.Analysis.Id); + Assert.AreEqual("LP3 fit", copy.Analysis.Name); + Assert.AreEqual("univariate", copy.Analysis.Kind); + Assert.AreEqual("created", copy.Analysis.State); + Assert.IsTrue(copy.Analysis.IsValid); + Assert.IsFalse(copy.Success); + Assert.AreEqual("analysis stale", copy.ErrorMessage); + CollectionAssert.AreEqual(response.ValidationErrors, copy.ValidationErrors); + CollectionAssert.AreEqual(response.ValidationWarnings, copy.ValidationWarnings); + Assert.AreEqual(7L, copy.ComputationTimeMs); + Assert.AreEqual("2026-07-01T00:00:00.0000000Z", copy.Timestamp); + CollectionAssert.AreEqual(response.NonFiniteFindings, copy.NonFiniteFindings); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null optional fields. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new AnalysisResourceResponse + { + Analysis = new AnalysisSummaryDto() + }); + StringAssert.Contains(json, "\"analysis\""); + StringAssert.Contains(json, "\"success\""); + + string defaultJson = TestJson.Serialize(new AnalysisResourceResponse()); + Assert.IsFalse(defaultJson.Contains("\"analysis\""), "Null analysis should be omitted."); + Assert.IsFalse(defaultJson.Contains("\"errorMessage\""), "Null errorMessage should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/AnalysisSummaryDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/AnalysisSummaryDtoTests.cs new file mode 100644 index 0000000..ac4c8fa --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/AnalysisSummaryDtoTests.cs @@ -0,0 +1,108 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and camelCase + /// wire names. + /// + [TestClass] + public class AnalysisSummaryDtoTests + { + /// + /// Verifies a new summary defaults to an empty identity with no kind, state, provenance, + /// or run information. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var summary = new AnalysisSummaryDto(); + Assert.AreEqual(Guid.Empty, summary.Id); + Assert.IsNull(summary.Name); + Assert.IsNull(summary.Description); + Assert.AreEqual(default(DateTime), summary.CreatedUtc); + Assert.IsNull(summary.Kind); + Assert.IsNull(summary.State); + Assert.IsFalse(summary.IsValid); + Assert.IsNull(summary.ValidationMessages); + Assert.IsNull(summary.Distribution); + Assert.IsNull(summary.UncertaintyMethod); + Assert.IsNull(summary.NumberOfSegments); + Assert.IsNull(summary.InputDataId); + Assert.IsNull(summary.StageTimeSeriesId); + Assert.IsNull(summary.DischargeTimeSeriesId); + Assert.IsNull(summary.LastRunUtc); + Assert.IsNull(summary.LastRunMs); + Assert.IsNull(summary.LastError); + } + + /// + /// Verifies every property survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var summary = new AnalysisSummaryDto + { + Id = Guid.NewGuid(), + Name = "LP3 fit", + Description = "Univariate analysis of the annual peaks.", + CreatedUtc = new DateTime(2026, 6, 30, 8, 15, 0), + Kind = "univariate", + State = "failed", + IsValid = true, + ValidationMessages = new List { "The input data has no observations." }, + Distribution = "logPearsonTypeIII", + UncertaintyMethod = "linkedMultivariateNormal", + NumberOfSegments = 2, + InputDataId = Guid.NewGuid(), + StageTimeSeriesId = Guid.NewGuid(), + DischargeTimeSeriesId = Guid.NewGuid(), + LastRunUtc = new DateTime(2026, 7, 1, 9, 30, 0), + LastRunMs = 5321, + LastError = "MCMC failed to initialize." + }; + + var copy = TestJson.Roundtrip(summary); + + Assert.AreEqual(summary.Id, copy.Id); + Assert.AreEqual("LP3 fit", copy.Name); + Assert.AreEqual("Univariate analysis of the annual peaks.", copy.Description); + Assert.AreEqual(summary.CreatedUtc, copy.CreatedUtc); + Assert.AreEqual("univariate", copy.Kind); + Assert.AreEqual("failed", copy.State); + Assert.IsTrue(copy.IsValid); + CollectionAssert.AreEqual(summary.ValidationMessages, copy.ValidationMessages); + Assert.AreEqual("logPearsonTypeIII", copy.Distribution); + Assert.AreEqual("linkedMultivariateNormal", copy.UncertaintyMethod); + Assert.AreEqual(2, copy.NumberOfSegments); + Assert.AreEqual(summary.InputDataId, copy.InputDataId); + Assert.AreEqual(summary.StageTimeSeriesId, copy.StageTimeSeriesId); + Assert.AreEqual(summary.DischargeTimeSeriesId, copy.DischargeTimeSeriesId); + Assert.AreEqual(summary.LastRunUtc, copy.LastRunUtc); + Assert.AreEqual(5321L, copy.LastRunMs); + Assert.AreEqual("MCMC failed to initialize.", copy.LastError); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null optional fields. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new AnalysisSummaryDto + { + Kind = "ratingCurve", + State = "succeeded" + }); + StringAssert.Contains(json, "\"id\""); + StringAssert.Contains(json, "\"createdUtc\""); + StringAssert.Contains(json, "\"isValid\""); + StringAssert.Contains(json, "\"kind\""); + StringAssert.Contains(json, "\"state\""); + Assert.IsFalse(json.Contains("\"lastError\""), "Null lastError should be omitted."); + Assert.IsFalse(json.Contains("\"validationMessages\""), "Null validationMessages should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/ApiInfoDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/ApiInfoDtoTests.cs new file mode 100644 index 0000000..e65935b --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/ApiInfoDtoTests.cs @@ -0,0 +1,64 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and camelCase wire names. + /// + [TestClass] + public class ApiInfoDtoTests + { + /// + /// Verifies a new info body defaults to the service name with an empty feature list. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var dto = new ApiInfoDto(); + Assert.AreEqual("RMC-BestFit API", dto.Name); + Assert.IsNull(dto.Version); + Assert.IsNull(dto.Description); + Assert.IsNotNull(dto.Features); + Assert.AreEqual(0, dto.Features.Count); + } + + /// + /// Verifies every property survives a JSON round-trip with the API's wire options. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var dto = new ApiInfoDto + { + Name = "Test API", + Version = "1.2.3", + Description = "A test service.", + Features = new List { "timeseries", "inputdata" } + }; + + var copy = TestJson.Roundtrip(dto); + + Assert.AreEqual("Test API", copy.Name); + Assert.AreEqual("1.2.3", copy.Version); + Assert.AreEqual("A test service.", copy.Description); + CollectionAssert.AreEqual(dto.Features, copy.Features); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null optional fields. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new ApiInfoDto { Description = "desc" }); + StringAssert.Contains(json, "\"name\""); + StringAssert.Contains(json, "\"features\""); + StringAssert.Contains(json, "\"description\""); + + string defaultJson = TestJson.Serialize(new ApiInfoDto()); + Assert.IsFalse(defaultJson.Contains("\"version\""), "Null version should be omitted."); + Assert.IsFalse(defaultJson.Contains("\"description\""), "Null description should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/BayesianOptionsDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/BayesianOptionsDtoTests.cs new file mode 100644 index 0000000..4533ebb --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/BayesianOptionsDtoTests.cs @@ -0,0 +1,89 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; +using RMC.BestFit.Estimation; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : all-null defaults (so the model library + /// keeps its own defaults), JSON round-trip, and camelCase wire names including string enum + /// values. + /// + [TestClass] + public class BayesianOptionsDtoTests + { + /// + /// Verifies every option defaults to null so omitted fields keep the model defaults. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var options = new BayesianOptionsDto(); + Assert.IsNull(options.Sampler); + Assert.IsNull(options.Iterations); + Assert.IsNull(options.WarmupIterations); + Assert.IsNull(options.ThinningInterval); + Assert.IsNull(options.NumberOfChains); + Assert.IsNull(options.PrngSeed); + Assert.IsNull(options.CredibleIntervalWidth); + Assert.IsNull(options.OutputLength); + Assert.IsNull(options.PointEstimator); + } + + /// + /// Verifies every property, including the two nullable enum options, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var options = new BayesianOptionsDto + { + Sampler = BayesianAnalysis.SamplerType.ARWMH, + Iterations = 20000, + WarmupIterations = 10000, + ThinningInterval = 10, + NumberOfChains = 4, + PrngSeed = 12345, + CredibleIntervalWidth = 0.95, + OutputLength = 5000, + PointEstimator = BayesianAnalysis.PointEstimateType.PosteriorMean + }; + + var copy = TestJson.Roundtrip(options); + + Assert.AreEqual(BayesianAnalysis.SamplerType.ARWMH, copy.Sampler); + Assert.AreEqual(20000, copy.Iterations); + Assert.AreEqual(10000, copy.WarmupIterations); + Assert.AreEqual(10, copy.ThinningInterval); + Assert.AreEqual(4, copy.NumberOfChains); + Assert.AreEqual(12345, copy.PrngSeed); + Assert.AreEqual(0.95, copy.CredibleIntervalWidth); + Assert.AreEqual(5000, copy.OutputLength); + Assert.AreEqual(BayesianAnalysis.PointEstimateType.PosteriorMean, copy.PointEstimator); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names, camelCase enum value strings, + /// and omits null options. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new BayesianOptionsDto + { + Sampler = BayesianAnalysis.SamplerType.DEMCzs, + PointEstimator = BayesianAnalysis.PointEstimateType.PosteriorMean, + PrngSeed = 42 + }); + StringAssert.Contains(json, "\"sampler\""); + StringAssert.Contains(json, "\"demCzs\""); + StringAssert.Contains(json, "\"pointEstimator\""); + StringAssert.Contains(json, "\"posteriorMean\""); + StringAssert.Contains(json, "\"prngSeed\""); + Assert.IsFalse(json.Contains("\"iterations\""), "Null iterations should be omitted."); + + string defaultJson = TestJson.Serialize(new BayesianOptionsDto()); + Assert.AreEqual("{}", defaultJson, "An all-default options object should serialize empty."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/BivariateResultsResponseTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/BivariateResultsResponseTests.cs new file mode 100644 index 0000000..2efa14f --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/BivariateResultsResponseTests.cs @@ -0,0 +1,90 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for and its supporting + /// and : defaults, JSON + /// round-trip, and camelCase wire names. + /// + [TestClass] + public class BivariateResultsResponseTests + { + /// + /// Verifies the defaults across the response and its supporting DTOs. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var response = new BivariateResultsResponse(); + Assert.AreEqual(Guid.Empty, response.AnalysisId); + Assert.IsNull(response.Kind); + Assert.IsNull(response.CopulaType); + Assert.IsNull(response.MarginalX); + Assert.IsNull(response.JointExceedance); + Assert.AreEqual(0, response.Parameters.Count); + + var marginal = new MarginalInfoDto(); + Assert.IsNull(marginal.AnalysisId); + Assert.IsNull(marginal.DistributionType); + + var curve = new JointExceedanceCurveDto(); + Assert.AreEqual(0, curve.X.Count); + Assert.IsNull(curve.ModeProbabilities); + Assert.AreEqual(0d, curve.CredibleIntervalWidth); + } + + /// + /// Verifies the nested structures survive a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var response = new BivariateResultsResponse + { + AnalysisId = Guid.NewGuid(), + Kind = "bivariate", + CopulaType = "gumbel", + EstimationMethod = "inferenceFromMargins", + MarginalX = new MarginalInfoDto { AnalysisId = Guid.NewGuid(), Name = "x", Kind = "univariate", DistributionType = "gumbel" }, + JointExceedance = new JointExceedanceCurveDto + { + X = new List { 100d }, + Y = new List { 200d }, + ModeProbabilities = new List { 0.01 }, + CiLower = new List { 0.005 }, + CiUpper = new List { 0.02 }, + CredibleIntervalWidth = 0.9 + } + }; + + var copy = TestJson.Roundtrip(response); + + Assert.AreEqual("gumbel", copy.CopulaType); + Assert.AreEqual("x", copy.MarginalX!.Name); + Assert.AreEqual(1, copy.JointExceedance!.X.Count); + Assert.AreEqual(0.01, copy.JointExceedance.ModeProbabilities![0]); + Assert.AreEqual(0.9, copy.JointExceedance.CredibleIntervalWidth); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new BivariateResultsResponse + { + Kind = "bivariate", + CopulaType = "frank", + MarginalY = new MarginalInfoDto { Kind = "mixture" }, + JointExceedance = new JointExceedanceCurveDto { X = new List { 1d } } + }); + StringAssert.Contains(json, "\"copulaType\""); + StringAssert.Contains(json, "\"marginalY\""); + StringAssert.Contains(json, "\"jointExceedance\""); + StringAssert.Contains(json, "\"credibleIntervalWidth\""); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/BlockOptionsDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/BlockOptionsDtoTests.cs new file mode 100644 index 0000000..eeab763 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/BlockOptionsDtoTests.cs @@ -0,0 +1,69 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and camelCase wire names. + /// + [TestClass] + public class BlockOptionsDtoTests + { + /// + /// Verifies a new echo defaults to all-null options. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var dto = new BlockOptionsDto(); + Assert.IsNull(dto.TimeBlock); + Assert.IsNull(dto.BlockFunction); + Assert.IsNull(dto.SmoothingFunction); + Assert.IsNull(dto.StartMonth); + Assert.IsNull(dto.EndMonth); + Assert.IsNull(dto.Period); + } + + /// + /// Verifies every property survives a JSON round-trip with the API's wire options. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var dto = new BlockOptionsDto + { + TimeBlock = "waterYear", + BlockFunction = "maximum", + SmoothingFunction = "movingAverage", + StartMonth = 10, + EndMonth = 9, + Period = 7 + }; + + var copy = TestJson.Roundtrip(dto); + + Assert.AreEqual("waterYear", copy.TimeBlock); + Assert.AreEqual("maximum", copy.BlockFunction); + Assert.AreEqual("movingAverage", copy.SmoothingFunction); + Assert.AreEqual(10, copy.StartMonth); + Assert.AreEqual(9, copy.EndMonth); + Assert.AreEqual(7, copy.Period); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and collapses an all-null echo to + /// an empty object. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new BlockOptionsDto { TimeBlock = "waterYear", BlockFunction = "maximum" }); + StringAssert.Contains(json, "\"timeBlock\""); + StringAssert.Contains(json, "\"blockFunction\""); + StringAssert.Contains(json, "\"waterYear\""); + + Assert.AreEqual("{}", TestJson.Serialize(new BlockOptionsDto()), + "All-null options should serialize to an empty object."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/Bulletin17CInfoDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/Bulletin17CInfoDtoTests.cs new file mode 100644 index 0000000..bc16dd1 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/Bulletin17CInfoDtoTests.cs @@ -0,0 +1,65 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and camelCase + /// wire names. + /// + [TestClass] + public class Bulletin17CInfoDtoTests + { + /// + /// Verifies a new info object defaults to no uncertainty method and no timings. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var info = new Bulletin17CInfoDto(); + Assert.IsNull(info.UncertaintyMethod); + Assert.IsNull(info.GmmElapsedMs); + Assert.IsNull(info.UncertaintyElapsedMs); + } + + /// + /// Verifies the uncertainty method and both timings survive a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var info = new Bulletin17CInfoDto + { + UncertaintyMethod = "linkedMultivariateNormal", + GmmElapsedMs = 84, + UncertaintyElapsedMs = 1312 + }; + + var copy = TestJson.Roundtrip(info); + + Assert.AreEqual("linkedMultivariateNormal", copy.UncertaintyMethod); + Assert.AreEqual(84L, copy.GmmElapsedMs); + Assert.AreEqual(1312L, copy.UncertaintyElapsedMs); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null fields. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new Bulletin17CInfoDto + { + UncertaintyMethod = "bootstrap", + GmmElapsedMs = 12, + UncertaintyElapsedMs = 30 + }); + StringAssert.Contains(json, "\"uncertaintyMethod\""); + StringAssert.Contains(json, "\"gmmElapsedMs\""); + StringAssert.Contains(json, "\"uncertaintyElapsedMs\""); + + string defaultJson = TestJson.Serialize(new Bulletin17CInfoDto()); + Assert.AreEqual("{}", defaultJson, "An all-default info object should serialize empty."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/CoincidentFrequencyResultsResponseTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/CoincidentFrequencyResultsResponseTests.cs new file mode 100644 index 0000000..ab175e9 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/CoincidentFrequencyResultsResponseTests.cs @@ -0,0 +1,70 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON + /// round-trip, and camelCase wire names. + /// + [TestClass] + public class CoincidentFrequencyResultsResponseTests + { + /// + /// Verifies the defaults: empty curves and chain-usage flags off. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var response = new CoincidentFrequencyResultsResponse(); + Assert.AreEqual(Guid.Empty, response.AnalysisId); + Assert.AreEqual(0, response.NumberOfBins); + Assert.AreEqual(0, response.ZValues.Count); + Assert.IsNull(response.AepMode); + Assert.IsNull(response.CiLower); + Assert.IsFalse(response.MarginalXChainUsed); + Assert.IsFalse(response.MarginalYChainUsed); + } + + /// + /// Verifies the curves survive a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var response = new CoincidentFrequencyResultsResponse + { + AnalysisId = Guid.NewGuid(), + Kind = "coincidentFrequency", + NumberOfBins = 5, + ZValues = new List { 10d, 12d, 14d, 16d, 18d }, + AepMode = new List { 0.5, 0.2, 0.1, 0.05, 0.01 }, + CiLower = new List { 0.4, 0.15, 0.08, 0.03, 0.005 }, + CiUpper = new List { 0.6, 0.25, 0.12, 0.07, 0.02 }, + CredibleIntervalWidth = 0.9, + MarginalXChainUsed = true + }; + + var copy = TestJson.Roundtrip(response); + + Assert.AreEqual(5, copy.NumberOfBins); + CollectionAssert.AreEqual(response.ZValues, copy.ZValues); + CollectionAssert.AreEqual(response.AepMode, copy.AepMode); + Assert.AreEqual(0.9, copy.CredibleIntervalWidth); + Assert.IsTrue(copy.MarginalXChainUsed); + Assert.IsFalse(copy.MarginalYChainUsed); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new CoincidentFrequencyResultsResponse { Kind = "coincidentFrequency" }); + StringAssert.Contains(json, "\"zValues\""); + StringAssert.Contains(json, "\"marginalXChainUsed\""); + StringAssert.Contains(json, "\"credibleIntervalWidth\""); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/CompositeComponentDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/CompositeComponentDtoTests.cs new file mode 100644 index 0000000..2f5b923 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/CompositeComponentDtoTests.cs @@ -0,0 +1,47 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and + /// camelCase wire names. + /// + [TestClass] + public class CompositeComponentDtoTests + { + /// + /// Verifies the defaults: empty id and no weight. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var component = new CompositeComponentDto(); + Assert.AreEqual(Guid.Empty, component.AnalysisId); + Assert.IsNull(component.Weight); + } + + /// + /// Verifies the id and weight survive a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var component = new CompositeComponentDto { AnalysisId = Guid.NewGuid(), Weight = 0.35 }; + var copy = TestJson.Roundtrip(component); + Assert.AreEqual(component.AnalysisId, copy.AnalysisId); + Assert.AreEqual(0.35, copy.Weight); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits the null weight. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new CompositeComponentDto { AnalysisId = Guid.NewGuid() }); + StringAssert.Contains(json, "\"analysisId\""); + Assert.IsFalse(json.Contains("\"weight\""), "Null weight should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/CompositeInfoDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/CompositeInfoDtoTests.cs new file mode 100644 index 0000000..495b865 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/CompositeInfoDtoTests.cs @@ -0,0 +1,78 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for and its supporting + /// : defaults, JSON round-trip, and camelCase wire names. + /// + [TestClass] + public class CompositeInfoDtoTests + { + /// + /// Verifies the defaults: null settings and an empty component list. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var info = new CompositeInfoDto(); + Assert.IsNull(info.CompositeType); + Assert.IsNull(info.AverageMethod); + Assert.IsNull(info.Dependency); + Assert.IsNull(info.IsMaximum); + Assert.AreEqual(0, info.Components.Count); + + var component = new CompositeComponentInfoDto(); + Assert.IsNull(component.AnalysisId); + Assert.IsNull(component.Name); + Assert.IsNull(component.Kind); + Assert.AreEqual(0d, component.Weight); + } + + /// + /// Verifies every property survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var info = new CompositeInfoDto + { + CompositeType = "modelAverage", + AverageMethod = "waic", + Dependency = "independent", + IsMaximum = true, + Components = new List + { + new() { AnalysisId = Guid.NewGuid(), Name = "gumbel fit", Kind = "univariate", Weight = 0.7 } + } + }; + + var copy = TestJson.Roundtrip(info); + + Assert.AreEqual("modelAverage", copy.CompositeType); + Assert.AreEqual("waic", copy.AverageMethod); + Assert.AreEqual("independent", copy.Dependency); + Assert.IsTrue(copy.IsMaximum); + Assert.AreEqual(1, copy.Components.Count); + Assert.AreEqual(0.7, copy.Components[0].Weight); + Assert.AreEqual("univariate", copy.Components[0].Kind); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new CompositeInfoDto + { + CompositeType = "mixture", + Components = new List { new() { Weight = 0.5 } } + }); + StringAssert.Contains(json, "\"compositeType\""); + StringAssert.Contains(json, "\"components\""); + StringAssert.Contains(json, "\"weight\""); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/CreateBivariateAnalysisRequestTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/CreateBivariateAnalysisRequestTests.cs new file mode 100644 index 0000000..c7c8348 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/CreateBivariateAnalysisRequestTests.cs @@ -0,0 +1,73 @@ +using Numerics.Distributions.Copulas; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, + /// and camelCase wire names. + /// + [TestClass] + public class CreateBivariateAnalysisRequestTests + { + /// + /// Verifies the defaults: normal copula, model-default estimation method, empty grid. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var request = new CreateBivariateAnalysisRequest(); + Assert.AreEqual(Guid.Empty, request.MarginalXAnalysisId); + Assert.AreEqual(Guid.Empty, request.MarginalYAnalysisId); + Assert.AreEqual(CopulaType.Normal, request.CopulaType); + Assert.IsNull(request.EstimationMethod); + Assert.AreEqual(0, request.XyOrdinates.Count); + Assert.IsNull(request.BayesianOptions); + Assert.IsNull(request.ParameterPriors); + } + + /// + /// Verifies every property survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var request = new CreateBivariateAnalysisRequest + { + MarginalXAnalysisId = Guid.NewGuid(), + MarginalYAnalysisId = Guid.NewGuid(), + CopulaType = CopulaType.Gumbel, + EstimationMethod = CopulaEstimationMethod.PseudoLikelihood, + XyOrdinates = new List { new() { X = 100d, Y = 200d } }, + Name = "bivariate" + }; + + var copy = TestJson.Roundtrip(request); + + Assert.AreEqual(request.MarginalXAnalysisId, copy.MarginalXAnalysisId); + Assert.AreEqual(request.MarginalYAnalysisId, copy.MarginalYAnalysisId); + Assert.AreEqual(CopulaType.Gumbel, copy.CopulaType); + Assert.AreEqual(CopulaEstimationMethod.PseudoLikelihood, copy.EstimationMethod); + Assert.AreEqual(1, copy.XyOrdinates.Count); + Assert.AreEqual("bivariate", copy.Name); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and enum strings. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new CreateBivariateAnalysisRequest + { + CopulaType = CopulaType.AliMikhailHaq, + XyOrdinates = new List { new() { X = 1d, Y = 2d } } + }); + StringAssert.Contains(json, "\"marginalXAnalysisId\""); + StringAssert.Contains(json, "\"copulaType\""); + StringAssert.Contains(json, "\"aliMikhailHaq\""); + StringAssert.Contains(json, "\"xyOrdinates\""); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/CreateBlockMaxInputDataRequestTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/CreateBlockMaxInputDataRequestTests.cs new file mode 100644 index 0000000..9a6e401 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/CreateBlockMaxInputDataRequestTests.cs @@ -0,0 +1,80 @@ +using Numerics.Data; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and + /// camelCase wire names including string enum values. + /// + [TestClass] + public class CreateBlockMaxInputDataRequestTests + { + /// + /// Verifies a new request defaults to water-year maxima with no smoothing and the U.S. + /// water-year month window. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var request = new CreateBlockMaxInputDataRequest(); + Assert.AreEqual(Guid.Empty, request.TimeSeriesId); + Assert.IsNull(request.Name); + Assert.IsNull(request.Description); + Assert.AreEqual(TimeBlockWindow.WaterYear, request.TimeBlock); + Assert.AreEqual(BlockFunctionType.Maximum, request.BlockFunction); + Assert.AreEqual(SmoothingFunctionType.None, request.SmoothingFunction); + Assert.AreEqual(10, request.StartMonth); + Assert.AreEqual(9, request.EndMonth); + Assert.AreEqual(1, request.Period); + } + + /// + /// Verifies every property, including the three enum-typed options, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var request = new CreateBlockMaxInputDataRequest + { + TimeSeriesId = Guid.NewGuid(), + Name = "7-day minima", + Description = "Custom year low-flow extraction.", + TimeBlock = TimeBlockWindow.CustomYear, + BlockFunction = BlockFunctionType.Minimum, + SmoothingFunction = SmoothingFunctionType.MovingAverage, + StartMonth = 4, + EndMonth = 3, + Period = 7 + }; + + var copy = TestJson.Roundtrip(request); + + Assert.AreEqual(request.TimeSeriesId, copy.TimeSeriesId); + Assert.AreEqual("7-day minima", copy.Name); + Assert.AreEqual("Custom year low-flow extraction.", copy.Description); + Assert.AreEqual(TimeBlockWindow.CustomYear, copy.TimeBlock); + Assert.AreEqual(BlockFunctionType.Minimum, copy.BlockFunction); + Assert.AreEqual(SmoothingFunctionType.MovingAverage, copy.SmoothingFunction); + Assert.AreEqual(4, copy.StartMonth); + Assert.AreEqual(3, copy.EndMonth); + Assert.AreEqual(7, copy.Period); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and camelCase enum value strings. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new CreateBlockMaxInputDataRequest()); + StringAssert.Contains(json, "\"timeSeriesId\""); + StringAssert.Contains(json, "\"timeBlock\""); + StringAssert.Contains(json, "\"waterYear\""); + StringAssert.Contains(json, "\"maximum\""); + StringAssert.Contains(json, "\"startMonth\""); + Assert.IsFalse(json.Contains("\"name\""), "Null name should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/CreateBulletin17CAnalysisRequestTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/CreateBulletin17CAnalysisRequestTests.cs new file mode 100644 index 0000000..e716985 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/CreateBulletin17CAnalysisRequestTests.cs @@ -0,0 +1,79 @@ +using Numerics.Distributions; +using RMC.BestFit.Analyses; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, + /// and camelCase wire names including string enum values. + /// + [TestClass] + public class CreateBulletin17CAnalysisRequestTests + { + /// + /// Verifies a new request defaults to the Log-Pearson Type III distribution with a null + /// uncertainty method (model default) and no ordinates, name, or description. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var request = new CreateBulletin17CAnalysisRequest(); + Assert.AreEqual(Guid.Empty, request.InputDataId); + Assert.AreEqual(UnivariateDistributionType.LogPearsonTypeIII, request.Distribution); + Assert.IsNull(request.UncertaintyMethod); + Assert.IsNull(request.ProbabilityOrdinates); + Assert.IsNull(request.Name); + Assert.IsNull(request.Description); + } + + /// + /// Verifies every property, including the two enum-typed options, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var request = new CreateBulletin17CAnalysisRequest + { + InputDataId = Guid.NewGuid(), + Distribution = UnivariateDistributionType.LogNormal, + UncertaintyMethod = UncertaintyMethod.BiasCorrectedBootstrap, + ProbabilityOrdinates = new List { 0.5, 0.01, 0.002 }, + Name = "B17C peaks", + Description = "Bulletin 17C fit of the annual peak record." + }; + + var copy = TestJson.Roundtrip(request); + + Assert.AreEqual(request.InputDataId, copy.InputDataId); + Assert.AreEqual(UnivariateDistributionType.LogNormal, copy.Distribution); + Assert.AreEqual(UncertaintyMethod.BiasCorrectedBootstrap, copy.UncertaintyMethod); + CollectionAssert.AreEqual(request.ProbabilityOrdinates, copy.ProbabilityOrdinates); + Assert.AreEqual("B17C peaks", copy.Name); + Assert.AreEqual("Bulletin 17C fit of the annual peak record.", copy.Description); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names, camelCase enum value strings, + /// and omits null optional fields. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new CreateBulletin17CAnalysisRequest + { + UncertaintyMethod = UncertaintyMethod.Bootstrap + }); + StringAssert.Contains(json, "\"inputDataId\""); + StringAssert.Contains(json, "\"distribution\""); + StringAssert.Contains(json, "\"logPearsonTypeIII\""); + StringAssert.Contains(json, "\"uncertaintyMethod\""); + StringAssert.Contains(json, "\"bootstrap\""); + + string defaultJson = TestJson.Serialize(new CreateBulletin17CAnalysisRequest()); + Assert.IsFalse(defaultJson.Contains("\"uncertaintyMethod\""), "Null uncertainty method should be omitted."); + Assert.IsFalse(defaultJson.Contains("\"name\""), "Null name should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/CreateCoincidentFrequencyAnalysisRequestTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/CreateCoincidentFrequencyAnalysisRequestTests.cs new file mode 100644 index 0000000..1a47016 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/CreateCoincidentFrequencyAnalysisRequestTests.cs @@ -0,0 +1,82 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; +using RMC.BestFit.Estimation; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON + /// round-trip, and camelCase wire names. + /// + [TestClass] + public class CreateCoincidentFrequencyAnalysisRequestTests + { + /// + /// Verifies the defaults: 50 bins, empty ordinates and surface, null options. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var request = new CreateCoincidentFrequencyAnalysisRequest(); + Assert.AreEqual(Guid.Empty, request.BivariateAnalysisId); + Assert.AreEqual(0, request.XValues.Count); + Assert.AreEqual(0, request.YValues.Count); + Assert.AreEqual(0, request.BivariateResponse.Count); + Assert.AreEqual(50, request.NumberOfBins); + Assert.IsNull(request.CredibleIntervalWidth); + Assert.IsNull(request.PointEstimator); + } + + /// + /// Verifies the ordinates and surface survive a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var request = new CreateCoincidentFrequencyAnalysisRequest + { + BivariateAnalysisId = Guid.NewGuid(), + XValues = new List { 1d, 2d }, + YValues = new List { 10d, 20d }, + BivariateResponse = new List> + { + new() { 100d, 110d }, + new() { 120d, 130d } + }, + NumberOfBins = 25, + CredibleIntervalWidth = 0.95, + PointEstimator = BayesianAnalysis.PointEstimateType.PosteriorMean + }; + + var copy = TestJson.Roundtrip(request); + + Assert.AreEqual(request.BivariateAnalysisId, copy.BivariateAnalysisId); + CollectionAssert.AreEqual(request.XValues, copy.XValues); + CollectionAssert.AreEqual(request.YValues, copy.YValues); + Assert.AreEqual(2, copy.BivariateResponse.Count); + Assert.AreEqual(130d, copy.BivariateResponse[1][1]); + Assert.AreEqual(25, copy.NumberOfBins); + Assert.AreEqual(0.95, copy.CredibleIntervalWidth); + Assert.AreEqual(BayesianAnalysis.PointEstimateType.PosteriorMean, copy.PointEstimator); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new CreateCoincidentFrequencyAnalysisRequest + { + XValues = new List { 1d }, + YValues = new List { 2d }, + BivariateResponse = new List> { new() { 3d } } + }); + StringAssert.Contains(json, "\"bivariateAnalysisId\""); + StringAssert.Contains(json, "\"xValues\""); + StringAssert.Contains(json, "\"yValues\""); + StringAssert.Contains(json, "\"bivariateResponse\""); + StringAssert.Contains(json, "\"numberOfBins\""); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/CreateCompetingRisksAnalysisRequestTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/CreateCompetingRisksAnalysisRequestTests.cs new file mode 100644 index 0000000..081e2fa --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/CreateCompetingRisksAnalysisRequestTests.cs @@ -0,0 +1,75 @@ +using Numerics.Distributions; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON + /// round-trip, and camelCase wire names. + /// + [TestClass] + public class CreateCompetingRisksAnalysisRequestTests + { + /// + /// Verifies the defaults: empty components and null optional fields. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var request = new CreateCompetingRisksAnalysisRequest(); + Assert.AreEqual(Guid.Empty, request.InputDataId); + Assert.AreEqual(0, request.Distributions.Count); + Assert.IsNull(request.ProbabilityOrdinates); + Assert.IsNull(request.BayesianOptions); + Assert.IsNull(request.ParameterPriors); + Assert.IsNull(request.QuantilePriors); + Assert.IsNull(request.UseSingleQuantile); + Assert.IsNull(request.Name); + Assert.IsNull(request.Description); + } + + /// + /// Verifies every property survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var request = new CreateCompetingRisksAnalysisRequest + { + InputDataId = Guid.NewGuid(), + Distributions = new List + { + UnivariateDistributionType.Gumbel, + UnivariateDistributionType.GeneralizedExtremeValue + }, + ProbabilityOrdinates = new List { 0.5, 0.01 }, + UseSingleQuantile = false, + Name = "competing risks" + }; + + var copy = TestJson.Roundtrip(request); + + Assert.AreEqual(request.InputDataId, copy.InputDataId); + CollectionAssert.AreEqual(request.Distributions, copy.Distributions); + CollectionAssert.AreEqual(request.ProbabilityOrdinates, copy.ProbabilityOrdinates); + Assert.IsFalse(copy.UseSingleQuantile!.Value); + Assert.AreEqual("competing risks", copy.Name); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and enum strings. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new CreateCompetingRisksAnalysisRequest + { + Distributions = new List { UnivariateDistributionType.GeneralizedExtremeValue } + }); + StringAssert.Contains(json, "\"inputDataId\""); + StringAssert.Contains(json, "\"distributions\""); + StringAssert.Contains(json, "\"generalizedExtremeValue\""); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/CreateCompositeAnalysisRequestTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/CreateCompositeAnalysisRequestTests.cs new file mode 100644 index 0000000..e69f5f4 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/CreateCompositeAnalysisRequestTests.cs @@ -0,0 +1,86 @@ +using Numerics.Data.Statistics; +using RMC.BestFit.Analyses; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; +using RMC.BestFit.Estimation; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, + /// and camelCase wire names. + /// + [TestClass] + public class CreateCompositeAnalysisRequestTests + { + /// + /// Verifies the defaults: competing risks combination as a maximum, with null options. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var request = new CreateCompositeAnalysisRequest(); + Assert.AreEqual(0, request.Components.Count); + Assert.AreEqual(CompositeType.CompetingRisks, request.CompositeType); + Assert.IsNull(request.AverageMethod); + Assert.IsNull(request.Dependency); + Assert.IsTrue(request.IsMaximum); + Assert.IsNull(request.ProbabilityOrdinates); + Assert.IsNull(request.CredibleIntervalWidth); + Assert.IsNull(request.PointEstimator); + } + + /// + /// Verifies every property survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var request = new CreateCompositeAnalysisRequest + { + Components = new List + { + new() { AnalysisId = Guid.NewGuid(), Weight = 0.6 }, + new() { AnalysisId = Guid.NewGuid(), Weight = 0.4 } + }, + CompositeType = CompositeType.Mixture, + AverageMethod = AverageMethod.WAIC, + Dependency = Probability.DependencyType.PerfectlyPositive, + IsMaximum = false, + ProbabilityOrdinates = new List { 0.5, 0.01 }, + CredibleIntervalWidth = 0.95, + PointEstimator = BayesianAnalysis.PointEstimateType.PosteriorMean, + Name = "composite" + }; + + var copy = TestJson.Roundtrip(request); + + Assert.AreEqual(2, copy.Components.Count); + Assert.AreEqual(0.6, copy.Components[0].Weight); + Assert.AreEqual(CompositeType.Mixture, copy.CompositeType); + Assert.AreEqual(AverageMethod.WAIC, copy.AverageMethod); + Assert.AreEqual(Probability.DependencyType.PerfectlyPositive, copy.Dependency); + Assert.IsFalse(copy.IsMaximum); + Assert.AreEqual(0.95, copy.CredibleIntervalWidth); + Assert.AreEqual(BayesianAnalysis.PointEstimateType.PosteriorMean, copy.PointEstimator); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and enum strings. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new CreateCompositeAnalysisRequest + { + Components = new List { new() { AnalysisId = Guid.NewGuid() } }, + CompositeType = CompositeType.ModelAverage + }); + StringAssert.Contains(json, "\"components\""); + StringAssert.Contains(json, "\"analysisId\""); + StringAssert.Contains(json, "\"compositeType\""); + StringAssert.Contains(json, "\"modelAverage\""); + StringAssert.Contains(json, "\"isMaximum\""); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/CreateDistributionFittingAnalysisRequestTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/CreateDistributionFittingAnalysisRequestTests.cs new file mode 100644 index 0000000..d267504 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/CreateDistributionFittingAnalysisRequestTests.cs @@ -0,0 +1,66 @@ +using Numerics.Distributions; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON + /// round-trip, and camelCase wire names. + /// + [TestClass] + public class CreateDistributionFittingAnalysisRequestTests + { + /// + /// Verifies the defaults: no candidate subset (all 15 fitted) and null metadata. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var request = new CreateDistributionFittingAnalysisRequest(); + Assert.AreEqual(Guid.Empty, request.InputDataId); + Assert.IsNull(request.Distributions); + Assert.IsNull(request.Name); + Assert.IsNull(request.Description); + } + + /// + /// Verifies every property survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var request = new CreateDistributionFittingAnalysisRequest + { + InputDataId = Guid.NewGuid(), + Distributions = new List + { + UnivariateDistributionType.LogPearsonTypeIII, + UnivariateDistributionType.Gumbel + }, + Name = "screen" + }; + + var copy = TestJson.Roundtrip(request); + + Assert.AreEqual(request.InputDataId, copy.InputDataId); + CollectionAssert.AreEqual(request.Distributions, copy.Distributions); + Assert.AreEqual("screen", copy.Name); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and enum strings. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new CreateDistributionFittingAnalysisRequest + { + Distributions = new List { UnivariateDistributionType.Weibull } + }); + StringAssert.Contains(json, "\"inputDataId\""); + StringAssert.Contains(json, "\"distributions\""); + StringAssert.Contains(json, "\"weibull\""); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/CreateManualInputDataRequestTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/CreateManualInputDataRequestTests.cs new file mode 100644 index 0000000..8e82a65 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/CreateManualInputDataRequestTests.cs @@ -0,0 +1,96 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and + /// camelCase wire names. + /// + [TestClass] + public class CreateManualInputDataRequestTests + { + /// + /// Verifies a new request defaults to an empty exact series with all optional settings null. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var request = new CreateManualInputDataRequest(); + Assert.IsNull(request.Name); + Assert.IsNull(request.Description); + Assert.IsNotNull(request.ExactData); + Assert.AreEqual(0, request.ExactData.Count); + Assert.IsNull(request.IntervalData); + Assert.IsNull(request.ThresholdData); + Assert.IsNull(request.PlottingParameter); + Assert.IsNull(request.LowOutlierThreshold); + Assert.IsNull(request.Lambda); + } + + /// + /// Verifies every property, including the nested observation lists, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var request = new CreateManualInputDataRequest + { + Name = "Historic record", + Description = "Systematic plus paleoflood data.", + ExactData = new List + { + new ExactObservationDto { Index = 1996, Value = 15200.5, IsLowOutlier = true } + }, + IntervalData = new List + { + new IntervalObservationDto { Index = 1889, LowerBound = 90000.0, UpperBound = 110000.0 } + }, + ThresholdData = new List + { + new ThresholdObservationDto { StartIndex = 1861, EndIndex = 1929, Value = 85000.0, NumberAbove = 1 } + }, + PlottingParameter = 0.44, + LowOutlierThreshold = 500.0, + Lambda = 1.5 + }; + + var copy = TestJson.Roundtrip(request); + + Assert.AreEqual("Historic record", copy.Name); + Assert.AreEqual("Systematic plus paleoflood data.", copy.Description); + Assert.AreEqual(1, copy.ExactData.Count); + Assert.AreEqual(1996, copy.ExactData[0].Index); + Assert.AreEqual(15200.5, copy.ExactData[0].Value); + Assert.IsTrue(copy.ExactData[0].IsLowOutlier); + Assert.IsNotNull(copy.IntervalData); + Assert.AreEqual(90000.0, copy.IntervalData[0].LowerBound); + Assert.IsNotNull(copy.ThresholdData); + Assert.AreEqual(85000.0, copy.ThresholdData[0].Value); + Assert.AreEqual(0.44, copy.PlottingParameter); + Assert.AreEqual(500.0, copy.LowOutlierThreshold); + Assert.AreEqual(1.5, copy.Lambda); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null optional settings. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new CreateManualInputDataRequest + { + PlottingParameter = 0.375, + Lambda = 2.0 + }); + StringAssert.Contains(json, "\"exactData\""); + StringAssert.Contains(json, "\"plottingParameter\""); + StringAssert.Contains(json, "\"lambda\""); + + string defaultJson = TestJson.Serialize(new CreateManualInputDataRequest()); + Assert.IsFalse(defaultJson.Contains("\"intervalData\""), "Null intervalData should be omitted."); + Assert.IsFalse(defaultJson.Contains("\"thresholdData\""), "Null thresholdData should be omitted."); + Assert.IsFalse(defaultJson.Contains("\"lowOutlierThreshold\""), "Null lowOutlierThreshold should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/CreateManualTimeSeriesRequestTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/CreateManualTimeSeriesRequestTests.cs new file mode 100644 index 0000000..08fa937 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/CreateManualTimeSeriesRequestTests.cs @@ -0,0 +1,74 @@ +using Numerics.Data; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and + /// camelCase wire names including string enum values. + /// + [TestClass] + public class CreateManualTimeSeriesRequestTests + { + /// + /// Verifies a new request defaults to a daily interval with an empty point list. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var request = new CreateManualTimeSeriesRequest(); + Assert.IsNull(request.Name); + Assert.IsNull(request.Description); + Assert.AreEqual(TimeInterval.OneDay, request.TimeInterval); + Assert.IsNotNull(request.Points); + Assert.AreEqual(0, request.Points.Count); + } + + /// + /// Verifies every property, including the enum-typed interval and points, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var request = new CreateManualTimeSeriesRequest + { + Name = "Gage measurements", + Description = "Discrete field measurements.", + TimeInterval = TimeInterval.Irregular, + Points = new List + { + new TimeSeriesPointDto { DateTime = new DateTime(1996, 10, 1), Value = 15200.5 }, + new TimeSeriesPointDto { DateTime = new DateTime(1997, 3, 15), Value = 8300.25 } + } + }; + + var copy = TestJson.Roundtrip(request); + + Assert.AreEqual("Gage measurements", copy.Name); + Assert.AreEqual("Discrete field measurements.", copy.Description); + Assert.AreEqual(TimeInterval.Irregular, copy.TimeInterval); + Assert.AreEqual(2, copy.Points.Count); + Assert.AreEqual(new DateTime(1996, 10, 1), copy.Points[0].DateTime); + Assert.AreEqual(15200.5, copy.Points[0].Value); + Assert.AreEqual(8300.25, copy.Points[1].Value); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and camelCase enum value strings. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new CreateManualTimeSeriesRequest + { + TimeInterval = TimeInterval.Irregular + }); + StringAssert.Contains(json, "\"timeInterval\""); + StringAssert.Contains(json, "\"points\""); + StringAssert.Contains(json, "\"irregular\""); + Assert.IsFalse(json.Contains("\"name\""), "Null name should be omitted."); + Assert.IsFalse(json.Contains("\"description\""), "Null description should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/CreateMixtureAnalysisRequestTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/CreateMixtureAnalysisRequestTests.cs new file mode 100644 index 0000000..e1d5097 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/CreateMixtureAnalysisRequestTests.cs @@ -0,0 +1,79 @@ +using Numerics.Distributions; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and + /// camelCase wire names. + /// + [TestClass] + public class CreateMixtureAnalysisRequestTests + { + /// + /// Verifies the defaults: empty components, zero inflation off, and null optional fields. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var request = new CreateMixtureAnalysisRequest(); + Assert.AreEqual(Guid.Empty, request.InputDataId); + Assert.AreEqual(0, request.Distributions.Count); + Assert.IsFalse(request.IsZeroInflated); + Assert.IsNull(request.ProbabilityOrdinates); + Assert.IsNull(request.BayesianOptions); + Assert.IsNull(request.ParameterPriors); + Assert.IsNull(request.QuantilePriors); + Assert.IsNull(request.UseSingleQuantile); + Assert.IsNull(request.Name); + Assert.IsNull(request.Description); + } + + /// + /// Verifies every property survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var request = new CreateMixtureAnalysisRequest + { + InputDataId = Guid.NewGuid(), + Distributions = new List + { + UnivariateDistributionType.Gumbel, + UnivariateDistributionType.LogNormal + }, + IsZeroInflated = true, + ProbabilityOrdinates = new List { 0.5, 0.01 }, + UseSingleQuantile = true, + Name = "mixture" + }; + + var copy = TestJson.Roundtrip(request); + + Assert.AreEqual(request.InputDataId, copy.InputDataId); + CollectionAssert.AreEqual(request.Distributions, copy.Distributions); + Assert.IsTrue(copy.IsZeroInflated); + CollectionAssert.AreEqual(request.ProbabilityOrdinates, copy.ProbabilityOrdinates); + Assert.IsTrue(copy.UseSingleQuantile); + Assert.AreEqual("mixture", copy.Name); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and enum strings. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new CreateMixtureAnalysisRequest + { + Distributions = new List { UnivariateDistributionType.Gumbel } + }); + StringAssert.Contains(json, "\"inputDataId\""); + StringAssert.Contains(json, "\"distributions\""); + StringAssert.Contains(json, "\"gumbel\""); + StringAssert.Contains(json, "\"isZeroInflated\""); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/CreatePointProcessAnalysisRequestTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/CreatePointProcessAnalysisRequestTests.cs new file mode 100644 index 0000000..8300f1e --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/CreatePointProcessAnalysisRequestTests.cs @@ -0,0 +1,81 @@ +using Numerics.Data; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, + /// and camelCase wire names. + /// + [TestClass] + public class CreatePointProcessAnalysisRequestTests + { + /// + /// Verifies the defaults: non-seasonal with every override null (model defaults apply). + /// + [TestMethod] + public void Defaults_AreExpected() + { + var request = new CreatePointProcessAnalysisRequest(); + Assert.AreEqual(Guid.Empty, request.InputDataId); + Assert.IsFalse(request.IsSeasonal); + Assert.IsNull(request.TimeBlock); + Assert.IsNull(request.StartMonth); + Assert.IsNull(request.Threshold); + Assert.IsNull(request.TotalYears); + Assert.IsNull(request.ProbabilityOrdinates); + Assert.IsNull(request.BayesianOptions); + Assert.IsNull(request.ParameterPriors); + Assert.IsNull(request.QuantilePriors); + Assert.IsNull(request.UseSingleQuantile); + } + + /// + /// Verifies every property survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var request = new CreatePointProcessAnalysisRequest + { + InputDataId = Guid.NewGuid(), + IsSeasonal = true, + TimeBlock = TimeBlockWindow.WaterYear, + StartMonth = 10, + Threshold = 425d, + TotalYears = 63.5, + ProbabilityOrdinates = new List { 0.1, 0.01 }, + Name = "pot analysis" + }; + + var copy = TestJson.Roundtrip(request); + + Assert.AreEqual(request.InputDataId, copy.InputDataId); + Assert.IsTrue(copy.IsSeasonal); + Assert.AreEqual(TimeBlockWindow.WaterYear, copy.TimeBlock); + Assert.AreEqual(10, copy.StartMonth); + Assert.AreEqual(425d, copy.Threshold); + Assert.AreEqual(63.5, copy.TotalYears); + Assert.AreEqual("pot analysis", copy.Name); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and enum strings. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new CreatePointProcessAnalysisRequest + { + IsSeasonal = true, + TimeBlock = TimeBlockWindow.WaterYear + }); + StringAssert.Contains(json, "\"inputDataId\""); + StringAssert.Contains(json, "\"isSeasonal\""); + StringAssert.Contains(json, "\"timeBlock\""); + StringAssert.Contains(json, "\"waterYear\""); + Assert.IsFalse(json.Contains("\"threshold\""), "Null threshold should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/CreatePotInputDataRequestTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/CreatePotInputDataRequestTests.cs new file mode 100644 index 0000000..e908b22 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/CreatePotInputDataRequestTests.cs @@ -0,0 +1,76 @@ +using Numerics.Data; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and + /// camelCase wire names including string enum values. + /// + [TestClass] + public class CreatePotInputDataRequestTests + { + /// + /// Verifies a new request defaults to a zero threshold with no smoothing and unit spacing. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var request = new CreatePotInputDataRequest(); + Assert.AreEqual(Guid.Empty, request.TimeSeriesId); + Assert.IsNull(request.Name); + Assert.IsNull(request.Description); + Assert.AreEqual(0.0, request.Threshold); + Assert.AreEqual(1, request.MinStepsBetweenPeaks); + Assert.AreEqual(SmoothingFunctionType.None, request.SmoothingFunction); + Assert.AreEqual(1, request.Period); + Assert.IsNull(request.Lambda); + } + + /// + /// Verifies every property, including the enum-typed smoothing option, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var request = new CreatePotInputDataRequest + { + TimeSeriesId = Guid.NewGuid(), + Name = "POT events", + Description = "Peaks above 10000 cfs.", + Threshold = 10000.0, + MinStepsBetweenPeaks = 7, + SmoothingFunction = SmoothingFunctionType.MovingSum, + Period = 3, + Lambda = 2.4 + }; + + var copy = TestJson.Roundtrip(request); + + Assert.AreEqual(request.TimeSeriesId, copy.TimeSeriesId); + Assert.AreEqual("POT events", copy.Name); + Assert.AreEqual("Peaks above 10000 cfs.", copy.Description); + Assert.AreEqual(10000.0, copy.Threshold); + Assert.AreEqual(7, copy.MinStepsBetweenPeaks); + Assert.AreEqual(SmoothingFunctionType.MovingSum, copy.SmoothingFunction); + Assert.AreEqual(3, copy.Period); + Assert.AreEqual(2.4, copy.Lambda); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and camelCase enum value strings. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new CreatePotInputDataRequest()); + StringAssert.Contains(json, "\"timeSeriesId\""); + StringAssert.Contains(json, "\"threshold\""); + StringAssert.Contains(json, "\"minStepsBetweenPeaks\""); + StringAssert.Contains(json, "\"none\""); + Assert.IsFalse(json.Contains("\"lambda\""), "Null lambda should be omitted."); + Assert.IsFalse(json.Contains("\"name\""), "Null name should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/CreateRatingCurveAnalysisRequestTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/CreateRatingCurveAnalysisRequestTests.cs new file mode 100644 index 0000000..c05009c --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/CreateRatingCurveAnalysisRequestTests.cs @@ -0,0 +1,84 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, + /// and camelCase wire names. + /// + [TestClass] + public class CreateRatingCurveAnalysisRequestTests + { + /// + /// Verifies a new request defaults to a single segment with no stage grid overrides, + /// Bayesian options, name, or description. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var request = new CreateRatingCurveAnalysisRequest(); + Assert.AreEqual(Guid.Empty, request.StageTimeSeriesId); + Assert.AreEqual(Guid.Empty, request.DischargeTimeSeriesId); + Assert.AreEqual(1, request.NumberOfSegments); + Assert.IsNull(request.MinStage); + Assert.IsNull(request.MaxStage); + Assert.IsNull(request.StageBins); + Assert.IsNull(request.BayesianOptions); + Assert.IsNull(request.Name); + Assert.IsNull(request.Description); + } + + /// + /// Verifies every property, including the nested Bayesian options, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var request = new CreateRatingCurveAnalysisRequest + { + StageTimeSeriesId = Guid.NewGuid(), + DischargeTimeSeriesId = Guid.NewGuid(), + NumberOfSegments = 2, + MinStage = 1.5, + MaxStage = 12.25, + StageBins = 150, + BayesianOptions = new BayesianOptionsDto + { + Iterations = 15000, + CredibleIntervalWidth = 0.95 + }, + Name = "Overbank rating", + Description = "Two-segment fit with overbank control." + }; + + var copy = TestJson.Roundtrip(request); + + Assert.AreEqual(request.StageTimeSeriesId, copy.StageTimeSeriesId); + Assert.AreEqual(request.DischargeTimeSeriesId, copy.DischargeTimeSeriesId); + Assert.AreEqual(2, copy.NumberOfSegments); + Assert.AreEqual(1.5, copy.MinStage); + Assert.AreEqual(12.25, copy.MaxStage); + Assert.AreEqual(150, copy.StageBins); + Assert.IsNotNull(copy.BayesianOptions); + Assert.AreEqual(15000, copy.BayesianOptions.Iterations); + Assert.AreEqual(0.95, copy.BayesianOptions.CredibleIntervalWidth); + Assert.AreEqual("Overbank rating", copy.Name); + Assert.AreEqual("Two-segment fit with overbank control.", copy.Description); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null optional fields. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new CreateRatingCurveAnalysisRequest()); + StringAssert.Contains(json, "\"stageTimeSeriesId\""); + StringAssert.Contains(json, "\"dischargeTimeSeriesId\""); + StringAssert.Contains(json, "\"numberOfSegments\""); + Assert.IsFalse(json.Contains("\"minStage\""), "Null minStage should be omitted."); + Assert.IsFalse(json.Contains("\"bayesianOptions\""), "Null Bayesian options should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/CreateTimeSeriesAnalysisRequestTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/CreateTimeSeriesAnalysisRequestTests.cs new file mode 100644 index 0000000..2f712b5 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/CreateTimeSeriesAnalysisRequestTests.cs @@ -0,0 +1,101 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; +using RMC.BestFit.Models; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, + /// and camelCase wire names. + /// + [TestClass] + public class CreateTimeSeriesAnalysisRequestTests + { + /// + /// Verifies the defaults: every optional field null so the model defaults apply. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var request = new CreateTimeSeriesAnalysisRequest(); + Assert.AreEqual(Guid.Empty, request.TimeSeriesId); + Assert.AreEqual(TimeSeriesModelType.Ar, request.ModelType); + Assert.IsNull(request.Order); + Assert.IsNull(request.POrder); + Assert.IsNull(request.DOrder); + Assert.IsNull(request.QOrder); + Assert.IsNull(request.XOrder); + Assert.IsNull(request.IncludeIntercept); + Assert.IsNull(request.TransformType); + Assert.IsNull(request.TrendType); + Assert.IsNull(request.IncludeSeasonality); + Assert.IsNull(request.CovariateTimeSeriesIds); + Assert.IsNull(request.CovariateExtension); + Assert.IsNull(request.TrainingTimeSteps); + Assert.IsNull(request.ForecastingTimeSteps); + Assert.IsNull(request.BayesianOptions); + Assert.IsNull(request.ParameterPriors); + } + + /// + /// Verifies every property survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var request = new CreateTimeSeriesAnalysisRequest + { + TimeSeriesId = Guid.NewGuid(), + ModelType = TimeSeriesModelType.Arimax, + POrder = 2, + DOrder = 1, + QOrder = 1, + XOrder = 1, + IncludeIntercept = false, + TransformType = Transform.Logarithmic, + TrendType = ARIMAX.Trend.Quadratic, + IncludeSeasonality = true, + CovariateTimeSeriesIds = new List { Guid.NewGuid() }, + CovariateExtension = ARIMAX.CovariateExtensionMethod.KNN, + TrainingTimeSteps = 800, + ForecastingTimeSteps = 24, + Name = "arimax" + }; + + var copy = TestJson.Roundtrip(request); + + Assert.AreEqual(TimeSeriesModelType.Arimax, copy.ModelType); + Assert.AreEqual(2, copy.POrder); + Assert.AreEqual(1, copy.DOrder); + Assert.AreEqual(1, copy.XOrder); + Assert.IsFalse(copy.IncludeIntercept!.Value); + Assert.AreEqual(Transform.Logarithmic, copy.TransformType); + Assert.AreEqual(ARIMAX.Trend.Quadratic, copy.TrendType); + Assert.IsTrue(copy.IncludeSeasonality!.Value); + Assert.AreEqual(1, copy.CovariateTimeSeriesIds!.Count); + Assert.AreEqual(ARIMAX.CovariateExtensionMethod.KNN, copy.CovariateExtension); + Assert.AreEqual(800, copy.TrainingTimeSteps); + Assert.AreEqual(24, copy.ForecastingTimeSteps); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and enum strings, omitting nulls. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new CreateTimeSeriesAnalysisRequest + { + ModelType = TimeSeriesModelType.Arima, + TransformType = Transform.BoxCox + }); + StringAssert.Contains(json, "\"timeSeriesId\""); + StringAssert.Contains(json, "\"modelType\""); + StringAssert.Contains(json, "\"arima\""); + StringAssert.Contains(json, "\"transformType\""); + StringAssert.Contains(json, "\"boxCox\""); + Assert.IsFalse(json.Contains("\"order\""), "Null order should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/CreateUnivariateAnalysisRequestTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/CreateUnivariateAnalysisRequestTests.cs new file mode 100644 index 0000000..ca194c5 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/CreateUnivariateAnalysisRequestTests.cs @@ -0,0 +1,78 @@ +using Numerics.Distributions; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; +using RMC.BestFit.Estimation; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, + /// and camelCase wire names including string enum values. + /// + [TestClass] + public class CreateUnivariateAnalysisRequestTests + { + /// + /// Verifies a new request defaults to the Log-Pearson Type III distribution with no + /// ordinates, Bayesian options, name, or description. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var request = new CreateUnivariateAnalysisRequest(); + Assert.AreEqual(Guid.Empty, request.InputDataId); + Assert.AreEqual(UnivariateDistributionType.LogPearsonTypeIII, request.Distribution); + Assert.IsNull(request.ProbabilityOrdinates); + Assert.IsNull(request.BayesianOptions); + Assert.IsNull(request.Name); + Assert.IsNull(request.Description); + } + + /// + /// Verifies every property, including the nested Bayesian options, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var request = new CreateUnivariateAnalysisRequest + { + InputDataId = Guid.NewGuid(), + Distribution = UnivariateDistributionType.GeneralizedExtremeValue, + ProbabilityOrdinates = new List { 0.5, 0.01, 0.001 }, + BayesianOptions = new BayesianOptionsDto + { + Iterations = 20000, + PointEstimator = BayesianAnalysis.PointEstimateType.PosteriorMean + }, + Name = "GEV fit", + Description = "Annual maxima frequency analysis." + }; + + var copy = TestJson.Roundtrip(request); + + Assert.AreEqual(request.InputDataId, copy.InputDataId); + Assert.AreEqual(UnivariateDistributionType.GeneralizedExtremeValue, copy.Distribution); + CollectionAssert.AreEqual(request.ProbabilityOrdinates, copy.ProbabilityOrdinates); + Assert.IsNotNull(copy.BayesianOptions); + Assert.AreEqual(20000, copy.BayesianOptions.Iterations); + Assert.AreEqual(BayesianAnalysis.PointEstimateType.PosteriorMean, copy.BayesianOptions.PointEstimator); + Assert.AreEqual("GEV fit", copy.Name); + Assert.AreEqual("Annual maxima frequency analysis.", copy.Description); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names, the camelCase distribution enum + /// string, and omits null optional fields. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new CreateUnivariateAnalysisRequest()); + StringAssert.Contains(json, "\"inputDataId\""); + StringAssert.Contains(json, "\"distribution\""); + StringAssert.Contains(json, "\"logPearsonTypeIII\""); + Assert.IsFalse(json.Contains("\"name\""), "Null name should be omitted."); + Assert.IsFalse(json.Contains("\"bayesianOptions\""), "Null Bayesian options should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/CreateUsgsPeaksInputDataRequestTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/CreateUsgsPeaksInputDataRequestTests.cs new file mode 100644 index 0000000..d4534a9 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/CreateUsgsPeaksInputDataRequestTests.cs @@ -0,0 +1,63 @@ +using Numerics.Data; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and + /// camelCase wire names including string enum values. + /// + [TestClass] + public class CreateUsgsPeaksInputDataRequestTests + { + /// + /// Verifies a new request defaults to an empty site number and the peak-discharge series. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var request = new CreateUsgsPeaksInputDataRequest(); + Assert.AreEqual(string.Empty, request.SiteNumber); + Assert.AreEqual(TimeSeriesDownload.TimeSeriesType.PeakDischarge, request.SeriesType); + Assert.IsNull(request.Name); + Assert.IsNull(request.Description); + } + + /// + /// Verifies every property, including the enum-typed series type, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var request = new CreateUsgsPeaksInputDataRequest + { + SiteNumber = "01646500", + SeriesType = TimeSeriesDownload.TimeSeriesType.PeakStage, + Name = "Little Falls peaks", + Description = "Annual peak stages." + }; + + var copy = TestJson.Roundtrip(request); + + Assert.AreEqual("01646500", copy.SiteNumber); + Assert.AreEqual(TimeSeriesDownload.TimeSeriesType.PeakStage, copy.SeriesType); + Assert.AreEqual("Little Falls peaks", copy.Name); + Assert.AreEqual("Annual peak stages.", copy.Description); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and camelCase enum value strings. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new CreateUsgsPeaksInputDataRequest { SiteNumber = "01646500" }); + StringAssert.Contains(json, "\"siteNumber\""); + StringAssert.Contains(json, "\"seriesType\""); + StringAssert.Contains(json, "\"peakDischarge\""); + Assert.IsFalse(json.Contains("\"name\""), "Null name should be omitted."); + Assert.IsFalse(json.Contains("\"description\""), "Null description should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/CreateUsgsTimeSeriesRequestTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/CreateUsgsTimeSeriesRequestTests.cs new file mode 100644 index 0000000..ae65baa --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/CreateUsgsTimeSeriesRequestTests.cs @@ -0,0 +1,67 @@ +using Numerics.Data; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and + /// camelCase wire names including string enum values. + /// + [TestClass] + public class CreateUsgsTimeSeriesRequestTests + { + /// + /// Verifies a new request defaults to an empty site number and daily discharge series. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var request = new CreateUsgsTimeSeriesRequest(); + Assert.AreEqual(string.Empty, request.SiteNumber); + Assert.AreEqual(TimeSeriesDownload.TimeSeriesType.DailyDischarge, request.SeriesType); + Assert.IsNull(request.Name); + Assert.IsNull(request.Description); + } + + /// + /// Verifies every property, including the enum-typed series type, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var request = new CreateUsgsTimeSeriesRequest + { + SiteNumber = "01646500", + SeriesType = TimeSeriesDownload.TimeSeriesType.MeasuredStage, + Name = "Little Falls stage", + Description = "Field measurements for a rating curve." + }; + + var copy = TestJson.Roundtrip(request); + + Assert.AreEqual("01646500", copy.SiteNumber); + Assert.AreEqual(TimeSeriesDownload.TimeSeriesType.MeasuredStage, copy.SeriesType); + Assert.AreEqual("Little Falls stage", copy.Name); + Assert.AreEqual("Field measurements for a rating curve.", copy.Description); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and camelCase enum value strings. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new CreateUsgsTimeSeriesRequest + { + SiteNumber = "01646500", + SeriesType = TimeSeriesDownload.TimeSeriesType.PeakStage + }); + StringAssert.Contains(json, "\"siteNumber\""); + StringAssert.Contains(json, "\"seriesType\""); + StringAssert.Contains(json, "\"peakStage\""); + Assert.IsFalse(json.Contains("\"name\""), "Null name should be omitted."); + Assert.IsFalse(json.Contains("\"description\""), "Null description should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/DefaultsResponseTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/DefaultsResponseTests.cs new file mode 100644 index 0000000..80dcc5c --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/DefaultsResponseTests.cs @@ -0,0 +1,83 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip including the + /// inherited fields, and camelCase wire names. + /// + [TestClass] + public class DefaultsResponseTests + { + /// + /// Verifies a new response defaults to success with empty ordinate/note lists and zero limits. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var response = new DefaultsResponse(); + Assert.IsTrue(response.Success); + Assert.IsNotNull(response.ProbabilityOrdinates); + Assert.AreEqual(0, response.ProbabilityOrdinates.Count); + Assert.AreEqual(0, response.MaxIterations); + Assert.AreEqual(0, response.MaxConcurrentRuns); + Assert.AreEqual(0, response.MaxResources); + Assert.IsNotNull(response.Notes); + Assert.AreEqual(0, response.Notes.Count); + } + + /// + /// Verifies every property, including the inherited response fields, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var response = new DefaultsResponse + { + ProbabilityOrdinates = new List { 0.5, 0.01, 0.001 }, + MaxIterations = 100000, + MaxConcurrentRuns = 4, + MaxResources = 500, + Notes = new List { "Probabilities are AEP.", "Resources are in-memory only." }, + Success = false, + ErrorMessage = "defaults unavailable", + ValidationErrors = new List { "e1" }, + ValidationWarnings = new List { "w1" }, + ComputationTimeMs = 6, + Timestamp = "2026-07-01T00:00:00.0000000Z", + NonFiniteFindings = new List { "$.p" } + }; + + var copy = TestJson.Roundtrip(response); + + CollectionAssert.AreEqual(response.ProbabilityOrdinates, copy.ProbabilityOrdinates); + Assert.AreEqual(100000, copy.MaxIterations); + Assert.AreEqual(4, copy.MaxConcurrentRuns); + Assert.AreEqual(500, copy.MaxResources); + CollectionAssert.AreEqual(response.Notes, copy.Notes); + Assert.IsFalse(copy.Success); + Assert.AreEqual("defaults unavailable", copy.ErrorMessage); + CollectionAssert.AreEqual(response.ValidationErrors, copy.ValidationErrors); + CollectionAssert.AreEqual(response.ValidationWarnings, copy.ValidationWarnings); + Assert.AreEqual(6L, copy.ComputationTimeMs); + Assert.AreEqual("2026-07-01T00:00:00.0000000Z", copy.Timestamp); + CollectionAssert.AreEqual(response.NonFiniteFindings, copy.NonFiniteFindings); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new DefaultsResponse()); + StringAssert.Contains(json, "\"probabilityOrdinates\""); + StringAssert.Contains(json, "\"maxIterations\""); + StringAssert.Contains(json, "\"maxConcurrentRuns\""); + StringAssert.Contains(json, "\"maxResources\""); + StringAssert.Contains(json, "\"notes\""); + Assert.IsFalse(json.Contains("\"errorMessage\""), "Null errorMessage should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/DeleteResourceResponseTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/DeleteResourceResponseTests.cs new file mode 100644 index 0000000..e732e70 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/DeleteResourceResponseTests.cs @@ -0,0 +1,72 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip including the + /// inherited fields, and camelCase wire names. + /// + [TestClass] + public class DeleteResourceResponseTests + { + /// + /// Verifies a new response defaults to success with an empty id and no resource type. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var response = new DeleteResourceResponse(); + Assert.IsTrue(response.Success); + Assert.AreEqual(Guid.Empty, response.DeletedId); + Assert.IsNull(response.ResourceType); + } + + /// + /// Verifies every property, including the inherited response fields, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var response = new DeleteResourceResponse + { + DeletedId = Guid.NewGuid(), + ResourceType = "timeSeries", + Success = false, + ErrorMessage = "delete failed", + ValidationErrors = new List { "bad id" }, + ValidationWarnings = new List { "warn" }, + ComputationTimeMs = 42, + Timestamp = "2026-07-01T00:00:00.0000000Z", + NonFiniteFindings = new List { "$.x" } + }; + + var copy = TestJson.Roundtrip(response); + + Assert.AreEqual(response.DeletedId, copy.DeletedId); + Assert.AreEqual("timeSeries", copy.ResourceType); + Assert.IsFalse(copy.Success); + Assert.AreEqual("delete failed", copy.ErrorMessage); + CollectionAssert.AreEqual(response.ValidationErrors, copy.ValidationErrors); + CollectionAssert.AreEqual(response.ValidationWarnings, copy.ValidationWarnings); + Assert.AreEqual(42L, copy.ComputationTimeMs); + Assert.AreEqual("2026-07-01T00:00:00.0000000Z", copy.Timestamp); + CollectionAssert.AreEqual(response.NonFiniteFindings, copy.NonFiniteFindings); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits the null resource type. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new DeleteResourceResponse { ResourceType = "inputData" }); + StringAssert.Contains(json, "\"deletedId\""); + StringAssert.Contains(json, "\"resourceType\""); + StringAssert.Contains(json, "\"success\""); + + string defaultJson = TestJson.Serialize(new DeleteResourceResponse()); + Assert.IsFalse(defaultJson.Contains("\"resourceType\""), "Null resourceType should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/DiagnosticsDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/DiagnosticsDtoTests.cs new file mode 100644 index 0000000..f55f082 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/DiagnosticsDtoTests.cs @@ -0,0 +1,77 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and camelCase + /// wire names. + /// + [TestClass] + public class DiagnosticsDtoTests + { + /// + /// Verifies a new diagnostics object defaults to null sampler settings (non-chain methods) + /// with an empty convergence-warning list. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var diagnostics = new DiagnosticsDto(); + Assert.IsNull(diagnostics.Sampler); + Assert.IsNull(diagnostics.Iterations); + Assert.IsNull(diagnostics.WarmupIterations); + Assert.IsNull(diagnostics.NumberOfChains); + Assert.IsNull(diagnostics.AcceptanceRates); + Assert.IsNotNull(diagnostics.ConvergenceWarnings); + Assert.AreEqual(0, diagnostics.ConvergenceWarnings.Count); + Assert.IsNull(diagnostics.ElapsedMs); + } + + /// + /// Verifies every property, including the per-chain acceptance rates, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var diagnostics = new DiagnosticsDto + { + Sampler = "demCzs", + Iterations = 20000, + WarmupIterations = 10000, + NumberOfChains = 4, + AcceptanceRates = new List { 0.28, 0.31, 0.27, 0.3 }, + ConvergenceWarnings = new List { "R-hat above 1.1 for Sigma." }, + ElapsedMs = 4210 + }; + + var copy = TestJson.Roundtrip(diagnostics); + + Assert.AreEqual("demCzs", copy.Sampler); + Assert.AreEqual(20000, copy.Iterations); + Assert.AreEqual(10000, copy.WarmupIterations); + Assert.AreEqual(4, copy.NumberOfChains); + CollectionAssert.AreEqual(diagnostics.AcceptanceRates, copy.AcceptanceRates); + CollectionAssert.AreEqual(diagnostics.ConvergenceWarnings, copy.ConvergenceWarnings); + Assert.AreEqual(4210L, copy.ElapsedMs); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null fields. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new DiagnosticsDto + { + Sampler = "demCzs", + ElapsedMs = 15 + }); + StringAssert.Contains(json, "\"sampler\""); + StringAssert.Contains(json, "\"convergenceWarnings\""); + StringAssert.Contains(json, "\"elapsedMs\""); + Assert.IsFalse(json.Contains("\"acceptanceRates\""), "Null acceptanceRates should be omitted."); + Assert.IsFalse(json.Contains("\"iterations\""), "Null iterations should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/DistributionFitDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/DistributionFitDtoTests.cs new file mode 100644 index 0000000..6aa01b4 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/DistributionFitDtoTests.cs @@ -0,0 +1,73 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and camelCase + /// wire names. + /// + [TestClass] + public class DistributionFitDtoTests + { + /// + /// Verifies the defaults: unranked, unnamed, unsuccessful with null criteria. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var fit = new DistributionFitDto(); + Assert.AreEqual(0, fit.Rank); + Assert.IsNull(fit.Distribution); + Assert.IsNull(fit.DisplayName); + Assert.IsNull(fit.Parameters); + Assert.IsNull(fit.Aic); + Assert.IsNull(fit.Bic); + Assert.IsNull(fit.Rmse); + Assert.IsFalse(fit.FitSucceeded); + Assert.IsNull(fit.ErrorMessage); + } + + /// + /// Verifies every property survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var fit = new DistributionFitDto + { + Rank = 1, + Distribution = "logPearsonTypeIII", + DisplayName = "Log-Pearson Type III", + Parameters = new List { new() { Name = "Mean (of log)", Value = 3.5 } }, + Aic = 250.4, + Bic = 255.1, + Rmse = 12.3, + FitSucceeded = true + }; + + var copy = TestJson.Roundtrip(fit); + + Assert.AreEqual(1, copy.Rank); + Assert.AreEqual("logPearsonTypeIII", copy.Distribution); + Assert.AreEqual(1, copy.Parameters!.Count); + Assert.AreEqual(250.4, copy.Aic); + Assert.AreEqual(255.1, copy.Bic); + Assert.AreEqual(12.3, copy.Rmse); + Assert.IsTrue(copy.FitSucceeded); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits nulls. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new DistributionFitDto { Rank = 2, Distribution = "gumbel", FitSucceeded = true }); + StringAssert.Contains(json, "\"rank\""); + StringAssert.Contains(json, "\"distribution\""); + StringAssert.Contains(json, "\"fitSucceeded\""); + Assert.IsFalse(json.Contains("\"errorMessage\""), "Null error message should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/DistributionFittingResultsResponseTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/DistributionFittingResultsResponseTests.cs new file mode 100644 index 0000000..cb473a1 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/DistributionFittingResultsResponseTests.cs @@ -0,0 +1,63 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON + /// round-trip, and camelCase wire names. + /// + [TestClass] + public class DistributionFittingResultsResponseTests + { + /// + /// Verifies the defaults: empty fits list and inherited response-envelope defaults. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var response = new DistributionFittingResultsResponse(); + Assert.AreEqual(Guid.Empty, response.AnalysisId); + Assert.IsNull(response.Kind); + Assert.AreEqual(0, response.Fits.Count); + Assert.IsTrue(response.Success); + } + + /// + /// Verifies the fits survive a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var response = new DistributionFittingResultsResponse + { + AnalysisId = Guid.NewGuid(), + Kind = "distributionFitting", + Fits = new List + { + new() { Rank = 1, Distribution = "gumbel", FitSucceeded = true, Aic = 100d }, + new() { Rank = 2, Distribution = "normal", FitSucceeded = true, Aic = 105d } + } + }; + + var copy = TestJson.Roundtrip(response); + + Assert.AreEqual(response.AnalysisId, copy.AnalysisId); + Assert.AreEqual("distributionFitting", copy.Kind); + Assert.AreEqual(2, copy.Fits.Count); + Assert.AreEqual("gumbel", copy.Fits[0].Distribution); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new DistributionFittingResultsResponse { Kind = "distributionFitting" }); + StringAssert.Contains(json, "\"analysisId\""); + StringAssert.Contains(json, "\"kind\""); + StringAssert.Contains(json, "\"fits\""); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/DistributionInfoDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/DistributionInfoDtoTests.cs new file mode 100644 index 0000000..c918bf6 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/DistributionInfoDtoTests.cs @@ -0,0 +1,64 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and camelCase wire names. + /// + [TestClass] + public class DistributionInfoDtoTests + { + /// + /// Verifies a new entry defaults to null names with an empty supported-by list. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var dto = new DistributionInfoDto(); + Assert.IsNull(dto.Name); + Assert.IsNull(dto.DisplayName); + Assert.IsNotNull(dto.SupportedBy); + Assert.AreEqual(0, dto.SupportedBy.Count); + } + + /// + /// Verifies every property survives a JSON round-trip with the API's wire options. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var dto = new DistributionInfoDto + { + Name = "logPearsonTypeIII", + DisplayName = "Log-Pearson Type III", + SupportedBy = new List { "univariate", "bulletin17c" } + }; + + var copy = TestJson.Roundtrip(dto); + + Assert.AreEqual("logPearsonTypeIII", copy.Name); + Assert.AreEqual("Log-Pearson Type III", copy.DisplayName); + CollectionAssert.AreEqual(dto.SupportedBy, copy.SupportedBy); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null names. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new DistributionInfoDto + { + Name = "gumbel", + DisplayName = "Gumbel" + }); + StringAssert.Contains(json, "\"name\""); + StringAssert.Contains(json, "\"displayName\""); + StringAssert.Contains(json, "\"supportedBy\""); + + string defaultJson = TestJson.Serialize(new DistributionInfoDto()); + Assert.IsFalse(defaultJson.Contains("\"displayName\""), "Null displayName should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/DistributionSpecDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/DistributionSpecDtoTests.cs new file mode 100644 index 0000000..7ddde94 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/DistributionSpecDtoTests.cs @@ -0,0 +1,60 @@ +using Numerics.Distributions; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and camelCase + /// wire names with string enum values. + /// + [TestClass] + public class DistributionSpecDtoTests + { + /// + /// Verifies the defaults: normal type with an empty parameter list. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var spec = new DistributionSpecDto(); + Assert.AreEqual(UnivariateDistributionType.Normal, spec.Type); + Assert.IsNotNull(spec.Parameters); + Assert.AreEqual(0, spec.Parameters.Count); + } + + /// + /// Verifies the type and parameter values survive a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var spec = new DistributionSpecDto + { + Type = UnivariateDistributionType.Triangular, + Parameters = new List { 10d, 20d, 45d } + }; + + var copy = TestJson.Roundtrip(spec); + + Assert.AreEqual(UnivariateDistributionType.Triangular, copy.Type); + CollectionAssert.AreEqual(new List { 10d, 20d, 45d }, copy.Parameters); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and a camelCase enum string. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new DistributionSpecDto + { + Type = UnivariateDistributionType.LogNormal, + Parameters = new List { 3.5, 0.25 } + }); + StringAssert.Contains(json, "\"type\""); + StringAssert.Contains(json, "\"logNormal\""); + StringAssert.Contains(json, "\"parameters\""); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/DistributionsResponseTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/DistributionsResponseTests.cs new file mode 100644 index 0000000..1c6f961 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/DistributionsResponseTests.cs @@ -0,0 +1,78 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip including the + /// inherited fields, and camelCase wire names. + /// + [TestClass] + public class DistributionsResponseTests + { + /// + /// Verifies a new response defaults to success with an empty distribution list. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var response = new DistributionsResponse(); + Assert.IsTrue(response.Success); + Assert.IsNotNull(response.Distributions); + Assert.AreEqual(0, response.Distributions.Count); + } + + /// + /// Verifies every property, including the inherited response fields, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var response = new DistributionsResponse + { + Distributions = new List + { + new DistributionInfoDto + { + Name = "logPearsonTypeIII", + DisplayName = "Log-Pearson Type III", + SupportedBy = new List { "univariate", "bulletin17c" } + } + }, + Success = false, + ErrorMessage = "discovery failed", + ValidationErrors = new List { "e1" }, + ValidationWarnings = new List { "w1" }, + ComputationTimeMs = 2, + Timestamp = "2026-07-01T00:00:00.0000000Z", + NonFiniteFindings = new List { "$.d" } + }; + + var copy = TestJson.Roundtrip(response); + + Assert.AreEqual(1, copy.Distributions.Count); + Assert.AreEqual("logPearsonTypeIII", copy.Distributions[0].Name); + Assert.AreEqual("Log-Pearson Type III", copy.Distributions[0].DisplayName); + CollectionAssert.AreEqual(response.Distributions[0].SupportedBy, copy.Distributions[0].SupportedBy); + Assert.IsFalse(copy.Success); + Assert.AreEqual("discovery failed", copy.ErrorMessage); + CollectionAssert.AreEqual(response.ValidationErrors, copy.ValidationErrors); + CollectionAssert.AreEqual(response.ValidationWarnings, copy.ValidationWarnings); + Assert.AreEqual(2L, copy.ComputationTimeMs); + Assert.AreEqual("2026-07-01T00:00:00.0000000Z", copy.Timestamp); + CollectionAssert.AreEqual(response.NonFiniteFindings, copy.NonFiniteFindings); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new DistributionsResponse()); + StringAssert.Contains(json, "\"distributions\""); + StringAssert.Contains(json, "\"success\""); + Assert.IsFalse(json.Contains("\"errorMessage\""), "Null errorMessage should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/EnumOptionsResponseTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/EnumOptionsResponseTests.cs new file mode 100644 index 0000000..bcf21c7 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/EnumOptionsResponseTests.cs @@ -0,0 +1,106 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip including the + /// inherited fields, and camelCase wire names. + /// + [TestClass] + public class EnumOptionsResponseTests + { + /// + /// Verifies a new response defaults to success with all ten option lists empty. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var response = new EnumOptionsResponse(); + Assert.IsTrue(response.Success); + Assert.IsNotNull(response.Samplers); + Assert.AreEqual(0, response.Samplers.Count); + Assert.IsNotNull(response.PointEstimators); + Assert.AreEqual(0, response.PointEstimators.Count); + Assert.IsNotNull(response.UncertaintyMethods); + Assert.AreEqual(0, response.UncertaintyMethods.Count); + Assert.IsNotNull(response.TimeBlockWindows); + Assert.AreEqual(0, response.TimeBlockWindows.Count); + Assert.IsNotNull(response.BlockFunctions); + Assert.AreEqual(0, response.BlockFunctions.Count); + Assert.IsNotNull(response.SmoothingFunctions); + Assert.AreEqual(0, response.SmoothingFunctions.Count); + Assert.IsNotNull(response.UsgsSeriesTypes); + Assert.AreEqual(0, response.UsgsSeriesTypes.Count); + Assert.IsNotNull(response.TimeIntervals); + Assert.AreEqual(0, response.TimeIntervals.Count); + Assert.IsNotNull(response.AnalysisKinds); + Assert.AreEqual(0, response.AnalysisKinds.Count); + Assert.IsNotNull(response.InputDataMethods); + Assert.AreEqual(0, response.InputDataMethods.Count); + } + + /// + /// Verifies every option list, plus the inherited response fields, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var response = new EnumOptionsResponse + { + Samplers = new List { "demCzs" }, + PointEstimators = new List { "posteriorMean", "posteriorMode" }, + UncertaintyMethods = new List { "multivariateNormal" }, + TimeBlockWindows = new List { "waterYear", "calendarYear" }, + BlockFunctions = new List { "maximum" }, + SmoothingFunctions = new List { "none", "movingAverage" }, + UsgsSeriesTypes = new List { "dailyDischarge", "peakDischarge" }, + TimeIntervals = new List { "oneDay", "irregular" }, + AnalysisKinds = new List { "univariate", "bulletin17c" }, + InputDataMethods = new List { "manual", "blockMaxima" }, + Success = false, + ErrorMessage = "options unavailable", + ValidationErrors = new List { "e1" }, + ValidationWarnings = new List { "w1" }, + ComputationTimeMs = 1, + Timestamp = "2026-07-01T00:00:00.0000000Z", + NonFiniteFindings = new List { "$.n" } + }; + + var copy = TestJson.Roundtrip(response); + + CollectionAssert.AreEqual(response.Samplers, copy.Samplers); + CollectionAssert.AreEqual(response.PointEstimators, copy.PointEstimators); + CollectionAssert.AreEqual(response.UncertaintyMethods, copy.UncertaintyMethods); + CollectionAssert.AreEqual(response.TimeBlockWindows, copy.TimeBlockWindows); + CollectionAssert.AreEqual(response.BlockFunctions, copy.BlockFunctions); + CollectionAssert.AreEqual(response.SmoothingFunctions, copy.SmoothingFunctions); + CollectionAssert.AreEqual(response.UsgsSeriesTypes, copy.UsgsSeriesTypes); + CollectionAssert.AreEqual(response.TimeIntervals, copy.TimeIntervals); + CollectionAssert.AreEqual(response.AnalysisKinds, copy.AnalysisKinds); + CollectionAssert.AreEqual(response.InputDataMethods, copy.InputDataMethods); + Assert.IsFalse(copy.Success); + Assert.AreEqual("options unavailable", copy.ErrorMessage); + CollectionAssert.AreEqual(response.ValidationErrors, copy.ValidationErrors); + CollectionAssert.AreEqual(response.ValidationWarnings, copy.ValidationWarnings); + Assert.AreEqual(1L, copy.ComputationTimeMs); + Assert.AreEqual("2026-07-01T00:00:00.0000000Z", copy.Timestamp); + CollectionAssert.AreEqual(response.NonFiniteFindings, copy.NonFiniteFindings); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new EnumOptionsResponse()); + StringAssert.Contains(json, "\"samplers\""); + StringAssert.Contains(json, "\"pointEstimators\""); + StringAssert.Contains(json, "\"timeBlockWindows\""); + StringAssert.Contains(json, "\"usgsSeriesTypes\""); + StringAssert.Contains(json, "\"inputDataMethods\""); + Assert.IsFalse(json.Contains("\"errorMessage\""), "Null errorMessage should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/ExactObservationDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/ExactObservationDtoTests.cs new file mode 100644 index 0000000..9e59095 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/ExactObservationDtoTests.cs @@ -0,0 +1,68 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and camelCase wire names. + /// + [TestClass] + public class ExactObservationDtoTests + { + /// + /// Verifies a new observation defaults to no index, a zero value, and no plotting position. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var dto = new ExactObservationDto(); + Assert.IsNull(dto.Index); + Assert.IsNull(dto.DateTime); + Assert.AreEqual(0.0, dto.Value); + Assert.IsFalse(dto.IsLowOutlier); + Assert.IsNull(dto.PlottingPosition); + } + + /// + /// Verifies every property survives a JSON round-trip with the API's wire options. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var dto = new ExactObservationDto + { + Index = 1996, + DateTime = new DateTime(1996, 1, 19), + Value = 15200.5, + IsLowOutlier = true, + PlottingPosition = 0.25 + }; + + var copy = TestJson.Roundtrip(dto); + + Assert.AreEqual(1996, copy.Index); + Assert.AreEqual(new DateTime(1996, 1, 19), copy.DateTime); + Assert.AreEqual(15200.5, copy.Value); + Assert.IsTrue(copy.IsLowOutlier); + Assert.AreEqual(0.25, copy.PlottingPosition); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null optional fields. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new ExactObservationDto { Index = 1996, PlottingPosition = 0.5 }); + StringAssert.Contains(json, "\"index\""); + StringAssert.Contains(json, "\"value\""); + StringAssert.Contains(json, "\"isLowOutlier\""); + StringAssert.Contains(json, "\"plottingPosition\""); + + string defaultJson = TestJson.Serialize(new ExactObservationDto()); + Assert.IsFalse(defaultJson.Contains("\"index\""), "Null index should be omitted."); + Assert.IsFalse(defaultJson.Contains("\"dateTime\""), "Null dateTime should be omitted."); + Assert.IsFalse(defaultJson.Contains("\"plottingPosition\""), "Null plottingPosition should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/FittedDistributionDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/FittedDistributionDtoTests.cs new file mode 100644 index 0000000..f5280a8 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/FittedDistributionDtoTests.cs @@ -0,0 +1,75 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and + /// camelCase wire names. + /// + [TestClass] + public class FittedDistributionDtoTests + { + /// + /// Verifies a new fitted distribution defaults to no type, display name, or point + /// estimator and an empty parameter list. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var fitted = new FittedDistributionDto(); + Assert.IsNull(fitted.Type); + Assert.IsNull(fitted.DisplayName); + Assert.IsNull(fitted.PointEstimator); + Assert.IsNotNull(fitted.Parameters); + Assert.AreEqual(0, fitted.Parameters.Count); + } + + /// + /// Verifies every property, including the nested parameter values, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var fitted = new FittedDistributionDto + { + Type = "logPearsonTypeIII", + DisplayName = "Log-Pearson Type III", + PointEstimator = "posteriorMode", + Parameters = new List + { + new ParameterValueDto { Name = "Mu", Value = 3.52 }, + new ParameterValueDto { Name = "Sigma", Value = 0.24 } + } + }; + + var copy = TestJson.Roundtrip(fitted); + + Assert.AreEqual("logPearsonTypeIII", copy.Type); + Assert.AreEqual("Log-Pearson Type III", copy.DisplayName); + Assert.AreEqual("posteriorMode", copy.PointEstimator); + Assert.AreEqual(2, copy.Parameters.Count); + Assert.AreEqual("Mu", copy.Parameters[0].Name); + Assert.AreEqual(3.52, copy.Parameters[0].Value); + Assert.AreEqual("Sigma", copy.Parameters[1].Name); + Assert.AreEqual(0.24, copy.Parameters[1].Value); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null optional fields. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new FittedDistributionDto + { + Type = "gumbel", + PointEstimator = "posteriorMean" + }); + StringAssert.Contains(json, "\"type\""); + StringAssert.Contains(json, "\"pointEstimator\""); + StringAssert.Contains(json, "\"parameters\""); + Assert.IsFalse(json.Contains("\"displayName\""), "Null displayName should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/FrequencyCurveDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/FrequencyCurveDtoTests.cs new file mode 100644 index 0000000..a4acca2 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/FrequencyCurveDtoTests.cs @@ -0,0 +1,73 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and camelCase + /// wire names. + /// + [TestClass] + public class FrequencyCurveDtoTests + { + /// + /// Verifies a new curve defaults to an empty probability grid with no curves and a zero + /// credible-interval width. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var curve = new FrequencyCurveDto(); + Assert.IsNotNull(curve.Probabilities); + Assert.AreEqual(0, curve.Probabilities.Count); + Assert.IsNull(curve.ModeCurve); + Assert.IsNull(curve.MeanCurve); + Assert.IsNull(curve.CiLower); + Assert.IsNull(curve.CiUpper); + Assert.AreEqual(0.0, curve.CredibleIntervalWidth); + } + + /// + /// Verifies every property, including all four aligned curves, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var curve = new FrequencyCurveDto + { + Probabilities = new List { 0.5, 0.01 }, + ModeCurve = new List { 3300.0, 15400.0 }, + MeanCurve = new List { 3350.0, 16100.0 }, + CiLower = new List { 2800.0, 11200.0 }, + CiUpper = new List { 3900.0, 24500.0 }, + CredibleIntervalWidth = 0.9 + }; + + var copy = TestJson.Roundtrip(curve); + + CollectionAssert.AreEqual(curve.Probabilities, copy.Probabilities); + CollectionAssert.AreEqual(curve.ModeCurve, copy.ModeCurve); + CollectionAssert.AreEqual(curve.MeanCurve, copy.MeanCurve); + CollectionAssert.AreEqual(curve.CiLower, copy.CiLower); + CollectionAssert.AreEqual(curve.CiUpper, copy.CiUpper); + Assert.AreEqual(0.9, copy.CredibleIntervalWidth); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null curves. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new FrequencyCurveDto + { + ModeCurve = new List { 1.0 } + }); + StringAssert.Contains(json, "\"probabilities\""); + StringAssert.Contains(json, "\"modeCurve\""); + StringAssert.Contains(json, "\"credibleIntervalWidth\""); + Assert.IsFalse(json.Contains("\"ciLower\""), "Null ciLower should be omitted."); + Assert.IsFalse(json.Contains("\"meanCurve\""), "Null meanCurve should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/FrequencyResultsResponseTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/FrequencyResultsResponseTests.cs new file mode 100644 index 0000000..2094e59 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/FrequencyResultsResponseTests.cs @@ -0,0 +1,126 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip including + /// the inherited fields, and camelCase wire names. + /// + [TestClass] + public class FrequencyResultsResponseTests + { + /// + /// Verifies a new response defaults to success with no results sections and an empty + /// parameter-summary list. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var response = new FrequencyResultsResponse(); + Assert.IsTrue(response.Success); + Assert.AreEqual(Guid.Empty, response.AnalysisId); + Assert.IsNull(response.Kind); + Assert.IsNull(response.FittedDistribution); + Assert.IsNull(response.FrequencyCurve); + Assert.IsNotNull(response.ParameterSummaries); + Assert.AreEqual(0, response.ParameterSummaries.Count); + Assert.IsNull(response.InformationCriteria); + Assert.IsNull(response.Diagnostics); + Assert.IsNull(response.Bulletin17C); + } + + /// + /// Verifies every property, including all nested result sections and the inherited + /// response fields, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var response = new FrequencyResultsResponse + { + AnalysisId = Guid.NewGuid(), + Kind = "bulletin17C", + FittedDistribution = new FittedDistributionDto + { + Type = "logPearsonTypeIII", + Parameters = new List + { + new ParameterValueDto { Name = "Mu", Value = 3.52 } + } + }, + FrequencyCurve = new FrequencyCurveDto + { + Probabilities = new List { 0.5, 0.01 }, + ModeCurve = new List { 3300.0, 15400.0 }, + CredibleIntervalWidth = 0.9 + }, + ParameterSummaries = new List + { + new ParameterSummaryDto { Name = "Mu", Mean = 3.52, Rhat = 1.001 } + }, + InformationCriteria = new InformationCriteriaDto { Aic = 512.4, Erl = 96.5 }, + Diagnostics = new DiagnosticsDto { Sampler = "demCzs", ElapsedMs = 4210 }, + Bulletin17C = new Bulletin17CInfoDto { UncertaintyMethod = "bootstrap", GmmElapsedMs = 84 }, + Success = false, + ErrorMessage = "results stale", + ValidationErrors = new List { "e1" }, + ValidationWarnings = new List { "w1" }, + ComputationTimeMs = 11, + Timestamp = "2026-07-01T00:00:00.0000000Z", + NonFiniteFindings = new List { "$.frequencyCurve.ciUpper[1]" } + }; + + var copy = TestJson.Roundtrip(response); + + Assert.AreEqual(response.AnalysisId, copy.AnalysisId); + Assert.AreEqual("bulletin17C", copy.Kind); + Assert.IsNotNull(copy.FittedDistribution); + Assert.AreEqual("logPearsonTypeIII", copy.FittedDistribution.Type); + Assert.AreEqual(1, copy.FittedDistribution.Parameters.Count); + Assert.AreEqual(3.52, copy.FittedDistribution.Parameters[0].Value); + Assert.IsNotNull(copy.FrequencyCurve); + CollectionAssert.AreEqual(response.FrequencyCurve.Probabilities, copy.FrequencyCurve.Probabilities); + CollectionAssert.AreEqual(response.FrequencyCurve.ModeCurve, copy.FrequencyCurve.ModeCurve); + Assert.AreEqual(0.9, copy.FrequencyCurve.CredibleIntervalWidth); + Assert.AreEqual(1, copy.ParameterSummaries.Count); + Assert.AreEqual("Mu", copy.ParameterSummaries[0].Name); + Assert.AreEqual(1.001, copy.ParameterSummaries[0].Rhat); + Assert.IsNotNull(copy.InformationCriteria); + Assert.AreEqual(512.4, copy.InformationCriteria.Aic); + Assert.AreEqual(96.5, copy.InformationCriteria.Erl); + Assert.IsNotNull(copy.Diagnostics); + Assert.AreEqual("demCzs", copy.Diagnostics.Sampler); + Assert.AreEqual(4210L, copy.Diagnostics.ElapsedMs); + Assert.IsNotNull(copy.Bulletin17C); + Assert.AreEqual("bootstrap", copy.Bulletin17C.UncertaintyMethod); + Assert.AreEqual(84L, copy.Bulletin17C.GmmElapsedMs); + Assert.IsFalse(copy.Success); + Assert.AreEqual("results stale", copy.ErrorMessage); + CollectionAssert.AreEqual(response.ValidationErrors, copy.ValidationErrors); + CollectionAssert.AreEqual(response.ValidationWarnings, copy.ValidationWarnings); + Assert.AreEqual(11L, copy.ComputationTimeMs); + Assert.AreEqual("2026-07-01T00:00:00.0000000Z", copy.Timestamp); + CollectionAssert.AreEqual(response.NonFiniteFindings, copy.NonFiniteFindings); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null result sections. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new FrequencyResultsResponse + { + Kind = "univariate", + FrequencyCurve = new FrequencyCurveDto() + }); + StringAssert.Contains(json, "\"analysisId\""); + StringAssert.Contains(json, "\"frequencyCurve\""); + StringAssert.Contains(json, "\"parameterSummaries\""); + StringAssert.Contains(json, "\"success\""); + Assert.IsFalse(json.Contains("\"bulletin17C\""), "Null bulletin17C should be omitted."); + Assert.IsFalse(json.Contains("\"fittedDistribution\""), "Null fittedDistribution should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/HealthCheckDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/HealthCheckDtoTests.cs new file mode 100644 index 0000000..d391536 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/HealthCheckDtoTests.cs @@ -0,0 +1,59 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and camelCase wire names. + /// + [TestClass] + public class HealthCheckDtoTests + { + /// + /// Verifies a new health check defaults to a "healthy" status with no version. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var dto = new HealthCheckDto(); + Assert.AreEqual("healthy", dto.Status); + Assert.AreEqual(default, dto.Timestamp); + Assert.IsNull(dto.Version); + } + + /// + /// Verifies every property survives a JSON round-trip with the API's wire options. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var dto = new HealthCheckDto + { + Status = "degraded", + Timestamp = new DateTime(2026, 7, 1, 12, 30, 0, DateTimeKind.Utc), + Version = "2.0.0.0" + }; + + var copy = TestJson.Roundtrip(dto); + + Assert.AreEqual("degraded", copy.Status); + Assert.AreEqual(dto.Timestamp, copy.Timestamp); + Assert.AreEqual("2.0.0.0", copy.Version); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits the null version. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new HealthCheckDto { Version = "2.0.0.0" }); + StringAssert.Contains(json, "\"status\""); + StringAssert.Contains(json, "\"timestamp\""); + StringAssert.Contains(json, "\"version\""); + + string defaultJson = TestJson.Serialize(new HealthCheckDto()); + Assert.IsFalse(defaultJson.Contains("\"version\""), "Null version should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/InformationCriteriaDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/InformationCriteriaDtoTests.cs new file mode 100644 index 0000000..bd898ab --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/InformationCriteriaDtoTests.cs @@ -0,0 +1,85 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and + /// camelCase wire names. + /// + [TestClass] + public class InformationCriteriaDtoTests + { + /// + /// Verifies every criterion defaults to null so values undefined for the estimation + /// method are omitted. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var criteria = new InformationCriteriaDto(); + Assert.IsNull(criteria.Aic); + Assert.IsNull(criteria.Bic); + Assert.IsNull(criteria.Dic); + Assert.IsNull(criteria.Waic); + Assert.IsNull(criteria.WaicPD); + Assert.IsNull(criteria.Looic); + Assert.IsNull(criteria.LooicSE); + Assert.IsNull(criteria.Rmse); + Assert.IsNull(criteria.Erl); + } + + /// + /// Verifies every criterion survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var criteria = new InformationCriteriaDto + { + Aic = 512.4, + Bic = 518.9, + Dic = 510.2, + Waic = 511.7, + WaicPD = 2.9, + Looic = 511.9, + LooicSE = 7.4, + Rmse = 1250.5, + Erl = 96.5 + }; + + var copy = TestJson.Roundtrip(criteria); + + Assert.AreEqual(512.4, copy.Aic); + Assert.AreEqual(518.9, copy.Bic); + Assert.AreEqual(510.2, copy.Dic); + Assert.AreEqual(511.7, copy.Waic); + Assert.AreEqual(2.9, copy.WaicPD); + Assert.AreEqual(511.9, copy.Looic); + Assert.AreEqual(7.4, copy.LooicSE); + Assert.AreEqual(1250.5, copy.Rmse); + Assert.AreEqual(96.5, copy.Erl); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null criteria. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new InformationCriteriaDto + { + Aic = 1.0, + WaicPD = 2.0, + LooicSE = 3.0 + }); + StringAssert.Contains(json, "\"aic\""); + StringAssert.Contains(json, "\"waicPD\""); + StringAssert.Contains(json, "\"looicSE\""); + Assert.IsFalse(json.Contains("\"rmse\""), "Null rmse should be omitted."); + + string defaultJson = TestJson.Serialize(new InformationCriteriaDto()); + Assert.AreEqual("{}", defaultJson, "An all-default criteria object should serialize empty."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/InputDataListResponseTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/InputDataListResponseTests.cs new file mode 100644 index 0000000..687715f --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/InputDataListResponseTests.cs @@ -0,0 +1,77 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip including the + /// inherited fields, and camelCase wire names. + /// + [TestClass] + public class InputDataListResponseTests + { + /// + /// Verifies a new listing defaults to success with a zero count and an empty summary list. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var response = new InputDataListResponse(); + Assert.IsTrue(response.Success); + Assert.AreEqual(0, response.Count); + Assert.IsNotNull(response.InputData); + Assert.AreEqual(0, response.InputData.Count); + } + + /// + /// Verifies every property, including the inherited response fields, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var response = new InputDataListResponse + { + Count = 1, + InputData = new List + { + new InputDataSummaryDto { Id = Guid.NewGuid(), Name = "Record A", RecordLength = 96 } + }, + Success = false, + ErrorMessage = "listing incomplete", + ValidationErrors = new List { "e1" }, + ValidationWarnings = new List { "w1" }, + ComputationTimeMs = 4, + Timestamp = "2026-07-01T00:00:00.0000000Z", + NonFiniteFindings = new List { "$.q" } + }; + + var copy = TestJson.Roundtrip(response); + + Assert.AreEqual(1, copy.Count); + Assert.AreEqual(1, copy.InputData.Count); + Assert.AreEqual(response.InputData[0].Id, copy.InputData[0].Id); + Assert.AreEqual("Record A", copy.InputData[0].Name); + Assert.AreEqual(96, copy.InputData[0].RecordLength); + Assert.IsFalse(copy.Success); + Assert.AreEqual("listing incomplete", copy.ErrorMessage); + CollectionAssert.AreEqual(response.ValidationErrors, copy.ValidationErrors); + CollectionAssert.AreEqual(response.ValidationWarnings, copy.ValidationWarnings); + Assert.AreEqual(4L, copy.ComputationTimeMs); + Assert.AreEqual("2026-07-01T00:00:00.0000000Z", copy.Timestamp); + CollectionAssert.AreEqual(response.NonFiniteFindings, copy.NonFiniteFindings); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new InputDataListResponse()); + StringAssert.Contains(json, "\"count\""); + StringAssert.Contains(json, "\"inputData\""); + StringAssert.Contains(json, "\"success\""); + Assert.IsFalse(json.Contains("\"errorMessage\""), "Null errorMessage should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/InputDataResourceResponseTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/InputDataResourceResponseTests.cs new file mode 100644 index 0000000..6c7fde4 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/InputDataResourceResponseTests.cs @@ -0,0 +1,97 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip including + /// the inherited fields, and camelCase wire names. + /// + [TestClass] + public class InputDataResourceResponseTests + { + /// + /// Verifies a new response defaults to success with no summary or observation lists. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var response = new InputDataResourceResponse(); + Assert.IsTrue(response.Success); + Assert.IsNull(response.InputData); + Assert.IsNull(response.ExactData); + Assert.IsNull(response.IntervalData); + Assert.IsNull(response.ThresholdData); + } + + /// + /// Verifies every property, including the inherited response fields, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var response = new InputDataResourceResponse + { + InputData = new InputDataSummaryDto { Id = Guid.NewGuid(), Name = "Annual maxima", ExactCount = 1 }, + ExactData = new List + { + new ExactObservationDto { Index = 1996, Value = 15200.5, PlottingPosition = 0.25 } + }, + IntervalData = new List + { + new IntervalObservationDto { Index = 1889, LowerBound = 90000.0, UpperBound = 110000.0 } + }, + ThresholdData = new List + { + new ThresholdObservationDto { StartIndex = 1861, EndIndex = 1929, Value = 85000.0 } + }, + Success = false, + ErrorMessage = "partial data", + ValidationErrors = new List { "e1" }, + ValidationWarnings = new List { "w1" }, + ComputationTimeMs = 11, + Timestamp = "2026-07-01T00:00:00.0000000Z", + NonFiniteFindings = new List { "$.exactData[0].value" } + }; + + var copy = TestJson.Roundtrip(response); + + Assert.IsNotNull(copy.InputData); + Assert.AreEqual(response.InputData.Id, copy.InputData.Id); + Assert.AreEqual("Annual maxima", copy.InputData.Name); + Assert.IsNotNull(copy.ExactData); + Assert.AreEqual(15200.5, copy.ExactData[0].Value); + Assert.AreEqual(0.25, copy.ExactData[0].PlottingPosition); + Assert.IsNotNull(copy.IntervalData); + Assert.AreEqual(110000.0, copy.IntervalData[0].UpperBound); + Assert.IsNotNull(copy.ThresholdData); + Assert.AreEqual(85000.0, copy.ThresholdData[0].Value); + Assert.IsFalse(copy.Success); + Assert.AreEqual("partial data", copy.ErrorMessage); + CollectionAssert.AreEqual(response.ValidationErrors, copy.ValidationErrors); + CollectionAssert.AreEqual(response.ValidationWarnings, copy.ValidationWarnings); + Assert.AreEqual(11L, copy.ComputationTimeMs); + Assert.AreEqual("2026-07-01T00:00:00.0000000Z", copy.Timestamp); + CollectionAssert.AreEqual(response.NonFiniteFindings, copy.NonFiniteFindings); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null observation lists. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new InputDataResourceResponse + { + InputData = new InputDataSummaryDto() + }); + StringAssert.Contains(json, "\"inputData\""); + StringAssert.Contains(json, "\"success\""); + + string defaultJson = TestJson.Serialize(new InputDataResourceResponse()); + Assert.IsFalse(defaultJson.Contains("\"exactData\""), "Null exactData should be omitted."); + Assert.IsFalse(defaultJson.Contains("\"intervalData\""), "Null intervalData should be omitted."); + Assert.IsFalse(defaultJson.Contains("\"thresholdData\""), "Null thresholdData should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/InputDataSummaryDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/InputDataSummaryDtoTests.cs new file mode 100644 index 0000000..9867d93 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/InputDataSummaryDtoTests.cs @@ -0,0 +1,110 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip including the + /// nested option echoes, and camelCase wire names. + /// + [TestClass] + public class InputDataSummaryDtoTests + { + /// + /// Verifies a new summary defaults to an empty id, zero counts, and null optional fields. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var dto = new InputDataSummaryDto(); + Assert.AreEqual(Guid.Empty, dto.Id); + Assert.IsNull(dto.Name); + Assert.IsNull(dto.Description); + Assert.AreEqual(default, dto.CreatedUtc); + Assert.IsNull(dto.Method); + Assert.IsNull(dto.SourceTimeSeriesId); + Assert.IsNull(dto.UsgsSiteNumber); + Assert.AreEqual(0, dto.RecordLength); + Assert.AreEqual(0, dto.ExactCount); + Assert.AreEqual(0, dto.IntervalCount); + Assert.AreEqual(0, dto.ThresholdCount); + Assert.AreEqual(0.0, dto.Lambda); + Assert.AreEqual(0.0, dto.PlottingParameter); + Assert.IsNull(dto.LowOutlierThreshold); + Assert.AreEqual(0, dto.LowOutlierCount); + Assert.IsNull(dto.BlockOptions); + Assert.IsNull(dto.PotOptions); + } + + /// + /// Verifies every property, including the nested block and POT option echoes, survives a + /// JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var dto = new InputDataSummaryDto + { + Id = Guid.NewGuid(), + Name = "Annual maxima", + Description = "Block maxima of daily flows.", + CreatedUtc = new DateTime(2026, 7, 1, 10, 0, 0, DateTimeKind.Utc), + Method = "blockMaxima", + SourceTimeSeriesId = Guid.NewGuid(), + UsgsSiteNumber = "01646500", + RecordLength = 96, + ExactCount = 95, + IntervalCount = 1, + ThresholdCount = 2, + Lambda = 1.0, + PlottingParameter = 0.44, + LowOutlierThreshold = 500.0, + LowOutlierCount = 3, + BlockOptions = new BlockOptionsDto { TimeBlock = "waterYear", StartMonth = 10 }, + PotOptions = new PotOptionsDto { Threshold = 10000.0, Period = 3 } + }; + + var copy = TestJson.Roundtrip(dto); + + Assert.AreEqual(dto.Id, copy.Id); + Assert.AreEqual("Annual maxima", copy.Name); + Assert.AreEqual("Block maxima of daily flows.", copy.Description); + Assert.AreEqual(dto.CreatedUtc, copy.CreatedUtc); + Assert.AreEqual("blockMaxima", copy.Method); + Assert.AreEqual(dto.SourceTimeSeriesId, copy.SourceTimeSeriesId); + Assert.AreEqual("01646500", copy.UsgsSiteNumber); + Assert.AreEqual(96, copy.RecordLength); + Assert.AreEqual(95, copy.ExactCount); + Assert.AreEqual(1, copy.IntervalCount); + Assert.AreEqual(2, copy.ThresholdCount); + Assert.AreEqual(1.0, copy.Lambda); + Assert.AreEqual(0.44, copy.PlottingParameter); + Assert.AreEqual(500.0, copy.LowOutlierThreshold); + Assert.AreEqual(3, copy.LowOutlierCount); + Assert.IsNotNull(copy.BlockOptions); + Assert.AreEqual("waterYear", copy.BlockOptions.TimeBlock); + Assert.AreEqual(10, copy.BlockOptions.StartMonth); + Assert.IsNotNull(copy.PotOptions); + Assert.AreEqual(10000.0, copy.PotOptions.Threshold); + Assert.AreEqual(3, copy.PotOptions.Period); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null optional fields. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new InputDataSummaryDto { Method = "manual" }); + StringAssert.Contains(json, "\"recordLength\""); + StringAssert.Contains(json, "\"lambda\""); + StringAssert.Contains(json, "\"plottingParameter\""); + StringAssert.Contains(json, "\"method\""); + + string defaultJson = TestJson.Serialize(new InputDataSummaryDto()); + Assert.IsFalse(defaultJson.Contains("\"blockOptions\""), "Null blockOptions should be omitted."); + Assert.IsFalse(defaultJson.Contains("\"potOptions\""), "Null potOptions should be omitted."); + Assert.IsFalse(defaultJson.Contains("\"sourceTimeSeriesId\""), "Null sourceTimeSeriesId should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/IntervalObservationDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/IntervalObservationDtoTests.cs new file mode 100644 index 0000000..a40ce6e --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/IntervalObservationDtoTests.cs @@ -0,0 +1,67 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and camelCase wire names. + /// + [TestClass] + public class IntervalObservationDtoTests + { + /// + /// Verifies a new observation defaults to zero bounds with no representative value. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var dto = new IntervalObservationDto(); + Assert.AreEqual(0, dto.Index); + Assert.AreEqual(0.0, dto.LowerBound); + Assert.AreEqual(0.0, dto.UpperBound); + Assert.IsNull(dto.Value); + Assert.IsNull(dto.PlottingPosition); + } + + /// + /// Verifies every property survives a JSON round-trip with the API's wire options. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var dto = new IntervalObservationDto + { + Index = 1889, + LowerBound = 90000.0, + UpperBound = 110000.0, + Value = 100000.0, + PlottingPosition = 0.01 + }; + + var copy = TestJson.Roundtrip(dto); + + Assert.AreEqual(1889, copy.Index); + Assert.AreEqual(90000.0, copy.LowerBound); + Assert.AreEqual(110000.0, copy.UpperBound); + Assert.AreEqual(100000.0, copy.Value); + Assert.AreEqual(0.01, copy.PlottingPosition); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null optional fields. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new IntervalObservationDto { Value = 100.0 }); + StringAssert.Contains(json, "\"index\""); + StringAssert.Contains(json, "\"lowerBound\""); + StringAssert.Contains(json, "\"upperBound\""); + StringAssert.Contains(json, "\"value\""); + + string defaultJson = TestJson.Serialize(new IntervalObservationDto()); + Assert.IsFalse(defaultJson.Contains("\"value\""), "Null value should be omitted."); + Assert.IsFalse(defaultJson.Contains("\"plottingPosition\""), "Null plottingPosition should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/ParameterPenaltyDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/ParameterPenaltyDtoTests.cs new file mode 100644 index 0000000..1282733 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/ParameterPenaltyDtoTests.cs @@ -0,0 +1,61 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and camelCase + /// wire names. + /// + [TestClass] + public class ParameterPenaltyDtoTests + { + /// + /// Verifies the defaults: empty name, zero mean/mse, and real-space (useLog false) penalty. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var penalty = new ParameterPenaltyDto(); + Assert.AreEqual(string.Empty, penalty.ParameterName); + Assert.AreEqual(0d, penalty.Mean); + Assert.AreEqual(0d, penalty.Mse); + Assert.IsFalse(penalty.UseLog); + } + + /// + /// Verifies every property survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var penalty = new ParameterPenaltyDto + { + ParameterName = "Skew (of log)", + Mean = -0.05, + Mse = 0.12, + UseLog = false + }; + + var copy = TestJson.Roundtrip(penalty); + + Assert.AreEqual("Skew (of log)", copy.ParameterName); + Assert.AreEqual(-0.05, copy.Mean); + Assert.AreEqual(0.12, copy.Mse); + Assert.IsFalse(copy.UseLog); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new ParameterPenaltyDto { ParameterName = "Skew (of log)", Mean = -0.1, Mse = 0.09, UseLog = true }); + StringAssert.Contains(json, "\"parameterName\""); + StringAssert.Contains(json, "\"mean\""); + StringAssert.Contains(json, "\"mse\""); + StringAssert.Contains(json, "\"useLog\""); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/ParameterPriorDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/ParameterPriorDtoTests.cs new file mode 100644 index 0000000..88b1399 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/ParameterPriorDtoTests.cs @@ -0,0 +1,68 @@ +using Numerics.Distributions; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and camelCase + /// wire names. + /// + [TestClass] + public class ParameterPriorDtoTests + { + /// + /// Verifies the defaults: empty name, no distribution, and a null (estimated) fixed flag. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var prior = new ParameterPriorDto(); + Assert.AreEqual(string.Empty, prior.ParameterName); + Assert.IsNull(prior.Distribution); + Assert.IsNull(prior.IsFixed); + } + + /// + /// Verifies every property survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var prior = new ParameterPriorDto + { + ParameterName = "Skew (of log)", + Distribution = new DistributionSpecDto + { + Type = UnivariateDistributionType.Normal, + Parameters = new List { -0.2, 0.3 } + }, + IsFixed = true + }; + + var copy = TestJson.Roundtrip(prior); + + Assert.AreEqual("Skew (of log)", copy.ParameterName); + Assert.IsNotNull(copy.Distribution); + Assert.AreEqual(UnivariateDistributionType.Normal, copy.Distribution!.Type); + CollectionAssert.AreEqual(new List { -0.2, 0.3 }, copy.Distribution.Parameters); + Assert.IsTrue(copy.IsFixed); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits the null fixed flag. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new ParameterPriorDto + { + ParameterName = "Mean (of log)", + Distribution = new DistributionSpecDto { Parameters = new List { 3d, 0.5 } } + }); + StringAssert.Contains(json, "\"parameterName\""); + StringAssert.Contains(json, "\"distribution\""); + Assert.IsFalse(json.Contains("\"isFixed\""), "Null isFixed should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/ParameterSummaryDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/ParameterSummaryDtoTests.cs new file mode 100644 index 0000000..80c41fc --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/ParameterSummaryDtoTests.cs @@ -0,0 +1,82 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and camelCase + /// wire names. + /// + [TestClass] + public class ParameterSummaryDtoTests + { + /// + /// Verifies a new summary defaults to no name and all-null statistics so non-chain methods + /// can omit the convergence diagnostics. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var summary = new ParameterSummaryDto(); + Assert.IsNull(summary.Name); + Assert.IsNull(summary.Mean); + Assert.IsNull(summary.Median); + Assert.IsNull(summary.StandardDeviation); + Assert.IsNull(summary.LowerCI); + Assert.IsNull(summary.UpperCI); + Assert.IsNull(summary.Rhat); + Assert.IsNull(summary.Ess); + } + + /// + /// Verifies every property, including the convergence diagnostics, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var summary = new ParameterSummaryDto + { + Name = "Mu", + Mean = 3.52, + Median = 3.51, + StandardDeviation = 0.031, + LowerCI = 3.46, + UpperCI = 3.58, + Rhat = 1.002, + Ess = 8450.0 + }; + + var copy = TestJson.Roundtrip(summary); + + Assert.AreEqual("Mu", copy.Name); + Assert.AreEqual(3.52, copy.Mean); + Assert.AreEqual(3.51, copy.Median); + Assert.AreEqual(0.031, copy.StandardDeviation); + Assert.AreEqual(3.46, copy.LowerCI); + Assert.AreEqual(3.58, copy.UpperCI); + Assert.AreEqual(1.002, copy.Rhat); + Assert.AreEqual(8450.0, copy.Ess); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null statistics. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new ParameterSummaryDto + { + Name = "Sigma", + StandardDeviation = 0.02, + LowerCI = 0.2, + UpperCI = 0.3, + Rhat = 1.01 + }); + StringAssert.Contains(json, "\"standardDeviation\""); + StringAssert.Contains(json, "\"lowerCI\""); + StringAssert.Contains(json, "\"upperCI\""); + StringAssert.Contains(json, "\"rhat\""); + Assert.IsFalse(json.Contains("\"ess\""), "Null ess should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/ParameterValueDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/ParameterValueDtoTests.cs new file mode 100644 index 0000000..7f8ee26 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/ParameterValueDtoTests.cs @@ -0,0 +1,56 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and camelCase + /// wire names. + /// + [TestClass] + public class ParameterValueDtoTests + { + /// + /// Verifies a new parameter value defaults to no name and a zero value. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var parameter = new ParameterValueDto(); + Assert.IsNull(parameter.Name); + Assert.AreEqual(0.0, parameter.Value); + } + + /// + /// Verifies the name and value survive a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var parameter = new ParameterValueDto + { + Name = "Sigma", + Value = 0.245 + }; + + var copy = TestJson.Roundtrip(parameter); + + Assert.AreEqual("Sigma", copy.Name); + Assert.AreEqual(0.245, copy.Value); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits the null name. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new ParameterValueDto { Name = "Mu", Value = 3.5 }); + StringAssert.Contains(json, "\"name\""); + StringAssert.Contains(json, "\"value\""); + + string defaultJson = TestJson.Serialize(new ParameterValueDto()); + Assert.IsFalse(defaultJson.Contains("\"name\""), "Null name should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/PotOptionsDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/PotOptionsDtoTests.cs new file mode 100644 index 0000000..ef3890c --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/PotOptionsDtoTests.cs @@ -0,0 +1,62 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and camelCase wire names. + /// + [TestClass] + public class PotOptionsDtoTests + { + /// + /// Verifies a new echo defaults to all-null options. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var dto = new PotOptionsDto(); + Assert.IsNull(dto.Threshold); + Assert.IsNull(dto.MinStepsBetweenPeaks); + Assert.IsNull(dto.SmoothingFunction); + Assert.IsNull(dto.Period); + } + + /// + /// Verifies every property survives a JSON round-trip with the API's wire options. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var dto = new PotOptionsDto + { + Threshold = 10000.0, + MinStepsBetweenPeaks = 7, + SmoothingFunction = "none", + Period = 3 + }; + + var copy = TestJson.Roundtrip(dto); + + Assert.AreEqual(10000.0, copy.Threshold); + Assert.AreEqual(7, copy.MinStepsBetweenPeaks); + Assert.AreEqual("none", copy.SmoothingFunction); + Assert.AreEqual(3, copy.Period); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and collapses an all-null echo to + /// an empty object. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new PotOptionsDto { Threshold = 10000.0, MinStepsBetweenPeaks = 7 }); + StringAssert.Contains(json, "\"threshold\""); + StringAssert.Contains(json, "\"minStepsBetweenPeaks\""); + + Assert.AreEqual("{}", TestJson.Serialize(new PotOptionsDto()), + "All-null options should serialize to an empty object."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/QuantilePenaltyDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/QuantilePenaltyDtoTests.cs new file mode 100644 index 0000000..733b4d1 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/QuantilePenaltyDtoTests.cs @@ -0,0 +1,62 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults (log10 space on by default), + /// JSON round-trip, and camelCase wire names. + /// + [TestClass] + public class QuantilePenaltyDtoTests + { + /// + /// Verifies the defaults: zero aep/mean/mse and log10 comparison enabled (the flood + /// discharge convention). + /// + [TestMethod] + public void Defaults_AreExpected() + { + var penalty = new QuantilePenaltyDto(); + Assert.AreEqual(0d, penalty.Aep); + Assert.AreEqual(0d, penalty.Mean); + Assert.AreEqual(0d, penalty.Mse); + Assert.IsTrue(penalty.UseLog10, "Log10 comparison should be the default for flood discharges."); + } + + /// + /// Verifies every property survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var penalty = new QuantilePenaltyDto + { + Aep = 0.002, + Mean = 4.85, + Mse = 0.02, + UseLog10 = false + }; + + var copy = TestJson.Roundtrip(penalty); + + Assert.AreEqual(0.002, copy.Aep); + Assert.AreEqual(4.85, copy.Mean); + Assert.AreEqual(0.02, copy.Mse); + Assert.IsFalse(copy.UseLog10); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new QuantilePenaltyDto { Aep = 0.01, Mean = 4.5, Mse = 0.05 }); + StringAssert.Contains(json, "\"aep\""); + StringAssert.Contains(json, "\"mean\""); + StringAssert.Contains(json, "\"mse\""); + StringAssert.Contains(json, "\"useLog10\""); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/QuantilePriorDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/QuantilePriorDtoTests.cs new file mode 100644 index 0000000..ed891fe --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/QuantilePriorDtoTests.cs @@ -0,0 +1,64 @@ +using Numerics.Distributions; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and camelCase + /// wire names. + /// + [TestClass] + public class QuantilePriorDtoTests + { + /// + /// Verifies the defaults: zero alpha and no distribution. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var prior = new QuantilePriorDto(); + Assert.AreEqual(0d, prior.Alpha); + Assert.IsNull(prior.Distribution); + } + + /// + /// Verifies the alpha and distribution survive a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var prior = new QuantilePriorDto + { + Alpha = 0.01, + Distribution = new DistributionSpecDto + { + Type = UnivariateDistributionType.LogNormal, + Parameters = new List { 4.7, 0.1 } + } + }; + + var copy = TestJson.Roundtrip(prior); + + Assert.AreEqual(0.01, copy.Alpha); + Assert.IsNotNull(copy.Distribution); + Assert.AreEqual(UnivariateDistributionType.LogNormal, copy.Distribution!.Type); + CollectionAssert.AreEqual(new List { 4.7, 0.1 }, copy.Distribution.Parameters); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new QuantilePriorDto + { + Alpha = 0.002, + Distribution = new DistributionSpecDto { Parameters = new List { 1d, 2d } } + }); + StringAssert.Contains(json, "\"alpha\""); + StringAssert.Contains(json, "\"distribution\""); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/RatingCurveDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/RatingCurveDtoTests.cs new file mode 100644 index 0000000..b982151 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/RatingCurveDtoTests.cs @@ -0,0 +1,85 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and camelCase + /// wire names. + /// + [TestClass] + public class RatingCurveDtoTests + { + /// + /// Verifies a new curve defaults to an empty stage grid with no discharge curves, a zero + /// credible-interval width, and no grid bounds. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var curve = new RatingCurveDto(); + Assert.IsNotNull(curve.Stages); + Assert.AreEqual(0, curve.Stages.Count); + Assert.IsNull(curve.ModeCurve); + Assert.IsNull(curve.MeanCurve); + Assert.IsNull(curve.CiLower); + Assert.IsNull(curve.CiUpper); + Assert.AreEqual(0.0, curve.CredibleIntervalWidth); + Assert.IsNull(curve.MinStage); + Assert.IsNull(curve.MaxStage); + Assert.IsNull(curve.StageBins); + } + + /// + /// Verifies every property, including all four aligned discharge curves and the grid + /// bounds, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var curve = new RatingCurveDto + { + Stages = new List { 2.0, 6.0, 10.0 }, + ModeCurve = new List { 150.0, 2400.0, 9800.0 }, + MeanCurve = new List { 155.0, 2450.0, 10100.0 }, + CiLower = new List { 120.0, 2000.0, 8200.0 }, + CiUpper = new List { 190.0, 2900.0, 12200.0 }, + CredibleIntervalWidth = 0.9, + MinStage = 2.0, + MaxStage = 10.0, + StageBins = 3 + }; + + var copy = TestJson.Roundtrip(curve); + + CollectionAssert.AreEqual(curve.Stages, copy.Stages); + CollectionAssert.AreEqual(curve.ModeCurve, copy.ModeCurve); + CollectionAssert.AreEqual(curve.MeanCurve, copy.MeanCurve); + CollectionAssert.AreEqual(curve.CiLower, copy.CiLower); + CollectionAssert.AreEqual(curve.CiUpper, copy.CiUpper); + Assert.AreEqual(0.9, copy.CredibleIntervalWidth); + Assert.AreEqual(2.0, copy.MinStage); + Assert.AreEqual(10.0, copy.MaxStage); + Assert.AreEqual(3, copy.StageBins); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null curves and bounds. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new RatingCurveDto + { + CiUpper = new List { 1.0 }, + StageBins = 100 + }); + StringAssert.Contains(json, "\"stages\""); + StringAssert.Contains(json, "\"ciUpper\""); + StringAssert.Contains(json, "\"credibleIntervalWidth\""); + StringAssert.Contains(json, "\"stageBins\""); + Assert.IsFalse(json.Contains("\"minStage\""), "Null minStage should be omitted."); + Assert.IsFalse(json.Contains("\"modeCurve\""), "Null modeCurve should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/RatingCurveResultsResponseTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/RatingCurveResultsResponseTests.cs new file mode 100644 index 0000000..c20fec9 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/RatingCurveResultsResponseTests.cs @@ -0,0 +1,124 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip + /// including the inherited fields, and camelCase wire names. + /// + [TestClass] + public class RatingCurveResultsResponseTests + { + /// + /// Verifies a new response defaults to success with no curve, zero counts, and empty + /// parameter lists. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var response = new RatingCurveResultsResponse(); + Assert.IsTrue(response.Success); + Assert.AreEqual(Guid.Empty, response.AnalysisId); + Assert.IsNull(response.Kind); + Assert.IsNull(response.RatingCurve); + Assert.AreEqual(0, response.NumberOfSegments); + Assert.AreEqual(0, response.AlignedObservationCount); + Assert.IsNotNull(response.Parameters); + Assert.AreEqual(0, response.Parameters.Count); + Assert.IsNotNull(response.ParameterSummaries); + Assert.AreEqual(0, response.ParameterSummaries.Count); + Assert.IsNull(response.InformationCriteria); + Assert.IsNull(response.Diagnostics); + } + + /// + /// Verifies every property, including all nested result sections and the inherited + /// response fields, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var response = new RatingCurveResultsResponse + { + AnalysisId = Guid.NewGuid(), + Kind = "ratingCurve", + RatingCurve = new RatingCurveDto + { + Stages = new List { 2.0, 10.0 }, + ModeCurve = new List { 150.0, 9800.0 }, + CredibleIntervalWidth = 0.9 + }, + NumberOfSegments = 2, + AlignedObservationCount = 48, + Parameters = new List + { + new ParameterValueDto { Name = "Xi", Value = 1.2 } + }, + ParameterSummaries = new List + { + new ParameterSummaryDto { Name = "Xi", Mean = 1.19, Rhat = 1.004 } + }, + InformationCriteria = new InformationCriteriaDto { Dic = 210.4 }, + Diagnostics = new DiagnosticsDto { Sampler = "demCzs", NumberOfChains = 4 }, + Success = false, + ErrorMessage = "results stale", + ValidationErrors = new List { "e1" }, + ValidationWarnings = new List { "w1" }, + ComputationTimeMs = 13, + Timestamp = "2026-07-01T00:00:00.0000000Z", + NonFiniteFindings = new List { "$.ratingCurve.ciUpper[0]" } + }; + + var copy = TestJson.Roundtrip(response); + + Assert.AreEqual(response.AnalysisId, copy.AnalysisId); + Assert.AreEqual("ratingCurve", copy.Kind); + Assert.IsNotNull(copy.RatingCurve); + CollectionAssert.AreEqual(response.RatingCurve.Stages, copy.RatingCurve.Stages); + CollectionAssert.AreEqual(response.RatingCurve.ModeCurve, copy.RatingCurve.ModeCurve); + Assert.AreEqual(0.9, copy.RatingCurve.CredibleIntervalWidth); + Assert.AreEqual(2, copy.NumberOfSegments); + Assert.AreEqual(48, copy.AlignedObservationCount); + Assert.AreEqual(1, copy.Parameters.Count); + Assert.AreEqual("Xi", copy.Parameters[0].Name); + Assert.AreEqual(1.2, copy.Parameters[0].Value); + Assert.AreEqual(1, copy.ParameterSummaries.Count); + Assert.AreEqual(1.19, copy.ParameterSummaries[0].Mean); + Assert.AreEqual(1.004, copy.ParameterSummaries[0].Rhat); + Assert.IsNotNull(copy.InformationCriteria); + Assert.AreEqual(210.4, copy.InformationCriteria.Dic); + Assert.IsNotNull(copy.Diagnostics); + Assert.AreEqual("demCzs", copy.Diagnostics.Sampler); + Assert.AreEqual(4, copy.Diagnostics.NumberOfChains); + Assert.IsFalse(copy.Success); + Assert.AreEqual("results stale", copy.ErrorMessage); + CollectionAssert.AreEqual(response.ValidationErrors, copy.ValidationErrors); + CollectionAssert.AreEqual(response.ValidationWarnings, copy.ValidationWarnings); + Assert.AreEqual(13L, copy.ComputationTimeMs); + Assert.AreEqual("2026-07-01T00:00:00.0000000Z", copy.Timestamp); + CollectionAssert.AreEqual(response.NonFiniteFindings, copy.NonFiniteFindings); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null result sections. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new RatingCurveResultsResponse + { + Kind = "ratingCurve", + RatingCurve = new RatingCurveDto() + }); + StringAssert.Contains(json, "\"analysisId\""); + StringAssert.Contains(json, "\"ratingCurve\""); + StringAssert.Contains(json, "\"alignedObservationCount\""); + StringAssert.Contains(json, "\"success\""); + + string defaultJson = TestJson.Serialize(new RatingCurveResultsResponse()); + Assert.IsFalse(defaultJson.Contains("\"ratingCurve\""), "Null ratingCurve should be omitted."); + Assert.IsFalse(defaultJson.Contains("\"diagnostics\""), "Null diagnostics should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/ResourceSummaryDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/ResourceSummaryDtoTests.cs new file mode 100644 index 0000000..3248fc0 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/ResourceSummaryDtoTests.cs @@ -0,0 +1,66 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and camelCase wire names. + /// + [TestClass] + public class ResourceSummaryDtoTests + { + /// + /// Verifies a new summary defaults to an empty id with all descriptive fields null. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var dto = new ResourceSummaryDto(); + Assert.AreEqual(Guid.Empty, dto.Id); + Assert.IsNull(dto.ResourceType); + Assert.IsNull(dto.Name); + Assert.AreEqual(default, dto.CreatedUtc); + Assert.IsNull(dto.Detail); + } + + /// + /// Verifies every property survives a JSON round-trip with the API's wire options. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var dto = new ResourceSummaryDto + { + Id = Guid.NewGuid(), + ResourceType = "analysis", + Name = "LP3 fit", + CreatedUtc = new DateTime(2026, 7, 1, 8, 15, 0, DateTimeKind.Utc), + Detail = "univariate, completed" + }; + + var copy = TestJson.Roundtrip(dto); + + Assert.AreEqual(dto.Id, copy.Id); + Assert.AreEqual("analysis", copy.ResourceType); + Assert.AreEqual("LP3 fit", copy.Name); + Assert.AreEqual(dto.CreatedUtc, copy.CreatedUtc); + Assert.AreEqual("univariate, completed", copy.Detail); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null optional fields. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new ResourceSummaryDto { Detail = "5 points" }); + StringAssert.Contains(json, "\"id\""); + StringAssert.Contains(json, "\"createdUtc\""); + StringAssert.Contains(json, "\"detail\""); + + string defaultJson = TestJson.Serialize(new ResourceSummaryDto()); + Assert.IsFalse(defaultJson.Contains("\"resourceType\""), "Null resourceType should be omitted."); + Assert.IsFalse(defaultJson.Contains("\"detail\""), "Null detail should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/ResourcesOverviewResponseTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/ResourcesOverviewResponseTests.cs new file mode 100644 index 0000000..ae33d09 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/ResourcesOverviewResponseTests.cs @@ -0,0 +1,90 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip including + /// the inherited fields, and camelCase wire names. + /// + [TestClass] + public class ResourcesOverviewResponseTests + { + /// + /// Verifies a new overview defaults to success with zero counts and an empty resource list. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var response = new ResourcesOverviewResponse(); + Assert.IsTrue(response.Success); + Assert.AreEqual(0, response.TimeSeriesCount); + Assert.AreEqual(0, response.InputDataCount); + Assert.AreEqual(0, response.AnalysisCount); + Assert.IsNotNull(response.Resources); + Assert.AreEqual(0, response.Resources.Count); + } + + /// + /// Verifies every property, including the inherited response fields, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var response = new ResourcesOverviewResponse + { + TimeSeriesCount = 2, + InputDataCount = 3, + AnalysisCount = 4, + Resources = new List + { + new ResourceSummaryDto + { + Id = Guid.NewGuid(), + ResourceType = "timeSeries", + Name = "Potomac daily", + CreatedUtc = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc), + Detail = "36500 points" + } + }, + Success = false, + ErrorMessage = "partial", + ValidationErrors = new List { "e1" }, + ValidationWarnings = new List { "w1" }, + ComputationTimeMs = 5, + Timestamp = "2026-07-01T00:00:00.0000000Z", + NonFiniteFindings = new List { "$.y" } + }; + + var copy = TestJson.Roundtrip(response); + + Assert.AreEqual(2, copy.TimeSeriesCount); + Assert.AreEqual(3, copy.InputDataCount); + Assert.AreEqual(4, copy.AnalysisCount); + Assert.AreEqual(1, copy.Resources.Count); + Assert.AreEqual(response.Resources[0].Id, copy.Resources[0].Id); + Assert.AreEqual("Potomac daily", copy.Resources[0].Name); + Assert.IsFalse(copy.Success); + Assert.AreEqual("partial", copy.ErrorMessage); + CollectionAssert.AreEqual(response.ValidationErrors, copy.ValidationErrors); + CollectionAssert.AreEqual(response.ValidationWarnings, copy.ValidationWarnings); + Assert.AreEqual(5L, copy.ComputationTimeMs); + Assert.AreEqual("2026-07-01T00:00:00.0000000Z", copy.Timestamp); + CollectionAssert.AreEqual(response.NonFiniteFindings, copy.NonFiniteFindings); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new ResourcesOverviewResponse()); + StringAssert.Contains(json, "\"timeSeriesCount\""); + StringAssert.Contains(json, "\"inputDataCount\""); + StringAssert.Contains(json, "\"analysisCount\""); + StringAssert.Contains(json, "\"resources\""); + Assert.IsFalse(json.Contains("\"errorMessage\""), "Null errorMessage should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/ResponseBaseTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/ResponseBaseTests.cs new file mode 100644 index 0000000..4fce03f --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/ResponseBaseTests.cs @@ -0,0 +1,80 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : shared success/error/diagnostic contract defaults, + /// JSON round-trip, and camelCase wire names, exercised through a private concrete subclass. + /// + [TestClass] + public class ResponseBaseTests + { + /// + /// Concrete subclass used to instantiate the abstract in tests. + /// + private sealed class TestResponse : ResponseBase + { + } + + /// + /// Verifies a new response defaults to success with all diagnostic fields null. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var response = new TestResponse(); + Assert.IsTrue(response.Success); + Assert.IsNull(response.ErrorMessage); + Assert.IsNull(response.ValidationErrors); + Assert.IsNull(response.ValidationWarnings); + Assert.IsNull(response.ComputationTimeMs); + Assert.IsNull(response.Timestamp); + Assert.IsNull(response.NonFiniteFindings); + } + + /// + /// Verifies every shared field survives a JSON round-trip with the API's wire options. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var response = new TestResponse + { + Success = false, + ErrorMessage = "Something failed.", + ValidationErrors = new List { "error one", "error two" }, + ValidationWarnings = new List { "warning one" }, + ComputationTimeMs = 1234, + Timestamp = "2026-07-01T12:00:00.0000000Z", + NonFiniteFindings = new List { "$.statistics.skew" } + }; + + var copy = TestJson.Roundtrip(response); + + Assert.IsFalse(copy.Success); + Assert.AreEqual("Something failed.", copy.ErrorMessage); + CollectionAssert.AreEqual(response.ValidationErrors, copy.ValidationErrors); + CollectionAssert.AreEqual(response.ValidationWarnings, copy.ValidationWarnings); + Assert.AreEqual(1234L, copy.ComputationTimeMs); + Assert.AreEqual("2026-07-01T12:00:00.0000000Z", copy.Timestamp); + CollectionAssert.AreEqual(response.NonFiniteFindings, copy.NonFiniteFindings); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null diagnostic fields. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new TestResponse { ErrorMessage = "boom" }); + StringAssert.Contains(json, "\"success\""); + StringAssert.Contains(json, "\"errorMessage\""); + + string defaultJson = TestJson.Serialize(new TestResponse()); + Assert.IsFalse(defaultJson.Contains("\"errorMessage\""), "Null errorMessage should be omitted."); + Assert.IsFalse(defaultJson.Contains("\"validationErrors\""), "Null validationErrors should be omitted."); + Assert.IsFalse(defaultJson.Contains("\"nonFiniteFindings\""), "Null nonFiniteFindings should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/SummaryStatisticsResponseTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/SummaryStatisticsResponseTests.cs new file mode 100644 index 0000000..26cca04 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/SummaryStatisticsResponseTests.cs @@ -0,0 +1,83 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip including + /// NaN measures and the inherited fields, and camelCase wire names. + /// + [TestClass] + public class SummaryStatisticsResponseTests + { + /// + /// Verifies a new response defaults to success with an empty id and statistics dictionary. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var response = new SummaryStatisticsResponse(); + Assert.IsTrue(response.Success); + Assert.AreEqual(Guid.Empty, response.InputDataId); + Assert.IsNotNull(response.Statistics); + Assert.AreEqual(0, response.Statistics.Count); + } + + /// + /// Verifies every property, including a NaN-valued measure and the inherited response + /// fields, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var response = new SummaryStatisticsResponse + { + InputDataId = Guid.NewGuid(), + Statistics = new Dictionary + { + ["mean"] = 15200.5, + ["standardDeviation"] = 4100.25, + ["skew"] = double.NaN + }, + Success = false, + ErrorMessage = "stats incomplete", + ValidationErrors = new List { "e1" }, + ValidationWarnings = new List { "w1" }, + ComputationTimeMs = 7, + Timestamp = "2026-07-01T00:00:00.0000000Z", + NonFiniteFindings = new List { "$.statistics.skew" } + }; + + var copy = TestJson.Roundtrip(response); + + Assert.AreEqual(response.InputDataId, copy.InputDataId); + Assert.AreEqual(3, copy.Statistics.Count); + Assert.AreEqual(15200.5, copy.Statistics["mean"]); + Assert.AreEqual(4100.25, copy.Statistics["standardDeviation"]); + Assert.IsTrue(double.IsNaN(copy.Statistics["skew"]), "NaN measure should survive the round-trip."); + Assert.IsFalse(copy.Success); + Assert.AreEqual("stats incomplete", copy.ErrorMessage); + CollectionAssert.AreEqual(response.ValidationErrors, copy.ValidationErrors); + CollectionAssert.AreEqual(response.ValidationWarnings, copy.ValidationWarnings); + Assert.AreEqual(7L, copy.ComputationTimeMs); + Assert.AreEqual("2026-07-01T00:00:00.0000000Z", copy.Timestamp); + CollectionAssert.AreEqual(response.NonFiniteFindings, copy.NonFiniteFindings); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and writes NaN as a named literal. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new SummaryStatisticsResponse + { + Statistics = new Dictionary { ["skew"] = double.NaN } + }); + StringAssert.Contains(json, "\"inputDataId\""); + StringAssert.Contains(json, "\"statistics\""); + StringAssert.Contains(json, "\"NaN\""); + Assert.IsFalse(json.Contains("\"errorMessage\""), "Null errorMessage should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/ThresholdObservationDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/ThresholdObservationDtoTests.cs new file mode 100644 index 0000000..8c7328a --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/ThresholdObservationDtoTests.cs @@ -0,0 +1,70 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and camelCase wire names. + /// + [TestClass] + public class ThresholdObservationDtoTests + { + /// + /// Verifies a new record defaults to a zero window and threshold with no computed counts. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var dto = new ThresholdObservationDto(); + Assert.AreEqual(0, dto.StartIndex); + Assert.AreEqual(0, dto.EndIndex); + Assert.AreEqual(0.0, dto.Value); + Assert.AreEqual(0, dto.NumberAbove); + Assert.IsNull(dto.NumberBelow); + Assert.IsNull(dto.PlottingPosition); + } + + /// + /// Verifies every property survives a JSON round-trip with the API's wire options. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var dto = new ThresholdObservationDto + { + StartIndex = 1861, + EndIndex = 1929, + Value = 85000.0, + NumberAbove = 2, + NumberBelow = 67, + PlottingPosition = 0.05 + }; + + var copy = TestJson.Roundtrip(dto); + + Assert.AreEqual(1861, copy.StartIndex); + Assert.AreEqual(1929, copy.EndIndex); + Assert.AreEqual(85000.0, copy.Value); + Assert.AreEqual(2, copy.NumberAbove); + Assert.AreEqual(67, copy.NumberBelow); + Assert.AreEqual(0.05, copy.PlottingPosition); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null computed fields. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new ThresholdObservationDto { NumberBelow = 10 }); + StringAssert.Contains(json, "\"startIndex\""); + StringAssert.Contains(json, "\"endIndex\""); + StringAssert.Contains(json, "\"numberAbove\""); + StringAssert.Contains(json, "\"numberBelow\""); + + string defaultJson = TestJson.Serialize(new ThresholdObservationDto()); + Assert.IsFalse(defaultJson.Contains("\"numberBelow\""), "Null numberBelow should be omitted."); + Assert.IsFalse(defaultJson.Contains("\"plottingPosition\""), "Null plottingPosition should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/TimeSeriesListResponseTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/TimeSeriesListResponseTests.cs new file mode 100644 index 0000000..db5c990 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/TimeSeriesListResponseTests.cs @@ -0,0 +1,77 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip including + /// the inherited fields, and camelCase wire names. + /// + [TestClass] + public class TimeSeriesListResponseTests + { + /// + /// Verifies a new listing defaults to success with a zero count and an empty summary list. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var response = new TimeSeriesListResponse(); + Assert.IsTrue(response.Success); + Assert.AreEqual(0, response.Count); + Assert.IsNotNull(response.TimeSeries); + Assert.AreEqual(0, response.TimeSeries.Count); + } + + /// + /// Verifies every property, including the inherited response fields, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var response = new TimeSeriesListResponse + { + Count = 1, + TimeSeries = new List + { + new TimeSeriesSummaryDto { Id = Guid.NewGuid(), Name = "Series A", PointCount = 10 } + }, + Success = false, + ErrorMessage = "listing incomplete", + ValidationErrors = new List { "e1" }, + ValidationWarnings = new List { "w1" }, + ComputationTimeMs = 3, + Timestamp = "2026-07-01T00:00:00.0000000Z", + NonFiniteFindings = new List { "$.z" } + }; + + var copy = TestJson.Roundtrip(response); + + Assert.AreEqual(1, copy.Count); + Assert.AreEqual(1, copy.TimeSeries.Count); + Assert.AreEqual(response.TimeSeries[0].Id, copy.TimeSeries[0].Id); + Assert.AreEqual("Series A", copy.TimeSeries[0].Name); + Assert.AreEqual(10, copy.TimeSeries[0].PointCount); + Assert.IsFalse(copy.Success); + Assert.AreEqual("listing incomplete", copy.ErrorMessage); + CollectionAssert.AreEqual(response.ValidationErrors, copy.ValidationErrors); + CollectionAssert.AreEqual(response.ValidationWarnings, copy.ValidationWarnings); + Assert.AreEqual(3L, copy.ComputationTimeMs); + Assert.AreEqual("2026-07-01T00:00:00.0000000Z", copy.Timestamp); + CollectionAssert.AreEqual(response.NonFiniteFindings, copy.NonFiniteFindings); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new TimeSeriesListResponse()); + StringAssert.Contains(json, "\"count\""); + StringAssert.Contains(json, "\"timeSeries\""); + StringAssert.Contains(json, "\"success\""); + Assert.IsFalse(json.Contains("\"errorMessage\""), "Null errorMessage should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/TimeSeriesPointDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/TimeSeriesPointDtoTests.cs new file mode 100644 index 0000000..0350f2c --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/TimeSeriesPointDtoTests.cs @@ -0,0 +1,57 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip (including NaN + /// missing-value markers), and camelCase wire names. + /// + [TestClass] + public class TimeSeriesPointDtoTests + { + /// + /// Verifies a new point defaults to the default date-time and a zero value. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var dto = new TimeSeriesPointDto(); + Assert.AreEqual(default, dto.DateTime); + Assert.AreEqual(0.0, dto.Value); + } + + /// + /// Verifies both properties survive a JSON round-trip, including a NaN missing-value marker. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var dto = new TimeSeriesPointDto + { + DateTime = new DateTime(1996, 10, 1, 6, 0, 0), + Value = 15200.5 + }; + + var copy = TestJson.Roundtrip(dto); + + Assert.AreEqual(new DateTime(1996, 10, 1, 6, 0, 0), copy.DateTime); + Assert.AreEqual(15200.5, copy.Value); + + var missing = TestJson.Roundtrip(new TimeSeriesPointDto { Value = double.NaN }); + Assert.IsTrue(double.IsNaN(missing.Value), "NaN missing-value marker should survive the round-trip."); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and writes NaN as a named literal. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new TimeSeriesPointDto { Value = double.NaN }); + StringAssert.Contains(json, "\"dateTime\""); + StringAssert.Contains(json, "\"value\""); + StringAssert.Contains(json, "\"NaN\""); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/TimeSeriesResourceResponseTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/TimeSeriesResourceResponseTests.cs new file mode 100644 index 0000000..601195e --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/TimeSeriesResourceResponseTests.cs @@ -0,0 +1,94 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip including + /// the inherited fields, and camelCase wire names. + /// + [TestClass] + public class TimeSeriesResourceResponseTests + { + /// + /// Verifies a new response defaults to success with no summary, points, or offset. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var response = new TimeSeriesResourceResponse(); + Assert.IsTrue(response.Success); + Assert.IsNull(response.TimeSeries); + Assert.IsNull(response.Points); + Assert.IsNull(response.PointsOffset); + } + + /// + /// Verifies every property, including the inherited response fields, survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var response = new TimeSeriesResourceResponse + { + TimeSeries = new TimeSeriesSummaryDto + { + Id = Guid.NewGuid(), + Name = "Potomac daily", + PointCount = 2 + }, + Points = new List + { + new TimeSeriesPointDto { DateTime = new DateTime(1996, 10, 1), Value = 15200.5 }, + new TimeSeriesPointDto { DateTime = new DateTime(1996, 10, 2), Value = 14100.0 } + }, + PointsOffset = 100, + Success = false, + ErrorMessage = "partial page", + ValidationErrors = new List { "e1" }, + ValidationWarnings = new List { "w1" }, + ComputationTimeMs = 9, + Timestamp = "2026-07-01T00:00:00.0000000Z", + NonFiniteFindings = new List { "$.points[0].value" } + }; + + var copy = TestJson.Roundtrip(response); + + Assert.IsNotNull(copy.TimeSeries); + Assert.AreEqual(response.TimeSeries.Id, copy.TimeSeries.Id); + Assert.AreEqual("Potomac daily", copy.TimeSeries.Name); + Assert.AreEqual(2, copy.TimeSeries.PointCount); + Assert.IsNotNull(copy.Points); + Assert.AreEqual(2, copy.Points.Count); + Assert.AreEqual(15200.5, copy.Points[0].Value); + Assert.AreEqual(100, copy.PointsOffset); + Assert.IsFalse(copy.Success); + Assert.AreEqual("partial page", copy.ErrorMessage); + CollectionAssert.AreEqual(response.ValidationErrors, copy.ValidationErrors); + CollectionAssert.AreEqual(response.ValidationWarnings, copy.ValidationWarnings); + Assert.AreEqual(9L, copy.ComputationTimeMs); + Assert.AreEqual("2026-07-01T00:00:00.0000000Z", copy.Timestamp); + CollectionAssert.AreEqual(response.NonFiniteFindings, copy.NonFiniteFindings); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null optional fields. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new TimeSeriesResourceResponse + { + TimeSeries = new TimeSeriesSummaryDto(), + PointsOffset = 0 + }); + StringAssert.Contains(json, "\"timeSeries\""); + StringAssert.Contains(json, "\"pointsOffset\""); + StringAssert.Contains(json, "\"success\""); + + string defaultJson = TestJson.Serialize(new TimeSeriesResourceResponse()); + Assert.IsFalse(defaultJson.Contains("\"points\""), "Null points should be omitted."); + Assert.IsFalse(defaultJson.Contains("\"timeSeries\""), "Null timeSeries should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/TimeSeriesResultsResponseTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/TimeSeriesResultsResponseTests.cs new file mode 100644 index 0000000..bf567f4 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/TimeSeriesResultsResponseTests.cs @@ -0,0 +1,94 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for and its supporting + /// : defaults, JSON round-trip, and camelCase wire names. + /// + [TestClass] + public class TimeSeriesResultsResponseTests + { + /// + /// Verifies the defaults across the response and the curve DTO. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var response = new TimeSeriesResultsResponse(); + Assert.AreEqual(Guid.Empty, response.AnalysisId); + Assert.IsNull(response.ModelType); + Assert.AreEqual(0, response.DataLength); + Assert.IsNull(response.Order); + Assert.IsNull(response.POrder); + Assert.IsNull(response.XOrder); + Assert.IsNull(response.Curve); + Assert.AreEqual(0, response.Parameters.Count); + + var curve = new TimeSeriesCurveDto(); + Assert.AreEqual(0, curve.TimeIndices.Count); + Assert.IsNull(curve.ModeCurve); + Assert.AreEqual(0d, curve.CredibleIntervalWidth); + } + + /// + /// Verifies the nested curve survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var response = new TimeSeriesResultsResponse + { + AnalysisId = Guid.NewGuid(), + Kind = "timeSeries", + ModelType = "arima", + TransformType = "logarithmic", + DataLength = 1096, + TrainingTimeSteps = 900, + ForecastingTimeSteps = 12, + POrder = 2, + DOrder = 1, + QOrder = 1, + Curve = new TimeSeriesCurveDto + { + TimeIndices = new List { 1d, 2d }, + ModeCurve = new List { 100d, 101d }, + CiLower = new List { 90d, 91d }, + CiUpper = new List { 110d, 111d }, + CredibleIntervalWidth = 0.9 + } + }; + + var copy = TestJson.Roundtrip(response); + + Assert.AreEqual("arima", copy.ModelType); + Assert.AreEqual(1096, copy.DataLength); + Assert.AreEqual(900, copy.TrainingTimeSteps); + Assert.AreEqual(12, copy.ForecastingTimeSteps); + Assert.AreEqual(2, copy.POrder); + Assert.AreEqual(2, copy.Curve!.TimeIndices.Count); + Assert.AreEqual(0.9, copy.Curve.CredibleIntervalWidth); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null orders. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new TimeSeriesResultsResponse + { + Kind = "timeSeries", + ModelType = "ar", + Order = 1, + Curve = new TimeSeriesCurveDto { TimeIndices = new List { 1d } } + }); + StringAssert.Contains(json, "\"modelType\""); + StringAssert.Contains(json, "\"order\""); + StringAssert.Contains(json, "\"curve\""); + StringAssert.Contains(json, "\"timeIndices\""); + Assert.IsFalse(json.Contains("\"pOrder\""), "Null pOrder should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/TimeSeriesSummaryDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/TimeSeriesSummaryDtoTests.cs new file mode 100644 index 0000000..914b8d9 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/TimeSeriesSummaryDtoTests.cs @@ -0,0 +1,88 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and camelCase wire names. + /// + [TestClass] + public class TimeSeriesSummaryDtoTests + { + /// + /// Verifies a new summary defaults to an empty id, zero counts, and null descriptive fields. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var dto = new TimeSeriesSummaryDto(); + Assert.AreEqual(Guid.Empty, dto.Id); + Assert.IsNull(dto.Name); + Assert.IsNull(dto.Description); + Assert.AreEqual(default, dto.CreatedUtc); + Assert.IsNull(dto.Source); + Assert.IsNull(dto.UsgsSiteNumber); + Assert.IsNull(dto.SeriesType); + Assert.IsNull(dto.TimeInterval); + Assert.AreEqual(0, dto.PointCount); + Assert.AreEqual(0, dto.MissingCount); + Assert.IsNull(dto.StartDate); + Assert.IsNull(dto.EndDate); + } + + /// + /// Verifies every property survives a JSON round-trip with the API's wire options. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var dto = new TimeSeriesSummaryDto + { + Id = Guid.NewGuid(), + Name = "Potomac daily flow", + Description = "USGS download.", + CreatedUtc = new DateTime(2026, 7, 1, 9, 0, 0, DateTimeKind.Utc), + Source = "usgs", + UsgsSiteNumber = "01646500", + SeriesType = "dailyDischarge", + TimeInterval = "oneDay", + PointCount = 36500, + MissingCount = 12, + StartDate = new DateTime(1930, 10, 1), + EndDate = new DateTime(2026, 6, 30) + }; + + var copy = TestJson.Roundtrip(dto); + + Assert.AreEqual(dto.Id, copy.Id); + Assert.AreEqual("Potomac daily flow", copy.Name); + Assert.AreEqual("USGS download.", copy.Description); + Assert.AreEqual(dto.CreatedUtc, copy.CreatedUtc); + Assert.AreEqual("usgs", copy.Source); + Assert.AreEqual("01646500", copy.UsgsSiteNumber); + Assert.AreEqual("dailyDischarge", copy.SeriesType); + Assert.AreEqual("oneDay", copy.TimeInterval); + Assert.AreEqual(36500, copy.PointCount); + Assert.AreEqual(12, copy.MissingCount); + Assert.AreEqual(new DateTime(1930, 10, 1), copy.StartDate); + Assert.AreEqual(new DateTime(2026, 6, 30), copy.EndDate); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null optional fields. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new TimeSeriesSummaryDto { UsgsSiteNumber = "01646500" }); + StringAssert.Contains(json, "\"pointCount\""); + StringAssert.Contains(json, "\"missingCount\""); + StringAssert.Contains(json, "\"usgsSiteNumber\""); + + string defaultJson = TestJson.Serialize(new TimeSeriesSummaryDto()); + Assert.IsFalse(defaultJson.Contains("\"startDate\""), "Null startDate should be omitted."); + Assert.IsFalse(defaultJson.Contains("\"endDate\""), "Null endDate should be omitted."); + Assert.IsFalse(defaultJson.Contains("\"usgsSiteNumber\""), "Null usgsSiteNumber should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/UncertainObservationDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/UncertainObservationDtoTests.cs new file mode 100644 index 0000000..78fd8ea --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/UncertainObservationDtoTests.cs @@ -0,0 +1,73 @@ +using Numerics.Distributions; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and + /// camelCase wire names. + /// + [TestClass] + public class UncertainObservationDtoTests + { + /// + /// Verifies the defaults: no index/date, no distribution, and null response-only fields. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var observation = new UncertainObservationDto(); + Assert.IsNull(observation.Index); + Assert.IsNull(observation.DateTime); + Assert.IsNull(observation.Distribution); + Assert.IsNull(observation.Value); + Assert.IsNull(observation.PlottingPosition); + } + + /// + /// Verifies every property survives a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var observation = new UncertainObservationDto + { + Index = 1875, + Distribution = new DistributionSpecDto + { + Type = UnivariateDistributionType.Triangular, + Parameters = new List { 40000d, 55000d, 90000d } + }, + Value = 61666.7, + PlottingPosition = 0.02 + }; + + var copy = TestJson.Roundtrip(observation); + + Assert.AreEqual(1875, copy.Index); + Assert.IsNotNull(copy.Distribution); + Assert.AreEqual(UnivariateDistributionType.Triangular, copy.Distribution!.Type); + CollectionAssert.AreEqual(new List { 40000d, 55000d, 90000d }, copy.Distribution.Parameters); + Assert.AreEqual(61666.7, copy.Value); + Assert.AreEqual(0.02, copy.PlottingPosition); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and omits null fields. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new UncertainObservationDto + { + Index = 1902, + Distribution = new DistributionSpecDto { Parameters = new List { 100d, 15d } } + }); + StringAssert.Contains(json, "\"index\""); + StringAssert.Contains(json, "\"distribution\""); + Assert.IsFalse(json.Contains("\"plottingPosition\""), "Null plotting position should be omitted."); + Assert.IsFalse(json.Contains("\"dateTime\""), "Null dateTime should be omitted."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/UsgsBlockMaxFrequencyWorkflowRequestTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/UsgsBlockMaxFrequencyWorkflowRequestTests.cs new file mode 100644 index 0000000..12ba86e --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/UsgsBlockMaxFrequencyWorkflowRequestTests.cs @@ -0,0 +1,73 @@ +using Numerics.Data; +using Numerics.Distributions; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Serialization tests for . + /// + [TestClass] + public class UsgsBlockMaxFrequencyWorkflowRequestTests + { + /// + /// Verifies the documented defaults. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var request = new UsgsBlockMaxFrequencyWorkflowRequest(); + Assert.AreEqual(TimeSeriesDownload.TimeSeriesType.DailyDischarge, request.SeriesType); + Assert.AreEqual(TimeBlockWindow.WaterYear, request.TimeBlock); + Assert.AreEqual(BlockFunctionType.Maximum, request.BlockFunction); + Assert.AreEqual(SmoothingFunctionType.None, request.SmoothingFunction); + Assert.AreEqual(10, request.StartMonth); + Assert.AreEqual(9, request.EndMonth); + Assert.AreEqual(1, request.Period); + Assert.AreEqual(UnivariateDistributionType.LogPearsonTypeIII, request.Distribution); + } + + /// + /// Verifies every property survives a wire round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var request = new UsgsBlockMaxFrequencyWorkflowRequest + { + SiteNumber = "01646500", + SeriesType = TimeSeriesDownload.TimeSeriesType.DailyStage, + TimeBlock = TimeBlockWindow.CalendarYear, + BlockFunction = BlockFunctionType.Minimum, + SmoothingFunction = SmoothingFunctionType.MovingAverage, + StartMonth = 4, + EndMonth = 3, + Period = 7, + Distribution = UnivariateDistributionType.Gumbel, + ProbabilityOrdinates = new List { 0.1 }, + BayesianOptions = new BayesianOptionsDto { Iterations = 5000 }, + Name = "n" + }; + var copy = TestJson.Roundtrip(request); + Assert.AreEqual(TimeBlockWindow.CalendarYear, copy.TimeBlock); + Assert.AreEqual(BlockFunctionType.Minimum, copy.BlockFunction); + Assert.AreEqual(SmoothingFunctionType.MovingAverage, copy.SmoothingFunction); + Assert.AreEqual(7, copy.Period); + Assert.AreEqual(UnivariateDistributionType.Gumbel, copy.Distribution); + Assert.AreEqual(5000, copy.BayesianOptions!.Iterations); + } + + /// + /// Verifies the camelCase wire names and enum strings. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new UsgsBlockMaxFrequencyWorkflowRequest { SiteNumber = "01646500" }); + StringAssert.Contains(json, "\"timeBlock\""); + StringAssert.Contains(json, "\"waterYear\""); + StringAssert.Contains(json, "\"startMonth\""); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/UsgsBulletin17CWorkflowRequestTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/UsgsBulletin17CWorkflowRequestTests.cs new file mode 100644 index 0000000..a419136 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/UsgsBulletin17CWorkflowRequestTests.cs @@ -0,0 +1,63 @@ +using Numerics.Data; +using Numerics.Distributions; +using RMC.BestFit.Analyses; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Serialization tests for . + /// + [TestClass] + public class UsgsBulletin17CWorkflowRequestTests + { + /// + /// Verifies the documented defaults (nullable uncertainty method keeps the model default). + /// + [TestMethod] + public void Defaults_AreExpected() + { + var request = new UsgsBulletin17CWorkflowRequest(); + Assert.AreEqual(TimeSeriesDownload.TimeSeriesType.PeakDischarge, request.SeriesType); + Assert.AreEqual(UnivariateDistributionType.LogPearsonTypeIII, request.Distribution); + Assert.IsNull(request.UncertaintyMethod); + Assert.IsNull(request.ProbabilityOrdinates); + } + + /// + /// Verifies every property survives a wire round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var request = new UsgsBulletin17CWorkflowRequest + { + SiteNumber = "01646500", + SeriesType = TimeSeriesDownload.TimeSeriesType.PeakStage, + Distribution = UnivariateDistributionType.LogNormal, + UncertaintyMethod = UncertaintyMethod.Bootstrap, + ProbabilityOrdinates = new List { 0.01 }, + Name = "n" + }; + var copy = TestJson.Roundtrip(request); + Assert.AreEqual(UnivariateDistributionType.LogNormal, copy.Distribution); + Assert.AreEqual(UncertaintyMethod.Bootstrap, copy.UncertaintyMethod); + } + + /// + /// Verifies the camelCase wire names and enum strings. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new UsgsBulletin17CWorkflowRequest + { + SiteNumber = "01646500", + UncertaintyMethod = UncertaintyMethod.BiasCorrectedBootstrap + }); + StringAssert.Contains(json, "\"uncertaintyMethod\""); + StringAssert.Contains(json, "\"biasCorrectedBootstrap\""); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/UsgsFrequencyWorkflowResponseTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/UsgsFrequencyWorkflowResponseTests.cs new file mode 100644 index 0000000..e9e252d --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/UsgsFrequencyWorkflowResponseTests.cs @@ -0,0 +1,69 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Serialization tests for . + /// + [TestClass] + public class UsgsFrequencyWorkflowResponseTests + { + /// + /// Verifies the documented defaults (all ids null, success true). + /// + [TestMethod] + public void Defaults_AreExpected() + { + var response = new UsgsFrequencyWorkflowResponse(); + Assert.IsTrue(response.Success); + Assert.IsNull(response.FailedStep); + Assert.IsNull(response.TimeSeriesId); + Assert.IsNull(response.InputDataId); + Assert.IsNull(response.AnalysisId); + Assert.IsNull(response.Results); + } + + /// + /// Verifies every property (including inherited failure fields) survives a wire round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var response = new UsgsFrequencyWorkflowResponse + { + Success = false, + ErrorMessage = "step failed", + ValidationErrors = new List { "e" }, + FailedStep = "runAnalysis", + TimeSeriesId = Guid.NewGuid(), + InputDataId = Guid.NewGuid(), + AnalysisId = Guid.NewGuid(), + Results = new FrequencyResultsResponse { AnalysisId = Guid.NewGuid(), Kind = "univariate" } + }; + var copy = TestJson.Roundtrip(response); + Assert.IsFalse(copy.Success); + Assert.AreEqual("runAnalysis", copy.FailedStep); + Assert.AreEqual(response.TimeSeriesId, copy.TimeSeriesId); + Assert.AreEqual(response.InputDataId, copy.InputDataId); + Assert.AreEqual(response.AnalysisId, copy.AnalysisId); + Assert.AreEqual("univariate", copy.Results!.Kind); + } + + /// + /// Verifies the camelCase wire names and null omission. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new UsgsFrequencyWorkflowResponse + { + FailedStep = "createInputData", + InputDataId = Guid.NewGuid() + }); + StringAssert.Contains(json, "\"failedStep\""); + StringAssert.Contains(json, "\"inputDataId\""); + Assert.IsFalse(json.Contains("\"results\"")); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/UsgsPeakFrequencyWorkflowRequestTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/UsgsPeakFrequencyWorkflowRequestTests.cs new file mode 100644 index 0000000..96f3248 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/UsgsPeakFrequencyWorkflowRequestTests.cs @@ -0,0 +1,66 @@ +using Numerics.Data; +using Numerics.Distributions; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Serialization tests for . + /// + [TestClass] + public class UsgsPeakFrequencyWorkflowRequestTests + { + /// + /// Verifies the documented defaults. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var request = new UsgsPeakFrequencyWorkflowRequest(); + Assert.AreEqual(string.Empty, request.SiteNumber); + Assert.AreEqual(TimeSeriesDownload.TimeSeriesType.PeakDischarge, request.SeriesType); + Assert.AreEqual(UnivariateDistributionType.LogPearsonTypeIII, request.Distribution); + Assert.IsNull(request.ProbabilityOrdinates); + Assert.IsNull(request.BayesianOptions); + Assert.IsNull(request.Name); + } + + /// + /// Verifies every property survives a wire round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var request = new UsgsPeakFrequencyWorkflowRequest + { + SiteNumber = "01646500", + SeriesType = TimeSeriesDownload.TimeSeriesType.PeakStage, + Distribution = UnivariateDistributionType.GeneralizedExtremeValue, + ProbabilityOrdinates = new List { 0.01, 0.5 }, + BayesianOptions = new BayesianOptionsDto { PrngSeed = 7 }, + Name = "workflow" + }; + var copy = TestJson.Roundtrip(request); + Assert.AreEqual("01646500", copy.SiteNumber); + Assert.AreEqual(TimeSeriesDownload.TimeSeriesType.PeakStage, copy.SeriesType); + Assert.AreEqual(UnivariateDistributionType.GeneralizedExtremeValue, copy.Distribution); + CollectionAssert.AreEqual(request.ProbabilityOrdinates, copy.ProbabilityOrdinates); + Assert.AreEqual(7, copy.BayesianOptions!.PrngSeed); + Assert.AreEqual("workflow", copy.Name); + } + + /// + /// Verifies the camelCase wire names and enum strings. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new UsgsPeakFrequencyWorkflowRequest { SiteNumber = "01646500" }); + StringAssert.Contains(json, "\"siteNumber\""); + StringAssert.Contains(json, "\"peakDischarge\""); + StringAssert.Contains(json, "\"logPearsonTypeIII\""); + Assert.IsFalse(json.Contains("\"name\"")); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/UsgsRatingCurveWorkflowRequestTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/UsgsRatingCurveWorkflowRequestTests.cs new file mode 100644 index 0000000..e903d2f --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/UsgsRatingCurveWorkflowRequestTests.cs @@ -0,0 +1,62 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Serialization tests for . + /// + [TestClass] + public class UsgsRatingCurveWorkflowRequestTests + { + /// + /// Verifies the documented defaults. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var request = new UsgsRatingCurveWorkflowRequest(); + Assert.AreEqual(string.Empty, request.SiteNumber); + Assert.AreEqual(1, request.NumberOfSegments); + Assert.IsNull(request.MinStage); + Assert.IsNull(request.MaxStage); + Assert.IsNull(request.StageBins); + } + + /// + /// Verifies every property survives a wire round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var request = new UsgsRatingCurveWorkflowRequest + { + SiteNumber = "01646500", + NumberOfSegments = 2, + MinStage = 1.5, + MaxStage = 12d, + StageBins = 100, + BayesianOptions = new BayesianOptionsDto { PrngSeed = 3 }, + Name = "n" + }; + var copy = TestJson.Roundtrip(request); + Assert.AreEqual(2, copy.NumberOfSegments); + Assert.AreEqual(1.5, copy.MinStage); + Assert.AreEqual(12d, copy.MaxStage); + Assert.AreEqual(100, copy.StageBins); + Assert.AreEqual(3, copy.BayesianOptions!.PrngSeed); + } + + /// + /// Verifies the camelCase wire names and null omission. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new UsgsRatingCurveWorkflowRequest { SiteNumber = "01646500" }); + StringAssert.Contains(json, "\"siteNumber\""); + StringAssert.Contains(json, "\"numberOfSegments\""); + Assert.IsFalse(json.Contains("\"minStage\"")); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/UsgsRatingCurveWorkflowResponseTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/UsgsRatingCurveWorkflowResponseTests.cs new file mode 100644 index 0000000..0a19d34 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/UsgsRatingCurveWorkflowResponseTests.cs @@ -0,0 +1,60 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Serialization tests for . + /// + [TestClass] + public class UsgsRatingCurveWorkflowResponseTests + { + /// + /// Verifies the documented defaults. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var response = new UsgsRatingCurveWorkflowResponse(); + Assert.IsTrue(response.Success); + Assert.IsNull(response.FailedStep); + Assert.IsNull(response.StageTimeSeriesId); + Assert.IsNull(response.DischargeTimeSeriesId); + Assert.IsNull(response.AnalysisId); + Assert.IsNull(response.Results); + } + + /// + /// Verifies every property survives a wire round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var response = new UsgsRatingCurveWorkflowResponse + { + Success = false, + FailedStep = "downloadDischarge", + StageTimeSeriesId = Guid.NewGuid(), + DischargeTimeSeriesId = Guid.NewGuid(), + AnalysisId = Guid.NewGuid(), + Results = new RatingCurveResultsResponse { Kind = "ratingCurve", NumberOfSegments = 2 } + }; + var copy = TestJson.Roundtrip(response); + Assert.AreEqual("downloadDischarge", copy.FailedStep); + Assert.AreEqual(response.StageTimeSeriesId, copy.StageTimeSeriesId); + Assert.AreEqual(response.DischargeTimeSeriesId, copy.DischargeTimeSeriesId); + Assert.AreEqual(2, copy.Results!.NumberOfSegments); + } + + /// + /// Verifies the camelCase wire names. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new UsgsRatingCurveWorkflowResponse { StageTimeSeriesId = Guid.NewGuid() }); + StringAssert.Contains(json, "\"stageTimeSeriesId\""); + StringAssert.Contains(json, "\"success\""); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/ValidationResponseTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/ValidationResponseTests.cs new file mode 100644 index 0000000..a55001f --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/ValidationResponseTests.cs @@ -0,0 +1,61 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and camelCase + /// wire names. + /// + [TestClass] + public class ValidationResponseTests + { + /// + /// Verifies a new response defaults to invalid with empty error and warning lists. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var response = new ValidationResponse(); + Assert.IsFalse(response.IsValid); + Assert.IsNotNull(response.Errors); + Assert.AreEqual(0, response.Errors.Count); + Assert.IsNotNull(response.Warnings); + Assert.AreEqual(0, response.Warnings.Count); + } + + /// + /// Verifies the verdict, errors, and warnings survive a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var response = new ValidationResponse + { + IsValid = true, + Errors = new List { "The input data has no observations." }, + Warnings = new List { "Record length is short." } + }; + + var copy = TestJson.Roundtrip(response); + + Assert.IsTrue(copy.IsValid); + CollectionAssert.AreEqual(response.Errors, copy.Errors); + CollectionAssert.AreEqual(response.Warnings, copy.Warnings); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names and does not carry the + /// envelope (this response type is standalone). + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new ValidationResponse()); + StringAssert.Contains(json, "\"isValid\""); + StringAssert.Contains(json, "\"errors\""); + StringAssert.Contains(json, "\"warnings\""); + Assert.IsFalse(json.Contains("\"success\""), "ValidationResponse should not carry the ResponseBase envelope."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/DTOs/XyOrdinateDtoTests.cs b/src/RMC.BestFit.Api.Tests/DTOs/XyOrdinateDtoTests.cs new file mode 100644 index 0000000..b983c0c --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/DTOs/XyOrdinateDtoTests.cs @@ -0,0 +1,45 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.DTOs +{ + /// + /// Unit tests for : defaults, JSON round-trip, and camelCase wire names. + /// + [TestClass] + public class XyOrdinateDtoTests + { + /// + /// Verifies the defaults are zero. + /// + [TestMethod] + public void Defaults_AreExpected() + { + var point = new XyOrdinateDto(); + Assert.AreEqual(0d, point.X); + Assert.AreEqual(0d, point.Y); + } + + /// + /// Verifies both coordinates survive a JSON round-trip. + /// + [TestMethod] + public void Roundtrip_PreservesValues() + { + var copy = TestJson.Roundtrip(new XyOrdinateDto { X = 350.5, Y = 410.25 }); + Assert.AreEqual(350.5, copy.X); + Assert.AreEqual(410.25, copy.Y); + } + + /// + /// Verifies the serialized JSON uses camelCase wire names. + /// + [TestMethod] + public void Serialize_UsesCamelCaseWireNames() + { + string json = TestJson.Serialize(new XyOrdinateDto { X = 1d, Y = 2d }); + StringAssert.Contains(json, "\"x\""); + StringAssert.Contains(json, "\"y\""); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Helpers/AnalysisRunHelperTests.cs b/src/RMC.BestFit.Api.Tests/Helpers/AnalysisRunHelperTests.cs new file mode 100644 index 0000000..46ce00b --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Helpers/AnalysisRunHelperTests.cs @@ -0,0 +1,110 @@ +using Numerics.Distributions; +using RMC.BestFit.Analyses; +using RMC.BestFit.Api.Helpers; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; +using RMC.BestFit.Models; + +namespace RMC.BestFit.Api.Tests.Helpers +{ + /// + /// Unit tests for : the outcome-normalization contract + /// (exercised with hand-built completion args — no estimator runs) and validation dispatch. + /// + [TestClass] + public class AnalysisRunHelperTests + { + /// + /// Verifies validation dispatches to the wrapped analysis for each kind. + /// + [TestMethod] + public void Validate_DispatchesPerKind() + { + Assert.IsTrue(AnalysisRunHelper.Validate(TestAnalyses.CreateUnivariateResource()).IsValid); + Assert.IsTrue(AnalysisRunHelper.Validate(TestAnalyses.CreateBulletin17CResource()).IsValid); + Assert.IsTrue(AnalysisRunHelper.Validate(TestAnalyses.CreateRatingCurveResource()).IsValid); + } + + /// + /// Verifies an invalid configuration (decreasing probability ordinates, which the model + /// requires to be strictly increasing) is reported invalid with messages. + /// + [TestMethod] + public void Validate_DecreasingOrdinates_Invalid() + { + var resource = TestAnalyses.CreateUnivariateResource(); + resource.Univariate!.ProbabilityOrdinates.Clear(); + resource.Univariate.ProbabilityOrdinates.AddRange(new[] { 0.5, 0.1 }); + var (isValid, messages) = AnalysisRunHelper.Validate(resource); + Assert.IsFalse(isValid); + Assert.IsTrue(messages.Count > 0); + } + + /// + /// Verifies a successful completion passes the outcome check silently. + /// + [TestMethod] + public void ThrowIfRunFailed_Success_NoThrow() + { + var completion = new AnalysisRunCompletedEventArgs(wasCanceled: false, succeeded: true, error: null); + AnalysisRunHelper.ThrowIfRunFailed(completion, isEstimated: true, CancellationToken.None); + } + + /// + /// Verifies a cancelled completion maps to (HTTP 499). + /// + [TestMethod] + public void ThrowIfRunFailed_Cancelled_Throws499Path() + { + var completion = new AnalysisRunCompletedEventArgs(wasCanceled: true, succeeded: false, error: null); + Assert.ThrowsException( + () => AnalysisRunHelper.ThrowIfRunFailed(completion, isEstimated: false, CancellationToken.None)); + } + + /// + /// Verifies a request-token cancellation maps to + /// even when the completion args did not record it. + /// + [TestMethod] + public void ThrowIfRunFailed_TokenCancelled_Throws499Path() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var completion = new AnalysisRunCompletedEventArgs(wasCanceled: false, succeeded: true, error: null); + Assert.ThrowsException( + () => AnalysisRunHelper.ThrowIfRunFailed(completion, isEstimated: true, cts.Token)); + } + + /// + /// Verifies a captured model error surfaces with its message (HTTP 500 path). This is the + /// exception-swallowing pattern: the model reports failures only through the event args. + /// + [TestMethod] + public void ThrowIfRunFailed_CapturedError_ThrowsWithMessage() + { + var completion = new AnalysisRunCompletedEventArgs(wasCanceled: false, succeeded: false, error: new InvalidOperationException("sampler exploded")); + var ex = Assert.ThrowsException( + () => AnalysisRunHelper.ThrowIfRunFailed(completion, isEstimated: false, CancellationToken.None)); + StringAssert.Contains(ex.Message, "sampler exploded"); + } + + /// + /// Verifies the silent-failure paths (no event fired, unsuccessful completion, or an + /// unestimated fit) all throw rather than returning a 200-with-no-results. + /// + [TestMethod] + public void ThrowIfRunFailed_SilentFailures_Throw() + { + Assert.ThrowsException( + () => AnalysisRunHelper.ThrowIfRunFailed(null, isEstimated: false, CancellationToken.None)); + + var unsuccessful = new AnalysisRunCompletedEventArgs(wasCanceled: false, succeeded: false, error: null); + Assert.ThrowsException( + () => AnalysisRunHelper.ThrowIfRunFailed(unsuccessful, isEstimated: false, CancellationToken.None)); + + var succeededButNotEstimated = new AnalysisRunCompletedEventArgs(wasCanceled: false, succeeded: true, error: null); + Assert.ThrowsException( + () => AnalysisRunHelper.ThrowIfRunFailed(succeededButNotEstimated, isEstimated: false, CancellationToken.None)); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Helpers/EnumHelperTests.cs b/src/RMC.BestFit.Api.Tests/Helpers/EnumHelperTests.cs new file mode 100644 index 0000000..fbe549a --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Helpers/EnumHelperTests.cs @@ -0,0 +1,73 @@ +using RMC.BestFit.Api.Helpers; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Estimation; + +namespace RMC.BestFit.Api.Tests.Helpers +{ + /// + /// Unit tests for camelCase conversions. + /// + [TestClass] + public class EnumHelperTests + { + /// + /// Verifies single-name conversion matches the serializer's camelCase policy. + /// + [TestMethod] + public void ToCamelCase_MatchesJsonPolicy() + { + Assert.AreEqual("waterYear", EnumHelper.ToCamelCase("WaterYear")); + Assert.AreEqual("maximum", EnumHelper.ToCamelCase("Maximum")); + Assert.AreEqual("posteriorMode", EnumHelper.ToCamelCase("PosteriorMode")); + } + + /// + /// Verifies enum enumeration returns every member in camelCase. + /// + [TestMethod] + public void CamelCaseNames_ListsAllMembers() + { + var kinds = EnumHelper.CamelCaseNames(); + CollectionAssert.AreEqual( + new List + { + "univariate", "bulletin17C", "ratingCurve", "mixture", "pointProcess", + "competingRisks", "composite", "distributionFitting", "bivariate", + "coincidentFrequency", "timeSeries" + }, + kinds); + + var samplers = EnumHelper.CamelCaseNames(); + Assert.AreEqual(Enum.GetNames().Length, samplers.Count); + } + + /// + /// Verifies MCP-parameter parsing: camelCase and any-case values parse, blank falls back + /// to the default, and invalid values throw with the accepted list. + /// + [TestMethod] + public void ParseOrDefault_ParsesCaseInsensitively_AndFallsBack() + { + Assert.AreEqual(AnalysisKind.RatingCurve, EnumHelper.ParseOrDefault("ratingCurve", AnalysisKind.Univariate)); + Assert.AreEqual(AnalysisKind.RatingCurve, EnumHelper.ParseOrDefault("RATINGCURVE", AnalysisKind.Univariate)); + Assert.AreEqual(AnalysisKind.Univariate, EnumHelper.ParseOrDefault(null, AnalysisKind.Univariate)); + Assert.AreEqual(AnalysisKind.Univariate, EnumHelper.ParseOrDefault(" ", AnalysisKind.Univariate)); + + var ex = Assert.ThrowsException(() => EnumHelper.ParseOrDefault("bogus", AnalysisKind.Univariate)); + StringAssert.Contains(ex.Message, "univariate"); + } + + /// + /// Verifies nullable MCP-parameter parsing: blank yields null (model default stays + /// authoritative) and invalid values throw. + /// + [TestMethod] + public void ParseOrNull_BlankYieldsNull_InvalidThrows() + { + Assert.IsNull(EnumHelper.ParseOrNull(null)); + Assert.IsNull(EnumHelper.ParseOrNull("")); + Assert.AreEqual(AnalysisKind.Bulletin17C, EnumHelper.ParseOrNull("bulletin17C")); + Assert.ThrowsException(() => EnumHelper.ParseOrNull("bogus")); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Helpers/ResponseFiniteAuditorTests.cs b/src/RMC.BestFit.Api.Tests/Helpers/ResponseFiniteAuditorTests.cs new file mode 100644 index 0000000..4409a96 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Helpers/ResponseFiniteAuditorTests.cs @@ -0,0 +1,123 @@ +using RMC.BestFit.Api.Helpers; + +namespace RMC.BestFit.Api.Tests.Helpers +{ + /// + /// Unit tests for : infinity detection in nested objects, + /// arrays, and dictionaries; NaN tolerance; and cycle safety. + /// + [TestClass] + public class ResponseFiniteAuditorTests + { + /// + /// A small DTO graph for auditing, with nested object, arrays, and dictionary members. + /// + private sealed class Node + { + /// + /// A scalar double member. + /// + public double Scalar { get; set; } + + /// + /// A one-dimensional array member. + /// + public double[]? Values { get; set; } + + /// + /// A two-dimensional array member. + /// + public double[,]? Grid { get; set; } + + /// + /// A dictionary member. + /// + public Dictionary? Map { get; set; } + + /// + /// A child node (used to build cycles). + /// + public Node? Child { get; set; } + + /// + /// A date-time member (regression: DateTime.Date chains must not recurse infinitely). + /// + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; + + /// + /// A nullable date-time member. + /// + public DateTime? StartDate { get; set; } = DateTime.UtcNow; + } + + /// + /// Verifies a fully finite graph yields no findings, and null roots are tolerated. + /// + [TestMethod] + public void Audit_FiniteGraph_NoFindings() + { + var node = new Node { Scalar = 1d, Values = new[] { 1d, 2d }, Map = new Dictionary { ["a"] = 3d } }; + Assert.AreEqual(0, ResponseFiniteAuditor.Audit(node).Count); + Assert.AreEqual(0, ResponseFiniteAuditor.Audit(null).Count); + } + + /// + /// Verifies NaN values are intentionally NOT flagged (missing-data marker). + /// + [TestMethod] + public void Audit_NaN_IsIgnored() + { + var node = new Node { Scalar = double.NaN, Values = new[] { double.NaN } }; + Assert.AreEqual(0, ResponseFiniteAuditor.Audit(node).Count); + } + + /// + /// Verifies infinities are reported with paths from scalars, 1D and 2D arrays, and dictionaries. + /// + [TestMethod] + public void Audit_Infinity_ReportedWithPaths() + { + var node = new Node + { + Scalar = double.PositiveInfinity, + Values = new[] { 1d, double.NegativeInfinity }, + Grid = new[,] { { 1d, double.PositiveInfinity } }, + Map = new Dictionary { ["bad"] = double.PositiveInfinity } + }; + + var findings = ResponseFiniteAuditor.Audit(node); + + Assert.AreEqual(4, findings.Count); + Assert.IsTrue(findings.Any(f => f.Contains(".Scalar"))); + Assert.IsTrue(findings.Any(f => f.Contains(".Values[1]"))); + Assert.IsTrue(findings.Any(f => f.Contains(".Grid[0,1]"))); + Assert.IsTrue(findings.Any(f => f.Contains(".Map[bad]"))); + } + + /// + /// Verifies reference cycles do not cause infinite recursion. + /// + [TestMethod] + public void Audit_Cycle_Terminates() + { + var a = new Node { Scalar = double.PositiveInfinity }; + var b = new Node { Child = a }; + a.Child = b; + var findings = ResponseFiniteAuditor.Audit(a); + Assert.AreEqual(1, findings.Count); + } + + /// + /// Regression: DateTime members must be treated as leaves. DateTime.Date returns a fresh + /// DateTime whose Date returns another, so walking its properties recurses without bound + /// (the reference-identity cycle guard cannot break value-type chains). + /// + [TestMethod] + public void Audit_DateTimeMembers_TerminateWithoutFindings() + { + var node = new Node { Scalar = 1d }; + var findings = ResponseFiniteAuditor.Audit(node); + Assert.AreEqual(0, findings.Count); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Integration/ApiIntegrationTests.cs b/src/RMC.BestFit.Api.Tests/Integration/ApiIntegrationTests.cs new file mode 100644 index 0000000..8ab5888 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Integration/ApiIntegrationTests.cs @@ -0,0 +1,620 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Integration +{ + /// + /// In-process integration tests exercising the full HTTP pipeline (routing, model binding, + /// JSON options, status-code mapping) with the USGS seam faked at the DI level. No network + /// access and no estimator runs — the suite completes in well under a second of compute. + /// + [TestClass] + public class ApiIntegrationTests + { + /// + /// The shared application factory with the faked USGS service. + /// + private static WebApplicationFactory _factory = null!; + + /// + /// The faked USGS seam injected into the host. + /// + private static FakeUsgsTimeSeriesService _usgs = null!; + + /// + /// JSON options matching the API contract for response deserialization. + /// + private static readonly JsonSerializerOptions JsonOptions = TestJson.Options; + + /// + /// Boots the in-process host once for the test class. + /// + /// The MSTest context (unused). + [ClassInitialize] + public static void ClassInitialize(TestContext context) + { + _usgs = new FakeUsgsTimeSeriesService { Result = TestSeries.DailyThreeWaterYears() }; + _factory = new WebApplicationFactory().WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.AddSingleton(_usgs); + }); + }); + } + + /// + /// Disposes the in-process host. + /// + [ClassCleanup] + public static void ClassCleanup() + { + _factory.Dispose(); + } + + /// + /// Verifies the health and info endpoints respond. + /// + [TestMethod] + public async Task Health_And_Info_Respond() + { + using var client = _factory.CreateClient(); + + var health = await client.GetAsync("/health"); + Assert.AreEqual(HttpStatusCode.OK, health.StatusCode); + + var info = await client.GetFromJsonAsync("/api/info", JsonOptions); + Assert.IsNotNull(info); + Assert.AreEqual("RMC-BestFit API", info.Name); + } + + /// + /// Verifies the metadata endpoints respond with populated listings. + /// + [TestMethod] + public async Task Metadata_Endpoints_Respond() + { + using var client = _factory.CreateClient(); + + var enums = await client.GetFromJsonAsync("/api/metadata/enums", JsonOptions); + Assert.IsNotNull(enums); + Assert.IsTrue(enums.Samplers.Count > 0); + + var distributions = await client.GetFromJsonAsync("/api/metadata/distributions", JsonOptions); + Assert.IsNotNull(distributions); + Assert.IsTrue(distributions.Distributions.Any(d => d.Name == "logPearsonTypeIII")); + + var defaults = await client.GetFromJsonAsync("/api/metadata/defaults", JsonOptions); + Assert.IsNotNull(defaults); + Assert.IsTrue(defaults.ProbabilityOrdinates.Count > 0); + } + + /// + /// Verifies the multi-step resource workflow over HTTP: USGS time series → block-max + /// input data → detail with observations. + /// + [TestMethod] + public async Task Workflow_UsgsSeries_To_BlockMaxInputData() + { + using var client = _factory.CreateClient(); + + // Step 1: create a time series from the (faked) USGS download. + var seriesResponse = await client.PostAsJsonAsync("/api/timeseries/usgs", + new CreateUsgsTimeSeriesRequest { SiteNumber = "01646500" }, JsonOptions); + Assert.AreEqual(HttpStatusCode.Created, seriesResponse.StatusCode); + var series = await seriesResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.IsNotNull(series?.TimeSeries); + + // Step 2: extract water-year block maxima from the stored series. + var inputDataResponse = await client.PostAsJsonAsync("/api/inputdata/block-max", + new CreateBlockMaxInputDataRequest { TimeSeriesId = series.TimeSeries.Id }, JsonOptions); + Assert.AreEqual(HttpStatusCode.Created, inputDataResponse.StatusCode); + var inputData = await inputDataResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.IsNotNull(inputData?.InputData); + Assert.AreEqual(3, inputData.InputData.ExactCount); + Assert.AreEqual(series.TimeSeries.Id, inputData.InputData.SourceTimeSeriesId); + + // Step 3: fetch the detail with observations and verify the planted maxima survived + // the HTTP round-trip. + var detail = await client.GetFromJsonAsync( + $"/api/inputdata/{inputData.InputData.Id}?includeData=true", JsonOptions); + Assert.IsNotNull(detail?.ExactData); + var values = detail.ExactData.Select(o => o.Value).OrderBy(v => v).ToList(); + CollectionAssert.AreEqual(new List { 500d, 650d, 800d }, values); + } + + /// + /// Verifies the error contract over HTTP: unknown ids return 404 bodies with + /// success=false, and invalid requests return 400 with validation errors. + /// + [TestMethod] + public async Task ErrorContract_404_And_400() + { + using var client = _factory.CreateClient(); + + var notFound = await client.GetAsync($"/api/timeseries/{Guid.NewGuid()}"); + Assert.AreEqual(HttpStatusCode.NotFound, notFound.StatusCode); + var notFoundBody = await notFound.Content.ReadFromJsonAsync(JsonOptions); + Assert.IsNotNull(notFoundBody); + Assert.IsFalse(notFoundBody.Success); + Assert.IsNotNull(notFoundBody.ErrorMessage); + + var badRequest = await client.PostAsJsonAsync("/api/inputdata/manual", new CreateManualInputDataRequest + { + ExactData = new List { new() { Index = 2000, Value = 100d } }, + ThresholdData = new List { new() { StartIndex = 1950, EndIndex = 1900, Value = 1d } } + }, JsonOptions); + Assert.AreEqual(HttpStatusCode.BadRequest, badRequest.StatusCode); + var badRequestBody = await badRequest.Content.ReadFromJsonAsync(JsonOptions); + Assert.IsNotNull(badRequestBody); + Assert.IsFalse(badRequestBody.Success); + Assert.IsNotNull(badRequestBody.ValidationErrors); + } + + /// + /// Verifies the analysis workflow over HTTP without running an estimator: create manual + /// input data → create a univariate analysis → validate → results are 404 before any run + /// → the Bulletin 17C route does not see the univariate id (kind guard). + /// + [TestMethod] + public async Task Workflow_CreateAnalysis_Validate_ResultsBeforeRun() + { + using var client = _factory.CreateClient(); + + var inputDataResponse = await client.PostAsJsonAsync("/api/inputdata/manual", new CreateManualInputDataRequest + { + ExactData = Enumerable.Range(0, 20) + .Select(i => new ExactObservationDto { Index = 2000 + i, Value = 100d + 20d * i }) + .ToList() + }, JsonOptions); + Assert.AreEqual(HttpStatusCode.Created, inputDataResponse.StatusCode); + var inputData = await inputDataResponse.Content.ReadFromJsonAsync(JsonOptions); + + var createResponse = await client.PostAsJsonAsync("/api/analyses/univariate", new CreateUnivariateAnalysisRequest + { + InputDataId = inputData!.InputData!.Id, + ProbabilityOrdinates = new List { 0.5, 0.1, 0.01 } + }, JsonOptions); + Assert.AreEqual(HttpStatusCode.Created, createResponse.StatusCode); + var created = await createResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.IsNotNull(created?.Analysis); + Assert.IsTrue(created.Analysis.IsValid); + var analysisId = created.Analysis.Id; + + var validation = await client.GetFromJsonAsync( + $"/api/analyses/univariate/{analysisId}/validate", JsonOptions); + Assert.IsNotNull(validation); + Assert.IsTrue(validation.IsValid); + + var resultsBeforeRun = await client.GetAsync($"/api/analyses/univariate/{analysisId}/results"); + Assert.AreEqual(HttpStatusCode.NotFound, resultsBeforeRun.StatusCode); + + var wrongKind = await client.GetAsync($"/api/analyses/bulletin17c/{analysisId}"); + Assert.AreEqual(HttpStatusCode.NotFound, wrongKind.StatusCode); + } + + /// + /// Smoke-tests the MCP endpoint: in stateless mode a bare JSON-RPC tools/list request must + /// return the registered tool set without a prior initialize handshake. + /// + [TestMethod] + public async Task Mcp_ToolsList_ReturnsRegisteredTools() + { + using var client = _factory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") + { + Content = new StringContent( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}", + System.Text.Encoding.UTF8, "application/json") + }; + request.Headers.Accept.ParseAdd("application/json"); + request.Headers.Accept.ParseAdd("text/event-stream"); + + var response = await client.SendAsync(request); + string body = await response.Content.ReadAsStringAsync(); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, $"MCP endpoint failed: {body}"); + StringAssert.Contains(body, "usgs_download_timeseries"); + StringAssert.Contains(body, "run_usgs_bulletin17c_workflow"); + StringAssert.Contains(body, "run_analysis"); + } + + /// + /// Verifies enum strings bind case-insensitively in camelCase over HTTP and the resources + /// overview reflects created resources. + /// + [TestMethod] + public async Task EnumBinding_And_ResourcesOverview() + { + using var client = _factory.CreateClient(); + + var response = await client.PostAsync("/api/timeseries/usgs", + new StringContent("{\"siteNumber\":\"01646500\",\"seriesType\":\"peakDischarge\"}", + System.Text.Encoding.UTF8, "application/json")); + Assert.AreEqual(HttpStatusCode.Created, response.StatusCode); + var created = await response.Content.ReadFromJsonAsync(JsonOptions); + Assert.AreEqual("peakDischarge", created!.TimeSeries!.SeriesType); + + var overview = await client.GetFromJsonAsync("/api/resources", JsonOptions); + Assert.IsNotNull(overview); + Assert.IsTrue(overview.TimeSeriesCount >= 1); + } + + /// + /// End-to-end Bayesian-inputs flow over HTTP: manual input data with an uncertain + /// observation, a prior-informed univariate analysis, and a Bulletin 17C analysis that + /// warns about the ignored uncertain data. No estimator runs. + /// + [TestMethod] + public async Task BayesianInputs_UncertainData_Priors_Penalties_Flow() + { + using var client = _factory.CreateClient(); + + // Manual input data with an uncertain (paleoflood-style) observation. + var inputRequest = new CreateManualInputDataRequest + { + ExactData = Enumerable.Range(0, 20) + .Select(i => new ExactObservationDto { Index = 2000 + i, Value = 100d + 20d * i }) + .ToList(), + UncertainData = new List + { + new() + { + Index = 1875, + Distribution = new DistributionSpecDto + { + Type = Numerics.Distributions.UnivariateDistributionType.Triangular, + Parameters = new List { 400d, 550d, 900d } + } + } + } + }; + var inputResponse = await client.PostAsJsonAsync("/api/inputdata/manual", inputRequest, JsonOptions); + Assert.AreEqual(HttpStatusCode.Created, inputResponse.StatusCode); + var input = await inputResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.AreEqual(1, input!.InputData!.UncertainCount); + + // The detail response echoes the uncertain observation with its distribution spec. + var detail = await client.GetFromJsonAsync( + $"/api/inputdata/{input.InputData.Id}?includeData=true", JsonOptions); + Assert.IsNotNull(detail!.UncertainData); + Assert.AreEqual(1875, detail.UncertainData![0].Index); + Assert.IsNotNull(detail.UncertainData[0].Value); + + // A univariate analysis with an informative skew prior and a quantile prior. + var univariateRequest = new CreateUnivariateAnalysisRequest + { + InputDataId = input.InputData.Id, + ParameterPriors = new List + { + new() + { + ParameterName = "Skew (of log)", + Distribution = new DistributionSpecDto + { + Type = Numerics.Distributions.UnivariateDistributionType.Normal, + Parameters = new List { -0.2, 0.3 } + } + } + }, + QuantilePriors = new List + { + new() + { + Alpha = 0.01, + Distribution = new DistributionSpecDto + { + Type = Numerics.Distributions.UnivariateDistributionType.LogNormal, + Parameters = new List { 6.5, 0.2 } + } + } + }, + UseSingleQuantile = true + }; + var univariateResponse = await client.PostAsJsonAsync("/api/analyses/univariate", univariateRequest, JsonOptions); + Assert.AreEqual(HttpStatusCode.Created, univariateResponse.StatusCode); + var univariate = await univariateResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.IsTrue(univariate!.Analysis!.IsValid); + + // An unknown prior parameter name is a 400 with the valid names listed. + univariateRequest.ParameterPriors![0].ParameterName = "No Such Parameter"; + var badPrior = await client.PostAsJsonAsync("/api/analyses/univariate", univariateRequest, JsonOptions); + Assert.AreEqual(HttpStatusCode.BadRequest, badPrior.StatusCode); + + // A Bulletin 17C analysis with a regional-skew penalty warns about the ignored + // uncertain observation on both the create and validate responses. + var b17cRequest = new CreateBulletin17CAnalysisRequest + { + InputDataId = input.InputData.Id, + ParameterPenalties = new List + { + new() { ParameterName = "Skew (of log)", Mean = -0.05, Mse = 0.12 } + } + }; + var b17cResponse = await client.PostAsJsonAsync("/api/analyses/bulletin17c", b17cRequest, JsonOptions); + Assert.AreEqual(HttpStatusCode.Created, b17cResponse.StatusCode); + var b17c = await b17cResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.IsNotNull(b17c!.Analysis!.Warnings); + StringAssert.Contains(b17c.Analysis.Warnings![0], "uncertain observation"); + + var validation = await client.GetFromJsonAsync( + $"/api/analyses/bulletin17c/{b17c.Analysis.Id}/validate", JsonOptions); + Assert.AreEqual(1, validation!.Warnings.Count); + } + + /// + /// End-to-end create→validate→kind-guard flow over HTTP for the mixture, point process, + /// and competing risks endpoints. No estimator runs. + /// + [TestMethod] + public async Task Phase4FrequencyKinds_Create_Validate_KindGuard() + { + using var client = _factory.CreateClient(); + + var inputRequest = new CreateManualInputDataRequest + { + ExactData = Enumerable.Range(0, 20) + .Select(i => new ExactObservationDto { Index = 2000 + i, Value = 100d + 20d * i }) + .ToList() + }; + var inputResponse = await client.PostAsJsonAsync("/api/inputdata/manual", inputRequest, JsonOptions); + var input = await inputResponse.Content.ReadFromJsonAsync(JsonOptions); + Guid inputId = input!.InputData!.Id; + + // Mixture. + var mixtureResponse = await client.PostAsJsonAsync("/api/analyses/mixture", new CreateMixtureAnalysisRequest + { + InputDataId = inputId, + Distributions = new List + { + Numerics.Distributions.UnivariateDistributionType.Gumbel, + Numerics.Distributions.UnivariateDistributionType.LogNormal + } + }, JsonOptions); + Assert.AreEqual(HttpStatusCode.Created, mixtureResponse.StatusCode); + var mixture = await mixtureResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.AreEqual("mixture", mixture!.Analysis!.Kind); + + var mixtureValidation = await client.GetFromJsonAsync( + $"/api/analyses/mixture/{mixture.Analysis.Id}/validate", JsonOptions); + Assert.IsTrue(mixtureValidation!.IsValid); + + // Competing risks. + var competingResponse = await client.PostAsJsonAsync("/api/analyses/competingrisks", new CreateCompetingRisksAnalysisRequest + { + InputDataId = inputId, + Distributions = new List + { + Numerics.Distributions.UnivariateDistributionType.Gumbel, + Numerics.Distributions.UnivariateDistributionType.GeneralizedExtremeValue + } + }, JsonOptions); + Assert.AreEqual(HttpStatusCode.Created, competingResponse.StatusCode); + var competing = await competingResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.AreEqual("competingRisks", competing!.Analysis!.Kind); + + // Point process over a POT extraction from a stored time series. + var seriesResponse = await client.PostAsync("/api/timeseries/usgs", + new StringContent("{\"siteNumber\":\"01646500\",\"seriesType\":\"dailyDischarge\"}", + System.Text.Encoding.UTF8, "application/json")); + var series = await seriesResponse.Content.ReadFromJsonAsync(JsonOptions); + var potResponse = await client.PostAsJsonAsync("/api/inputdata/peaks-over-threshold", new CreatePotInputDataRequest + { + TimeSeriesId = series!.TimeSeries!.Id, + Threshold = 400d, + MinStepsBetweenPeaks = 7 + }, JsonOptions); + Assert.AreEqual(HttpStatusCode.Created, potResponse.StatusCode); + var pot = await potResponse.Content.ReadFromJsonAsync(JsonOptions); + + var pointProcessResponse = await client.PostAsJsonAsync("/api/analyses/pointprocess", new CreatePointProcessAnalysisRequest + { + InputDataId = pot!.InputData!.Id + }, JsonOptions); + Assert.AreEqual(HttpStatusCode.Created, pointProcessResponse.StatusCode); + var pointProcess = await pointProcessResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.AreEqual("pointProcess", pointProcess!.Analysis!.Kind); + Assert.IsNotNull(pointProcess.Analysis.Threshold, "The POT threshold must seed the analysis."); + + // Kind guards: each new id 404s on a different kind's route. + var guard1 = await client.GetAsync($"/api/analyses/univariate/{mixture.Analysis.Id}"); + Assert.AreEqual(HttpStatusCode.NotFound, guard1.StatusCode); + var guard2 = await client.GetAsync($"/api/analyses/mixture/{competing.Analysis.Id}"); + Assert.AreEqual(HttpStatusCode.NotFound, guard2.StatusCode); + var guard3 = await client.GetAsync($"/api/analyses/pointprocess/{mixture.Analysis.Id}"); + Assert.AreEqual(HttpStatusCode.NotFound, guard3.StatusCode); + + // Results before any run are 404s. + var results = await client.GetAsync($"/api/analyses/mixture/{mixture.Analysis.Id}/results"); + Assert.AreEqual(HttpStatusCode.NotFound, results.StatusCode); + } + + /// + /// End-to-end composite and distribution-fitting flow over HTTP: create a composite over + /// two unrun univariate components (validate reports they require estimation), and create + /// a fitting screen with a candidate subset. No estimator runs. + /// + [TestMethod] + public async Task CompositeAndFitting_Create_Validate_Flow() + { + using var client = _factory.CreateClient(); + + var inputRequest = new CreateManualInputDataRequest + { + ExactData = Enumerable.Range(0, 20) + .Select(i => new ExactObservationDto { Index = 2000 + i, Value = 100d + 20d * i }) + .ToList() + }; + var inputResponse = await client.PostAsJsonAsync("/api/inputdata/manual", inputRequest, JsonOptions); + var input = await inputResponse.Content.ReadFromJsonAsync(JsonOptions); + Guid inputId = input!.InputData!.Id; + + var componentA = await (await client.PostAsJsonAsync("/api/analyses/univariate", + new CreateUnivariateAnalysisRequest { InputDataId = inputId }, JsonOptions)) + .Content.ReadFromJsonAsync(JsonOptions); + var componentB = await (await client.PostAsJsonAsync("/api/analyses/univariate", + new CreateUnivariateAnalysisRequest { InputDataId = inputId }, JsonOptions)) + .Content.ReadFromJsonAsync(JsonOptions); + + var compositeResponse = await client.PostAsJsonAsync("/api/analyses/composite", new CreateCompositeAnalysisRequest + { + Components = new List + { + new() { AnalysisId = componentA!.Analysis!.Id }, + new() { AnalysisId = componentB!.Analysis!.Id } + } + }, JsonOptions); + Assert.AreEqual(HttpStatusCode.Created, compositeResponse.StatusCode); + var composite = await compositeResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.AreEqual("composite", composite!.Analysis!.Kind); + + var compositeValidation = await client.GetFromJsonAsync( + $"/api/analyses/composite/{composite.Analysis.Id}/validate", JsonOptions); + Assert.IsFalse(compositeValidation!.IsValid, "Unrun components must fail composite validation."); + + // A rating curve id is rejected as a composite component with a 400. + var badComponent = await client.PostAsJsonAsync("/api/analyses/composite", new CreateCompositeAnalysisRequest + { + Components = new List { new() { AnalysisId = composite.Analysis.Id } } + }, JsonOptions); + Assert.AreEqual(HttpStatusCode.BadRequest, badComponent.StatusCode); + + var fittingResponse = await client.PostAsJsonAsync("/api/analyses/distributionfitting", + new CreateDistributionFittingAnalysisRequest + { + InputDataId = inputId, + Distributions = new List + { + Numerics.Distributions.UnivariateDistributionType.LogPearsonTypeIII, + Numerics.Distributions.UnivariateDistributionType.Gumbel + } + }, JsonOptions); + Assert.AreEqual(HttpStatusCode.Created, fittingResponse.StatusCode); + var fitting = await fittingResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.AreEqual("distributionFitting", fitting!.Analysis!.Kind); + Assert.AreEqual(2, fitting.Analysis.ComponentDistributions!.Count); + + var fittingResults = await client.GetAsync($"/api/analyses/distributionfitting/{fitting.Analysis.Id}/results"); + Assert.AreEqual(HttpStatusCode.NotFound, fittingResults.StatusCode); + } + + /// + /// End-to-end bivariate and coincident frequency flow over HTTP: create a bivariate over + /// two unrun marginals (validate names them), then a coincident frequency analysis over + /// it (validate reports the unestimated bivariate). No estimator runs. + /// + [TestMethod] + public async Task BivariateAndCoincidentFrequency_Create_Validate_Flow() + { + using var client = _factory.CreateClient(); + + var inputRequest = new CreateManualInputDataRequest + { + ExactData = Enumerable.Range(0, 20) + .Select(i => new ExactObservationDto { Index = 2000 + i, Value = 100d + 20d * i }) + .ToList() + }; + var inputResponse = await client.PostAsJsonAsync("/api/inputdata/manual", inputRequest, JsonOptions); + var input = await inputResponse.Content.ReadFromJsonAsync(JsonOptions); + Guid inputId = input!.InputData!.Id; + + var marginalX = await (await client.PostAsJsonAsync("/api/analyses/univariate", + new CreateUnivariateAnalysisRequest { InputDataId = inputId }, JsonOptions)) + .Content.ReadFromJsonAsync(JsonOptions); + var marginalY = await (await client.PostAsJsonAsync("/api/analyses/univariate", + new CreateUnivariateAnalysisRequest { InputDataId = inputId }, JsonOptions)) + .Content.ReadFromJsonAsync(JsonOptions); + + var bivariateResponse = await client.PostAsJsonAsync("/api/analyses/bivariate", new CreateBivariateAnalysisRequest + { + MarginalXAnalysisId = marginalX!.Analysis!.Id, + MarginalYAnalysisId = marginalY!.Analysis!.Id, + XyOrdinates = new List { new() { X = 300d, Y = 320d } } + }, JsonOptions); + Assert.AreEqual(HttpStatusCode.Created, bivariateResponse.StatusCode); + var bivariate = await bivariateResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.AreEqual("bivariate", bivariate!.Analysis!.Kind); + + var bivariateValidation = await client.GetFromJsonAsync( + $"/api/analyses/bivariate/{bivariate.Analysis.Id}/validate", JsonOptions); + Assert.IsFalse(bivariateValidation!.IsValid); + Assert.IsTrue(bivariateValidation.Errors.Any(e => e.Contains("marginal"))); + + var cfaResponse = await client.PostAsJsonAsync("/api/analyses/coincidentfrequency", new CreateCoincidentFrequencyAnalysisRequest + { + BivariateAnalysisId = bivariate.Analysis.Id, + XValues = new List { 100d, 200d }, + YValues = new List { 50d, 100d }, + BivariateResponse = new List> + { + new() { 10d, 11d }, + new() { 12d, 13d } + } + }, JsonOptions); + Assert.AreEqual(HttpStatusCode.Created, cfaResponse.StatusCode); + var cfa = await cfaResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.AreEqual("coincidentFrequency", cfa!.Analysis!.Kind); + Assert.AreEqual(bivariate.Analysis.Id, cfa.Analysis.BivariateAnalysisId); + + var cfaValidation = await client.GetFromJsonAsync( + $"/api/analyses/coincidentfrequency/{cfa.Analysis.Id}/validate", JsonOptions); + Assert.IsFalse(cfaValidation!.IsValid); + + // Kind guards across the new routes. + var guard = await client.GetAsync($"/api/analyses/bivariate/{cfa.Analysis.Id}"); + Assert.AreEqual(HttpStatusCode.NotFound, guard.StatusCode); + } + + /// + /// End-to-end time-series flow over HTTP: create each model family against a stored + /// series, verify validation and the irrelevant-field rejection. No estimator runs. + /// + [TestMethod] + public async Task TimeSeries_Create_Validate_Flow() + { + using var client = _factory.CreateClient(); + + var seriesResponse = await client.PostAsync("/api/timeseries/usgs", + new StringContent("{\"siteNumber\":\"01646500\",\"seriesType\":\"dailyDischarge\"}", + System.Text.Encoding.UTF8, "application/json")); + var series = await seriesResponse.Content.ReadFromJsonAsync(JsonOptions); + Guid seriesId = series!.TimeSeries!.Id; + + foreach (string modelType in new[] { "ar", "ma", "arima", "arimax" }) + { + var createResponse = await client.PostAsync("/api/analyses/timeseries", + new StringContent($"{{\"timeSeriesId\":\"{seriesId}\",\"modelType\":\"{modelType}\"}}", + System.Text.Encoding.UTF8, "application/json")); + Assert.AreEqual(HttpStatusCode.Created, createResponse.StatusCode, $"Create must succeed for {modelType}."); + var created = await createResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.AreEqual("timeSeries", created!.Analysis!.Kind); + Assert.AreEqual(modelType, created.Analysis.TimeSeriesModelType); + + var validation = await client.GetFromJsonAsync( + $"/api/analyses/timeseries/{created.Analysis.Id}/validate", JsonOptions); + Assert.IsTrue(validation!.IsValid, $"A default {modelType} configuration must validate."); + } + + // An irrelevant field is rejected with the offending field named. + var badResponse = await client.PostAsJsonAsync("/api/analyses/timeseries", new CreateTimeSeriesAnalysisRequest + { + TimeSeriesId = seriesId, + ModelType = Api.Store.TimeSeriesModelType.Ar, + IncludeSeasonality = true + }, JsonOptions); + Assert.AreEqual(HttpStatusCode.BadRequest, badResponse.StatusCode); + + // A time-series id 404s on the univariate route and vice versa. + var listResponse = await client.GetFromJsonAsync("/api/analyses/timeseries", JsonOptions); + var anyTimeSeries = listResponse!.Analyses.First(); + var crossGuard = await client.GetAsync($"/api/analyses/univariate/{anyTimeSeries.Id}"); + Assert.AreEqual(HttpStatusCode.NotFound, crossGuard.StatusCode); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Mappers/AnalysisMapperTests.cs b/src/RMC.BestFit.Api.Tests/Mappers/AnalysisMapperTests.cs new file mode 100644 index 0000000..3c454a6 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Mappers/AnalysisMapperTests.cs @@ -0,0 +1,121 @@ +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Mappers +{ + /// + /// Unit tests for : kind-specific summary fields and live validation. + /// + [TestClass] + public class AnalysisMapperTests + { + /// + /// Verifies the univariate summary reports kind, distribution, state, and validity. + /// + [TestMethod] + public void ToSummary_Univariate_MapsKindAndDistribution() + { + var resource = TestAnalyses.CreateUnivariateResource(); + var summary = AnalysisMapper.ToSummary(resource); + + Assert.AreEqual(resource.Id, summary.Id); + Assert.AreEqual("univariate", summary.Kind); + Assert.AreEqual("logPearsonTypeIII", summary.Distribution); + Assert.AreEqual("created", summary.State); + Assert.IsTrue(summary.IsValid); + Assert.IsNull(summary.ValidationMessages); + Assert.IsNull(summary.Warnings, "No creation warnings means the field is omitted."); + Assert.IsNull(summary.UncertaintyMethod); + Assert.IsNull(summary.NumberOfSegments); + } + + /// + /// Verifies creation warnings recorded on the resource surface on the summary. + /// + [TestMethod] + public void ToSummary_CreationWarnings_Surfaced() + { + var analysis = TestAnalyses.CreateBulletin17CResource(); + var resource = new AnalysisResource + { + Name = analysis.Name, + Kind = analysis.Kind, + Bulletin17C = analysis.Bulletin17C, + CreationWarnings = new[] { "uncertain observations are ignored" } + }; + + var summary = AnalysisMapper.ToSummary(resource); + + Assert.IsNotNull(summary.Warnings); + Assert.AreEqual(1, summary.Warnings.Count); + StringAssert.Contains(summary.Warnings[0], "ignored"); + } + + /// + /// Verifies the Bulletin 17C summary reports its uncertainty method. + /// + [TestMethod] + public void ToSummary_Bulletin17C_MapsUncertaintyMethod() + { + var resource = TestAnalyses.CreateBulletin17CResource(); + var summary = AnalysisMapper.ToSummary(resource); + + Assert.AreEqual("bulletin17C", summary.Kind); + Assert.AreEqual("logPearsonTypeIII", summary.Distribution); + // The model's default uncertainty method is LinkedMultivariateNormal; the API leaves + // it authoritative when the create request omits the field. + Assert.AreEqual("linkedMultivariateNormal", summary.UncertaintyMethod); + } + + /// + /// Verifies the rating curve summary reports segments and no distribution. + /// + [TestMethod] + public void ToSummary_RatingCurve_MapsSegments() + { + var resource = TestAnalyses.CreateRatingCurveResource(); + var summary = AnalysisMapper.ToSummary(resource); + + Assert.AreEqual("ratingCurve", summary.Kind); + Assert.IsNull(summary.Distribution); + Assert.AreEqual(1, summary.NumberOfSegments); + } + + /// + /// Verifies run-state bookkeeping fields flow into the summary. + /// + [TestMethod] + public void ToSummary_RunState_Mapped() + { + var resource = TestAnalyses.CreateUnivariateResource(); + resource.State = AnalysisRunState.Failed; + resource.LastError = "boom"; + resource.LastRunUtc = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc); + resource.LastRunMs = 1234; + + var summary = AnalysisMapper.ToSummary(resource); + + Assert.AreEqual("failed", summary.State); + Assert.AreEqual("boom", summary.LastError); + Assert.AreEqual(1234, summary.LastRunMs); + Assert.IsNotNull(summary.LastRunUtc); + } + + /// + /// Verifies the list response carries the count and one summary per resource. + /// + [TestMethod] + public void ToListResponse_MapsAllResources() + { + var resources = new List + { + TestAnalyses.CreateUnivariateResource(), + TestAnalyses.CreateRatingCurveResource() + }; + var response = AnalysisMapper.ToListResponse(resources); + Assert.AreEqual(2, response.Count); + Assert.AreEqual(2, response.Analyses.Count); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Mappers/BayesianOptionsMapperTests.cs b/src/RMC.BestFit.Api.Tests/Mappers/BayesianOptionsMapperTests.cs new file mode 100644 index 0000000..1bacfa2 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Mappers/BayesianOptionsMapperTests.cs @@ -0,0 +1,128 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Tests.Support; +using RMC.BestFit.Estimation; + +namespace RMC.BestFit.Api.Tests.Mappers +{ + /// + /// Unit tests for : null-field passthrough, assignment, + /// simulation-defaults switching, and the iteration cap. + /// + [TestClass] + public class BayesianOptionsMapperTests + { + /// + /// Builds a fresh Bayesian analysis attached to a small univariate model. + /// + /// The Bayesian analysis. + private static BayesianAnalysis CreateBayesianAnalysis() + { + return TestAnalyses.CreateUnivariateResource().Univariate!.BayesianAnalysis; + } + + /// + /// Verifies a null options object leaves every setting untouched. + /// + [TestMethod] + public void Apply_NullOptions_LeavesDefaults() + { + var bayesian = CreateBayesianAnalysis(); + bool defaultUseSimulationDefaults = bayesian.UseSimulationDefaults; + int defaultIterations = bayesian.Iterations; + + BayesianOptionsMapper.Apply(bayesian, null, maxIterations: 500_000); + + Assert.AreEqual(defaultUseSimulationDefaults, bayesian.UseSimulationDefaults); + Assert.AreEqual(defaultIterations, bayesian.Iterations); + } + + /// + /// Verifies supplied fields are assigned and simulation defaults are switched off so the + /// run does not overwrite them. + /// + [TestMethod] + public void Apply_SimulationFields_AssignedAndDefaultsDisabled() + { + var bayesian = CreateBayesianAnalysis(); + var options = new BayesianOptionsDto + { + Sampler = BayesianAnalysis.SamplerType.DEMCz, + Iterations = 5000, + WarmupIterations = 2500, + ThinningInterval = 5, + NumberOfChains = 4, + PrngSeed = 12345, + CredibleIntervalWidth = 0.95, + OutputLength = 2000, + PointEstimator = BayesianAnalysis.PointEstimateType.PosteriorMean + }; + + BayesianOptionsMapper.Apply(bayesian, options, maxIterations: 500_000); + + Assert.IsFalse(bayesian.UseSimulationDefaults); + Assert.AreEqual(BayesianAnalysis.SamplerType.DEMCz, bayesian.Type); + Assert.AreEqual(5000, bayesian.Iterations); + Assert.AreEqual(2500, bayesian.WarmupIterations); + Assert.AreEqual(5, bayesian.ThinningInterval); + Assert.AreEqual(4, bayesian.NumberOfChains); + Assert.AreEqual(12345, bayesian.PRNGSeed); + Assert.AreEqual(0.95, bayesian.CredibleIntervalWidth); + Assert.AreEqual(2000, bayesian.OutputLength); + Assert.AreEqual(BayesianAnalysis.PointEstimateType.PosteriorMean, bayesian.PointEstimator); + } + + /// + /// Verifies non-simulation fields (point estimator, CI width, seed) do not switch off the + /// automatic simulation defaults. + /// + [TestMethod] + public void Apply_NonSimulationFields_KeepSimulationDefaults() + { + var bayesian = CreateBayesianAnalysis(); + bool defaultUseSimulationDefaults = bayesian.UseSimulationDefaults; + + BayesianOptionsMapper.Apply(bayesian, new BayesianOptionsDto + { + PointEstimator = BayesianAnalysis.PointEstimateType.PosteriorMean, + CredibleIntervalWidth = 0.8, + PrngSeed = 42 + }, maxIterations: 500_000); + + Assert.AreEqual(defaultUseSimulationDefaults, bayesian.UseSimulationDefaults); + Assert.AreEqual(0.8, bayesian.CredibleIntervalWidth); + } + + /// + /// Verifies iterations above the server cap are rejected. + /// + [TestMethod] + public void Apply_IterationsOverCap_Throws() + { + var bayesian = CreateBayesianAnalysis(); + var ex = Assert.ThrowsException(() => + BayesianOptionsMapper.Apply(bayesian, new BayesianOptionsDto { Iterations = 1_000_000 }, maxIterations: 500_000)); + StringAssert.Contains(ex.Message, "500000"); + } + + /// + /// Verifies warm-up must be below iterations when both are supplied. + /// + [TestMethod] + public void Apply_WarmupNotBelowIterations_Throws() + { + var bayesian = CreateBayesianAnalysis(); + Assert.ThrowsException(() => + BayesianOptionsMapper.Apply(bayesian, new BayesianOptionsDto { Iterations = 1000, WarmupIterations = 1000 }, maxIterations: 500_000)); + } + + /// + /// Verifies the null guard on the target analysis. + /// + [TestMethod] + public void Apply_NullAnalysis_Throws() + { + Assert.ThrowsException(() => BayesianOptionsMapper.Apply(null!, new BayesianOptionsDto(), 1000)); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Mappers/BivariateResultsMapperTests.cs b/src/RMC.BestFit.Api.Tests/Mappers/BivariateResultsMapperTests.cs new file mode 100644 index 0000000..fc2e2ec --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Mappers/BivariateResultsMapperTests.cs @@ -0,0 +1,42 @@ +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Mappers +{ + /// + /// Unit tests for : the run-first and kind guards on both + /// entry points. Curve mapping is exercised end-to-end by user-run smoke tests (results + /// require MCMC, which unit tests never run). + /// + [TestClass] + public class BivariateResultsMapperTests + { + /// + /// Verifies the run-first and kind guards on the bivariate entry point. + /// + [TestMethod] + public void ToBivariateResults_Guards() + { + Assert.ThrowsException( + () => BivariateResultsMapper.ToBivariateResults(TestAnalyses.CreateBivariateResource())); + Assert.ThrowsException( + () => BivariateResultsMapper.ToBivariateResults(TestAnalyses.CreateUnivariateResource())); + Assert.ThrowsException( + () => BivariateResultsMapper.ToBivariateResults(null!)); + } + + /// + /// Verifies the run-first and kind guards on the coincident frequency entry point. + /// + [TestMethod] + public void ToCoincidentFrequencyResults_Guards() + { + Assert.ThrowsException( + () => BivariateResultsMapper.ToCoincidentFrequencyResults(TestAnalyses.CreateCoincidentFrequencyResource())); + Assert.ThrowsException( + () => BivariateResultsMapper.ToCoincidentFrequencyResults(TestAnalyses.CreateBivariateResource())); + Assert.ThrowsException( + () => BivariateResultsMapper.ToCoincidentFrequencyResults(null!)); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Mappers/DistributionFittingResultsMapperTests.cs b/src/RMC.BestFit.Api.Tests/Mappers/DistributionFittingResultsMapperTests.cs new file mode 100644 index 0000000..90f7070 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Mappers/DistributionFittingResultsMapperTests.cs @@ -0,0 +1,85 @@ +using Numerics.Distributions; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; +using RMC.BestFit.Models; + +namespace RMC.BestFit.Api.Tests.Mappers +{ + /// + /// Unit tests for : ranking order, failed-fit + /// handling, NaN criteria, and the run-first/kind guards. Fits are hand-built — no MLE runs. + /// + [TestClass] + public class DistributionFittingResultsMapperTests + { + /// + /// Verifies successful fits rank by AIC ascending with failed fits last, carrying + /// parameters for successes and error messages for failures. + /// + [TestMethod] + public void ToRankedFits_OrdersByAic_FailuresLast() + { + var gumbel = new Gumbel(100d, 20d); + var normal = new Normal(120d, 30d); + var fits = new List + { + new(normal, aic: 210d, bic: 214d, rmse: 9d, fitSucceeded: true), + new(new LogNormal(), fitSucceeded: false) { ErrorMessage = "solver failed" }, + new(gumbel, aic: 205d, bic: 211d, rmse: 8d, fitSucceeded: true) + }; + + var ranked = DistributionFittingResultsMapper.ToRankedFits(fits); + + Assert.AreEqual(3, ranked.Count); + Assert.AreEqual(1, ranked[0].Rank); + Assert.AreEqual("gumbel", ranked[0].Distribution); + Assert.AreEqual(205d, ranked[0].Aic); + Assert.IsNotNull(ranked[0].Parameters); + Assert.AreEqual(2, ranked[0].Parameters!.Count); + + Assert.AreEqual("normal", ranked[1].Distribution); + + Assert.AreEqual(3, ranked[2].Rank); + Assert.IsFalse(ranked[2].FitSucceeded); + Assert.AreEqual("solver failed", ranked[2].ErrorMessage); + Assert.IsNull(ranked[2].Parameters, "Failed fits carry no parameter estimates."); + Assert.IsNull(ranked[2].Aic, "NaN criteria map to null."); + } + + /// + /// Verifies a successful fit with NaN AIC ranks after finite-AIC successes but before + /// failures. + /// + [TestMethod] + public void ToRankedFits_NanAic_AfterFiniteSuccesses() + { + var fits = new List + { + new(new Gumbel(1d, 2d), fitSucceeded: true), + new(new Normal(0d, 1d), aic: 50d, fitSucceeded: true), + new(new LogNormal(), fitSucceeded: false) + }; + + var ranked = DistributionFittingResultsMapper.ToRankedFits(fits); + + Assert.AreEqual("normal", ranked[0].Distribution); + Assert.AreEqual("gumbel", ranked[1].Distribution); + Assert.IsFalse(ranked[2].FitSucceeded); + } + + /// + /// Verifies the run-first and kind guards on the resource-level entry point. + /// + [TestMethod] + public void ToResults_Guards() + { + Assert.ThrowsException( + () => DistributionFittingResultsMapper.ToResults(TestAnalyses.CreateDistributionFittingResource())); + Assert.ThrowsException( + () => DistributionFittingResultsMapper.ToResults(TestAnalyses.CreateUnivariateResource())); + Assert.ThrowsException( + () => DistributionFittingResultsMapper.ToResults(null!)); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Mappers/DistributionSpecMapperTests.cs b/src/RMC.BestFit.Api.Tests/Mappers/DistributionSpecMapperTests.cs new file mode 100644 index 0000000..0a8801c --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Mappers/DistributionSpecMapperTests.cs @@ -0,0 +1,105 @@ +using Numerics.Distributions; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Mappers; + +namespace RMC.BestFit.Api.Tests.Mappers +{ + /// + /// Unit tests for : spec materialization, parameter-count + /// and parameter-value validation, and the response echo. + /// + [TestClass] + public class DistributionSpecMapperTests + { + /// + /// Verifies a valid spec builds the distribution with the supplied parameters applied. + /// + [TestMethod] + public void ToDistribution_BuildsConfiguredDistribution() + { + var spec = new DistributionSpecDto + { + Type = UnivariateDistributionType.Normal, + Parameters = new List { 100d, 20d } + }; + + var distribution = DistributionSpecMapper.ToDistribution(spec, "test.distribution"); + + Assert.AreEqual(UnivariateDistributionType.Normal, distribution.Type); + CollectionAssert.AreEqual(new[] { 100d, 20d }, distribution.GetParameters); + + var triangular = DistributionSpecMapper.ToDistribution(new DistributionSpecDto + { + Type = UnivariateDistributionType.Triangular, + Parameters = new List { 1d, 2d, 3d } + }, "test.distribution"); + Assert.AreEqual(UnivariateDistributionType.Triangular, triangular.Type); + } + + /// + /// Verifies a null spec is rejected with the field context in the message. + /// + [TestMethod] + public void ToDistribution_NullSpec_Throws() + { + var ex = Assert.ThrowsException( + () => DistributionSpecMapper.ToDistribution(null, "parameterPriors[0].distribution")); + StringAssert.Contains(ex.Message, "parameterPriors[0].distribution"); + } + + /// + /// Verifies a wrong parameter count is rejected with the canonical names in the message. + /// + [TestMethod] + public void ToDistribution_WrongParameterCount_Throws() + { + var spec = new DistributionSpecDto + { + Type = UnivariateDistributionType.Normal, + Parameters = new List { 100d } + }; + + var ex = Assert.ThrowsException( + () => DistributionSpecMapper.ToDistribution(spec, "uncertainData[3].distribution")); + StringAssert.Contains(ex.Message, "uncertainData[3].distribution"); + StringAssert.Contains(ex.Message, "2"); + } + + /// + /// Verifies invalid parameter values (a non-positive standard deviation) are rejected. + /// + [TestMethod] + public void ToDistribution_InvalidParameterValues_Throws() + { + var spec = new DistributionSpecDto + { + Type = UnivariateDistributionType.Normal, + Parameters = new List { 100d, -5d } + }; + + var ex = Assert.ThrowsException( + () => DistributionSpecMapper.ToDistribution(spec, "test.distribution")); + StringAssert.Contains(ex.Message, "test.distribution"); + } + + /// + /// Verifies the echo carries the type and current parameter values, round-tripping the spec. + /// + [TestMethod] + public void ToSpec_EchoesTypeAndParameters() + { + var spec = new DistributionSpecDto + { + Type = UnivariateDistributionType.LogNormal, + Parameters = new List { 3.5, 0.25 } + }; + + var echo = DistributionSpecMapper.ToSpec(DistributionSpecMapper.ToDistribution(spec, "test")); + + Assert.AreEqual(UnivariateDistributionType.LogNormal, echo.Type); + CollectionAssert.AreEqual(spec.Parameters, echo.Parameters); + + Assert.ThrowsException(() => DistributionSpecMapper.ToSpec(null!)); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Mappers/InputDataMapperTests.cs b/src/RMC.BestFit.Api.Tests/Mappers/InputDataMapperTests.cs new file mode 100644 index 0000000..ebf3a4d --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Mappers/InputDataMapperTests.cs @@ -0,0 +1,169 @@ +using Numerics.Data; +using Numerics.Distributions; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Models; + +namespace RMC.BestFit.Api.Tests.Mappers +{ + /// + /// Unit tests for : summary composition, observation extraction, + /// and the method-specific option echoes. + /// + [TestClass] + public class InputDataMapperTests + { + /// + /// Builds a data frame with exact, interval, and threshold observations. + /// + /// The data frame. + private static DataFrame CreateDataFrame() + { + var dataFrame = new DataFrame(); + dataFrame.ExactSeries.Add(new ExactData(2000, 100d, 0.5, isLowOutlier: true)); + dataFrame.ExactSeries.Add(new ExactData(2001, 200d)); + dataFrame.UncertainSeries.Add(new UncertainData(1875, new Triangular(400d, 550d, 900d))); + dataFrame.IntervalSeries.Add(new IntervalData(1950, 200d, 250d, 300d)); + dataFrame.ThresholdSeries.Add(new ThresholdData(1900, 1949, 180d) { NumberAbove = 1 }); + return dataFrame; + } + + /// + /// Verifies the summary carries record composition, lambda, and the low-outlier count. + /// + [TestMethod] + public void ToSummary_MapsCompositionAndCounts() + { + var resource = new InputDataResource + { + Name = "data", + DataFrame = CreateDataFrame(), + Method = InputDataMethod.Manual + }; + + var summary = InputDataMapper.ToSummary(resource); + + Assert.AreEqual(resource.Id, summary.Id); + Assert.AreEqual("manual", summary.Method); + Assert.AreEqual(2, summary.ExactCount); + Assert.AreEqual(1, summary.UncertainCount); + Assert.AreEqual(1, summary.IntervalCount); + Assert.AreEqual(1, summary.ThresholdCount); + Assert.AreEqual(1, summary.LowOutlierCount); + Assert.AreEqual(resource.DataFrame.Lambda, summary.Lambda); + Assert.IsNull(summary.BlockOptions); + Assert.IsNull(summary.PotOptions); + } + + /// + /// Verifies the block-options echo is populated for block-maxima resources. + /// + [TestMethod] + public void ToSummary_BlockMaxima_EchoesOptions() + { + var resource = new InputDataResource + { + Name = "block", + DataFrame = CreateDataFrame(), + Method = InputDataMethod.BlockMaxima, + TimeBlock = TimeBlockWindow.WaterYear, + BlockFunction = BlockFunctionType.Maximum, + SmoothingFunction = SmoothingFunctionType.None, + StartMonth = 10, + EndMonth = 9, + Period = 1 + }; + + var summary = InputDataMapper.ToSummary(resource); + + Assert.IsNotNull(summary.BlockOptions); + Assert.AreEqual("waterYear", summary.BlockOptions.TimeBlock); + Assert.AreEqual("maximum", summary.BlockOptions.BlockFunction); + Assert.AreEqual(10, summary.BlockOptions.StartMonth); + Assert.IsNull(summary.PotOptions); + } + + /// + /// Verifies the POT-options echo is populated for peaks-over-threshold resources. + /// + [TestMethod] + public void ToSummary_PeaksOverThreshold_EchoesOptions() + { + var resource = new InputDataResource + { + Name = "pot", + DataFrame = CreateDataFrame(), + Method = InputDataMethod.PeaksOverThreshold, + Threshold = 400d, + MinStepsBetweenPeaks = 7, + SmoothingFunction = SmoothingFunctionType.None, + Period = 1 + }; + + var summary = InputDataMapper.ToSummary(resource); + + Assert.IsNotNull(summary.PotOptions); + Assert.AreEqual(400d, summary.PotOptions.Threshold); + Assert.AreEqual(7, summary.PotOptions.MinStepsBetweenPeaks); + Assert.IsNull(summary.BlockOptions); + } + + /// + /// Verifies observation lists are only included when requested and carry the model values. + /// + [TestMethod] + public void ToResourceResponse_IncludeData_ExtractsObservations() + { + var resource = new InputDataResource + { + Name = "data", + DataFrame = CreateDataFrame(), + Method = InputDataMethod.Manual + }; + + var withoutData = InputDataMapper.ToResourceResponse(resource); + Assert.IsNull(withoutData.ExactData); + Assert.IsNull(withoutData.UncertainData); + Assert.IsNull(withoutData.IntervalData); + Assert.IsNull(withoutData.ThresholdData); + + var withData = InputDataMapper.ToResourceResponse(resource, includeData: true); + Assert.IsNotNull(withData.ExactData); + Assert.AreEqual(2, withData.ExactData.Count); + Assert.AreEqual(2000, withData.ExactData[0].Index); + Assert.AreEqual(100d, withData.ExactData[0].Value); + Assert.IsTrue(withData.ExactData[0].IsLowOutlier); + + Assert.IsNotNull(withData.UncertainData); + Assert.AreEqual(1875, withData.UncertainData[0].Index); + Assert.IsNotNull(withData.UncertainData[0].Distribution); + Assert.AreEqual(UnivariateDistributionType.Triangular, withData.UncertainData[0].Distribution!.Type); + CollectionAssert.AreEqual(new List { 400d, 550d, 900d }, withData.UncertainData[0].Distribution!.Parameters); + Assert.IsNotNull(withData.UncertainData[0].Value); + + Assert.IsNotNull(withData.IntervalData); + Assert.AreEqual(200d, withData.IntervalData[0].LowerBound); + Assert.AreEqual(300d, withData.IntervalData[0].UpperBound); + + Assert.IsNotNull(withData.ThresholdData); + Assert.AreEqual(1900, withData.ThresholdData[0].StartIndex); + Assert.AreEqual(1, withData.ThresholdData[0].NumberAbove); + } + + /// + /// Verifies the list response carries the count and one summary per resource. + /// + [TestMethod] + public void ToListResponse_MapsAllResources() + { + var resources = new List + { + new() { Name = "a", DataFrame = CreateDataFrame(), Method = InputDataMethod.Manual }, + new() { Name = "b", DataFrame = CreateDataFrame(), Method = InputDataMethod.Manual } + }; + var response = InputDataMapper.ToListResponse(resources); + Assert.AreEqual(2, response.Count); + Assert.AreEqual(2, response.InputData.Count); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Mappers/MetadataMapperTests.cs b/src/RMC.BestFit.Api.Tests/Mappers/MetadataMapperTests.cs new file mode 100644 index 0000000..3b0f2dd --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Mappers/MetadataMapperTests.cs @@ -0,0 +1,120 @@ +using Numerics.Distributions; +using RMC.BestFit.Api.Mappers; + +namespace RMC.BestFit.Api.Tests.Mappers +{ + /// + /// Unit tests for : distribution discovery, enum listings, and + /// display names. + /// + [TestClass] + public class MetadataMapperTests + { + /// + /// Verifies the distribution listing includes the 15 univariate distributions and marks + /// Log-Pearson Type III as supported by both analysis kinds. + /// + [TestMethod] + public void ToDistributionsResponse_ListsSupportedDistributions() + { + var response = MetadataMapper.ToDistributionsResponse(); + + Assert.AreEqual(15, response.Distributions.Count(d => d.SupportedBy.Contains("univariate"))); + + var lp3 = response.Distributions.Single(d => d.Name == "logPearsonTypeIII"); + CollectionAssert.Contains(lp3.SupportedBy, "univariate"); + CollectionAssert.Contains(lp3.SupportedBy, "bulletin17c"); + Assert.AreEqual("Log-Pearson Type III", lp3.DisplayName); + + var gev = response.Distributions.Single(d => d.Name == "generalizedExtremeValue"); + CollectionAssert.Contains(gev.SupportedBy, "univariate"); + CollectionAssert.DoesNotContain(gev.SupportedBy, "bulletin17c"); + } + + /// + /// Verifies the mixture, point process, and competing risks support flags: mixtures and + /// competing risks accept the full univariate set while the point process is GEV-only. + /// + [TestMethod] + public void ToDistributionsResponse_ListsPhase4Support() + { + var response = MetadataMapper.ToDistributionsResponse(); + + Assert.AreEqual(15, response.Distributions.Count(d => d.SupportedBy.Contains("mixture"))); + Assert.AreEqual(15, response.Distributions.Count(d => d.SupportedBy.Contains("competingrisks"))); + + var pointProcess = response.Distributions.Where(d => d.SupportedBy.Contains("pointprocess")).ToList(); + Assert.AreEqual(1, pointProcess.Count, "The point process is GEV-only by construction."); + Assert.AreEqual("generalizedExtremeValue", pointProcess[0].Name); + } + + /// + /// Verifies each listed distribution carries its canonical parameter names — what + /// distribution specs, parameter priors, and penalties are matched against. + /// + [TestMethod] + public void ToDistributionsResponse_ListsParameterNames() + { + var response = MetadataMapper.ToDistributionsResponse(); + + foreach (var info in response.Distributions) + { + Assert.IsTrue(info.ParameterNames.Count > 0, $"'{info.Name}' must list its parameter names."); + } + + var normal = response.Distributions.Single(d => d.Name == "normal"); + Assert.AreEqual(2, normal.ParameterNames.Count); + } + + /// + /// Verifies the prior-distribution listing contains the common prior/measurement-error + /// types and every listed name is factory-constructible camelCase. + /// + [TestMethod] + public void ToEnumOptionsResponse_ListsPriorDistributions() + { + var response = MetadataMapper.ToEnumOptionsResponse(); + + CollectionAssert.Contains(response.PriorDistributions, "normal"); + CollectionAssert.Contains(response.PriorDistributions, "logNormal"); + CollectionAssert.Contains(response.PriorDistributions, "uniform"); + CollectionAssert.Contains(response.PriorDistributions, "triangular"); + Assert.IsTrue(response.PriorDistributions.Count >= 15, "The spec list must cover at least the fitting distributions."); + CollectionAssert.DoesNotContain(response.PriorDistributions, "competingRisks"); + CollectionAssert.DoesNotContain(response.PriorDistributions, "mixture"); + CollectionAssert.DoesNotContain(response.PriorDistributions, "userDefined"); + } + + /// + /// Verifies the enum listings are populated with the expected camelCase members. + /// + [TestMethod] + public void ToEnumOptionsResponse_ListsCamelCaseMembers() + { + var response = MetadataMapper.ToEnumOptionsResponse(); + + CollectionAssert.Contains(response.Samplers, "demCzs"); + CollectionAssert.Contains(response.PointEstimators, "posteriorMean"); + CollectionAssert.Contains(response.PointEstimators, "posteriorMode"); + CollectionAssert.Contains(response.UncertaintyMethods, "multivariateNormal"); + CollectionAssert.Contains(response.TimeBlockWindows, "waterYear"); + CollectionAssert.Contains(response.BlockFunctions, "maximum"); + CollectionAssert.Contains(response.SmoothingFunctions, "none"); + CollectionAssert.Contains(response.UsgsSeriesTypes, "measuredDischarge"); + CollectionAssert.Contains(response.TimeIntervals, "oneDay"); + CollectionAssert.Contains(response.AnalysisKinds, "univariate"); + CollectionAssert.Contains(response.InputDataMethods, "blockMaxima"); + } + + /// + /// Verifies display names for the documented distributions. + /// + [TestMethod] + public void ToDisplayName_MapsDocumentedNames() + { + Assert.AreEqual("Gamma", MetadataMapper.ToDisplayName(UnivariateDistributionType.GammaDistribution)); + Assert.AreEqual("Ln-Normal", MetadataMapper.ToDisplayName(UnivariateDistributionType.LnNormal)); + Assert.AreEqual("Generalized Extreme Value", MetadataMapper.ToDisplayName(UnivariateDistributionType.GeneralizedExtremeValue)); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Mappers/PriorMapperTests.cs b/src/RMC.BestFit.Api.Tests/Mappers/PriorMapperTests.cs new file mode 100644 index 0000000..5b69a91 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Mappers/PriorMapperTests.cs @@ -0,0 +1,266 @@ +using Numerics.Distributions; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Tests.Support; +using RMC.BestFit.Models; + +namespace RMC.BestFit.Api.Tests.Mappers +{ + /// + /// Unit tests for : name-matched parameter priors (including the + /// default-flat-prior reset guard), quantile priors, and Bulletin 17C penalties. Models are + /// only configured — never estimated. + /// + [TestClass] + public class PriorMapperTests + { + /// + /// Builds a Log-Pearson Type III univariate model over the standard test record. + /// + /// The configured model. + private static UnivariateDistribution CreateModel() + { + return new UnivariateDistribution(TestAnalyses.CreateDataFrame(), UnivariateDistributionType.LogPearsonTypeIII); + } + + /// + /// Verifies a prior is applied to the named parameter (leaving the others untouched), + /// the fixed flag is honored, and the default-flat-priors flag is switched off. + /// + [TestMethod] + public void ApplyParameterPriors_AppliesNamedPrior() + { + var model = CreateModel(); + var untouchedPrior = model.Parameters[0].PriorDistribution; + string skewName = model.Parameters[2].DisplayName; + + PriorMapper.ApplyParameterPriors(model, new List + { + new() + { + ParameterName = skewName, + Distribution = new DistributionSpecDto + { + Type = UnivariateDistributionType.Normal, + Parameters = new List { -0.2, 0.3 } + }, + IsFixed = true + } + }); + + Assert.IsFalse(model.UseDefaultFlatPriors, "Supplying a prior must switch off the default flat priors."); + Assert.AreEqual(UnivariateDistributionType.Normal, model.Parameters[2].PriorDistribution.Type); + CollectionAssert.AreEqual(new[] { -0.2, 0.3 }, model.Parameters[2].PriorDistribution.GetParameters); + Assert.IsTrue(model.Parameters[2].IsFixed); + Assert.AreSame(untouchedPrior, model.Parameters[0].PriorDistribution, "Unnamed parameters must keep their existing priors."); + } + + /// + /// Verifies applied priors survive a later data-frame replacement — the model rebuilds + /// default parameters on data changes only while UseDefaultFlatPriors is true, and the + /// mapper switches that flag off before applying. + /// + [TestMethod] + public void ApplyParameterPriors_SurviveDataFrameChange() + { + var model = CreateModel(); + string skewName = model.Parameters[2].DisplayName; + PriorMapper.ApplyParameterPriors(model, new List + { + new() + { + ParameterName = skewName, + Distribution = new DistributionSpecDto + { + Type = UnivariateDistributionType.Normal, + Parameters = new List { -0.1, 0.25 } + } + } + }); + + model.DataFrame = TestAnalyses.CreateDataFrame(25); + + Assert.AreEqual(UnivariateDistributionType.Normal, model.Parameters[2].PriorDistribution.Type, + "The informative prior must survive data-frame changes once default flat priors are off."); + } + + /// + /// Verifies a client name without the trailing short-form marker (e.g., "Skew (of log)" + /// for "Skew (of log) (γ)") matches, so clients never need to type Greek letters. + /// + [TestMethod] + public void ApplyParameterPriors_MatchesNameWithoutShortForm() + { + var model = CreateModel(); + string fullName = model.Parameters[2].DisplayName; + string clientName = fullName[..fullName.LastIndexOf(" (", StringComparison.Ordinal)]; + + PriorMapper.ApplyParameterPriors(model, new List + { + new() + { + ParameterName = clientName, + Distribution = new DistributionSpecDto + { + Type = UnivariateDistributionType.Normal, + Parameters = new List { -0.2, 0.3 } + } + } + }); + + Assert.AreEqual(UnivariateDistributionType.Normal, model.Parameters[2].PriorDistribution.Type); + } + + /// + /// Verifies unknown, duplicated, and blank parameter names are rejected, with the valid + /// names listed for the unknown case. + /// + [TestMethod] + public void ApplyParameterPriors_BadNames_Throw() + { + var spec = new DistributionSpecDto { Type = UnivariateDistributionType.Normal, Parameters = new List { 0d, 1d } }; + + var unknown = Assert.ThrowsException(() => PriorMapper.ApplyParameterPriors(CreateModel(), + new List { new() { ParameterName = "No Such Parameter", Distribution = spec } })); + StringAssert.Contains(unknown.Message, "No Such Parameter"); + StringAssert.Contains(unknown.Message, "Valid names"); + + var model = CreateModel(); + string name = model.Parameters[0].DisplayName; + var duplicate = Assert.ThrowsException(() => PriorMapper.ApplyParameterPriors(model, + new List + { + new() { ParameterName = name, Distribution = spec }, + new() { ParameterName = name, Distribution = spec } + })); + StringAssert.Contains(duplicate.Message, "more than once"); + + var blank = Assert.ThrowsException(() => PriorMapper.ApplyParameterPriors(CreateModel(), + new List { new() { ParameterName = " ", Distribution = spec } })); + StringAssert.Contains(blank.Message, "parameterName is required"); + } + + /// + /// Verifies a null or empty prior list is a no-op that keeps the default flat priors on. + /// + [TestMethod] + public void ApplyParameterPriors_NullOrEmpty_IsNoOp() + { + var model = CreateModel(); + PriorMapper.ApplyParameterPriors(model, null); + Assert.IsTrue(model.UseDefaultFlatPriors); + PriorMapper.ApplyParameterPriors(model, new List()); + Assert.IsTrue(model.UseDefaultFlatPriors); + Assert.ThrowsException(() => PriorMapper.ApplyParameterPriors(null!, null)); + } + + /// + /// Verifies quantile priors are enabled and assigned with the client's ordinates and + /// distributions, honoring the single-quantile flag. + /// + [TestMethod] + public void ApplyQuantilePriors_EnablesAndAssigns() + { + var model = CreateModel(); + PriorMapper.ApplyQuantilePriors(model, new List + { + new() { Alpha = 0.01, Distribution = new DistributionSpecDto { Type = UnivariateDistributionType.LogNormal, Parameters = new List { 4.7, 0.1 } } } + }, useSingleQuantile: true); + + Assert.IsTrue(model.EnableQuantilePriors); + Assert.IsTrue(model.UseSingleQuantile); + Assert.AreEqual(1, model.QuantilePriors.Count); + Assert.AreEqual(0.01, model.QuantilePriors[0].Alpha); + Assert.AreEqual(UnivariateDistributionType.LogNormal, model.QuantilePriors[0].Distribution.Type); + } + + /// + /// Verifies out-of-range exceedance probabilities are rejected and a null or empty list + /// leaves quantile priors disabled. + /// + [TestMethod] + public void ApplyQuantilePriors_Validation() + { + var spec = new DistributionSpecDto { Type = UnivariateDistributionType.Normal, Parameters = new List { 0d, 1d } }; + var ex = Assert.ThrowsException(() => PriorMapper.ApplyQuantilePriors(CreateModel(), + new List { new() { Alpha = 1.0, Distribution = spec } }, null)); + StringAssert.Contains(ex.Message, "strictly between 0 and 1"); + + var model = CreateModel(); + PriorMapper.ApplyQuantilePriors(model, null, useSingleQuantile: true); + Assert.IsFalse(model.EnableQuantilePriors, "A null prior list must not enable quantile priors."); + } + + /// + /// Verifies a parameter penalty fills the pre-created, index-aligned entry by name and + /// leaves the other entries disabled — the collection is never replaced. + /// + [TestMethod] + public void ApplyPenalties_FillsParameterPenaltyByName() + { + var distribution = new Bulletin17CDistribution(TestAnalyses.CreateDataFrame(), UnivariateDistributionType.LogPearsonTypeIII); + int entryCount = distribution.ParameterPenalties.Count; + string skewName = distribution.ParameterPenalties[2].Name; + + PriorMapper.ApplyPenalties(distribution, + new List { new() { ParameterName = skewName, Mean = -0.05, Mse = 0.12 } }, + quantilePenalties: null); + + Assert.AreEqual(entryCount, distribution.ParameterPenalties.Count, "The penalty collection must stay index-aligned with the parameters."); + Assert.IsTrue(distribution.ParameterPenalties[2].Enabled); + Assert.AreEqual(-0.05, distribution.ParameterPenalties[2].Mean); + Assert.AreEqual(0.12, distribution.ParameterPenalties[2].MSE); + Assert.IsFalse(distribution.ParameterPenalties[0].Enabled, "Unnamed penalties must stay disabled."); + } + + /// + /// Verifies quantile penalties replace the pre-created placeholder contents with enabled + /// entries carrying the client's values. + /// + [TestMethod] + public void ApplyPenalties_ReplacesQuantilePenaltyContents() + { + var distribution = new Bulletin17CDistribution(TestAnalyses.CreateDataFrame(), UnivariateDistributionType.LogPearsonTypeIII); + + PriorMapper.ApplyPenalties(distribution, parameterPenalties: null, quantilePenalties: new List + { + new() { Aep = 0.002, Mean = 4.85, Mse = 0.02 }, + new() { Aep = 0.01, Mean = 4.5, Mse = 0.05, UseLog10 = false } + }); + + Assert.AreEqual(2, distribution.QuantilePenalties.Count); + Assert.IsTrue(distribution.QuantilePenalties[0].Enabled); + Assert.AreEqual(0.002, distribution.QuantilePenalties[0].AEP); + Assert.IsTrue(distribution.QuantilePenalties[0].UseLog10); + Assert.IsFalse(distribution.QuantilePenalties[1].UseLog10); + } + + /// + /// Verifies penalty validation: unknown parameter names, non-positive MSE, log-space + /// penalties with non-positive means, and out-of-range exceedance probabilities. + /// + [TestMethod] + public void ApplyPenalties_Validation() + { + var distribution = new Bulletin17CDistribution(TestAnalyses.CreateDataFrame(), UnivariateDistributionType.LogPearsonTypeIII); + + var unknown = Assert.ThrowsException(() => PriorMapper.ApplyPenalties(distribution, + new List { new() { ParameterName = "No Such Parameter", Mean = 0d, Mse = 1d } }, null)); + StringAssert.Contains(unknown.Message, "Valid names"); + + var badMse = Assert.ThrowsException(() => PriorMapper.ApplyPenalties(distribution, + new List { new() { ParameterName = distribution.ParameterPenalties[0].Name, Mean = 0d, Mse = 0d } }, null)); + StringAssert.Contains(badMse.Message, "greater than 0"); + + var badLog = Assert.ThrowsException(() => PriorMapper.ApplyPenalties(distribution, + new List { new() { ParameterName = distribution.ParameterPenalties[0].Name, Mean = -1d, Mse = 1d, UseLog = true } }, null)); + StringAssert.Contains(badLog.Message, "useLog"); + + var badAep = Assert.ThrowsException(() => PriorMapper.ApplyPenalties(distribution, null, + new List { new() { Aep = 0d, Mean = 1d, Mse = 1d } })); + StringAssert.Contains(badAep.Message, "strictly between 0 and 1"); + + Assert.ThrowsException(() => PriorMapper.ApplyPenalties(null!, null, null)); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Mappers/ResultsMapperTests.cs b/src/RMC.BestFit.Api.Tests/Mappers/ResultsMapperTests.cs new file mode 100644 index 0000000..2357019 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Mappers/ResultsMapperTests.cs @@ -0,0 +1,199 @@ +using Numerics.Distributions; +using Numerics.Mathematics.Optimization; +using Numerics.Sampling.MCMC; +using RMC.BestFit.Api.Mappers; + +namespace RMC.BestFit.Api.Tests.Mappers +{ + /// + /// Unit tests for : both confidence-interval array shapes, + /// posterior parameter summaries built from synthetic MCMC results (no sampler run), and the + /// convergence-warning thresholds. + /// + [TestClass] + public class ResultsMapperTests + { + /// + /// Builds an uncertainty results object with the univariate/B17C [p, 2] CI shape. + /// + /// The results object. + private static UncertaintyAnalysisResults CreateFrequencyShapedResults() + { + return new UncertaintyAnalysisResults + { + ModeCurve = new[] { 100d, 200d, 300d }, + MeanCurve = new[] { 110d, 210d, 310d }, + ConfidenceIntervals = new[,] { { 90d, 120d }, { 180d, 230d }, { 270d, 340d } }, + AIC = 10d, + BIC = 12d, + DIC = 11d, + RMSE = 0.5, + ERL = double.NaN + }; + } + + /// + /// Builds an uncertainty results object with the rating-curve [n, 3] (stage, lower, upper) CI shape. + /// + /// The results object. + private static UncertaintyAnalysisResults CreateRatingShapedResults() + { + return new UncertaintyAnalysisResults + { + ModeCurve = new[] { 10d, 20d }, + MeanCurve = new[] { 11d, 21d }, + ConfidenceIntervals = new[,] { { 1.0, 8d, 13d }, { 2.0, 16d, 26d } }, + AIC = double.NaN, + BIC = double.NaN, + DIC = 5d, + RMSE = 0.1, + ERL = double.NaN + }; + } + + /// + /// Builds synthetic MCMC results from deterministic parameter sets (no sampler run). + /// + /// The number of parameter sets. + /// The synthetic MCMC results with two parameters centered at 100 and 10. + private static MCMCResults CreateSyntheticMcmcResults(int count = 200) + { + var sets = new List(count); + for (int i = 0; i < count; i++) + { + // Deterministic spread: parameter 0 in [95, 105), parameter 1 in [9.5, 10.5). + double offset = (i % 100) / 100d; + sets.Add(new ParameterSet(new[] { 95d + 10d * offset, 9.5 + 1d * offset }, double.NaN)); + } + var map = new ParameterSet(new[] { 100d, 10d }, double.NaN); + return new MCMCResults(map, sets, alpha: 0.1); + } + + /// + /// Verifies the [p, 2] CI shape splits into aligned lower/upper lists. + /// + [TestMethod] + public void BuildFrequencyCurve_SplitsTwoColumnIntervals() + { + var probabilities = new List { 0.5, 0.1, 0.01 }; + var curve = ResultsMapper.BuildFrequencyCurve(CreateFrequencyShapedResults(), probabilities, credibleIntervalWidth: 0.9); + + CollectionAssert.AreEqual(probabilities, curve.Probabilities); + CollectionAssert.AreEqual(new List { 100d, 200d, 300d }, curve.ModeCurve); + CollectionAssert.AreEqual(new List { 110d, 210d, 310d }, curve.MeanCurve); + CollectionAssert.AreEqual(new List { 90d, 180d, 270d }, curve.CiLower); + CollectionAssert.AreEqual(new List { 120d, 230d, 340d }, curve.CiUpper); + Assert.AreEqual(0.9, curve.CredibleIntervalWidth); + } + + /// + /// Verifies the [n, 3] CI shape splits into stage grid plus lower/upper discharge lists. + /// + [TestMethod] + public void BuildRatingCurve_SplitsThreeColumnIntervals() + { + var curve = ResultsMapper.BuildRatingCurve(CreateRatingShapedResults(), credibleIntervalWidth: 0.9, minStage: 1d, maxStage: 2d, stageBins: 2); + + CollectionAssert.AreEqual(new List { 1d, 2d }, curve.Stages); + CollectionAssert.AreEqual(new List { 8d, 16d }, curve.CiLower); + CollectionAssert.AreEqual(new List { 13d, 26d }, curve.CiUpper); + CollectionAssert.AreEqual(new List { 10d, 20d }, curve.ModeCurve); + Assert.AreEqual(1d, curve.MinStage); + Assert.AreEqual(2d, curve.MaxStage); + Assert.AreEqual(2, curve.StageBins); + } + + /// + /// Verifies missing confidence intervals leave the CI lists null rather than throwing. + /// + [TestMethod] + public void BuildFrequencyCurve_NoIntervals_LeavesCiNull() + { + var results = new UncertaintyAnalysisResults { ModeCurve = new[] { 1d } }; + var curve = ResultsMapper.BuildFrequencyCurve(results, new List { 0.5 }, 0.9); + Assert.IsNull(curve.CiLower); + Assert.IsNull(curve.CiUpper); + } + + /// + /// Verifies parameter summaries pair names with synthetic posterior statistics and honor + /// the chain-diagnostics switch (B17C reports rhat/ess as null). + /// + [TestMethod] + public void BuildParameterSummaries_FromSyntheticResults() + { + var results = CreateSyntheticMcmcResults(); + var names = new List { "Mu", "Sigma" }; + + var withChains = ResultsMapper.BuildParameterSummaries(names, results, includeChainDiagnostics: true); + Assert.AreEqual(2, withChains.Count); + Assert.AreEqual("Mu", withChains[0].Name); + Assert.IsNotNull(withChains[0].Mean); + Assert.AreEqual(100d, withChains[0].Mean!.Value, 1.0); + Assert.IsNotNull(withChains[1].Mean); + Assert.AreEqual(10d, withChains[1].Mean!.Value, 0.1); + Assert.IsNotNull(withChains[0].LowerCI); + Assert.IsNotNull(withChains[0].UpperCI); + Assert.IsTrue(withChains[0].LowerCI < withChains[0].UpperCI); + + var withoutChains = ResultsMapper.BuildParameterSummaries(names, results, includeChainDiagnostics: false); + Assert.IsNull(withoutChains[0].Rhat); + Assert.IsNull(withoutChains[0].Ess); + } + + /// + /// Verifies null MCMC results yield an empty summary list. + /// + [TestMethod] + public void BuildParameterSummaries_NullResults_Empty() + { + var summaries = ResultsMapper.BuildParameterSummaries(new List { "Mu" }, null, includeChainDiagnostics: true); + Assert.AreEqual(0, summaries.Count); + } + + /// + /// Verifies the convergence scan flags high R-hat and low effective sample size, and stays + /// silent for healthy diagnostics. + /// + [TestMethod] + public void BuildConvergenceWarnings_FlagsThresholds() + { + var results = CreateSyntheticMcmcResults(); + var names = new List { "Mu", "Sigma" }; + + // Healthy diagnostics → no warnings. + results.ParameterResults[0].SummaryStatistics.Rhat = 1.01; + results.ParameterResults[0].SummaryStatistics.ESS = 5000d; + results.ParameterResults[1].SummaryStatistics.Rhat = 1.02; + results.ParameterResults[1].SummaryStatistics.ESS = 4000d; + Assert.AreEqual(0, ResultsMapper.BuildConvergenceWarnings(results, names).Count); + + // High R-hat on Mu, low ESS on Sigma → two warnings naming the parameters. + results.ParameterResults[0].SummaryStatistics.Rhat = 1.5; + results.ParameterResults[1].SummaryStatistics.ESS = 50d; + var warnings = ResultsMapper.BuildConvergenceWarnings(results, names); + Assert.AreEqual(2, warnings.Count); + StringAssert.Contains(warnings[0], "Mu"); + StringAssert.Contains(warnings[1], "Sigma"); + } + + /// + /// Verifies NaN diagnostics (single-chain runs) produce neither warnings nor summary values. + /// + [TestMethod] + public void BuildConvergenceWarnings_NaNDiagnostics_Silent() + { + var results = CreateSyntheticMcmcResults(); + results.ParameterResults[0].SummaryStatistics.Rhat = double.NaN; + results.ParameterResults[0].SummaryStatistics.ESS = double.NaN; + results.ParameterResults[1].SummaryStatistics.Rhat = double.NaN; + results.ParameterResults[1].SummaryStatistics.ESS = double.NaN; + + Assert.AreEqual(0, ResultsMapper.BuildConvergenceWarnings(results, new List { "Mu", "Sigma" }).Count); + + var summaries = ResultsMapper.BuildParameterSummaries(new List { "Mu", "Sigma" }, results, includeChainDiagnostics: true); + Assert.IsNull(summaries[0].Rhat); + Assert.IsNull(summaries[0].Ess); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Mappers/TimeSeriesMapperTests.cs b/src/RMC.BestFit.Api.Tests/Mappers/TimeSeriesMapperTests.cs new file mode 100644 index 0000000..9aa337d --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Mappers/TimeSeriesMapperTests.cs @@ -0,0 +1,96 @@ +using Numerics.Data; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Mappers +{ + /// + /// Unit tests for : summary fields, point paging, and listing. + /// + [TestClass] + public class TimeSeriesMapperTests + { + /// + /// Builds a USGS-sourced resource over the ten-point irregular series. + /// + /// The resource. + private static TimeSeriesResource CreateResource() + { + return new TimeSeriesResource(TestSeries.IrregularPeaks()) + { + Name = "peaks", + Description = "test series", + Source = TimeSeriesSource.Usgs, + UsgsSiteNumber = "01646500", + UsgsSeriesType = TimeSeriesDownload.TimeSeriesType.PeakDischarge + }; + } + + /// + /// Verifies the summary carries identity, provenance, and camelCase enum names. + /// + [TestMethod] + public void ToSummary_MapsAllFields() + { + var resource = CreateResource(); + var summary = TimeSeriesMapper.ToSummary(resource); + + Assert.AreEqual(resource.Id, summary.Id); + Assert.AreEqual("peaks", summary.Name); + Assert.AreEqual("test series", summary.Description); + Assert.AreEqual("usgs", summary.Source); + Assert.AreEqual("01646500", summary.UsgsSiteNumber); + Assert.AreEqual("peakDischarge", summary.SeriesType); + Assert.AreEqual("irregular", summary.TimeInterval); + Assert.AreEqual(10, summary.PointCount); + Assert.AreEqual(0, summary.MissingCount); + Assert.AreEqual(new DateTime(2000, 5, 1), summary.StartDate); + Assert.AreEqual(new DateTime(2009, 5, 1), summary.EndDate); + } + + /// + /// Verifies points are excluded by default and included with paging when requested. + /// + [TestMethod] + public void ToResourceResponse_PagesPoints() + { + var resource = CreateResource(); + + var withoutPoints = TimeSeriesMapper.ToResourceResponse(resource); + Assert.IsNull(withoutPoints.Points); + Assert.IsNull(withoutPoints.PointsOffset); + + var page = TimeSeriesMapper.ToResourceResponse(resource, includePoints: true, offset: 8, limit: 5); + Assert.IsNotNull(page.Points); + Assert.AreEqual(2, page.Points.Count); + Assert.AreEqual(8, page.PointsOffset); + Assert.AreEqual(1800d, page.Points[0].Value); + } + + /// + /// Verifies negative paging arguments are clamped rather than throwing. + /// + [TestMethod] + public void ToResourceResponse_NegativePaging_Clamped() + { + var resource = CreateResource(); + var page = TimeSeriesMapper.ToResourceResponse(resource, includePoints: true, offset: -5, limit: -1); + Assert.IsNotNull(page.Points); + Assert.AreEqual(0, page.Points.Count); + Assert.AreEqual(0, page.PointsOffset); + } + + /// + /// Verifies the list response carries the count and one summary per resource. + /// + [TestMethod] + public void ToListResponse_MapsAllResources() + { + var resources = new List { CreateResource(), CreateResource() }; + var response = TimeSeriesMapper.ToListResponse(resources); + Assert.AreEqual(2, response.Count); + Assert.AreEqual(2, response.TimeSeries.Count); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Mappers/TimeSeriesResultsMapperTests.cs b/src/RMC.BestFit.Api.Tests/Mappers/TimeSeriesResultsMapperTests.cs new file mode 100644 index 0000000..e52e7c5 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Mappers/TimeSeriesResultsMapperTests.cs @@ -0,0 +1,34 @@ +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Mappers +{ + /// + /// Unit tests for : the run-first and kind guards for + /// every model family. Curve mapping is exercised end-to-end by user-run smoke tests + /// (results require MCMC, which unit tests never run). + /// + [TestClass] + public class TimeSeriesResultsMapperTests + { + /// + /// Verifies the run-first guard for each model family and the kind guard. + /// + [TestMethod] + public void ToResults_Guards() + { + foreach (var modelType in Enum.GetValues()) + { + Assert.ThrowsException( + () => TimeSeriesResultsMapper.ToResults(TestAnalyses.CreateTimeSeriesAnalysisResource(modelType)), + $"An unrun {modelType} analysis must report run-first."); + } + + Assert.ThrowsException( + () => TimeSeriesResultsMapper.ToResults(TestAnalyses.CreateUnivariateResource())); + Assert.ThrowsException( + () => TimeSeriesResultsMapper.ToResults(null!)); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Mcp/AdvancedAnalysisToolsTests.cs b/src/RMC.BestFit.Api.Tests/Mcp/AdvancedAnalysisToolsTests.cs new file mode 100644 index 0000000..4aa213e --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Mcp/AdvancedAnalysisToolsTests.cs @@ -0,0 +1,287 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; +using Numerics.Distributions; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Mcp; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Mcp +{ + /// + /// Unit tests for : creation tools with contract parity. + /// Run tools are not invoked (they execute estimators). + /// + [TestClass] + public class AdvancedAnalysisToolsTests + { + /// + /// The store shared by the tool stack. + /// + private InMemoryResourceStore _store = null!; + + /// + /// The tools under test. + /// + private AdvancedAnalysisTools _tools = null!; + + /// + /// Creates a fresh tool stack before each test. + /// + [TestInitialize] + public void Initialize() + { + _store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + _tools = new AdvancedAnalysisTools(new AnalysisService(_store, Options.Create(new ApiOptions()))); + } + + /// + /// Verifies the mixture creation tool parses component names and priors into the model. + /// + [TestMethod] + public void CreateMixtureAnalysis_ParsesComponents() + { + var input = _store.AddInputData(TestAnalyses.CreateInputDataResource()); + + string json = _tools.CreateMixtureAnalysis( + input.Id, + distributions: new[] { "gumbel", "logNormal" }, + isZeroInflated: true, + prngSeed: 11); + + using var document = JsonDocument.Parse(json); + var summary = document.RootElement.GetProperty("analysis"); + Assert.AreEqual("mixture", summary.GetProperty("kind").GetString()); + + var resource = _store.GetAnalysis(summary.GetProperty("id").GetGuid())!; + var model = resource.Mixture!.MixtureDistribution; + Assert.IsTrue(model.IsZeroInflated); + Assert.AreEqual(2, model.Mixture!.Distributions.Length); + Assert.AreEqual(11, resource.Mixture.BayesianAnalysis.PRNGSeed); + + Assert.ThrowsException(() => _tools.CreateMixtureAnalysis( + input.Id, distributions: new[] { "notADistribution" })); + } + + /// + /// Verifies the point process creation tool parses seasonal options and overrides. + /// + [TestMethod] + public void CreatePointProcessAnalysis_ParsesOptions() + { + var input = _store.AddInputData(new InputDataResource + { + Name = "pot", + DataFrame = TestAnalyses.CreatePotDataFrame(), + Method = InputDataMethod.PeaksOverThreshold, + Threshold = 400d + }); + + string json = _tools.CreatePointProcessAnalysis( + input.Id, + isSeasonal: true, + timeBlock: "waterYear", + startMonth: 10, + totalYears: 20d); + + using var document = JsonDocument.Parse(json); + var summary = document.RootElement.GetProperty("analysis"); + Assert.AreEqual("pointProcess", summary.GetProperty("kind").GetString()); + + var resource = _store.GetAnalysis(summary.GetProperty("id").GetGuid())!; + var model = resource.PointProcess!.PointProcess; + Assert.IsTrue(model.IsSeasonal); + Assert.AreEqual(10, model.StartMonth); + Assert.AreEqual(20d, model.TotalYears); + } + + /// + /// Verifies the composite creation tool aligns weights with component ids and rejects + /// misaligned arrays. + /// + [TestMethod] + public void CreateCompositeAnalysis_AlignsWeights() + { + var input = _store.AddInputData(TestAnalyses.CreateInputDataResource()); + var service = new AnalysisService(_store, Options.Create(new ApiOptions())); + var componentA = service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id }); + var componentB = service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id }); + + string json = _tools.CreateCompositeAnalysis( + new[] { componentA.Id, componentB.Id }, + compositeType: "mixture", + weights: new[] { 0.6, 0.3 }); + + using var document = JsonDocument.Parse(json); + var summary = document.RootElement.GetProperty("analysis"); + Assert.AreEqual("composite", summary.GetProperty("kind").GetString()); + Assert.AreEqual("mixture", summary.GetProperty("compositeType").GetString()); + + var resource = _store.GetAnalysis(summary.GetProperty("id").GetGuid())!; + Assert.AreEqual(0.6, resource.Composite!.Analyses[0].Weight); + Assert.AreEqual(0.3, resource.Composite.Analyses[1].Weight); + + Assert.ThrowsException(() => _tools.CreateCompositeAnalysis( + new[] { componentA.Id }, weights: new[] { 0.5, 0.5 })); + } + + /// + /// Verifies the distribution-fitting creation tool honors a candidate subset. + /// + [TestMethod] + public void CreateDistributionFittingAnalysis_ParsesSubset() + { + var input = _store.AddInputData(TestAnalyses.CreateInputDataResource()); + + string json = _tools.CreateDistributionFittingAnalysis( + input.Id, + distributions: new[] { "logPearsonTypeIII", "generalizedExtremeValue" }); + + using var document = JsonDocument.Parse(json); + var resource = _store.GetAnalysis(document.RootElement.GetProperty("analysis").GetProperty("id").GetGuid())!; + Assert.AreEqual(2, resource.DistributionFitting!.DistributionList.Count); + } + + /// + /// Verifies the bivariate creation tool zips the parallel ordinate arrays and rejects + /// misaligned ones. + /// + [TestMethod] + public void CreateBivariateAnalysis_ZipsOrdinates() + { + var input = _store.AddInputData(TestAnalyses.CreateInputDataResource()); + var service = new AnalysisService(_store, Options.Create(new ApiOptions())); + var marginalX = service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id }); + var marginalY = service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id }); + + string json = _tools.CreateBivariateAnalysis( + marginalX.Id, marginalY.Id, + xOrdinates: new[] { 400d, 200d }, + yOrdinates: new[] { 430d, 220d }, + copulaType: "gumbel"); + + using var document = JsonDocument.Parse(json); + var summary = document.RootElement.GetProperty("analysis"); + Assert.AreEqual("bivariate", summary.GetProperty("kind").GetString()); + Assert.AreEqual("gumbel", summary.GetProperty("copulaType").GetString()); + + var resource = _store.GetAnalysis(summary.GetProperty("id").GetGuid())!; + Assert.AreEqual(2, resource.Bivariate!.XYOrdinates.Count); + Assert.AreEqual(200d, resource.Bivariate.XYOrdinates[0].X, "The grid must be sorted by x."); + + Assert.ThrowsException(() => _tools.CreateBivariateAnalysis( + marginalX.Id, marginalY.Id, xOrdinates: new[] { 1d }, yOrdinates: new[] { 1d, 2d })); + } + + /// + /// Verifies the coincident frequency creation tool parses the surface JSON and rejects + /// malformed payloads. + /// + [TestMethod] + public void CreateCoincidentFrequencyAnalysis_ParsesSurfaceJson() + { + var input = _store.AddInputData(TestAnalyses.CreateInputDataResource()); + var service = new AnalysisService(_store, Options.Create(new ApiOptions())); + var marginalX = service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id }); + var marginalY = service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id }); + var bivariate = service.CreateBivariate(new CreateBivariateAnalysisRequest + { + MarginalXAnalysisId = marginalX.Id, + MarginalYAnalysisId = marginalY.Id, + XyOrdinates = new List { new() { X = 1d, Y = 1d } } + }); + + string json = _tools.CreateCoincidentFrequencyAnalysis( + bivariate.Id, + xValues: new[] { 1d, 2d }, + yValues: new[] { 10d, 20d }, + bivariateResponseJson: "[[100,110],[120,130]]", + numberOfBins: 25); + + using var document = JsonDocument.Parse(json); + var summary = document.RootElement.GetProperty("analysis"); + Assert.AreEqual("coincidentFrequency", summary.GetProperty("kind").GetString()); + Assert.AreEqual(25, summary.GetProperty("numberOfBins").GetInt32()); + + var parseError = Assert.ThrowsException(() => _tools.CreateCoincidentFrequencyAnalysis( + bivariate.Id, new[] { 1d }, new[] { 1d }, bivariateResponseJson: "not json")); + StringAssert.Contains(parseError.Message, "bivariateResponseJson"); + } + + /// + /// Verifies the time-series creation tool parses the model type and family options. + /// + [TestMethod] + public void CreateTimeSeriesAnalysis_ParsesModelType() + { + var source = _store.AddTimeSeries(new TimeSeriesResource(TestSeries.DailyThreeWaterYears()) + { + Name = "daily", + Source = TimeSeriesSource.Manual + }); + + string json = _tools.CreateTimeSeriesAnalysis( + source.Id, + modelType: "arima", + pOrder: 2, + dOrder: 1, + transformType: "logarithmic", + forecastingTimeSteps: 12); + + using var document = JsonDocument.Parse(json); + var summary = document.RootElement.GetProperty("analysis"); + Assert.AreEqual("timeSeries", summary.GetProperty("kind").GetString()); + Assert.AreEqual("arima", summary.GetProperty("timeSeriesModelType").GetString()); + + var resource = _store.GetAnalysis(summary.GetProperty("id").GetGuid())!; + Assert.AreEqual(2, resource.Arima!.ARIMA.POrder); + Assert.AreEqual(RMC.BestFit.Models.Transform.Logarithmic, resource.Arima.ARIMA.TransformType); + Assert.AreEqual(12, resource.Arima.ForecastingTimeSteps); + + var badField = Assert.ThrowsException(() => _tools.CreateTimeSeriesAnalysis( + source.Id, modelType: "ar", pOrder: 2)); + StringAssert.Contains(badField.Message, "pOrder"); + + var blankType = Assert.ThrowsException(() => _tools.CreateTimeSeriesAnalysis( + source.Id, modelType: " ")); + StringAssert.Contains(blankType.Message, "modelType"); + } + + /// + /// Verifies the competing risks creation tool parses component names and priors. + /// + [TestMethod] + public void CreateCompetingRisksAnalysis_ParsesComponents() + { + var input = _store.AddInputData(TestAnalyses.CreateInputDataResource()); + var probe = new RMC.BestFit.Models.CompetingRisksModel(TestAnalyses.CreateDataFrame(), + new List { UnivariateDistributionType.Gumbel, UnivariateDistributionType.GeneralizedExtremeValue }); + string parameterName = probe.Parameters[0].DisplayName; + + string json = _tools.CreateCompetingRisksAnalysis( + input.Id, + distributions: new[] { "gumbel", "generalizedExtremeValue" }, + parameterPriors: new List + { + new() + { + ParameterName = parameterName, + Distribution = new DistributionSpecDto + { + Type = UnivariateDistributionType.Normal, + Parameters = new List { 0d, 10d } + } + } + }); + + using var document = JsonDocument.Parse(json); + var resource = _store.GetAnalysis(document.RootElement.GetProperty("analysis").GetProperty("id").GetGuid())!; + var model = resource.CompetingRisks!.CompetingRisksDistribution; + Assert.AreEqual(2, model.CompetingRisks!.Distributions.Count); + Assert.IsFalse(model.UseDefaultFlatPriors); + Assert.AreEqual(UnivariateDistributionType.Normal, model.Parameters[0].PriorDistribution.Type); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Mcp/AnalysisToolsTests.cs b/src/RMC.BestFit.Api.Tests/Mcp/AnalysisToolsTests.cs new file mode 100644 index 0000000..864c0ad --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Mcp/AnalysisToolsTests.cs @@ -0,0 +1,194 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; +using Numerics.Distributions; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Mcp; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Services.Exceptions; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; +using RMC.BestFit.Models; + +namespace RMC.BestFit.Api.Tests.Mcp +{ + /// + /// Unit tests for : creation and validation tools with contract + /// parity. Run tools are not invoked (they execute estimators). + /// + [TestClass] + public class AnalysisToolsTests + { + /// + /// The store shared by the tool stack. + /// + private InMemoryResourceStore _store = null!; + + /// + /// The tools under test. + /// + private AnalysisTools _tools = null!; + + /// + /// Creates a fresh tool stack before each test. + /// + [TestInitialize] + public void Initialize() + { + _store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + _tools = new AnalysisTools(new AnalysisService(_store, Options.Create(new ApiOptions()))); + } + + /// + /// Verifies the univariate creation tool parses flat MCMC parameters into the analysis. + /// + [TestMethod] + public void CreateUnivariateAnalysis_ParsesFlatOptions() + { + var input = _store.AddInputData(TestAnalyses.CreateInputDataResource()); + + string json = _tools.CreateUnivariateAnalysis( + input.Id, + distribution: "generalizedExtremeValue", + probabilityOrdinates: new[] { 0.5, 0.01 }, + prngSeed: 42, + sampler: "demCz", + pointEstimator: "posteriorMean"); + + using var document = JsonDocument.Parse(json); + var summary = document.RootElement.GetProperty("analysis"); + Assert.AreEqual("univariate", summary.GetProperty("kind").GetString()); + Assert.AreEqual("generalizedExtremeValue", summary.GetProperty("distribution").GetString()); + Assert.IsTrue(summary.GetProperty("isValid").GetBoolean()); + + var resource = _store.GetAnalysis(summary.GetProperty("id").GetGuid())!; + Assert.AreEqual(42, resource.Univariate!.BayesianAnalysis.PRNGSeed); + Assert.AreEqual(2, resource.Univariate.ProbabilityOrdinates.Count); + } + + /// + /// Verifies the Bulletin 17C creation tool honors the nullable uncertainty method. + /// + [TestMethod] + public void CreateBulletin17CAnalysis_ParsesMethod() + { + var input = _store.AddInputData(TestAnalyses.CreateInputDataResource()); + string json = _tools.CreateBulletin17CAnalysis(input.Id, uncertaintyMethod: "bootstrap"); + using var document = JsonDocument.Parse(json); + Assert.AreEqual("bootstrap", document.RootElement.GetProperty("analysis").GetProperty("uncertaintyMethod").GetString()); + } + + /// + /// Verifies the rating curve creation tool links both series and validation reports through + /// the validate tool. + /// + [TestMethod] + public void CreateRatingCurveAnalysis_ThenValidate() + { + var (stage, discharge) = TestAnalyses.CreateStageDischargePair(); + var stageResource = _store.AddTimeSeries(new TimeSeriesResource(stage) { Name = "stage", Source = TimeSeriesSource.Manual }); + var dischargeResource = _store.AddTimeSeries(new TimeSeriesResource(discharge) { Name = "discharge", Source = TimeSeriesSource.Manual }); + + string json = _tools.CreateRatingCurveAnalysis(stageResource.Id, dischargeResource.Id, prngSeed: 7); + using var document = JsonDocument.Parse(json); + var id = document.RootElement.GetProperty("analysis").GetProperty("id").GetGuid(); + + string validationJson = _tools.ValidateAnalysis(id); + using var validationDocument = JsonDocument.Parse(validationJson); + Assert.IsTrue(validationDocument.RootElement.GetProperty("isValid").GetBoolean()); + } + + /// + /// Verifies the results tool enforces run-first semantics. + /// + [TestMethod] + public void GetAnalysisResults_BeforeRun_Throws() + { + var input = _store.AddInputData(TestAnalyses.CreateInputDataResource()); + string json = _tools.CreateUnivariateAnalysis(input.Id); + using var document = JsonDocument.Parse(json); + var id = document.RootElement.GetProperty("analysis").GetProperty("id").GetGuid(); + + var ex = Assert.ThrowsException(() => _tools.GetAnalysisResults(id)); + StringAssert.Contains(ex.Message, "Run it first"); + } + + /// + /// Verifies the univariate creation tool applies typed parameter-prior and quantile-prior + /// arguments onto the model. + /// + [TestMethod] + public void CreateUnivariateAnalysis_AppliesPriors() + { + var input = _store.AddInputData(TestAnalyses.CreateInputDataResource()); + var probe = new UnivariateDistribution(TestAnalyses.CreateDataFrame(), UnivariateDistributionType.LogPearsonTypeIII); + string skewName = probe.Parameters[2].DisplayName; + + string json = _tools.CreateUnivariateAnalysis( + input.Id, + parameterPriors: new List + { + new() + { + ParameterName = skewName, + Distribution = new DistributionSpecDto + { + Type = UnivariateDistributionType.Normal, + Parameters = new List { -0.2, 0.3 } + } + } + }, + quantilePriors: new List + { + new() + { + Alpha = 0.01, + Distribution = new DistributionSpecDto + { + Type = UnivariateDistributionType.LogNormal, + Parameters = new List { 4.7, 0.1 } + } + } + }, + useSingleQuantile: true); + + using var document = JsonDocument.Parse(json); + var resource = _store.GetAnalysis(document.RootElement.GetProperty("analysis").GetProperty("id").GetGuid())!; + var model = resource.Univariate!.UnivariateDistribution; + Assert.IsFalse(model.UseDefaultFlatPriors); + Assert.AreEqual(UnivariateDistributionType.Normal, model.Parameters[2].PriorDistribution.Type); + Assert.IsTrue(model.EnableQuantilePriors); + Assert.IsTrue(model.UseSingleQuantile); + } + + /// + /// Verifies the Bulletin 17C creation tool applies typed penalty arguments onto the + /// distribution (the regional-skew workflow). + /// + [TestMethod] + public void CreateBulletin17CAnalysis_AppliesPenalties() + { + var input = _store.AddInputData(TestAnalyses.CreateInputDataResource()); + var probe = new Bulletin17CDistribution(TestAnalyses.CreateDataFrame(), UnivariateDistributionType.LogPearsonTypeIII); + string skewName = probe.ParameterPenalties[2].Name; + + string json = _tools.CreateBulletin17CAnalysis( + input.Id, + parameterPenalties: new List + { + new() { ParameterName = skewName, Mean = -0.05, Mse = 0.12 } + }, + quantilePenalties: new List + { + new() { Aep = 0.002, Mean = 4.85, Mse = 0.02 } + }); + + using var document = JsonDocument.Parse(json); + var resource = _store.GetAnalysis(document.RootElement.GetProperty("analysis").GetProperty("id").GetGuid())!; + var distribution = resource.Bulletin17C!.Bulletin17CDistribution; + Assert.IsTrue(distribution.ParameterPenalties[2].Enabled); + Assert.AreEqual(1, distribution.QuantilePenalties.Count); + Assert.IsTrue(distribution.QuantilePenalties[0].Enabled); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Mcp/InputDataToolsTests.cs b/src/RMC.BestFit.Api.Tests/Mcp/InputDataToolsTests.cs new file mode 100644 index 0000000..b781a76 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Mcp/InputDataToolsTests.cs @@ -0,0 +1,103 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Mcp; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Mcp +{ + /// + /// Unit tests for : contract parity with the REST endpoints via + /// the faked USGS seam. + /// + [TestClass] + public class InputDataToolsTests + { + /// + /// The store shared by the tool stack. + /// + private InMemoryResourceStore _store = null!; + + /// + /// The faked USGS seam. + /// + private FakeUsgsTimeSeriesService _usgs = null!; + + /// + /// The tools under test. + /// + private InputDataTools _tools = null!; + + /// + /// Creates a fresh tool stack before each test. + /// + [TestInitialize] + public void Initialize() + { + _store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + _usgs = new FakeUsgsTimeSeriesService { Result = TestSeries.IrregularPeaks() }; + _tools = new InputDataTools(new InputDataService(_store, _usgs)); + } + + /// + /// Verifies the direct USGS peaks tool creates an input-data resource. + /// + [TestMethod] + public async Task CreateInputDataUsgsPeaks_CreatesResource() + { + string json = await _tools.CreateInputDataUsgsPeaks("01646500"); + using var document = JsonDocument.Parse(json); + var summary = document.RootElement.GetProperty("inputData"); + + Assert.AreEqual("usgsPeakDischarge", summary.GetProperty("method").GetString()); + Assert.AreEqual(10, summary.GetProperty("exactCount").GetInt32()); + } + + /// + /// Verifies the block-maxima tool extracts the planted water-year maxima from a stored series. + /// + [TestMethod] + public void CreateInputDataBlockMax_ExtractsPlantedMaxima() + { + var series = _store.AddTimeSeries(new TimeSeriesResource(TestSeries.DailyThreeWaterYears()) + { + Name = "daily", + Source = TimeSeriesSource.Manual + }); + + string json = _tools.CreateInputDataBlockMax(series.Id); + using var document = JsonDocument.Parse(json); + var summary = document.RootElement.GetProperty("inputData"); + + Assert.AreEqual("blockMaxima", summary.GetProperty("method").GetString()); + Assert.AreEqual(3, summary.GetProperty("exactCount").GetInt32()); + Assert.AreEqual(series.Id, summary.GetProperty("sourceTimeSeriesId").GetGuid()); + } + + /// + /// Verifies the manual tool builds observations and the get tool returns them with + /// plotting positions. + /// + [TestMethod] + public void CreateInputDataManual_ThenGet_RoundTrips() + { + string createdJson = _tools.CreateInputDataManual(new List + { + new() { Index = 2000, Value = 100d }, + new() { Index = 2001, Value = 200d } + }); + using var createdDocument = JsonDocument.Parse(createdJson); + var id = createdDocument.RootElement.GetProperty("inputData").GetProperty("id").GetGuid(); + + string detailJson = _tools.GetInputData(id, includeData: true); + using var detailDocument = JsonDocument.Parse(detailJson); + var exactData = detailDocument.RootElement.GetProperty("exactData"); + + Assert.AreEqual(2, exactData.GetArrayLength()); + Assert.IsTrue(exactData[0].GetProperty("plottingPosition").GetDouble() > 0d); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Mcp/McpJsonTests.cs b/src/RMC.BestFit.Api.Tests/Mcp/McpJsonTests.cs new file mode 100644 index 0000000..678c015 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Mcp/McpJsonTests.cs @@ -0,0 +1,46 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Mcp; + +namespace RMC.BestFit.Api.Tests.Mcp +{ + /// + /// Unit tests for : the MCP tool serialization must match the REST wire + /// contract exactly. + /// + [TestClass] + public class McpJsonTests + { + /// + /// Verifies camelCase names, string enums, and null omission — the same contract as REST. + /// + [TestMethod] + public void Serialize_MatchesRestWireContract() + { + var response = new DeleteResourceResponse + { + DeletedId = Guid.Parse("11111111-2222-3333-4444-555555555555"), + ResourceType = "timeSeries" + }; + string json = McpJson.Serialize(response); + + StringAssert.Contains(json, "\"deletedId\""); + StringAssert.Contains(json, "\"resourceType\""); + StringAssert.Contains(json, "\"success\":true"); + Assert.IsFalse(json.Contains("\"errorMessage\""), "Null properties must be omitted."); + } + + /// + /// Verifies NaN serializes as a named floating-point literal rather than throwing. + /// + [TestMethod] + public void Serialize_NaN_UsesNamedLiteral() + { + var response = new SummaryStatisticsResponse + { + Statistics = new Dictionary { ["skew"] = double.NaN } + }; + string json = McpJson.Serialize(response); + StringAssert.Contains(json, "NaN"); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Mcp/MetadataToolsTests.cs b/src/RMC.BestFit.Api.Tests/Mcp/MetadataToolsTests.cs new file mode 100644 index 0000000..e28bf7b --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Mcp/MetadataToolsTests.cs @@ -0,0 +1,87 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.Mcp; +using RMC.BestFit.Api.Services.Exceptions; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; +using RMC.BestFit.Models; + +namespace RMC.BestFit.Api.Tests.Mcp +{ + /// + /// Unit tests for : metadata payload, resource overview, and typed deletion. + /// + [TestClass] + public class MetadataToolsTests + { + /// + /// The store backing the tools. + /// + private InMemoryResourceStore _store = null!; + + /// + /// The tools under test. + /// + private MetadataTools _tools = null!; + + /// + /// Creates a fresh store and tool instance before each test. + /// + [TestInitialize] + public void Initialize() + { + _store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + _tools = new MetadataTools(_store, Options.Create(new ApiOptions())); + } + + /// + /// Verifies the metadata JSON carries distributions, enum lists, and defaults. + /// + [TestMethod] + public void GetMetadata_CarriesDistributionsEnumsAndDefaults() + { + string json = _tools.GetMetadata(); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + + Assert.IsTrue(root.GetProperty("distributions").GetArrayLength() >= 15); + Assert.IsTrue(root.GetProperty("enums").GetProperty("samplers").GetArrayLength() > 0); + Assert.IsTrue(root.GetProperty("defaults").GetProperty("probabilityOrdinates").GetArrayLength() > 0); + } + + /// + /// Verifies the resource overview lists stored resources with counts. + /// + [TestMethod] + public void ListResources_ListsStoredResources() + { + _store.AddTimeSeries(new TimeSeriesResource(TestSeries.IrregularPeaks()) { Name = "ts", Source = TimeSeriesSource.Manual }); + _store.AddInputData(new InputDataResource { Name = "id", DataFrame = new DataFrame(), Method = InputDataMethod.Manual }); + + string json = _tools.ListResources(); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + + Assert.AreEqual(1, root.GetProperty("timeSeriesCount").GetInt32()); + Assert.AreEqual(1, root.GetProperty("inputDataCount").GetInt32()); + Assert.AreEqual(2, root.GetProperty("resources").GetArrayLength()); + } + + /// + /// Verifies typed deletion removes the resource and guards type/id errors. + /// + [TestMethod] + public void DeleteResource_RemovesByType_AndGuards() + { + var resource = _store.AddTimeSeries(new TimeSeriesResource(TestSeries.IrregularPeaks()) { Name = "ts", Source = TimeSeriesSource.Manual }); + + string json = _tools.DeleteResource("timeSeries", resource.Id); + StringAssert.Contains(json, resource.Id.ToString()); + Assert.IsNull(_store.GetTimeSeries(resource.Id)); + + Assert.ThrowsException(() => _tools.DeleteResource("bogus", Guid.NewGuid())); + Assert.ThrowsException(() => _tools.DeleteResource("analysis", Guid.NewGuid())); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Mcp/TimeSeriesToolsTests.cs b/src/RMC.BestFit.Api.Tests/Mcp/TimeSeriesToolsTests.cs new file mode 100644 index 0000000..054b3e3 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Mcp/TimeSeriesToolsTests.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Mcp; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Mcp +{ + /// + /// Unit tests for : contract parity with the REST endpoints via + /// the faked USGS seam. + /// + [TestClass] + public class TimeSeriesToolsTests + { + /// + /// The faked USGS seam. + /// + private FakeUsgsTimeSeriesService _usgs = null!; + + /// + /// The tools under test. + /// + private TimeSeriesTools _tools = null!; + + /// + /// Creates a fresh tool stack before each test. + /// + [TestInitialize] + public void Initialize() + { + var store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + _usgs = new FakeUsgsTimeSeriesService { Result = TestSeries.DailyThreeWaterYears() }; + _tools = new TimeSeriesTools(new TimeSeriesService(store, _usgs)); + } + + /// + /// Verifies the download tool parses the series type string and returns the resource id. + /// + [TestMethod] + public async Task UsgsDownloadTimeSeries_ParsesTypeAndReturnsId() + { + string json = await _tools.UsgsDownloadTimeSeries("01646500", "peakDischarge"); + using var document = JsonDocument.Parse(json); + var summary = document.RootElement.GetProperty("timeSeries"); + + Assert.AreEqual("01646500", summary.GetProperty("usgsSiteNumber").GetString()); + Assert.AreEqual("peakDischarge", summary.GetProperty("seriesType").GetString()); + Assert.AreNotEqual(Guid.Empty, summary.GetProperty("id").GetGuid()); + Assert.AreEqual(Numerics.Data.TimeSeriesDownload.TimeSeriesType.PeakDischarge, _usgs.LastSeriesType); + } + + /// + /// Verifies an invalid series type string fails with the accepted-values message. + /// + [TestMethod] + public async Task UsgsDownloadTimeSeries_InvalidType_Throws() + { + var ex = await Assert.ThrowsExceptionAsync( + () => _tools.UsgsDownloadTimeSeries("01646500", "bogusType")); + StringAssert.Contains(ex.Message, "dailyDischarge"); + } + + /// + /// Verifies the manual creation and retrieval tools round-trip a series with paging. + /// + [TestMethod] + public void CreateManual_ThenGet_RoundTrips() + { + string createdJson = _tools.CreateManualTimeSeries(new List + { + new() { DateTime = new DateTime(2020, 1, 2), Value = 2d }, + new() { DateTime = new DateTime(2020, 1, 1), Value = 1d } + }, "irregular", "manual series"); + + using var createdDocument = JsonDocument.Parse(createdJson); + var id = createdDocument.RootElement.GetProperty("timeSeries").GetProperty("id").GetGuid(); + + string detailJson = _tools.GetTimeSeries(id, includePoints: true, offset: 0, limit: 10); + using var detailDocument = JsonDocument.Parse(detailJson); + var points = detailDocument.RootElement.GetProperty("points"); + + Assert.AreEqual(2, points.GetArrayLength()); + Assert.AreEqual(1d, points[0].GetProperty("value").GetDouble(), "Points must be sorted by date."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Mcp/WorkflowToolsTests.cs b/src/RMC.BestFit.Api.Tests/Mcp/WorkflowToolsTests.cs new file mode 100644 index 0000000..55ec9af --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Mcp/WorkflowToolsTests.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.Mcp; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Services.Exceptions; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Mcp +{ + /// + /// Unit tests for : the in-body failure contract over the faked + /// USGS seam. Success paths are not exercised (they run estimators). + /// + [TestClass] + public class WorkflowToolsTests + { + /// + /// The faked USGS seam. + /// + private FakeUsgsTimeSeriesService _usgs = null!; + + /// + /// The tools under test. + /// + private WorkflowTools _tools = null!; + + /// + /// Creates a fresh tool stack before each test. + /// + [TestInitialize] + public void Initialize() + { + var store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + _usgs = new FakeUsgsTimeSeriesService { Result = TestSeries.IrregularPeaks() }; + var timeSeries = new TimeSeriesService(store, _usgs); + var inputData = new InputDataService(store, _usgs); + var analyses = new AnalysisService(store, Options.Create(new ApiOptions())); + _tools = new WorkflowTools(new WorkflowService(timeSeries, inputData, analyses)); + } + + /// + /// Verifies a step failure is reported in the tool's JSON body with the failed step named. + /// + [TestMethod] + public async Task PeakFrequencyWorkflow_StepFailure_ReportedInBody() + { + _usgs.ExceptionToThrow = new UsgsDataNotFoundException("no data"); + string json = await _tools.RunUsgsPeakFrequencyWorkflow("01646500"); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + + Assert.IsFalse(root.GetProperty("success").GetBoolean()); + Assert.AreEqual("createInputData", root.GetProperty("failedStep").GetString()); + Assert.IsTrue(root.TryGetProperty("errorMessage", out _)); + } + + /// + /// Verifies invalid enum strings on workflow tools fail with the accepted-values message + /// before any download happens. + /// + [TestMethod] + public async Task Bulletin17CWorkflow_InvalidMethodString_Throws() + { + var ex = await Assert.ThrowsExceptionAsync( + () => _tools.RunUsgsBulletin17CWorkflow("01646500", uncertaintyMethod: "bogus")); + StringAssert.Contains(ex.Message, "multivariateNormal"); + Assert.IsNull(_usgs.LastSiteNumber, "No download may happen when the parameters are invalid."); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/RMC.BestFit.Api.Tests.csproj b/src/RMC.BestFit.Api.Tests/RMC.BestFit.Api.Tests.csproj new file mode 100644 index 0000000..6e5e504 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/RMC.BestFit.Api.Tests.csproj @@ -0,0 +1,38 @@ + + + + net10.0 + latest + enable + enable + RMC.BestFit.Api.Tests + Unit and integration tests for the RMC-BestFit REST API and MCP server. + U.S. Army Corps of Engineers + RMC-BestFit + + true + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/src/RMC.BestFit.Api.Tests/Services/AnalysisServiceTests.cs b/src/RMC.BestFit.Api.Tests/Services/AnalysisServiceTests.cs new file mode 100644 index 0000000..1f4f8a5 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Services/AnalysisServiceTests.cs @@ -0,0 +1,1198 @@ +using Microsoft.Extensions.Options; +using Numerics.Distributions; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Helpers; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Services.Exceptions; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; +using RMC.BestFit.Models; + +namespace RMC.BestFit.Api.Tests.Services +{ + /// + /// Unit tests for : creation for all three kinds with input + /// cloning, kind-guarded lookups, run preconditions (validation failure, run-lock conflict), + /// and results-before-run semantics. No estimators are run. + /// + [TestClass] + public class AnalysisServiceTests + { + /// + /// The store backing the service under test. + /// + private InMemoryResourceStore _store = null!; + + /// + /// The service under test. + /// + private AnalysisService _service = null!; + + /// + /// Creates a fresh store and service before each test. + /// + [TestInitialize] + public void Initialize() + { + _store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + _service = new AnalysisService(_store, Options.Create(new ApiOptions())); + } + + /// + /// Adds a valid input-data resource to the store. + /// + /// The stored resource. + private InputDataResource AddInputData() + { + return _store.AddInputData(TestAnalyses.CreateInputDataResource()); + } + + /// + /// Adds a stage/discharge time-series pair to the store. + /// + /// The stored stage and discharge resources. + private (TimeSeriesResource Stage, TimeSeriesResource Discharge) AddStageDischargePair() + { + var (stage, discharge) = TestAnalyses.CreateStageDischargePair(); + var stageResource = _store.AddTimeSeries(new TimeSeriesResource(stage) { Name = "stage", Source = TimeSeriesSource.Manual }); + var dischargeResource = _store.AddTimeSeries(new TimeSeriesResource(discharge) { Name = "discharge", Source = TimeSeriesSource.Manual }); + return (stageResource, dischargeResource); + } + + /// + /// Verifies univariate creation stores the resource with provenance, defaulted name, and + /// applied ordinates/options. + /// + [TestMethod] + public void CreateUnivariate_StoresConfiguredResource() + { + var input = AddInputData(); + var resource = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest + { + InputDataId = input.Id, + Distribution = UnivariateDistributionType.GeneralizedExtremeValue, + ProbabilityOrdinates = new List { 0.5, 0.1, 0.01 }, + BayesianOptions = new BayesianOptionsDto { PrngSeed = 42 } + }); + + Assert.AreEqual(AnalysisKind.Univariate, resource.Kind); + Assert.AreEqual(input.Id, resource.InputDataId); + StringAssert.Contains(resource.Name, "Generalized Extreme Value"); + Assert.AreEqual(3, resource.Univariate!.ProbabilityOrdinates.Count); + Assert.AreEqual(42, resource.Univariate.BayesianAnalysis.PRNGSeed); + Assert.AreSame(resource, _store.GetAnalysis(resource.Id)); + } + + /// + /// Verifies the analysis owns a CLONE of the input data: mutating the source data frame + /// afterwards does not change the analysis, and the clone preserves lambda and plotting settings. + /// + [TestMethod] + public void CreateUnivariate_ClonesInputData() + { + var input = AddInputData(); + input.DataFrame.SetLambda(0.85); + input.DataFrame.PlottingParameter = 0.44; + + var resource = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id }); + var analysisFrame = resource.Univariate!.UnivariateDistribution.DataFrame; + + Assert.AreNotSame(input.DataFrame, analysisFrame); + Assert.AreEqual(0.85, analysisFrame.Lambda, 1e-12, "Lambda must survive the clone round-trip."); + Assert.AreEqual(0.44, analysisFrame.PlottingParameter, 1e-12, "PlottingParameter must survive the clone round-trip."); + + int countBefore = analysisFrame.ExactSeries.Count; + input.DataFrame.ExactSeries.Add(new ExactData(2099, 9999d)); + Assert.AreEqual(countBefore, analysisFrame.ExactSeries.Count, "Mutating the source must not affect the analysis clone."); + } + + /// + /// Verifies invalid probability ordinates are rejected. + /// + [TestMethod] + public void CreateUnivariate_InvalidOrdinates_Throws() + { + var input = AddInputData(); + Assert.ThrowsException(() => _service.CreateUnivariate(new CreateUnivariateAnalysisRequest + { + InputDataId = input.Id, + ProbabilityOrdinates = new List { 0.5, 1.5 } + })); + } + + /// + /// Verifies an unknown input-data id fails with not-found semantics. + /// + [TestMethod] + public void CreateUnivariate_UnknownInputData_ThrowsNotFound() + { + Assert.ThrowsException(() => + _service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = Guid.NewGuid() })); + } + + /// + /// Verifies Bulletin 17C creation applies the uncertainty method and rejects unsupported + /// distributions. + /// + [TestMethod] + public void CreateBulletin17C_AppliesMethod_AndGuardsDistribution() + { + var input = AddInputData(); + var resource = _service.CreateBulletin17C(new CreateBulletin17CAnalysisRequest + { + InputDataId = input.Id, + UncertaintyMethod = RMC.BestFit.Analyses.UncertaintyMethod.Bootstrap + }); + Assert.AreEqual(AnalysisKind.Bulletin17C, resource.Kind); + Assert.AreEqual(RMC.BestFit.Analyses.UncertaintyMethod.Bootstrap, resource.Bulletin17C!.UncertaintyMethod); + + var ex = Assert.ThrowsException(() => _service.CreateBulletin17C(new CreateBulletin17CAnalysisRequest + { + InputDataId = input.Id, + Distribution = UnivariateDistributionType.GeneralizedExtremeValue + })); + StringAssert.Contains(ex.Message, "not supported"); + } + + /// + /// Verifies rating curve creation clones both series and applies the stage grid options. + /// + [TestMethod] + public void CreateRatingCurve_ClonesSeries_AndAppliesGrid() + { + var (stage, discharge) = AddStageDischargePair(); + var resource = _service.CreateRatingCurve(new CreateRatingCurveAnalysisRequest + { + StageTimeSeriesId = stage.Id, + DischargeTimeSeriesId = discharge.Id, + NumberOfSegments = 1, + MinStage = 2d, + MaxStage = 9d, + StageBins = 50 + }); + + Assert.AreEqual(AnalysisKind.RatingCurve, resource.Kind); + Assert.AreEqual(stage.Id, resource.StageTimeSeriesId); + Assert.AreEqual(discharge.Id, resource.DischargeTimeSeriesId); + Assert.IsFalse(resource.RatingCurve!.UseDefaultStageBins); + Assert.AreEqual(2d, resource.RatingCurve.MinStage); + Assert.AreEqual(9d, resource.RatingCurve.MaxStage); + Assert.AreEqual(50, resource.RatingCurve.StageBins); + + // Clone independence: adding to the source series does not change the model's data. + int alignedBefore = resource.RatingCurve.RatingCurve.GetAlignedObservations().Count; + stage.TimeSeries.Add(new Numerics.Data.SeriesOrdinate(new DateTime(2030, 1, 1), 99d)); + discharge.TimeSeries.Add(new Numerics.Data.SeriesOrdinate(new DateTime(2030, 1, 1), 999d)); + Assert.AreEqual(alignedBefore, resource.RatingCurve.RatingCurve.GetAlignedObservations().Count); + } + + /// + /// Verifies inconsistent stage-grid options are rejected. + /// + [TestMethod] + public void CreateRatingCurve_InconsistentGrid_Throws() + { + var (stage, discharge) = AddStageDischargePair(); + Assert.ThrowsException(() => _service.CreateRatingCurve(new CreateRatingCurveAnalysisRequest + { + StageTimeSeriesId = stage.Id, + DischargeTimeSeriesId = discharge.Id, + MinStage = 2d + })); + Assert.ThrowsException(() => _service.CreateRatingCurve(new CreateRatingCurveAnalysisRequest + { + StageTimeSeriesId = stage.Id, + DischargeTimeSeriesId = discharge.Id, + MinStage = 9d, + MaxStage = 2d + })); + } + + /// + /// Verifies kind-guarded lookups: an id of one kind is not found through another kind's guard. + /// + [TestMethod] + public void Get_KindMismatch_ThrowsNotFound() + { + var input = AddInputData(); + var univariate = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id }); + + Assert.AreSame(univariate, _service.Get(univariate.Id, AnalysisKind.Univariate)); + Assert.AreSame(univariate, _service.Get(univariate.Id)); + Assert.ThrowsException(() => _service.Get(univariate.Id, AnalysisKind.RatingCurve)); + } + + /// + /// Verifies list filtering by kind. + /// + [TestMethod] + public void List_FiltersByKind() + { + var input = AddInputData(); + _service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id }); + _service.CreateBulletin17C(new CreateBulletin17CAnalysisRequest { InputDataId = input.Id }); + + Assert.AreEqual(2, _service.List().Count); + Assert.AreEqual(1, _service.List(AnalysisKind.Univariate).Count); + Assert.AreEqual(1, _service.List(AnalysisKind.Bulletin17C).Count); + Assert.AreEqual(0, _service.List(AnalysisKind.RatingCurve).Count); + } + + /// + /// Verifies the validate endpoint surface reports validity without running. + /// + [TestMethod] + public void Validate_ReportsVerdict() + { + var input = AddInputData(); + var resource = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id }); + var response = _service.Validate(resource.Id, AnalysisKind.Univariate); + Assert.IsTrue(response.IsValid); + Assert.AreEqual(0, response.Errors.Count); + } + + /// + /// Verifies an invalid configuration fails the run with the validation messages BEFORE any + /// estimation starts. The configuration is corrupted directly on the model (decreasing + /// ordinates) because the service's own creation path sorts client ordinates. + /// + [TestMethod] + public async Task Run_InvalidConfiguration_ThrowsRequestValidation() + { + var input = AddInputData(); + var resource = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id }); + resource.Univariate!.ProbabilityOrdinates.Clear(); + resource.Univariate.ProbabilityOrdinates.AddRange(new[] { 0.5, 0.1 }); + + var ex = await Assert.ThrowsExceptionAsync( + () => _service.RunFrequencyAsync(resource.Id, AnalysisKind.Univariate)); + Assert.IsTrue(ex.Errors.Count > 0); + Assert.AreEqual(AnalysisRunState.Created, resource.State, "Validation failures must not mark the analysis as run."); + } + + /// + /// Verifies client-supplied ordinates are de-duplicated and sorted ascending (the model + /// requires strictly increasing exceedance probabilities). + /// + [TestMethod] + public void CreateUnivariate_SortsAndDeduplicatesOrdinates() + { + var input = AddInputData(); + var resource = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest + { + InputDataId = input.Id, + ProbabilityOrdinates = new List { 0.5, 0.01, 0.1, 0.5 } + }); + CollectionAssert.AreEqual(new List { 0.01, 0.1, 0.5 }, resource.Univariate!.ProbabilityOrdinates.ToList()); + Assert.IsTrue(AnalysisRunHelper.Validate(resource).IsValid); + } + + /// + /// Verifies a second run request while the run lock is held is rejected with conflict + /// semantics (HTTP 409) without starting an estimation. + /// + [TestMethod] + public async Task Run_WhileLocked_ThrowsConflict() + { + var input = AddInputData(); + var resource = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id }); + + Assert.IsTrue(resource.RunLock.Wait(0), "Test setup: acquire the run lock."); + try + { + await Assert.ThrowsExceptionAsync( + () => _service.RunFrequencyAsync(resource.Id, AnalysisKind.Univariate)); + } + finally + { + resource.RunLock.Release(); + } + } + + /// + /// Verifies a frequency run request against a rating curve id fails with not-found semantics. + /// + [TestMethod] + public async Task RunFrequency_OnRatingCurve_ThrowsNotFound() + { + var (stage, discharge) = AddStageDischargePair(); + var resource = _service.CreateRatingCurve(new CreateRatingCurveAnalysisRequest + { + StageTimeSeriesId = stage.Id, + DischargeTimeSeriesId = discharge.Id + }); + await Assert.ThrowsExceptionAsync( + () => _service.RunFrequencyAsync(resource.Id, expectedKind: null)); + } + + /// + /// Verifies results retrieval before any run reports not-found with run guidance. + /// + [TestMethod] + public void GetResults_BeforeRun_ThrowsNotFound() + { + var input = AddInputData(); + var univariate = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id }); + var ex = Assert.ThrowsException( + () => _service.GetFrequencyResults(univariate.Id, AnalysisKind.Univariate)); + StringAssert.Contains(ex.Message, "Run it first"); + + var (stage, discharge) = AddStageDischargePair(); + var rating = _service.CreateRatingCurve(new CreateRatingCurveAnalysisRequest + { + StageTimeSeriesId = stage.Id, + DischargeTimeSeriesId = discharge.Id + }); + Assert.ThrowsException(() => _service.GetRatingCurveResults(rating.Id)); + } + + /// + /// Verifies delete removes the resource, honors the kind guard, and refuses while running. + /// + [TestMethod] + public void Delete_HonorsKindGuard_AndRunLock() + { + var input = AddInputData(); + var resource = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id }); + + Assert.ThrowsException(() => _service.Delete(resource.Id, AnalysisKind.Bulletin17C)); + + Assert.IsTrue(resource.RunLock.Wait(0)); + try + { + Assert.ThrowsException(() => _service.Delete(resource.Id, AnalysisKind.Univariate)); + } + finally + { + resource.RunLock.Release(); + } + + _service.Delete(resource.Id, AnalysisKind.Univariate); + Assert.IsNull(_store.GetAnalysis(resource.Id)); + } + + /// + /// Verifies univariate creation applies informative parameter priors (switching off the + /// default flat priors) and quantile priors onto the cloned model. + /// + [TestMethod] + public void CreateUnivariate_AppliesPriors() + { + var input = AddInputData(); + var probe = new UnivariateDistribution(TestAnalyses.CreateDataFrame(), UnivariateDistributionType.LogPearsonTypeIII); + string skewName = probe.Parameters[2].DisplayName; + + var resource = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest + { + InputDataId = input.Id, + Distribution = UnivariateDistributionType.LogPearsonTypeIII, + ParameterPriors = new List + { + new() + { + ParameterName = skewName, + Distribution = new DistributionSpecDto + { + Type = UnivariateDistributionType.Normal, + Parameters = new List { -0.2, 0.3 } + } + } + }, + QuantilePriors = new List + { + new() + { + Alpha = 0.01, + Distribution = new DistributionSpecDto + { + Type = UnivariateDistributionType.LogNormal, + Parameters = new List { 4.7, 0.1 } + } + } + }, + UseSingleQuantile = true + }); + + var model = resource.Univariate!.UnivariateDistribution; + Assert.IsFalse(model.UseDefaultFlatPriors); + Assert.AreEqual(UnivariateDistributionType.Normal, model.Parameters[2].PriorDistribution.Type); + Assert.IsTrue(model.EnableQuantilePriors); + Assert.IsTrue(model.UseSingleQuantile); + Assert.AreEqual(1, model.QuantilePriors.Count); + Assert.AreEqual(0.01, model.QuantilePriors[0].Alpha); + } + + /// + /// Verifies an unknown prior parameter name is rejected with the valid names listed. + /// + [TestMethod] + public void CreateUnivariate_UnknownPriorParameter_Throws() + { + var input = AddInputData(); + var ex = Assert.ThrowsException(() => _service.CreateUnivariate(new CreateUnivariateAnalysisRequest + { + InputDataId = input.Id, + ParameterPriors = new List + { + new() + { + ParameterName = "No Such Parameter", + Distribution = new DistributionSpecDto { Type = UnivariateDistributionType.Normal, Parameters = new List { 0d, 1d } } + } + } + })); + StringAssert.Contains(ex.Message, "Valid names"); + } + + /// + /// Verifies Bulletin 17C creation fills the named parameter penalty entry in place and + /// replaces the quantile penalty contents with enabled entries. + /// + [TestMethod] + public void CreateBulletin17C_AppliesPenalties() + { + var input = AddInputData(); + var probe = new Bulletin17CDistribution(TestAnalyses.CreateDataFrame(), UnivariateDistributionType.LogPearsonTypeIII); + string skewName = probe.ParameterPenalties[2].Name; + + var resource = _service.CreateBulletin17C(new CreateBulletin17CAnalysisRequest + { + InputDataId = input.Id, + ParameterPenalties = new List + { + new() { ParameterName = skewName, Mean = -0.05, Mse = 0.12 } + }, + QuantilePenalties = new List + { + new() { Aep = 0.002, Mean = 4.85, Mse = 0.02 } + } + }); + + var distribution = resource.Bulletin17C!.Bulletin17CDistribution; + Assert.IsTrue(distribution.ParameterPenalties[2].Enabled); + Assert.AreEqual(-0.05, distribution.ParameterPenalties[2].Mean); + Assert.AreEqual(0.12, distribution.ParameterPenalties[2].MSE); + Assert.AreEqual(1, distribution.QuantilePenalties.Count); + Assert.IsTrue(distribution.QuantilePenalties[0].Enabled); + Assert.AreEqual(0.002, distribution.QuantilePenalties[0].AEP); + Assert.AreEqual(0, resource.CreationWarnings.Count, "No uncertain data means no creation warnings."); + } + + /// + /// Verifies creating a Bulletin 17C analysis over input data containing uncertain + /// observations records a warning (the Expected Moments Algorithm ignores them) that the + /// validate response surfaces. + /// + [TestMethod] + public void CreateBulletin17C_UncertainData_RecordsWarning() + { + var input = _store.AddInputData(new InputDataResource + { + Name = "with uncertain", + DataFrame = TestAnalyses.CreateDataFrameWithUncertain(), + Method = InputDataMethod.Manual + }); + + var resource = _service.CreateBulletin17C(new CreateBulletin17CAnalysisRequest { InputDataId = input.Id }); + + Assert.AreEqual(1, resource.CreationWarnings.Count); + StringAssert.Contains(resource.CreationWarnings[0], "uncertain observation"); + StringAssert.Contains(resource.CreationWarnings[0], "ignored"); + + var validation = _service.Validate(resource.Id, AnalysisKind.Bulletin17C); + Assert.AreEqual(1, validation.Warnings.Count); + StringAssert.Contains(validation.Warnings[0], "uncertain observation"); + } + + /// + /// Verifies rating curve creation applies informative parameter priors onto the model. + /// + [TestMethod] + public void CreateRatingCurve_AppliesParameterPriors() + { + var (stage, discharge) = AddStageDischargePair(); + var (probeStage, probeDischarge) = TestAnalyses.CreateStageDischargePair(); + var probe = new RatingCurve(probeStage, probeDischarge, numberOfSegments: 1); + string offsetName = probe.Parameters[0].DisplayName; + + var resource = _service.CreateRatingCurve(new CreateRatingCurveAnalysisRequest + { + StageTimeSeriesId = stage.Id, + DischargeTimeSeriesId = discharge.Id, + ParameterPriors = new List + { + new() + { + ParameterName = offsetName, + Distribution = new DistributionSpecDto + { + Type = UnivariateDistributionType.Normal, + Parameters = new List { 1d, 0.5 } + } + } + } + }); + + var model = resource.RatingCurve!.RatingCurve; + Assert.IsFalse(model.UseDefaultFlatPriors); + Assert.AreEqual(UnivariateDistributionType.Normal, model.Parameters[0].PriorDistribution.Type); + } + + /// + /// Verifies mixture creation configures components, zero inflation, and priors on the + /// cloned model, and rejects unsupported or over-long component lists. + /// + [TestMethod] + public void CreateMixture_ConfiguresModel_AndValidatesComponents() + { + var input = AddInputData(); + var resource = _service.CreateMixture(new CreateMixtureAnalysisRequest + { + InputDataId = input.Id, + Distributions = new List + { + UnivariateDistributionType.Gumbel, + UnivariateDistributionType.LogNormal + }, + IsZeroInflated = true, + BayesianOptions = new BayesianOptionsDto { PrngSeed = 7 } + }); + + Assert.AreEqual(AnalysisKind.Mixture, resource.Kind); + Assert.AreEqual(input.Id, resource.InputDataId); + var model = resource.Mixture!.MixtureDistribution; + Assert.IsTrue(model.IsZeroInflated); + Assert.AreEqual(2, model.Mixture!.Distributions.Length); + Assert.AreNotSame(input.DataFrame, model.DataFrame); + Assert.AreEqual(7, resource.Mixture.BayesianAnalysis.PRNGSeed); + + Assert.ThrowsException(() => _service.CreateMixture(new CreateMixtureAnalysisRequest + { + InputDataId = input.Id, + Distributions = Enumerable.Repeat(UnivariateDistributionType.Gumbel, 4).ToList() + })); + Assert.ThrowsException(() => _service.CreateMixture(new CreateMixtureAnalysisRequest + { + InputDataId = input.Id, + Distributions = new List() + })); + } + + /// + /// Verifies point process creation seeds the threshold from a POT resource, honors + /// explicit threshold/record-span overrides (switching defaults off and recomputing λ), + /// and rejects seasonal options without the seasonal flag. + /// + [TestMethod] + public void CreatePointProcess_SeedsThreshold_AndHonorsOverrides() + { + var input = _store.AddInputData(new InputDataResource + { + Name = "pot", + DataFrame = TestAnalyses.CreatePotDataFrame(), + Method = InputDataMethod.PeaksOverThreshold, + Threshold = 400d + }); + + var seeded = _service.CreatePointProcess(new CreatePointProcessAnalysisRequest { InputDataId = input.Id }); + var seededModel = seeded.PointProcess!.PointProcess; + Assert.IsFalse(double.IsNaN(seededModel.Threshold), "The POT resource's threshold must seed the model."); + + var overridden = _service.CreatePointProcess(new CreatePointProcessAnalysisRequest + { + InputDataId = input.Id, + Threshold = 425d, + TotalYears = 12.5 + }); + var overriddenModel = overridden.PointProcess!.PointProcess; + Assert.IsFalse(overriddenModel.UseDefaults, "Explicit overrides must switch off the model defaults."); + Assert.AreEqual(425d, overriddenModel.Threshold); + Assert.AreEqual(12.5, overriddenModel.TotalYears); + Assert.AreEqual(overriddenModel.DataFrame.ExactSeries.Count / 12.5, overriddenModel.Lambda, 1e-9, + "λ must be recomputed from the explicit record span."); + + Assert.ThrowsException(() => _service.CreatePointProcess(new CreatePointProcessAnalysisRequest + { + InputDataId = input.Id, + TimeBlock = Numerics.Data.TimeBlockWindow.WaterYear + })); + } + + /// + /// Verifies competing risks creation configures components and rejects invalid lists. + /// + [TestMethod] + public void CreateCompetingRisks_ConfiguresModel() + { + var input = AddInputData(); + var resource = _service.CreateCompetingRisks(new CreateCompetingRisksAnalysisRequest + { + InputDataId = input.Id, + Distributions = new List + { + UnivariateDistributionType.Gumbel, + UnivariateDistributionType.GeneralizedExtremeValue + } + }); + + Assert.AreEqual(AnalysisKind.CompetingRisks, resource.Kind); + var model = resource.CompetingRisks!.CompetingRisksDistribution; + Assert.AreEqual(2, model.CompetingRisks!.Distributions.Count); + Assert.AreNotSame(input.DataFrame, model.DataFrame); + + Assert.ThrowsException(() => _service.CreateCompetingRisks(new CreateCompetingRisksAnalysisRequest + { + InputDataId = input.Id, + Distributions = Enumerable.Repeat(UnivariateDistributionType.Gumbel, 4).ToList() + })); + } + + /// + /// Adds two unrun univariate analyses to the store and creates a composite over them. + /// + /// The composition method. + /// The composite resource and its two component resources. + private (AnalysisResource Composite, AnalysisResource ComponentA, AnalysisResource ComponentB) CreateStoredComposite( + RMC.BestFit.Analyses.CompositeType compositeType = RMC.BestFit.Analyses.CompositeType.CompetingRisks) + { + var input = AddInputData(); + var componentA = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id, Distribution = UnivariateDistributionType.Gumbel }); + var componentB = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id, Distribution = UnivariateDistributionType.LogNormal }); + var composite = _service.CreateComposite(new CreateCompositeAnalysisRequest + { + Components = new List + { + new() { AnalysisId = componentA.Id, Weight = compositeType == RMC.BestFit.Analyses.CompositeType.Mixture ? 0.6 : null }, + new() { AnalysisId = componentB.Id, Weight = compositeType == RMC.BestFit.Analyses.CompositeType.Mixture ? 0.4 : null } + }, + CompositeType = compositeType + }); + return (composite, componentA, componentB); + } + + /// + /// Verifies composite creation stores live component references, provenance ids, and the + /// composition settings, and applies the presentation options. + /// + [TestMethod] + public void CreateComposite_StoresLiveReferences() + { + var (composite, componentA, componentB) = CreateStoredComposite(); + + Assert.AreEqual(AnalysisKind.Composite, composite.Kind); + Assert.IsNotNull(composite.ComponentResources); + Assert.AreSame(componentA, composite.ComponentResources[0], "Components must be LIVE references, not clones."); + Assert.AreSame(componentB, composite.ComponentResources[1]); + CollectionAssert.AreEqual(new List { componentA.Id, componentB.Id }, composite.ComponentAnalysisIds); + Assert.AreEqual(2, composite.Composite!.Analyses.Count); + Assert.AreSame(componentA.Univariate, composite.Composite.Analyses[0].UnivariateAnalysis); + } + + /// + /// Verifies composite creation rejects unknown components, non-univariate component + /// kinds, nested composites, and invalid mixture weights. + /// + [TestMethod] + public void CreateComposite_Validation() + { + Assert.ThrowsException(() => _service.CreateComposite(new CreateCompositeAnalysisRequest + { + Components = new List { new() { AnalysisId = Guid.NewGuid() } } + })); + + var (stage, discharge) = AddStageDischargePair(); + var rating = _service.CreateRatingCurve(new CreateRatingCurveAnalysisRequest + { + StageTimeSeriesId = stage.Id, + DischargeTimeSeriesId = discharge.Id + }); + var badKind = Assert.ThrowsException(() => _service.CreateComposite(new CreateCompositeAnalysisRequest + { + Components = new List { new() { AnalysisId = rating.Id } } + })); + StringAssert.Contains(badKind.Message, "cannot be a composite component"); + + // A nested composite is rejected by the API-level kind guard (the model's + // WeightedUnivariateAnalysis setter would also reject it one layer deeper). + var (composite, _, _) = CreateStoredComposite(); + var nested = Assert.ThrowsException(() => _service.CreateComposite(new CreateCompositeAnalysisRequest + { + Components = new List { new() { AnalysisId = composite.Id } } + })); + StringAssert.Contains(nested.Message, "cannot be a composite component"); + + var input = AddInputData(); + var component = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id }); + var badWeights = Assert.ThrowsException(() => _service.CreateComposite(new CreateCompositeAnalysisRequest + { + Components = new List + { + new() { AnalysisId = component.Id, Weight = 0.7 }, + new() { AnalysisId = component.Id, Weight = 0.7 } + }, + CompositeType = RMC.BestFit.Analyses.CompositeType.Mixture + })); + StringAssert.Contains(badWeights.Message, "sum"); + + var missingWeight = Assert.ThrowsException(() => _service.CreateComposite(new CreateCompositeAnalysisRequest + { + Components = new List { new() { AnalysisId = component.Id } }, + CompositeType = RMC.BestFit.Analyses.CompositeType.Mixture + })); + StringAssert.Contains(missingWeight.Message, "weight"); + } + + /// + /// Verifies the component-lock guard: a composite run while a component's run lock is + /// held reports 409 naming the busy component — deterministically before the + /// children-unestimated validation error. + /// + [TestMethod] + public async Task RunComposite_ComponentRunning_Conflicts409() + { + var (composite, componentA, _) = CreateStoredComposite(); + + Assert.IsTrue(componentA.RunLock.Wait(0)); + try + { + var ex = await Assert.ThrowsExceptionAsync( + () => _service.RunFrequencyAsync(composite.Id, AnalysisKind.Composite)); + StringAssert.Contains(ex.Message, componentA.Id.ToString()); + Assert.AreEqual(1, composite.RunLock.CurrentCount, "The composite's own lock must be released after the conflict."); + } + finally + { + componentA.RunLock.Release(); + } + + // With all locks free, the run proceeds to validation and fails there instead — + // the components have not been estimated. Both component locks must be released. + await Assert.ThrowsExceptionAsync( + () => _service.RunFrequencyAsync(composite.Id, AnalysisKind.Composite)); + Assert.AreEqual(1, componentA.RunLock.CurrentCount, "Component locks must be released after a failed run."); + } + + /// + /// Verifies a composite listing the same component twice does not deadlock on its own + /// component lock (the lock set is de-duplicated by resource id). + /// + [TestMethod] + public async Task RunComposite_DuplicateComponent_NoSelfConflict() + { + var input = AddInputData(); + var component = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id }); + var composite = _service.CreateComposite(new CreateCompositeAnalysisRequest + { + Components = new List + { + new() { AnalysisId = component.Id, Weight = 0.4 }, + new() { AnalysisId = component.Id, Weight = 0.4 } + }, + CompositeType = RMC.BestFit.Analyses.CompositeType.Mixture + }); + + // Must fail on validation (component unestimated), NOT on a self-inflicted 409. + await Assert.ThrowsExceptionAsync( + () => _service.RunFrequencyAsync(composite.Id, AnalysisKind.Composite)); + Assert.AreEqual(1, component.RunLock.CurrentCount); + } + + /// + /// Verifies deleting a component from the store leaves the composite readable and its + /// live reference intact. + /// + [TestMethod] + public void DeleteComponent_CompositeSurvives() + { + var (composite, componentA, _) = CreateStoredComposite(); + + _service.Delete(componentA.Id); + + Assert.IsNull(_store.GetAnalysis(componentA.Id)); + var summary = AnalysisMapper.ToSummary(_service.Get(composite.Id, AnalysisKind.Composite)); + Assert.IsNotNull(summary.ComponentAnalysisIds); + Assert.AreSame(componentA, composite.ComponentResources![0], "The live reference must survive store deletion."); + } + + /// + /// Verifies distribution-fitting creation clones the input, honors a candidate subset, + /// and rejects unsupported types; results before any run are not found. + /// + [TestMethod] + public void CreateDistributionFitting_SubsetAndValidation() + { + var input = AddInputData(); + var all = _service.CreateDistributionFitting(new CreateDistributionFittingAnalysisRequest { InputDataId = input.Id }); + Assert.AreEqual(15, all.DistributionFitting!.DistributionList.Count, "The default candidate set is all 15 distributions."); + + var subset = _service.CreateDistributionFitting(new CreateDistributionFittingAnalysisRequest + { + InputDataId = input.Id, + Distributions = new List + { + UnivariateDistributionType.LogPearsonTypeIII, + UnivariateDistributionType.GeneralizedExtremeValue + } + }); + Assert.AreEqual(2, subset.DistributionFitting!.DistributionList.Count); + Assert.AreNotSame(input.DataFrame, subset.DistributionFitting.DataFrame); + + Assert.ThrowsException(() => _service.CreateDistributionFitting(new CreateDistributionFittingAnalysisRequest + { + InputDataId = input.Id, + Distributions = new List { UnivariateDistributionType.Cauchy } + })); + + Assert.ThrowsException(() => _service.GetDistributionFittingResults(subset.Id)); + } + + /// + /// Adds two unrun univariate marginals to the store and creates a bivariate over them. + /// + /// The bivariate resource and its two marginal resources. + private (AnalysisResource Bivariate, AnalysisResource MarginalX, AnalysisResource MarginalY) CreateStoredBivariate() + { + var input = AddInputData(); + var marginalX = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id, Distribution = UnivariateDistributionType.Gumbel }); + var marginalY = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id, Distribution = UnivariateDistributionType.LogNormal }); + var bivariate = _service.CreateBivariate(new CreateBivariateAnalysisRequest + { + MarginalXAnalysisId = marginalX.Id, + MarginalYAnalysisId = marginalY.Id, + XyOrdinates = new List + { + new() { X = 400d, Y = 430d }, + new() { X = 200d, Y = 220d } + } + }); + return (bivariate, marginalX, marginalY); + } + + /// + /// Verifies bivariate creation stores live marginal references, sorts the XY grid by x, + /// and the API-level validation reports unestimated marginals without running. + /// + [TestMethod] + public void CreateBivariate_StoresLiveReferences_AndSortsGrid() + { + var (bivariate, marginalX, marginalY) = CreateStoredBivariate(); + + Assert.AreEqual(AnalysisKind.Bivariate, bivariate.Kind); + Assert.AreSame(marginalX, bivariate.MarginalXResource); + Assert.AreSame(marginalY, bivariate.MarginalYResource); + Assert.AreSame(marginalX.Univariate!.UnivariateDistribution, bivariate.Bivariate!.BivariateDistribution.MarginalX, + "The bivariate must reference the LIVE marginal model, not a clone."); + Assert.AreEqual(2, bivariate.Bivariate.XYOrdinates.Count); + Assert.AreEqual(200d, bivariate.Bivariate.XYOrdinates[0].X, "The XY grid must be sorted by x."); + + var validation = _service.Validate(bivariate.Id, AnalysisKind.Bivariate); + Assert.IsFalse(validation.IsValid); + Assert.IsTrue(validation.Errors.Any(e => e.Contains("marginal X")), "Validation must name the unestimated marginal."); + } + + /// + /// Verifies bivariate creation rejections: identical marginals, invalid marginal kinds, + /// the unsupported full-likelihood method, and insufficient data overlap. + /// + [TestMethod] + public void CreateBivariate_Validation() + { + var input = AddInputData(); + var marginal = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id }); + + var same = Assert.ThrowsException(() => _service.CreateBivariate(new CreateBivariateAnalysisRequest + { + MarginalXAnalysisId = marginal.Id, + MarginalYAnalysisId = marginal.Id, + XyOrdinates = new List { new() { X = 1d, Y = 1d } } + })); + StringAssert.Contains(same.Message, "distinct"); + + var competing = _service.CreateCompetingRisks(new CreateCompetingRisksAnalysisRequest + { + InputDataId = input.Id, + Distributions = new List { UnivariateDistributionType.Gumbel, UnivariateDistributionType.LogNormal } + }); + var badKind = Assert.ThrowsException(() => _service.CreateBivariate(new CreateBivariateAnalysisRequest + { + MarginalXAnalysisId = marginal.Id, + MarginalYAnalysisId = competing.Id, + XyOrdinates = new List { new() { X = 1d, Y = 1d } } + })); + StringAssert.Contains(badKind.Message, "cannot be the marginal Y"); + + var other = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id }); + var badMethod = Assert.ThrowsException(() => _service.CreateBivariate(new CreateBivariateAnalysisRequest + { + MarginalXAnalysisId = marginal.Id, + MarginalYAnalysisId = other.Id, + EstimationMethod = Numerics.Distributions.Copulas.CopulaEstimationMethod.FullLikelihood, + XyOrdinates = new List { new() { X = 1d, Y = 1d } } + })); + StringAssert.Contains(badMethod.Message, "fullLikelihood"); + + // A marginal over a frame sharing only 5 time indexes with the other fails the + // overlap requirement. + var shortFrame = new DataFrame(); + shortFrame.ExactSeries.SuppressCollectionChanged = true; + for (int i = 0; i < 5; i++) + { + shortFrame.ExactSeries.Add(new ExactData(2000 + i, 150d + 10d * i)); + } + for (int i = 0; i < 10; i++) + { + shortFrame.ExactSeries.Add(new ExactData(1900 + i, 150d + 10d * i)); + } + shortFrame.ExactSeries.SuppressCollectionChanged = false; + shortFrame.ExactSeries.RaiseCollectionChangedReset(); + var shortInput = _store.AddInputData(new InputDataResource { Name = "short", DataFrame = shortFrame, Method = InputDataMethod.Manual }); + var shortMarginal = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = shortInput.Id }); + var overlap = Assert.ThrowsException(() => _service.CreateBivariate(new CreateBivariateAnalysisRequest + { + MarginalXAnalysisId = marginal.Id, + MarginalYAnalysisId = shortMarginal.Id, + XyOrdinates = new List { new() { X = 1d, Y = 1d } } + })); + StringAssert.Contains(overlap.Message, "overlapping"); + } + + /// + /// Verifies a bivariate run reports 409 while a marginal's run lock is held, releasing + /// every acquired lock. + /// + [TestMethod] + public async Task RunBivariate_MarginalRunning_Conflicts409() + { + var (bivariate, marginalX, _) = CreateStoredBivariate(); + + Assert.IsTrue(marginalX.RunLock.Wait(0)); + try + { + var ex = await Assert.ThrowsExceptionAsync( + () => _service.RunBivariateAsync(bivariate.Id)); + StringAssert.Contains(ex.Message, marginalX.Id.ToString()); + } + finally + { + marginalX.RunLock.Release(); + } + + // With locks free the run fails validation instead (marginals unestimated), and + // every lock is back to available. + await Assert.ThrowsExceptionAsync(() => _service.RunBivariateAsync(bivariate.Id)); + Assert.AreEqual(1, marginalX.RunLock.CurrentCount); + Assert.AreEqual(1, bivariate.RunLock.CurrentCount); + } + + /// + /// Verifies coincident frequency creation validates the surface shape and bins, stores + /// transitive live references, and its runs conflict while a transitive marginal is busy. + /// + [TestMethod] + public async Task CreateCoincidentFrequency_ShapeValidation_AndTransitiveLocks() + { + var (bivariate, marginalX, _) = CreateStoredBivariate(); + + var valid = new CreateCoincidentFrequencyAnalysisRequest + { + BivariateAnalysisId = bivariate.Id, + XValues = new List { 100d, 200d, 300d }, + YValues = new List { 50d, 100d, 150d }, + BivariateResponse = new List> + { + new() { 10d, 11d, 12d }, + new() { 13d, 14d, 15d }, + new() { 16d, 17d, 18d } + } + }; + var cfa = _service.CreateCoincidentFrequency(valid); + Assert.AreEqual(AnalysisKind.CoincidentFrequency, cfa.Kind); + Assert.AreSame(bivariate, cfa.BivariateResource); + Assert.AreEqual(3, cfa.ComponentResources!.Count, "Locks must cover the bivariate plus its transitive marginals."); + + // The pre-run validation reports the unestimated bivariate without running. + var validation = _service.Validate(cfa.Id, AnalysisKind.CoincidentFrequency); + Assert.IsFalse(validation.IsValid); + + Assert.IsTrue(marginalX.RunLock.Wait(0)); + try + { + await Assert.ThrowsExceptionAsync(() => _service.RunCoincidentFrequencyAsync(cfa.Id)); + } + finally + { + marginalX.RunLock.Release(); + } + + var ragged = Assert.ThrowsException(() => _service.CreateCoincidentFrequency(new CreateCoincidentFrequencyAnalysisRequest + { + BivariateAnalysisId = bivariate.Id, + XValues = new List { 100d, 200d }, + YValues = new List { 50d, 100d }, + BivariateResponse = new List> { new() { 10d, 11d }, new() { 13d } } + })); + StringAssert.Contains(ragged.Message, "bivariateResponse[1]"); + + var wrongRows = Assert.ThrowsException(() => _service.CreateCoincidentFrequency(new CreateCoincidentFrequencyAnalysisRequest + { + BivariateAnalysisId = bivariate.Id, + XValues = new List { 100d, 200d, 300d }, + YValues = new List { 50d, 100d }, + BivariateResponse = new List> { new() { 10d, 11d }, new() { 13d, 14d } } + })); + StringAssert.Contains(wrongRows.Message, "one row per xValues"); + + Assert.ThrowsException(() => _service.CreateCoincidentFrequency(new CreateCoincidentFrequencyAnalysisRequest + { + BivariateAnalysisId = bivariate.Id, + XValues = valid.XValues, + YValues = valid.YValues, + BivariateResponse = valid.BivariateResponse, + NumberOfBins = 4 + })); + + // A non-bivariate id 404s on the CFA create. + var input = AddInputData(); + var univariate = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest { InputDataId = input.Id }); + Assert.ThrowsException(() => _service.CreateCoincidentFrequency(new CreateCoincidentFrequencyAnalysisRequest + { + BivariateAnalysisId = univariate.Id, + XValues = valid.XValues, + YValues = valid.YValues, + BivariateResponse = valid.BivariateResponse + })); + } + + /// + /// Adds the synthetic daily series to the store as a time-series resource. + /// + /// The stored resource. + private TimeSeriesResource AddDailySeries() + { + return _store.AddTimeSeries(new TimeSeriesResource(TestSeries.DailyThreeWaterYears()) + { + Name = "daily", + Source = TimeSeriesSource.Manual + }); + } + + /// + /// Verifies time-series creation for every model family: defaults, clone independence, + /// explicit orders, training-window override, and the forecast horizon. + /// + [TestMethod] + public void CreateTimeSeries_ConfiguresEachFamily() + { + var source = AddDailySeries(); + + var ar = _service.CreateTimeSeries(new CreateTimeSeriesAnalysisRequest + { + TimeSeriesId = source.Id, + ModelType = TimeSeriesModelType.Ar, + ForecastingTimeSteps = 12 + }); + Assert.AreEqual(AnalysisKind.TimeSeries, ar.Kind); + Assert.AreEqual(TimeSeriesModelType.Ar, ar.TimeSeriesModel); + Assert.AreEqual(1, ar.Ar!.AutoRegressive.Order, "The AR order defaults to 1."); + Assert.IsTrue(ar.Ar.AutoRegressive.IncludeIntercept, "The intercept defaults to on."); + Assert.AreEqual(12, ar.Ar.ForecastingTimeSteps); + Assert.AreNotSame(source.TimeSeries, ar.Ar.AutoRegressive.TimeSeries, "The series must be cloned."); + Assert.AreEqual(source.Id, ar.TimeSeriesId); + + var ma = _service.CreateTimeSeries(new CreateTimeSeriesAnalysisRequest + { + TimeSeriesId = source.Id, + ModelType = TimeSeriesModelType.Ma, + Order = 2 + }); + Assert.AreEqual(2, ma.Ma!.MovingAverage.Order); + + var arima = _service.CreateTimeSeries(new CreateTimeSeriesAnalysisRequest + { + TimeSeriesId = source.Id, + ModelType = TimeSeriesModelType.Arima, + POrder = 2, + DOrder = 1, + QOrder = 1, + TrainingTimeSteps = 900 + }); + Assert.AreEqual(2, arima.Arima!.ARIMA.POrder); + Assert.AreEqual(1, arima.Arima.ARIMA.DOrder); + Assert.AreEqual(1, arima.Arima.ARIMA.QOrder); + Assert.IsFalse(arima.Arima.ARIMA.UseDefaultTrainingSteps, "An explicit training window must switch defaults off."); + Assert.AreEqual(900, arima.Arima.ARIMA.TrainingTimeSteps); + + var covariate = AddDailySeries(); + var arimax = _service.CreateTimeSeries(new CreateTimeSeriesAnalysisRequest + { + TimeSeriesId = source.Id, + ModelType = TimeSeriesModelType.Arimax, + POrder = 1, + XOrder = 1, + TrendType = ARIMAX.Trend.Linear, + IncludeSeasonality = true, + CovariateTimeSeriesIds = new List { covariate.Id }, + CovariateExtension = ARIMAX.CovariateExtensionMethod.KNN + }); + Assert.AreEqual(TimeSeriesModelType.Arimax, arimax.TimeSeriesModel); + Assert.AreEqual(1, arimax.Arimax!.ARIMAX.XOrderB); + Assert.AreEqual(ARIMAX.Trend.Linear, arimax.Arimax.ARIMAX.TrendType); + Assert.IsTrue(arimax.Arimax.ARIMAX.IncludeSeasonality); + Assert.AreEqual(ARIMAX.CovariateExtensionMethod.KNN, arimax.Arimax.ARIMAX.CovariateExtension); + CollectionAssert.AreEqual(new List { covariate.Id }, arimax.CovariateTimeSeriesIds); + } + + /// + /// Verifies fields that do not apply to the chosen model type are rejected with the + /// offending fields named, and out-of-range forecast horizons and missing covariates fail. + /// + [TestMethod] + public void CreateTimeSeries_RejectsIrrelevantFields() + { + var source = AddDailySeries(); + + var arWithArima = Assert.ThrowsException(() => _service.CreateTimeSeries(new CreateTimeSeriesAnalysisRequest + { + TimeSeriesId = source.Id, + ModelType = TimeSeriesModelType.Ar, + POrder = 1, + CovariateExtension = ARIMAX.CovariateExtensionMethod.KNN + })); + StringAssert.Contains(arWithArima.Message, "pOrder"); + StringAssert.Contains(arWithArima.Message, "covariateExtension"); + StringAssert.Contains(arWithArima.Message, "'ar'"); + + var arimaWithOrder = Assert.ThrowsException(() => _service.CreateTimeSeries(new CreateTimeSeriesAnalysisRequest + { + TimeSeriesId = source.Id, + ModelType = TimeSeriesModelType.Arima, + Order = 2 + })); + StringAssert.Contains(arimaWithOrder.Message, "order"); + + var arimaWithSeasonality = Assert.ThrowsException(() => _service.CreateTimeSeries(new CreateTimeSeriesAnalysisRequest + { + TimeSeriesId = source.Id, + ModelType = TimeSeriesModelType.Arima, + IncludeSeasonality = true + })); + StringAssert.Contains(arimaWithSeasonality.Message, "includeSeasonality"); + + Assert.ThrowsException(() => _service.CreateTimeSeries(new CreateTimeSeriesAnalysisRequest + { + TimeSeriesId = source.Id, + ModelType = TimeSeriesModelType.Ar, + ForecastingTimeSteps = 101 + })); + + Assert.ThrowsException(() => _service.CreateTimeSeries(new CreateTimeSeriesAnalysisRequest + { + TimeSeriesId = source.Id, + ModelType = TimeSeriesModelType.Arimax, + CovariateTimeSeriesIds = new List { Guid.NewGuid() } + })); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Services/Exceptions/RequestValidationExceptionTests.cs b/src/RMC.BestFit.Api.Tests/Services/Exceptions/RequestValidationExceptionTests.cs new file mode 100644 index 0000000..ae9ae3c --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Services/Exceptions/RequestValidationExceptionTests.cs @@ -0,0 +1,33 @@ +using RMC.BestFit.Api.Services.Exceptions; + +namespace RMC.BestFit.Api.Tests.Services.Exceptions +{ + /// + /// Unit tests for error-list carriage. + /// + [TestClass] + public class RequestValidationExceptionTests + { + /// + /// Verifies the error list is captured and the default summary message is used. + /// + [TestMethod] + public void Constructor_Errors_CapturedWithDefaultMessage() + { + var ex = new RequestValidationException(new[] { "error one", "error two" }); + Assert.AreEqual(2, ex.Errors.Count); + Assert.AreEqual("error one", ex.Errors[0]); + StringAssert.Contains(ex.Message, "validationErrors"); + } + + /// + /// Verifies a custom summary message overrides the default. + /// + [TestMethod] + public void Constructor_CustomMessage_Preserved() + { + var ex = new RequestValidationException(new[] { "e" }, "custom summary"); + Assert.AreEqual("custom summary", ex.Message); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Services/Exceptions/ResourceConflictExceptionTests.cs b/src/RMC.BestFit.Api.Tests/Services/Exceptions/ResourceConflictExceptionTests.cs new file mode 100644 index 0000000..38930b5 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Services/Exceptions/ResourceConflictExceptionTests.cs @@ -0,0 +1,21 @@ +using RMC.BestFit.Api.Services.Exceptions; + +namespace RMC.BestFit.Api.Tests.Services.Exceptions +{ + /// + /// Unit tests for message construction. + /// + [TestClass] + public class ResourceConflictExceptionTests + { + /// + /// Verifies the constructor preserves the message. + /// + [TestMethod] + public void Constructor_Message_Preserved() + { + var ex = new ResourceConflictException("conflict"); + Assert.AreEqual("conflict", ex.Message); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Services/Exceptions/ResourceNotFoundExceptionTests.cs b/src/RMC.BestFit.Api.Tests/Services/Exceptions/ResourceNotFoundExceptionTests.cs new file mode 100644 index 0000000..4e0a2de --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Services/Exceptions/ResourceNotFoundExceptionTests.cs @@ -0,0 +1,33 @@ +using RMC.BestFit.Api.Services.Exceptions; + +namespace RMC.BestFit.Api.Tests.Services.Exceptions +{ + /// + /// Unit tests for message construction. + /// + [TestClass] + public class ResourceNotFoundExceptionTests + { + /// + /// Verifies the plain-message constructor preserves the message. + /// + [TestMethod] + public void Constructor_Message_Preserved() + { + var ex = new ResourceNotFoundException("missing"); + Assert.AreEqual("missing", ex.Message); + } + + /// + /// Verifies the type-and-id constructor includes both in the message. + /// + [TestMethod] + public void Constructor_TypeAndId_BuildsActionableMessage() + { + var id = Guid.NewGuid(); + var ex = new ResourceNotFoundException("time series", id); + StringAssert.Contains(ex.Message, "time series"); + StringAssert.Contains(ex.Message, id.ToString()); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Services/Exceptions/UsgsDataNotFoundExceptionTests.cs b/src/RMC.BestFit.Api.Tests/Services/Exceptions/UsgsDataNotFoundExceptionTests.cs new file mode 100644 index 0000000..8dba3fc --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Services/Exceptions/UsgsDataNotFoundExceptionTests.cs @@ -0,0 +1,21 @@ +using RMC.BestFit.Api.Services.Exceptions; + +namespace RMC.BestFit.Api.Tests.Services.Exceptions +{ + /// + /// Unit tests for message construction. + /// + [TestClass] + public class UsgsDataNotFoundExceptionTests + { + /// + /// Verifies the constructor preserves the message. + /// + [TestMethod] + public void Constructor_Message_Preserved() + { + var ex = new UsgsDataNotFoundException("no data"); + Assert.AreEqual("no data", ex.Message); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Services/Exceptions/UsgsUnavailableExceptionTests.cs b/src/RMC.BestFit.Api.Tests/Services/Exceptions/UsgsUnavailableExceptionTests.cs new file mode 100644 index 0000000..14caa1f --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Services/Exceptions/UsgsUnavailableExceptionTests.cs @@ -0,0 +1,34 @@ +using RMC.BestFit.Api.Services.Exceptions; + +namespace RMC.BestFit.Api.Tests.Services.Exceptions +{ + /// + /// Unit tests for status-code carriage. + /// + [TestClass] + public class UsgsUnavailableExceptionTests + { + /// + /// Verifies the default status code is 502 (upstream error). + /// + [TestMethod] + public void Constructor_DefaultStatusCode_Is502() + { + var ex = new UsgsUnavailableException("upstream failed"); + Assert.AreEqual(502, ex.StatusCode); + Assert.AreEqual("upstream failed", ex.Message); + } + + /// + /// Verifies the status code and inner exception are preserved. + /// + [TestMethod] + public void Constructor_CustomStatusCodeAndInner_Preserved() + { + var inner = new InvalidOperationException("No internet connection."); + var ex = new UsgsUnavailableException("offline", statusCode: 503, innerException: inner); + Assert.AreEqual(503, ex.StatusCode); + Assert.AreSame(inner, ex.InnerException); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Services/InputDataServiceTests.cs b/src/RMC.BestFit.Api.Tests/Services/InputDataServiceTests.cs new file mode 100644 index 0000000..5e67538 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Services/InputDataServiceTests.cs @@ -0,0 +1,448 @@ +using Microsoft.Extensions.Options; +using Numerics.Data; +using Numerics.Distributions; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Services.Exceptions; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; +using RMC.BestFit.Models; + +namespace RMC.BestFit.Api.Tests.Services +{ + /// + /// Unit tests for : manual data-frame assembly, block-maxima and + /// peaks-over-threshold extraction against a synthetic series with planted maxima, and the + /// direct USGS peak path through the faked download seam. No estimators are run. + /// + [TestClass] + public class InputDataServiceTests + { + /// + /// The store backing the service under test. + /// + private InMemoryResourceStore _store = null!; + + /// + /// The faked USGS seam. + /// + private FakeUsgsTimeSeriesService _usgs = null!; + + /// + /// The service under test. + /// + private InputDataService _service = null!; + + /// + /// Creates a fresh store, fake, and service before each test. + /// + [TestInitialize] + public void Initialize() + { + _store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + _usgs = new FakeUsgsTimeSeriesService(); + _service = new InputDataService(_store, _usgs); + } + + /// + /// Adds the synthetic three-water-year daily series to the store as a resource. + /// + /// The stored time-series resource. + private TimeSeriesResource AddDailySeries() + { + var resource = new TimeSeriesResource(TestSeries.DailyThreeWaterYears()) + { + Name = "daily", + Source = TimeSeriesSource.Manual + }; + return _store.AddTimeSeries(resource); + } + + /// + /// Verifies constructor null guards. + /// + [TestMethod] + public void Constructor_NullDependencies_Throw() + { + Assert.ThrowsException(() => _ = new InputDataService(null!, _usgs)); + Assert.ThrowsException(() => _ = new InputDataService(_store, null!)); + } + + /// + /// Verifies manual creation populates exact observations, computes plotting positions, and + /// resolves indices from either 'index' or 'dateTime'. + /// + [TestMethod] + public void CreateManual_PopulatesExactSeriesAndPlottingPositions() + { + var request = new CreateManualInputDataRequest + { + ExactData = new List + { + new() { Index = 2000, Value = 100d }, + new() { DateTime = new DateTime(2001, 5, 1), Value = 200d }, + new() { Index = 2002, Value = 150d } + } + }; + + var resource = _service.CreateManual(request); + var dataFrame = resource.DataFrame; + + Assert.AreEqual(InputDataMethod.Manual, resource.Method); + Assert.AreEqual(3, dataFrame.ExactSeries.Count); + foreach (ExactData data in dataFrame.ExactSeries) + { + Assert.IsTrue(data.PlottingPosition > 0d && data.PlottingPosition < 1d, + $"Plotting position {data.PlottingPosition} for index {data.Index} was not computed."); + } + } + + /// + /// Verifies an exact observation without index or dateTime is rejected. + /// + [TestMethod] + public void CreateManual_ObservationWithoutIndexOrDate_Throws() + { + var request = new CreateManualInputDataRequest + { + ExactData = new List { new() { Value = 100d } } + }; + Assert.ThrowsException(() => _service.CreateManual(request)); + } + + /// + /// Verifies interval data defaults its representative value to the midpoint, and threshold + /// records carry the number-above count. + /// + [TestMethod] + public void CreateManual_IntervalAndThresholdData_Populated() + { + var request = new CreateManualInputDataRequest + { + ExactData = new List + { + new() { Index = 2000, Value = 100d }, + new() { Index = 2001, Value = 140d } + }, + IntervalData = new List + { + new() { Index = 1950, LowerBound = 200d, UpperBound = 300d } + }, + ThresholdData = new List + { + new() { StartIndex = 1900, EndIndex = 1949, Value = 180d, NumberAbove = 1 } + } + }; + + var resource = _service.CreateManual(request); + var dataFrame = resource.DataFrame; + + Assert.AreEqual(1, dataFrame.IntervalSeries.Count); + var interval = (IntervalData)dataFrame.IntervalSeries[0]; + Assert.AreEqual(250d, interval.Value); + + Assert.AreEqual(1, dataFrame.ThresholdSeries.Count); + var threshold = (ThresholdData)dataFrame.ThresholdSeries[0]; + Assert.AreEqual(1, threshold.NumberAbove); + Assert.AreEqual(50, threshold.Duration); + } + + /// + /// Verifies an explicit lambda overrides the recomputed events-per-span value. + /// + [TestMethod] + public void CreateManual_ExplicitLambda_Overrides() + { + var request = new CreateManualInputDataRequest + { + ExactData = new List + { + new() { Index = 2000, Value = 100d }, + new() { Index = 2000, Value = 140d }, + new() { Index = 2001, Value = 120d } + }, + Lambda = 1.5 + }; + var resource = _service.CreateManual(request); + Assert.AreEqual(1.5, resource.DataFrame.Lambda); + } + + /// + /// Verifies invalid manual data (start index after end index on a threshold) surfaces the + /// model-layer validation messages as a request validation failure. + /// + [TestMethod] + public void CreateManual_InvalidThreshold_ThrowsRequestValidation() + { + var request = new CreateManualInputDataRequest + { + ExactData = new List { new() { Index = 2000, Value = 100d } }, + ThresholdData = new List + { + new() { StartIndex = 1950, EndIndex = 1900, Value = 180d } + } + }; + var ex = Assert.ThrowsException(() => _service.CreateManual(request)); + Assert.IsTrue(ex.Errors.Count > 0); + } + + /// + /// Verifies water-year block-maxima extraction recovers exactly the planted annual maxima + /// of the synthetic daily series. + /// + [TestMethod] + public void CreateBlockMax_RecoversPlantedWaterYearMaxima() + { + var source = AddDailySeries(); + var request = new CreateBlockMaxInputDataRequest { TimeSeriesId = source.Id }; + + var resource = _service.CreateBlockMax(request); + var dataFrame = resource.DataFrame; + + Assert.AreEqual(InputDataMethod.BlockMaxima, resource.Method); + Assert.AreEqual(source.Id, resource.SourceTimeSeriesId); + Assert.AreEqual(TestSeries.PlantedMaxima.Count, dataFrame.ExactSeries.Count); + + var values = new List(); + foreach (ExactData data in dataFrame.ExactSeries) values.Add(data.Value); + CollectionAssert.AreEquivalent(TestSeries.PlantedMaxima.Values.ToList(), values); + } + + /// + /// Verifies block-maxima extraction fails with 404 semantics when the source id is unknown. + /// + [TestMethod] + public void CreateBlockMax_UnknownTimeSeries_ThrowsNotFound() + { + var request = new CreateBlockMaxInputDataRequest { TimeSeriesId = Guid.NewGuid() }; + Assert.ThrowsException(() => _service.CreateBlockMax(request)); + } + + /// + /// Verifies peaks-over-threshold extraction finds the planted peaks and reports the + /// model-computed lambda. + /// + [TestMethod] + public void CreatePeaksOverThreshold_FindsPlantedPeaks_AndComputesLambda() + { + var source = AddDailySeries(); + var request = new CreatePotInputDataRequest + { + TimeSeriesId = source.Id, + Threshold = 400d, + MinStepsBetweenPeaks = 7 + }; + + var resource = _service.CreatePeaksOverThreshold(request); + var dataFrame = resource.DataFrame; + + Assert.AreEqual(InputDataMethod.PeaksOverThreshold, resource.Method); + Assert.AreEqual(3, dataFrame.ExactSeries.Count); + + // The model's collection-reset handler recomputes lambda as events / IndexSpan of the + // peak years (1991-1993 → 3 events / 3 years = 1.0), overwriting the record-span value + // (3 / 4 = 0.75) that CreatePeaksOverThresholdSeries itself computes. The API matches + // the model (and desktop) behavior; clients needing the record-span rate pass an + // explicit lambda. If this assertion starts failing at 0.75, the model-layer ordering + // changed and this test should be updated to the new canonical value. + Assert.AreEqual(1.0, dataFrame.Lambda, 1e-9); + Assert.AreEqual(400d, resource.Threshold); + } + + /// + /// Verifies an explicit lambda on the POT request overrides the model-computed value. + /// + [TestMethod] + public void CreatePeaksOverThreshold_ExplicitLambda_Overrides() + { + var source = AddDailySeries(); + var request = new CreatePotInputDataRequest + { + TimeSeriesId = source.Id, + Threshold = 400d, + MinStepsBetweenPeaks = 7, + Lambda = 0.75 + }; + var resource = _service.CreatePeaksOverThreshold(request); + Assert.AreEqual(0.75, resource.DataFrame.Lambda, 1e-9); + } + + /// + /// Verifies a threshold above every value produces a request validation failure rather + /// than an empty resource. + /// + [TestMethod] + public void CreatePeaksOverThreshold_ThresholdAboveAllValues_ThrowsRequestValidation() + { + var source = AddDailySeries(); + var request = new CreatePotInputDataRequest { TimeSeriesId = source.Id, Threshold = 10_000d }; + Assert.ThrowsException(() => _service.CreatePeaksOverThreshold(request)); + } + + /// + /// Verifies the direct USGS peak path populates exact observations from the downloaded + /// series through the faked seam. + /// + [TestMethod] + public async Task CreateFromUsgsPeaksAsync_PopulatesExactSeries() + { + _usgs.Result = TestSeries.IrregularPeaks(); + var request = new CreateUsgsPeaksInputDataRequest { SiteNumber = "01646500" }; + + var resource = await _service.CreateFromUsgsPeaksAsync(request); + + Assert.AreEqual("01646500", _usgs.LastSiteNumber); + Assert.AreEqual(TimeSeriesDownload.TimeSeriesType.PeakDischarge, _usgs.LastSeriesType); + Assert.AreEqual(InputDataMethod.UsgsPeakDischarge, resource.Method); + Assert.AreEqual("01646500", resource.UsgsSiteNumber); + Assert.AreEqual(10, resource.DataFrame.ExactSeries.Count); + } + + /// + /// Verifies the peak-stage series type maps to the peak-stage method discriminator. + /// + [TestMethod] + public async Task CreateFromUsgsPeaksAsync_PeakStage_MapsMethod() + { + _usgs.Result = TestSeries.IrregularPeaks(); + var request = new CreateUsgsPeaksInputDataRequest + { + SiteNumber = "01646500", + SeriesType = TimeSeriesDownload.TimeSeriesType.PeakStage + }; + var resource = await _service.CreateFromUsgsPeaksAsync(request); + Assert.AreEqual(InputDataMethod.UsgsPeakStage, resource.Method); + } + + /// + /// Verifies non-peak series types are rejected before any download occurs. + /// + [TestMethod] + public async Task CreateFromUsgsPeaksAsync_NonPeakSeriesType_Throws() + { + var request = new CreateUsgsPeaksInputDataRequest + { + SiteNumber = "01646500", + SeriesType = TimeSeriesDownload.TimeSeriesType.DailyDischarge + }; + await Assert.ThrowsExceptionAsync(() => _service.CreateFromUsgsPeaksAsync(request)); + Assert.IsNull(_usgs.LastSiteNumber); + } + + /// + /// Verifies summary statistics are computed for a stored resource and Get/Delete enforce + /// not-found semantics. + /// + [TestMethod] + public void SummaryStatistics_Get_Delete_Lifecycle() + { + var resource = _service.CreateManual(new CreateManualInputDataRequest + { + ExactData = new List + { + new() { Index = 2000, Value = 100d }, + new() { Index = 2001, Value = 200d }, + new() { Index = 2002, Value = 300d } + } + }); + + var statistics = _service.GetSummaryStatistics(resource.Id); + Assert.IsTrue(statistics.Count > 0); + + Assert.AreSame(resource, _service.Get(resource.Id)); + _service.Delete(resource.Id); + Assert.ThrowsException(() => _service.Get(resource.Id)); + Assert.ThrowsException(() => _service.GetSummaryStatistics(resource.Id)); + Assert.ThrowsException(() => _service.Delete(resource.Id)); + } + + /// + /// Verifies manual creation builds the uncertain series from measurement-error + /// distribution specs, with the nominal value taken from the distribution mean. + /// + [TestMethod] + public void CreateManual_BuildsUncertainSeries() + { + var resource = _service.CreateManual(new CreateManualInputDataRequest + { + ExactData = new List + { + new() { Index = 2000, Value = 100d }, + new() { Index = 2001, Value = 200d }, + new() { Index = 2002, Value = 300d } + }, + UncertainData = new List + { + new() + { + Index = 1875, + Distribution = new DistributionSpecDto + { + Type = UnivariateDistributionType.Triangular, + Parameters = new List { 400d, 550d, 900d } + } + }, + new() + { + DateTime = new DateTime(1902, 6, 1), + Distribution = new DistributionSpecDto + { + Type = UnivariateDistributionType.Normal, + Parameters = new List { 500d, 50d } + } + } + } + }); + + var series = resource.DataFrame.UncertainSeries; + Assert.AreEqual(2, series.Count); + var first = (UncertainData)series[0]; + Assert.AreEqual(1875, first.Index); + Assert.AreEqual(UnivariateDistributionType.Triangular, first.Distribution.Type); + var second = (UncertainData)series[1]; + Assert.AreEqual(1902, second.Index, "The dateTime year must become the time index."); + Assert.AreEqual(500d, second.Value, 1e-9, "The nominal value must be the distribution mean."); + } + + /// + /// Verifies uncertain-data validation failures surface as request errors: a missing + /// index/date, an invalid distribution spec, and an index overlapping an exact observation. + /// + [TestMethod] + public void CreateManual_UncertainValidation_Throws() + { + var exact = new List + { + new() { Index = 2000, Value = 100d }, + new() { Index = 2001, Value = 200d } + }; + var goodSpec = new DistributionSpecDto { Type = UnivariateDistributionType.Normal, Parameters = new List { 500d, 50d } }; + + var missingIndex = Assert.ThrowsException(() => _service.CreateManual(new CreateManualInputDataRequest + { + ExactData = exact, + UncertainData = new List { new() { Distribution = goodSpec } } + })); + StringAssert.Contains(missingIndex.Message, "uncertainData[0]"); + + var badSpec = Assert.ThrowsException(() => _service.CreateManual(new CreateManualInputDataRequest + { + ExactData = exact, + UncertainData = new List + { + new() { Index = 1875, Distribution = new DistributionSpecDto { Type = UnivariateDistributionType.Normal, Parameters = new List { 500d } } } + } + })); + StringAssert.Contains(badSpec.Message, "uncertainData[0].distribution"); + + // An uncertain observation may not share a time index with an exact observation — + // the data frame's own validation reports it (HTTP 400 via RequestValidationException). + Assert.ThrowsException(() => _service.CreateManual(new CreateManualInputDataRequest + { + ExactData = exact, + UncertainData = new List { new() { Index = 2000, Distribution = goodSpec } } + })); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Services/TimeSeriesServiceTests.cs b/src/RMC.BestFit.Api.Tests/Services/TimeSeriesServiceTests.cs new file mode 100644 index 0000000..7441b63 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Services/TimeSeriesServiceTests.cs @@ -0,0 +1,150 @@ +using Microsoft.Extensions.Options; +using Numerics.Data; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Services.Exceptions; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Services +{ + /// + /// Unit tests for using the faked USGS seam. + /// + [TestClass] + public class TimeSeriesServiceTests + { + /// + /// The store backing the service under test. + /// + private InMemoryResourceStore _store = null!; + + /// + /// The faked USGS seam. + /// + private FakeUsgsTimeSeriesService _usgs = null!; + + /// + /// The service under test. + /// + private TimeSeriesService _service = null!; + + /// + /// Creates a fresh store, fake, and service before each test. + /// + [TestInitialize] + public void Initialize() + { + _store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + _usgs = new FakeUsgsTimeSeriesService { Result = TestSeries.DailyThreeWaterYears() }; + _service = new TimeSeriesService(_store, _usgs); + } + + /// + /// Verifies constructor null guards. + /// + [TestMethod] + public void Constructor_NullDependencies_Throw() + { + Assert.ThrowsException(() => _ = new TimeSeriesService(null!, _usgs)); + Assert.ThrowsException(() => _ = new TimeSeriesService(_store, null!)); + } + + /// + /// Verifies the USGS creation path stores the resource with provenance and a defaulted name. + /// + [TestMethod] + public async Task CreateFromUsgsAsync_StoresResourceWithProvenance() + { + var request = new CreateUsgsTimeSeriesRequest + { + SiteNumber = "01646500", + SeriesType = TimeSeriesDownload.TimeSeriesType.DailyDischarge + }; + + var resource = await _service.CreateFromUsgsAsync(request); + + Assert.AreEqual("01646500", _usgs.LastSiteNumber); + Assert.AreEqual(TimeSeriesDownload.TimeSeriesType.DailyDischarge, _usgs.LastSeriesType); + Assert.AreEqual(TimeSeriesSource.Usgs, resource.Source); + Assert.AreEqual("01646500", resource.UsgsSiteNumber); + StringAssert.Contains(resource.Name, "01646500"); + Assert.AreSame(resource, _store.GetTimeSeries(resource.Id)); + Assert.AreEqual(TestSeries.DailyThreeWaterYears().Count, resource.PointCount); + } + + /// + /// Verifies an explicit name overrides the generated default. + /// + [TestMethod] + public async Task CreateFromUsgsAsync_ExplicitName_Preserved() + { + var request = new CreateUsgsTimeSeriesRequest { SiteNumber = "01646500", Name = "My gage" }; + var resource = await _service.CreateFromUsgsAsync(request); + Assert.AreEqual("My gage", resource.Name); + } + + /// + /// Verifies manual creation sorts unsorted points by date. + /// + [TestMethod] + public void CreateManual_SortsPointsByDate() + { + var request = new CreateManualTimeSeriesRequest + { + TimeInterval = TimeInterval.Irregular, + Points = new List + { + new() { DateTime = new DateTime(2020, 3, 1), Value = 3d }, + new() { DateTime = new DateTime(2020, 1, 1), Value = 1d }, + new() { DateTime = new DateTime(2020, 2, 1), Value = 2d } + } + }; + + var resource = _service.CreateManual(request); + + Assert.AreEqual(3, resource.PointCount); + Assert.AreEqual(new DateTime(2020, 1, 1), resource.StartDate); + Assert.AreEqual(new DateTime(2020, 3, 1), resource.EndDate); + Assert.AreEqual(1d, resource.TimeSeries[0].Value); + Assert.AreEqual(3d, resource.TimeSeries[2].Value); + Assert.AreEqual(TimeSeriesSource.Manual, resource.Source); + } + + /// + /// Verifies manual creation rejects an empty point list. + /// + [TestMethod] + public void CreateManual_NoPoints_Throws() + { + var request = new CreateManualTimeSeriesRequest { Points = new List() }; + Assert.ThrowsException(() => _service.CreateManual(request)); + } + + /// + /// Verifies Get throws the not-found exception for unknown ids. + /// + [TestMethod] + public void Get_UnknownId_ThrowsNotFound() + { + Assert.ThrowsException(() => _service.Get(Guid.NewGuid())); + } + + /// + /// Verifies Delete removes the resource and throws for unknown ids. + /// + [TestMethod] + public void Delete_RemovesResource_AndThrowsWhenMissing() + { + var resource = _service.CreateManual(new CreateManualTimeSeriesRequest + { + TimeInterval = TimeInterval.Irregular, + Points = new List { new() { DateTime = DateTime.UtcNow, Value = 1d } } + }); + _service.Delete(resource.Id); + Assert.AreEqual(0, _service.List().Count); + Assert.ThrowsException(() => _service.Delete(resource.Id)); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Services/UsgsTimeSeriesServiceTests.cs b/src/RMC.BestFit.Api.Tests/Services/UsgsTimeSeriesServiceTests.cs new file mode 100644 index 0000000..a33fa4e --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Services/UsgsTimeSeriesServiceTests.cs @@ -0,0 +1,125 @@ +using Numerics.Data; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Services.Exceptions; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Services +{ + /// + /// Unit tests for exception translation, exercised through + /// the injectable download delegate (no network access). + /// + [TestClass] + public class UsgsTimeSeriesServiceTests + { + /// + /// Builds a service whose download delegate throws the given exception. + /// + /// The exception the underlying download throws. + /// The service under test. + private static UsgsTimeSeriesService CreateThrowing(Exception exception) + { + return new UsgsTimeSeriesService((_, _) => throw exception); + } + + /// + /// Verifies the constructor rejects a null delegate. + /// + [TestMethod] + public void Constructor_NullDelegate_Throws() + { + Assert.ThrowsException(() => _ = new UsgsTimeSeriesService(null!)); + } + + /// + /// Verifies a successful download returns the series and raw text unchanged. + /// + [TestMethod] + public async Task DownloadAsync_Success_ReturnsSeriesAndRawText() + { + var series = TestSeries.IrregularPeaks(); + var service = new UsgsTimeSeriesService((_, _) => Task.FromResult((series, "raw"))); + var (result, rawText) = await service.DownloadAsync("01646500", TimeSeriesDownload.TimeSeriesType.PeakDischarge); + Assert.AreSame(series, result); + Assert.AreEqual("raw", rawText); + } + + /// + /// Verifies an invalid-request failure (bad site number) is rethrown as ArgumentException (HTTP 400). + /// + [TestMethod] + public async Task DownloadAsync_ArgumentException_Rethrown() + { + var service = CreateThrowing(new ArgumentException("The USGS site number must be 8 digits.")); + await Assert.ThrowsExceptionAsync( + () => service.DownloadAsync("123", TimeSeriesDownload.TimeSeriesType.DailyDischarge)); + } + + /// + /// Verifies the no-internet failure maps to with status 503. + /// + [TestMethod] + public async Task DownloadAsync_NoInternet_Maps503() + { + var service = CreateThrowing(new InvalidOperationException("No internet connection.")); + var ex = await Assert.ThrowsExceptionAsync( + () => service.DownloadAsync("01646500", TimeSeriesDownload.TimeSeriesType.DailyDischarge)); + Assert.AreEqual(503, ex.StatusCode); + } + + /// + /// Verifies the no-data failure maps to (HTTP 404). + /// + [TestMethod] + public async Task DownloadAsync_NoDataFound_MapsNotFound() + { + var service = CreateThrowing(new Exception("No data found matching all criteria.")); + var ex = await Assert.ThrowsExceptionAsync( + () => service.DownloadAsync("01646500", TimeSeriesDownload.TimeSeriesType.PeakDischarge)); + StringAssert.Contains(ex.Message, "01646500"); + } + + /// + /// Verifies any other upstream failure maps to with status 502. + /// + [TestMethod] + public async Task DownloadAsync_UpstreamError_Maps502() + { + var service = CreateThrowing(new Exception("USGS returned status 500: server error")); + var ex = await Assert.ThrowsExceptionAsync( + () => service.DownloadAsync("01646500", TimeSeriesDownload.TimeSeriesType.DailyDischarge)); + Assert.AreEqual(502, ex.StatusCode); + } + + /// + /// Verifies an empty downloaded series maps to . + /// + [TestMethod] + public async Task DownloadAsync_EmptySeries_MapsNotFound() + { + var service = new UsgsTimeSeriesService((_, _) => Task.FromResult((new TimeSeries(TimeInterval.OneDay), "raw"))); + await Assert.ThrowsExceptionAsync( + () => service.DownloadAsync("01646500", TimeSeriesDownload.TimeSeriesType.DailyDischarge)); + } + + /// + /// Verifies a pre-cancelled token short-circuits with + /// before the download runs. + /// + [TestMethod] + public async Task DownloadAsync_PreCancelled_Throws() + { + bool called = false; + var service = new UsgsTimeSeriesService((_, _) => + { + called = true; + return Task.FromResult((TestSeries.IrregularPeaks(), "raw")); + }); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + await Assert.ThrowsExceptionAsync( + () => service.DownloadAsync("01646500", TimeSeriesDownload.TimeSeriesType.DailyDischarge, cts.Token)); + Assert.IsFalse(called); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Services/WorkflowServiceTests.cs b/src/RMC.BestFit.Api.Tests/Services/WorkflowServiceTests.cs new file mode 100644 index 0000000..56dddf9 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Services/WorkflowServiceTests.cs @@ -0,0 +1,192 @@ +using Microsoft.Extensions.Options; +using Numerics.Distributions; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Services.Exceptions; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Services +{ + /// + /// Unit tests for step composition and in-body failure + /// reporting, using the faked USGS seam. Only failure paths are exercised — success paths + /// require estimator runs, which unit tests never perform. + /// + [TestClass] + public class WorkflowServiceTests + { + /// + /// The store shared by the composed services. + /// + private InMemoryResourceStore _store = null!; + + /// + /// The faked USGS seam. + /// + private FakeUsgsTimeSeriesService _usgs = null!; + + /// + /// The service under test. + /// + private WorkflowService _service = null!; + + /// + /// Creates a fresh composed service stack before each test. + /// + [TestInitialize] + public void Initialize() + { + _store = new InMemoryResourceStore(Options.Create(new ApiOptions())); + _usgs = new FakeUsgsTimeSeriesService { Result = TestSeries.DailyThreeWaterYears() }; + var timeSeries = new TimeSeriesService(_store, _usgs); + var inputData = new InputDataService(_store, _usgs); + var analyses = new AnalysisService(_store, Options.Create(new ApiOptions())); + _service = new WorkflowService(timeSeries, inputData, analyses); + } + + /// + /// Verifies constructor null guards. + /// + [TestMethod] + public void Constructor_NullDependencies_Throw() + { + var timeSeries = new TimeSeriesService(_store, _usgs); + var inputData = new InputDataService(_store, _usgs); + var analyses = new AnalysisService(_store, Options.Create(new ApiOptions())); + Assert.ThrowsException(() => _ = new WorkflowService(null!, inputData, analyses)); + Assert.ThrowsException(() => _ = new WorkflowService(timeSeries, null!, analyses)); + Assert.ThrowsException(() => _ = new WorkflowService(timeSeries, inputData, null!)); + } + + /// + /// Verifies a USGS download failure is reported in-body on the first step with no ids created. + /// + [TestMethod] + public async Task PeakFrequency_DownloadFails_ReportsFirstStep() + { + _usgs.ExceptionToThrow = new UsgsDataNotFoundException("no data for site"); + var response = await _service.RunUsgsPeakFrequencyAsync(new UsgsPeakFrequencyWorkflowRequest { SiteNumber = "01646500" }); + + Assert.IsFalse(response.Success); + Assert.AreEqual("createInputData", response.FailedStep); + StringAssert.Contains(response.ErrorMessage, "no data for site"); + Assert.IsNull(response.InputDataId); + Assert.IsNull(response.AnalysisId); + Assert.IsNull(response.Results); + } + + /// + /// Verifies a mid-workflow failure preserves the ids of resources created by the completed + /// steps: the Bulletin 17C workflow with an unsupported distribution fails at analysis + /// creation, after the input data was created. + /// + [TestMethod] + public async Task Bulletin17C_UnsupportedDistribution_KeepsInputDataId() + { + _usgs.Result = TestSeries.IrregularPeaks(); + var response = await _service.RunUsgsBulletin17CAsync(new UsgsBulletin17CWorkflowRequest + { + SiteNumber = "01646500", + Distribution = UnivariateDistributionType.GeneralizedExtremeValue + }); + + Assert.IsFalse(response.Success); + Assert.AreEqual("createAnalysis", response.FailedStep); + Assert.IsNotNull(response.InputDataId, "The input-data id from the completed step must be preserved."); + Assert.IsNotNull(_store.GetInputData(response.InputDataId!.Value)); + Assert.IsNull(response.AnalysisId); + Assert.IsNull(response.Results); + } + + /// + /// Verifies the block-maxima workflow reports a first-step download failure with the + /// step name specific to that workflow. + /// + [TestMethod] + public async Task BlockMaxFrequency_DownloadFails_ReportsDownloadStep() + { + _usgs.ExceptionToThrow = new UsgsUnavailableException("offline", statusCode: 503); + var response = await _service.RunUsgsBlockMaxFrequencyAsync(new UsgsBlockMaxFrequencyWorkflowRequest { SiteNumber = "01646500" }); + + Assert.IsFalse(response.Success); + Assert.AreEqual("downloadTimeSeries", response.FailedStep); + Assert.IsNull(response.TimeSeriesId); + } + + /// + /// Verifies the rating curve workflow fails on the second download when only the first + /// succeeds, preserving the stage series id. + /// + [TestMethod] + public async Task RatingCurve_SecondDownloadFails_KeepsStageId() + { + // The fake throws only on the second call (discharge). + int calls = 0; + var stageSeries = TestSeries.IrregularPeaks(); + var throwingFake = new SequencedFakeUsgs(() => + { + calls++; + if (calls >= 2) throw new UsgsDataNotFoundException("no measured discharge"); + return stageSeries; + }); + var timeSeries = new TimeSeriesService(_store, throwingFake); + var inputData = new InputDataService(_store, throwingFake); + var analyses = new AnalysisService(_store, Options.Create(new ApiOptions())); + var service = new WorkflowService(timeSeries, inputData, analyses); + + var response = await service.RunUsgsRatingCurveAsync(new UsgsRatingCurveWorkflowRequest { SiteNumber = "01646500" }); + + Assert.IsFalse(response.Success); + Assert.AreEqual("downloadDischarge", response.FailedStep); + Assert.IsNotNull(response.StageTimeSeriesId); + Assert.IsNull(response.DischargeTimeSeriesId); + Assert.IsNull(response.AnalysisId); + } + + /// + /// Verifies client cancellation propagates (mapped to HTTP 499 by the controller) rather + /// than being folded into the response body. + /// + [TestMethod] + public async Task Workflow_Cancellation_Propagates() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + await Assert.ThrowsExceptionAsync( + () => _service.RunUsgsPeakFrequencyAsync(new UsgsPeakFrequencyWorkflowRequest { SiteNumber = "01646500" }, cts.Token)); + } + + /// + /// USGS fake whose per-call behavior is scripted by a delegate, used to fail a specific + /// step in a multi-download workflow. + /// + private sealed class SequencedFakeUsgs : IUsgsTimeSeriesService + { + /// + /// Produces the series for each call, or throws to script a failure. + /// + private readonly Func _next; + + /// + /// Constructs the fake with the per-call script. + /// + /// The per-call series factory. + public SequencedFakeUsgs(Func next) + { + _next = next; + } + + /// + public Task<(Numerics.Data.TimeSeries TimeSeries, string RawText)> DownloadAsync( + string siteNumber, + Numerics.Data.TimeSeriesDownload.TimeSeriesType seriesType, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult((_next(), "scripted")); + } + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Store/AnalysisResourceTests.cs b/src/RMC.BestFit.Api.Tests/Store/AnalysisResourceTests.cs new file mode 100644 index 0000000..cf865a6 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Store/AnalysisResourceTests.cs @@ -0,0 +1,179 @@ +using Numerics.Distributions; +using RMC.BestFit.Analyses; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; +using RMC.BestFit.Models; + +namespace RMC.BestFit.Api.Tests.Store +{ + /// + /// Unit tests for : kind dispatch, run-state defaults, and the + /// single-entry run lock. No estimation is performed — analyses are only constructed. + /// + [TestClass] + public class AnalysisResourceTests + { + /// + /// Builds a small data frame with a few exact observations. + /// + /// The data frame. + private static DataFrame CreateDataFrame() + { + var dataFrame = new DataFrame(); + dataFrame.ExactSeries.Add(new ExactData(2000, 100d)); + dataFrame.ExactSeries.Add(new ExactData(2001, 150d)); + dataFrame.ExactSeries.Add(new ExactData(2002, 120d)); + return dataFrame; + } + + /// + /// Verifies a univariate resource exposes the wrapped analysis through the kind dispatcher. + /// + [TestMethod] + public void Analysis_UnivariateKind_ReturnsWrappedAnalysis() + { + var analysis = new UnivariateAnalysis(new UnivariateDistribution(CreateDataFrame(), UnivariateDistributionType.Normal)); + var resource = new AnalysisResource { Name = "a", Kind = AnalysisKind.Univariate, Univariate = analysis }; + Assert.AreSame(analysis, resource.Analysis); + } + + /// + /// Verifies the kind dispatcher throws when the typed field matching the kind is missing. + /// + [TestMethod] + public void Analysis_KindWithoutInstance_Throws() + { + var resource = new AnalysisResource { Name = "a", Kind = AnalysisKind.Univariate }; + Assert.ThrowsException(() => _ = resource.Analysis); + } + + /// + /// Verifies the kind dispatcher resolves the mixture, point process, and competing risks + /// arms, and each throws when its typed field is missing. + /// + [TestMethod] + public void Analysis_Phase4Kinds_Dispatch() + { + var mixture = TestAnalyses.CreateMixtureResource(); + Assert.AreSame(mixture.Mixture, mixture.Analysis); + + var pointProcess = TestAnalyses.CreatePointProcessResource(); + Assert.AreSame(pointProcess.PointProcess, pointProcess.Analysis); + + var competingRisks = TestAnalyses.CreateCompetingRisksResource(); + Assert.AreSame(competingRisks.CompetingRisks, competingRisks.Analysis); + + Assert.ThrowsException( + () => _ = new AnalysisResource { Name = "a", Kind = AnalysisKind.Mixture }.Analysis); + Assert.ThrowsException( + () => _ = new AnalysisResource { Name = "a", Kind = AnalysisKind.PointProcess }.Analysis); + Assert.ThrowsException( + () => _ = new AnalysisResource { Name = "a", Kind = AnalysisKind.CompetingRisks }.Analysis); + + var composite = TestAnalyses.CreateCompositeResource(); + Assert.AreSame(composite.Composite, composite.Analysis); + Assert.AreEqual(2, composite.ComponentResources!.Count); + + var fitting = TestAnalyses.CreateDistributionFittingResource(); + Assert.AreSame(fitting.DistributionFitting, fitting.Analysis); + + var bivariate = TestAnalyses.CreateBivariateResource(); + Assert.AreSame(bivariate.Bivariate, bivariate.Analysis); + Assert.AreEqual(2, bivariate.ComponentResources!.Count); + + var cfa = TestAnalyses.CreateCoincidentFrequencyResource(); + Assert.AreSame(cfa.CoincidentFrequency, cfa.Analysis); + Assert.AreEqual(3, cfa.ComponentResources!.Count, "CFA locks cover the bivariate plus its transitive marginals."); + + Assert.ThrowsException( + () => _ = new AnalysisResource { Name = "a", Kind = AnalysisKind.Composite }.Analysis); + Assert.ThrowsException( + () => _ = new AnalysisResource { Name = "a", Kind = AnalysisKind.DistributionFitting }.Analysis); + Assert.ThrowsException( + () => _ = new AnalysisResource { Name = "a", Kind = AnalysisKind.Bivariate }.Analysis); + Assert.ThrowsException( + () => _ = new AnalysisResource { Name = "a", Kind = AnalysisKind.CoincidentFrequency }.Analysis); + + foreach (var modelType in Enum.GetValues()) + { + var timeSeries = TestAnalyses.CreateTimeSeriesAnalysisResource(modelType); + Assert.IsNotNull(timeSeries.Analysis, $"The {modelType} sub-switch must resolve."); + } + Assert.ThrowsException( + () => _ = new AnalysisResource { Name = "a", Kind = AnalysisKind.TimeSeries, TimeSeriesModel = TimeSeriesModelType.Ar }.Analysis); + Assert.ThrowsException( + () => _ = new AnalysisResource { Name = "a", Kind = AnalysisKind.TimeSeries }.Analysis, + "A TimeSeries resource without a model discriminator must throw."); + } + + /// + /// Verifies unrun Phase-4 resources throw the run-first message from the results mapper. + /// + [TestMethod] + public void FrequencyResults_BeforeRun_Throw() + { + Assert.ThrowsException( + () => ResultsMapper.ToFrequencyResults(TestAnalyses.CreateMixtureResource())); + Assert.ThrowsException( + () => ResultsMapper.ToFrequencyResults(TestAnalyses.CreatePointProcessResource())); + Assert.ThrowsException( + () => ResultsMapper.ToFrequencyResults(TestAnalyses.CreateCompetingRisksResource())); + } + + /// + /// Verifies run-state defaults for a freshly created resource. + /// + [TestMethod] + public void RunState_Defaults() + { + var resource = new AnalysisResource { Name = "a", Kind = AnalysisKind.Univariate }; + Assert.AreEqual(AnalysisRunState.Created, resource.State); + Assert.IsNull(resource.LastRunUtc); + Assert.IsNull(resource.LastError); + Assert.IsNull(resource.LastRunMs); + } + + /// + /// Verifies the run lock is single-entry: a second immediate acquisition fails until released. + /// + [TestMethod] + public void RunLock_IsSingleEntry() + { + var resource = new AnalysisResource { Name = "a", Kind = AnalysisKind.Univariate }; + Assert.IsTrue(resource.RunLock.Wait(0)); + try + { + Assert.IsFalse(resource.RunLock.Wait(0)); + } + finally + { + resource.RunLock.Release(); + } + Assert.IsTrue(resource.RunLock.Wait(0)); + resource.RunLock.Release(); + } + + /// + /// Verifies provenance ids round-trip. + /// + [TestMethod] + public void Provenance_RoundTrip() + { + var inputDataId = Guid.NewGuid(); + var stageId = Guid.NewGuid(); + var dischargeId = Guid.NewGuid(); + var resource = new AnalysisResource + { + Name = "a", + Kind = AnalysisKind.RatingCurve, + InputDataId = inputDataId, + StageTimeSeriesId = stageId, + DischargeTimeSeriesId = dischargeId + }; + Assert.AreEqual(inputDataId, resource.InputDataId); + Assert.AreEqual(stageId, resource.StageTimeSeriesId); + Assert.AreEqual(dischargeId, resource.DischargeTimeSeriesId); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Store/InMemoryResourceStoreTests.cs b/src/RMC.BestFit.Api.Tests/Store/InMemoryResourceStoreTests.cs new file mode 100644 index 0000000..77bc21f --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Store/InMemoryResourceStoreTests.cs @@ -0,0 +1,147 @@ +using Microsoft.Extensions.Options; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.Services.Exceptions; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; +using RMC.BestFit.Models; + +namespace RMC.BestFit.Api.Tests.Store +{ + /// + /// Unit tests for : add/get/list/delete per resource type, + /// capacity enforcement, cross-type isolation, and concurrent adds. + /// + [TestClass] + public class InMemoryResourceStoreTests + { + /// + /// Builds a store with the given capacity. + /// + /// The capacity cap. + /// A fresh store. + private static InMemoryResourceStore CreateStore(int maxResources = 500) + { + return new InMemoryResourceStore(Options.Create(new ApiOptions { MaxResources = maxResources })); + } + + /// + /// Builds a minimal time-series resource for store tests. + /// + /// The resource name. + /// The resource. + private static TimeSeriesResource CreateTimeSeriesResource(string name = "ts") + { + return new TimeSeriesResource(TestSeries.IrregularPeaks()) { Name = name, Source = TimeSeriesSource.Manual }; + } + + /// + /// Builds a minimal input-data resource for store tests. + /// + /// The resource name. + /// The resource. + private static InputDataResource CreateInputDataResource(string name = "id") + { + return new InputDataResource { Name = name, DataFrame = new DataFrame(), Method = InputDataMethod.Manual }; + } + + /// + /// Verifies the constructor rejects a null options argument. + /// + [TestMethod] + public void Constructor_NullOptions_Throws() + { + Assert.ThrowsException(() => _ = new InMemoryResourceStore(null!)); + } + + /// + /// Verifies add/get round-trips a time-series resource and the id lookup returns the same instance. + /// + [TestMethod] + public void AddTimeSeries_ThenGet_ReturnsSameInstance() + { + var store = CreateStore(); + var resource = CreateTimeSeriesResource(); + store.AddTimeSeries(resource); + Assert.AreSame(resource, store.GetTimeSeries(resource.Id)); + Assert.AreEqual(1, store.TotalCount); + } + + /// + /// Verifies lookups with unknown ids return null for all three resource types. + /// + [TestMethod] + public void Get_UnknownId_ReturnsNull() + { + var store = CreateStore(); + Assert.IsNull(store.GetTimeSeries(Guid.NewGuid())); + Assert.IsNull(store.GetInputData(Guid.NewGuid())); + Assert.IsNull(store.GetAnalysis(Guid.NewGuid())); + } + + /// + /// Verifies a time-series id cannot be resolved through the input-data lookup (type isolation). + /// + [TestMethod] + public void Get_WrongTypeLookup_ReturnsNull() + { + var store = CreateStore(); + var resource = CreateTimeSeriesResource(); + store.AddTimeSeries(resource); + Assert.IsNull(store.GetInputData(resource.Id)); + } + + /// + /// Verifies listings are ordered by creation time. + /// + [TestMethod] + public void ListTimeSeries_OrdersByCreationTime() + { + var store = CreateStore(); + var first = CreateTimeSeriesResource("first"); + var second = CreateTimeSeriesResource("second"); + store.AddTimeSeries(second); + store.AddTimeSeries(first); + var list = store.ListTimeSeries(); + Assert.AreEqual(2, list.Count); + Assert.IsTrue(list[0].CreatedUtc <= list[1].CreatedUtc); + } + + /// + /// Verifies delete removes the resource and reports false for unknown ids. + /// + [TestMethod] + public void Delete_RemovesResource_AndReportsMissing() + { + var store = CreateStore(); + var resource = CreateInputDataResource(); + store.AddInputData(resource); + Assert.IsTrue(store.DeleteInputData(resource.Id)); + Assert.IsFalse(store.DeleteInputData(resource.Id)); + Assert.IsNull(store.GetInputData(resource.Id)); + } + + /// + /// Verifies creations beyond the configured capacity are rejected with the conflict exception. + /// + [TestMethod] + public void Add_BeyondCapacity_ThrowsConflict() + { + var store = CreateStore(maxResources: 2); + store.AddTimeSeries(CreateTimeSeriesResource()); + store.AddInputData(CreateInputDataResource()); + Assert.ThrowsException(() => store.AddTimeSeries(CreateTimeSeriesResource())); + } + + /// + /// Verifies concurrent adds are all retained (thread safety of the underlying dictionaries). + /// + [TestMethod] + public void Add_Concurrently_RetainsAllResources() + { + var store = CreateStore(maxResources: 1000); + Parallel.For(0, 100, i => store.AddTimeSeries(CreateTimeSeriesResource($"ts-{i}"))); + Assert.AreEqual(100, store.ListTimeSeries().Count); + Assert.AreEqual(100, store.TotalCount); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Store/InputDataResourceTests.cs b/src/RMC.BestFit.Api.Tests/Store/InputDataResourceTests.cs new file mode 100644 index 0000000..26e2c28 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Store/InputDataResourceTests.cs @@ -0,0 +1,65 @@ +using Numerics.Data; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Models; + +namespace RMC.BestFit.Api.Tests.Store +{ + /// + /// Unit tests for construction and provenance properties. + /// + [TestClass] + public class InputDataResourceTests + { + /// + /// Verifies the required properties and provenance init-properties round-trip. + /// + [TestMethod] + public void Properties_RoundTrip() + { + var dataFrame = new DataFrame(); + var sourceId = Guid.NewGuid(); + var resource = new InputDataResource + { + Name = "block max", + Description = "annual maxima", + DataFrame = dataFrame, + Method = InputDataMethod.BlockMaxima, + SourceTimeSeriesId = sourceId, + TimeBlock = TimeBlockWindow.WaterYear, + BlockFunction = BlockFunctionType.Maximum, + SmoothingFunction = SmoothingFunctionType.None, + StartMonth = 10, + EndMonth = 9, + Period = 1 + }; + + Assert.AreEqual("block max", resource.Name); + Assert.AreEqual("annual maxima", resource.Description); + Assert.AreSame(dataFrame, resource.DataFrame); + Assert.AreEqual(InputDataMethod.BlockMaxima, resource.Method); + Assert.AreEqual(sourceId, resource.SourceTimeSeriesId); + Assert.AreEqual(TimeBlockWindow.WaterYear, resource.TimeBlock); + Assert.AreEqual(BlockFunctionType.Maximum, resource.BlockFunction); + Assert.AreEqual(SmoothingFunctionType.None, resource.SmoothingFunction); + Assert.AreEqual(10, resource.StartMonth); + Assert.AreEqual(9, resource.EndMonth); + Assert.AreEqual(1, resource.Period); + Assert.AreNotEqual(Guid.Empty, resource.Id); + } + + /// + /// Verifies optional provenance fields default to null for a manual resource. + /// + [TestMethod] + public void Defaults_ManualResource_HasNullProvenance() + { + var resource = new InputDataResource { Name = "manual", DataFrame = new DataFrame(), Method = InputDataMethod.Manual }; + Assert.IsNull(resource.SourceTimeSeriesId); + Assert.IsNull(resource.UsgsSiteNumber); + Assert.IsNull(resource.TimeBlock); + Assert.IsNull(resource.Threshold); + Assert.IsNull(resource.MinStepsBetweenPeaks); + Assert.IsNull(resource.Description); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Store/TimeSeriesResourceTests.cs b/src/RMC.BestFit.Api.Tests/Store/TimeSeriesResourceTests.cs new file mode 100644 index 0000000..52b5033 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Store/TimeSeriesResourceTests.cs @@ -0,0 +1,79 @@ +using Numerics.Data; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Api.Tests.Support; + +namespace RMC.BestFit.Api.Tests.Store +{ + /// + /// Unit tests for : constructor validation and the summary + /// statistics computed at creation. + /// + [TestClass] + public class TimeSeriesResourceTests + { + /// + /// Verifies the constructor rejects a null series. + /// + [TestMethod] + public void Constructor_NullSeries_Throws() + { + Assert.ThrowsException(() => _ = new TimeSeriesResource(null!) { Name = "x", Source = TimeSeriesSource.Manual }); + } + + /// + /// Verifies point count, missing count, and date range are computed from the series. + /// + [TestMethod] + public void Constructor_ComputesSummary() + { + var series = new TimeSeries(TimeInterval.OneDay); + series.Add(new SeriesOrdinate(new DateTime(2020, 1, 1), 1d)); + series.Add(new SeriesOrdinate(new DateTime(2020, 1, 2), double.NaN)); + series.Add(new SeriesOrdinate(new DateTime(2020, 1, 3), 3d)); + + var resource = new TimeSeriesResource(series) { Name = "ts", Source = TimeSeriesSource.Manual }; + + Assert.AreEqual(3, resource.PointCount); + Assert.AreEqual(1, resource.MissingCount); + Assert.AreEqual(new DateTime(2020, 1, 1), resource.StartDate); + Assert.AreEqual(new DateTime(2020, 1, 3), resource.EndDate); + } + + /// + /// Verifies an empty series yields null start/end dates and zero counts. + /// + [TestMethod] + public void Constructor_EmptySeries_HasNullDates() + { + var resource = new TimeSeriesResource(new TimeSeries(TimeInterval.Irregular)) { Name = "ts", Source = TimeSeriesSource.Manual }; + Assert.AreEqual(0, resource.PointCount); + Assert.AreEqual(0, resource.MissingCount); + Assert.IsNull(resource.StartDate); + Assert.IsNull(resource.EndDate); + } + + /// + /// Verifies identity and provenance init-properties round-trip and each resource gets a unique id. + /// + [TestMethod] + public void Properties_RoundTrip_AndIdsAreUnique() + { + var a = new TimeSeriesResource(TestSeries.IrregularPeaks()) + { + Name = "usgs series", + Description = "desc", + Source = TimeSeriesSource.Usgs, + UsgsSiteNumber = "01646500", + UsgsSeriesType = TimeSeriesDownload.TimeSeriesType.PeakDischarge + }; + var b = new TimeSeriesResource(TestSeries.IrregularPeaks()) { Name = "b", Source = TimeSeriesSource.Manual }; + + Assert.AreEqual("usgs series", a.Name); + Assert.AreEqual("desc", a.Description); + Assert.AreEqual(TimeSeriesSource.Usgs, a.Source); + Assert.AreEqual("01646500", a.UsgsSiteNumber); + Assert.AreEqual(TimeSeriesDownload.TimeSeriesType.PeakDischarge, a.UsgsSeriesType); + Assert.AreNotEqual(a.Id, b.Id); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Support/FakeUsgsTimeSeriesService.cs b/src/RMC.BestFit.Api.Tests/Support/FakeUsgsTimeSeriesService.cs new file mode 100644 index 0000000..e8ed9dd --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Support/FakeUsgsTimeSeriesService.cs @@ -0,0 +1,57 @@ +using Numerics.Data; +using RMC.BestFit.Api.Services; + +namespace RMC.BestFit.Api.Tests.Support +{ + /// + /// Configurable in-memory fake of the USGS download seam: returns a canned series or throws a + /// configured exception, and records the last requested site/series type for assertions. + /// + public class FakeUsgsTimeSeriesService : IUsgsTimeSeriesService + { + /// + /// The series returned by when no exception is configured. + /// + public TimeSeries? Result { get; set; } + + /// + /// The raw text returned alongside . + /// + public string RawText { get; set; } = "canned"; + + /// + /// When set, throws this exception instead of returning a series. + /// + public Exception? ExceptionToThrow { get; set; } + + /// + /// The site number of the most recent download request. + /// + public string? LastSiteNumber { get; private set; } + + /// + /// The series type of the most recent download request. + /// + public TimeSeriesDownload.TimeSeriesType? LastSeriesType { get; private set; } + + /// + /// Returns the canned series or throws the configured exception. + /// + /// The requested site number (recorded). + /// The requested series type (recorded). + /// Observed for pre-cancelled tokens. + /// The canned series and raw text. + public Task<(TimeSeries TimeSeries, string RawText)> DownloadAsync( + string siteNumber, + TimeSeriesDownload.TimeSeriesType seriesType, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + LastSiteNumber = siteNumber; + LastSeriesType = seriesType; + if (ExceptionToThrow != null) throw ExceptionToThrow; + if (Result == null) throw new InvalidOperationException("FakeUsgsTimeSeriesService.Result was not configured."); + return Task.FromResult((Result, RawText)); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Support/TestAnalyses.cs b/src/RMC.BestFit.Api.Tests/Support/TestAnalyses.cs new file mode 100644 index 0000000..60abfc8 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Support/TestAnalyses.cs @@ -0,0 +1,311 @@ +using Numerics.Data; +using Numerics.Distributions; +using RMC.BestFit.Analyses; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Models; + +namespace RMC.BestFit.Api.Tests.Support +{ + /// + /// Builders for small, valid analysis resources used by service/mapper/controller tests. + /// Analyses are only constructed and configured — never run (no MCMC/GMM in unit tests). + /// + public static class TestAnalyses + { + /// + /// Builds a data frame with a deterministic 20-year systematic record (values 100..480). + /// + /// The number of annual observations. Default 20. + /// The data frame. + public static DataFrame CreateDataFrame(int count = 20) + { + var dataFrame = new DataFrame(); + dataFrame.ExactSeries.SuppressCollectionChanged = true; + for (int i = 0; i < count; i++) + { + dataFrame.ExactSeries.Add(new ExactData(2000 + i, 100d + 20d * i)); + } + dataFrame.ExactSeries.SuppressCollectionChanged = false; + dataFrame.ExactSeries.RaiseCollectionChangedReset(); + return dataFrame; + } + + /// + /// Builds a data frame with the standard 20-year systematic record plus two uncertain + /// observations (historical floods described by measurement-error distributions). + /// + /// The data frame. + public static DataFrame CreateDataFrameWithUncertain() + { + var dataFrame = CreateDataFrame(); + dataFrame.UncertainSeries.SuppressCollectionChanged = true; + dataFrame.UncertainSeries.Add(new UncertainData(1902, new Normal(600d, 60d))); + dataFrame.UncertainSeries.Add(new UncertainData(1875, new Triangular(500d, 650d, 900d))); + dataFrame.UncertainSeries.SuppressCollectionChanged = false; + dataFrame.UncertainSeries.RaiseCollectionChangedReset(); + return dataFrame; + } + + /// + /// Builds an input-data resource over . + /// + /// The resource name. + /// The input-data resource. + public static InputDataResource CreateInputDataResource(string name = "input data") + { + return new InputDataResource + { + Name = name, + DataFrame = CreateDataFrame(), + Method = InputDataMethod.Manual + }; + } + + /// + /// Builds an unrun univariate analysis resource over a fresh data frame. + /// + /// The distribution type. Default Log-Pearson Type III. + /// The analysis resource. + public static AnalysisResource CreateUnivariateResource(UnivariateDistributionType type = UnivariateDistributionType.LogPearsonTypeIII) + { + var analysis = new UnivariateAnalysis(new UnivariateDistribution(CreateDataFrame(), type)); + return new AnalysisResource { Name = "univariate", Kind = AnalysisKind.Univariate, Univariate = analysis }; + } + + /// + /// Builds an unrun Bulletin 17C analysis resource over a fresh data frame. + /// + /// The analysis resource. + public static AnalysisResource CreateBulletin17CResource() + { + var analysis = new Bulletin17CAnalysis(new Bulletin17CDistribution(CreateDataFrame(), UnivariateDistributionType.LogPearsonTypeIII)); + return new AnalysisResource { Name = "b17c", Kind = AnalysisKind.Bulletin17C, Bulletin17C = analysis }; + } + + /// + /// Builds a peaks-over-threshold data frame extracted from a 15-water-year synthetic + /// daily series (one planted peak per year) — the same extraction path the input-data + /// service uses, with enough exceedances for point process parameter constraints. + /// + /// The extraction threshold. Default 400 (below the planted peaks). + /// The data frame. + public static DataFrame CreatePotDataFrame(double threshold = 400d) + { + var dataFrame = new DataFrame(); + dataFrame.CreatePeaksOverThresholdSeries(TestSeries.DailyWithAnnualPeaks(), threshold, minStepsBetweenPeaks: 7); + return dataFrame; + } + + /// + /// Builds an unrun mixture analysis resource with two components over a fresh data frame. + /// + /// The analysis resource. + public static AnalysisResource CreateMixtureResource() + { + var model = new MixtureModel(CreateDataFrame(), + new List { UnivariateDistributionType.Gumbel, UnivariateDistributionType.LogNormal }); + return new AnalysisResource { Name = "mixture", Kind = AnalysisKind.Mixture, Mixture = new MixtureAnalysis(model) }; + } + + /// + /// Builds an unrun point process analysis resource over a fresh POT data frame. + /// + /// The analysis resource. + public static AnalysisResource CreatePointProcessResource() + { + var model = new PointProcessModel(); + model.DataFrame = CreatePotDataFrame(); + return new AnalysisResource { Name = "pointprocess", Kind = AnalysisKind.PointProcess, PointProcess = new PointProcessAnalysis(model) }; + } + + /// + /// Builds an unrun competing risks analysis resource with two components over a fresh data frame. + /// + /// The analysis resource. + public static AnalysisResource CreateCompetingRisksResource() + { + var model = new CompetingRisksModel(CreateDataFrame(), + new List { UnivariateDistributionType.Gumbel, UnivariateDistributionType.GeneralizedExtremeValue }); + return new AnalysisResource { Name = "competingrisks", Kind = AnalysisKind.CompetingRisks, CompetingRisks = new CompetingRiskAnalysis(model) }; + } + + /// + /// Builds an unrun composite analysis resource (competing risks type) over two fresh, + /// unrun univariate component resources, with live component references wired the way the + /// service wires them. + /// + /// The composite resource; its components are reachable via ComponentResources. + public static AnalysisResource CreateCompositeResource() + { + var componentA = CreateUnivariateResource(UnivariateDistributionType.Gumbel); + var componentB = CreateUnivariateResource(UnivariateDistributionType.LogNormal); + var analysis = new CompositeAnalysis(new List + { + new(componentA.Univariate!, 0d), + new(componentB.Univariate!, 0d) + }); + return new AnalysisResource + { + Name = "composite", + Kind = AnalysisKind.Composite, + Composite = analysis, + ComponentResources = new List { componentA, componentB }, + ComponentAnalysisIds = new List { componentA.Id, componentB.Id } + }; + } + + /// + /// Builds an unrun bivariate copula analysis resource over two fresh, unrun univariate + /// marginal resources whose frames share all 20 time indexes, with live references wired + /// the way the service wires them. + /// + /// The bivariate resource; its marginals are reachable via MarginalX/YResource. + public static AnalysisResource CreateBivariateResource() + { + var marginalX = CreateUnivariateResource(UnivariateDistributionType.Gumbel); + var marginalY = CreateUnivariateResource(UnivariateDistributionType.LogNormal); + var distribution = new BivariateDistribution( + marginalX.Univariate!.UnivariateDistribution, + marginalY.Univariate!.UnivariateDistribution, + Numerics.Distributions.Copulas.CopulaType.Normal); + var analysis = new BivariateAnalysis(distribution) + { + XYOrdinates = new UncertainOrderedPairedData( + new List + { + new(200d, new Deterministic(220d)), + new(400d, new Deterministic(430d)) + }, + false, SortOrder.Ascending, false, SortOrder.Ascending, + UnivariateDistributionType.Deterministic) + }; + return new AnalysisResource + { + Name = "bivariate", + Kind = AnalysisKind.Bivariate, + Bivariate = analysis, + MarginalXResource = marginalX, + MarginalYResource = marginalY, + ComponentResources = new List { marginalX, marginalY }, + MarginalXAnalysisId = marginalX.Id, + MarginalYAnalysisId = marginalY.Id + }; + } + + /// + /// Builds an unrun coincident frequency analysis resource over a fresh bivariate resource + /// with a small strictly-monotone 3x3 response surface. + /// + /// The coincident frequency resource; the upstream is reachable via BivariateResource. + public static AnalysisResource CreateCoincidentFrequencyResource() + { + var bivariate = CreateBivariateResource(); + var analysis = new CoincidentFrequencyAnalysis( + bivariate.Bivariate!, + new[] { 100d, 200d, 300d }, + new[] { 50d, 100d, 150d }, + new double[,] + { + { 10d, 11d, 12d }, + { 13d, 14d, 15d }, + { 16d, 17d, 18d } + }); + var components = new List { bivariate }; + components.AddRange(bivariate.ComponentResources!); + return new AnalysisResource + { + Name = "cfa", + Kind = AnalysisKind.CoincidentFrequency, + CoincidentFrequency = analysis, + BivariateResource = bivariate, + ComponentResources = components, + BivariateAnalysisId = bivariate.Id + }; + } + + /// + /// Builds an unrun distribution-fitting analysis resource over a fresh data frame. + /// + /// The analysis resource. + public static AnalysisResource CreateDistributionFittingResource() + { + return new AnalysisResource + { + Name = "fitting", + Kind = AnalysisKind.DistributionFitting, + DistributionFitting = new FittingAnalysis(CreateDataFrame()) + }; + } + + /// + /// Builds an unrun time-series analysis resource of the requested model family over the + /// synthetic three-water-year daily series. + /// + /// The model family. Default Ar. + /// The analysis resource. + public static AnalysisResource CreateTimeSeriesAnalysisResource(TimeSeriesModelType modelType = TimeSeriesModelType.Ar) + { + var series = TestSeries.DailyThreeWaterYears(); + return modelType switch + { + TimeSeriesModelType.Ar => new AnalysisResource + { + Name = "ar", + Kind = AnalysisKind.TimeSeries, + TimeSeriesModel = TimeSeriesModelType.Ar, + Ar = new ARAnalysis(new AutoRegressive(series)) + }, + TimeSeriesModelType.Ma => new AnalysisResource + { + Name = "ma", + Kind = AnalysisKind.TimeSeries, + TimeSeriesModel = TimeSeriesModelType.Ma, + Ma = new MAAnalysis(new MovingAverage(series)) + }, + TimeSeriesModelType.Arima => new AnalysisResource + { + Name = "arima", + Kind = AnalysisKind.TimeSeries, + TimeSeriesModel = TimeSeriesModelType.Arima, + Arima = new ARIMAAnalysis(new ARIMA(series)) + }, + _ => new AnalysisResource + { + Name = "arimax", + Kind = AnalysisKind.TimeSeries, + TimeSeriesModel = TimeSeriesModelType.Arimax, + Arimax = new ARIMAXAnalysis(new ARIMAX(series)) + } + }; + } + + /// + /// Builds a pair of date-aligned stage/discharge measurement series (15 shared dates). + /// + /// The stage and discharge series. + public static (TimeSeries Stage, TimeSeries Discharge) CreateStageDischargePair() + { + var stage = new TimeSeries(TimeInterval.Irregular); + var discharge = new TimeSeries(TimeInterval.Irregular); + for (int i = 0; i < 15; i++) + { + var date = new DateTime(2010, 1, 1).AddMonths(i); + double h = 2d + 0.5d * i; + stage.Add(new SeriesOrdinate(date, h)); + discharge.Add(new SeriesOrdinate(date, 25d * Math.Pow(h - 1d, 1.6))); + } + return (stage, discharge); + } + + /// + /// Builds an unrun rating curve analysis resource over a fresh aligned measurement pair. + /// + /// The analysis resource. + public static AnalysisResource CreateRatingCurveResource() + { + var (stage, discharge) = CreateStageDischargePair(); + var analysis = new RatingCurveAnalysis(new RatingCurve(stage, discharge, numberOfSegments: 1)); + return new AnalysisResource { Name = "rating", Kind = AnalysisKind.RatingCurve, RatingCurve = analysis }; + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Support/TestJson.cs b/src/RMC.BestFit.Api.Tests/Support/TestJson.cs new file mode 100644 index 0000000..2066e30 --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Support/TestJson.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.Tests.Support +{ + /// + /// Shared JSON options for DTO round-trip tests, mirroring the serializer configuration in the + /// API's Program.cs (camelCase, string enums, ignore-null, named floating-point literals). + /// + public static class TestJson + { + /// + /// The serializer options matching the API's wire contract. + /// + public static readonly JsonSerializerOptions Options = CreateOptions(); + + /// + /// Builds the serializer options matching the API's Program.cs configuration. + /// + /// The configured options. + private static JsonSerializerOptions CreateOptions() + { + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals + }; + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + return options; + } + + /// + /// Serializes a value and deserializes it back, returning the round-tripped instance. + /// + /// The DTO type. + /// The value to round-trip. + /// The deserialized copy. + public static T Roundtrip(T value) + { + string json = JsonSerializer.Serialize(value, Options); + var result = JsonSerializer.Deserialize(json, Options); + Assert.IsNotNull(result, "Round-trip deserialization returned null."); + return result; + } + + /// + /// Serializes a value to a JSON string using the API's wire options. + /// + /// The DTO type. + /// The value to serialize. + /// The JSON text. + public static string Serialize(T value) + { + return JsonSerializer.Serialize(value, Options); + } + } +} diff --git a/src/RMC.BestFit.Api.Tests/Support/TestSeries.cs b/src/RMC.BestFit.Api.Tests/Support/TestSeries.cs new file mode 100644 index 0000000..4161e6c --- /dev/null +++ b/src/RMC.BestFit.Api.Tests/Support/TestSeries.cs @@ -0,0 +1,94 @@ +using Numerics.Data; + +namespace RMC.BestFit.Api.Tests.Support +{ + /// + /// Builders for small synthetic time series with known block maxima and peaks, used to verify + /// input-data extraction without network access or long computations. + /// + public static class TestSeries + { + /// + /// The planted water-year maxima of , keyed by water year. + /// + public static readonly IReadOnlyDictionary PlantedMaxima = new Dictionary + { + [1991] = 500d, + [1992] = 800d, + [1993] = 650d + }; + + /// + /// Builds a complete daily series spanning water years 1991-1993 (1990-10-01 through + /// 1993-09-30) with a strictly receding base flow (300 declining by 0.1 per day, always + /// below 400) and one planted maximum per water year: 500 on 1991-03-15, 800 on + /// 1992-01-10, and 650 on 1993-06-05. + /// + /// The synthetic daily series. + /// + /// The base flow recedes rather than staying constant because the peaks-over-threshold + /// clustering in the model layer treats a non-decreasing run as one continuing event; a + /// flat plateau would merge planted peaks into a single cluster. + /// + public static TimeSeries DailyThreeWaterYears() + { + var series = new TimeSeries(TimeInterval.OneDay); + var start = new DateTime(1990, 10, 1); + var end = new DateTime(1993, 9, 30); + int dayIndex = 0; + for (var date = start; date <= end; date = date.AddDays(1), dayIndex++) + { + double value = 300d - 0.1d * dayIndex; + if (date == new DateTime(1991, 3, 15)) value = 500d; + else if (date == new DateTime(1992, 1, 10)) value = 800d; + else if (date == new DateTime(1993, 6, 5)) value = 650d; + series.Add(new SeriesOrdinate(date, value)); + } + return series; + } + + /// + /// Builds a complete daily series spanning the requested number of water years starting + /// 1990-10-01, with a receding base flow (always below 400) and one planted peak per + /// water year on March 15 (500, 520, 540, ...). Intended for peaks-over-threshold + /// extraction at threshold 400, which yields exactly one event per water year — enough + /// exceedances for point process parameter constraints. + /// + /// The number of water years to span. Default 15. + /// The synthetic daily series. + public static TimeSeries DailyWithAnnualPeaks(int waterYears = 15) + { + var series = new TimeSeries(TimeInterval.OneDay); + var start = new DateTime(1990, 10, 1); + var end = start.AddYears(waterYears).AddDays(-1); + int dayIndex = 0; + for (var date = start; date <= end; date = date.AddDays(1), dayIndex++) + { + // Recede slowly and wrap so the base flow stays within (250, 300) — always below + // the 400 threshold and never a non-decreasing run that would merge peak clusters. + double value = 300d - 0.01d * (dayIndex % 5000); + if (date.Month == 3 && date.Day == 15) + { + value = 500d + 20d * (date.Year - 1991); + } + series.Add(new SeriesOrdinate(date, value)); + } + return series; + } + + /// + /// Builds an irregular series of discrete measurements (e.g., annual peak values), one per + /// year from 2000 through 2009, with values 1000, 1100, ..., 1900. + /// + /// The synthetic irregular series. + public static TimeSeries IrregularPeaks() + { + var series = new TimeSeries(TimeInterval.Irregular); + for (int year = 2000; year < 2010; year++) + { + series.Add(new SeriesOrdinate(new DateTime(year, 5, 1), 1000d + (year - 2000) * 100d)); + } + return series; + } + } +} diff --git a/src/RMC.BestFit.Api/Configuration/ApiOptions.cs b/src/RMC.BestFit.Api/Configuration/ApiOptions.cs new file mode 100644 index 0000000..bee557c --- /dev/null +++ b/src/RMC.BestFit.Api/Configuration/ApiOptions.cs @@ -0,0 +1,38 @@ +namespace RMC.BestFit.Api.Configuration +{ + /// + /// Configurable limits for the REST API host, bound from the "Api" configuration section. + /// + /// + /// The limits protect the host from unbounded memory growth (the resource store is in-memory) + /// and CPU oversubscription (each MCMC run parallelizes internally). + /// + public class ApiOptions + { + /// + /// The configuration section name the options are bound from. + /// + public const string SectionName = "Api"; + + /// + /// The maximum total number of resources (time series + input data + analyses) the + /// in-memory store will hold. Creation requests beyond the cap are rejected with + /// HTTP 409 so clients holding resource ids never see them evicted. Default = 500. + /// + public int MaxResources { get; set; } = 500; + + /// + /// The maximum number of analyses allowed to run concurrently. Each Bayesian MCMC run + /// parallelizes internally, so this throttle prevents CPU oversubscription when multiple + /// clients trigger runs at the same time. Default = 2. + /// + public int MaxConcurrentRuns { get; set; } = 2; + + /// + /// The maximum number of MCMC iterations a client may request per analysis run. Guards + /// the synchronous run endpoints against requests that would exceed typical HTTP client + /// timeouts. Default = 500,000. + /// + public int MaxIterations { get; set; } = 500_000; + } +} diff --git a/src/RMC.BestFit.Api/Controllers/ApiControllerBase.cs b/src/RMC.BestFit.Api/Controllers/ApiControllerBase.cs new file mode 100644 index 0000000..6162c17 --- /dev/null +++ b/src/RMC.BestFit.Api/Controllers/ApiControllerBase.cs @@ -0,0 +1,160 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.Mvc; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Helpers; +using RMC.BestFit.Api.Services.Exceptions; + +namespace RMC.BestFit.Api.Controllers +{ + /// + /// Base controller providing the shared execute-and-map pipeline: every endpoint's work runs + /// inside , + /// which stamps timing/timestamp, audits the response for non-finite values, and translates + /// the API's typed exceptions into the documented HTTP status codes. + /// + /// + /// Status mapping: 400 validation/argument errors, 404 unknown resource or no USGS data, + /// 409 conflicts (store full, analysis already running), 499 client cancellation, + /// 502/503 USGS upstream failures, 500 anything else. + /// + [ApiController] + [Produces("application/json")] + public abstract class ApiControllerBase : ControllerBase + { + /// + /// Runs an asynchronous operation and maps its outcome (or exception) to the shared + /// response contract. + /// + /// The response DTO type. + /// The operation producing the response body. + /// The controller's logger. + /// Short operation name used in log messages. + /// The status code for the success path (200 or 201). + /// The action result carrying the response body. + protected async Task> ExecuteAsync( + Func> operation, + ILogger logger, + string operationName, + int successStatusCode = StatusCodes.Status200OK) + where TResponse : ResponseBase, new() + { + ArgumentNullException.ThrowIfNull(operation); + ArgumentNullException.ThrowIfNull(logger); + var stopwatch = Stopwatch.StartNew(); + try + { + // Success defaults to true on ResponseBase and is NOT forced here: workflow + // endpoints report step failures in-body (success=false with partial resource ids) + // while still returning through this success path. + var response = await operation(); + response.ComputationTimeMs = stopwatch.ElapsedMilliseconds; + response.Timestamp = DateTime.UtcNow.ToString("O"); + + // Final seatbelt: fail loudly on ±Infinity rather than letting the JSON serializer + // emit a value most clients cannot parse. NaN is legitimate (missing data) and passes. + var findings = ResponseFiniteAuditor.Audit(response); + if (findings.Count > 0) + { + logger.LogError("[API] {Operation}: non-finite values detected in response at: {Paths}", + operationName, string.Join("; ", findings)); + return Fail(StatusCodes.Status500InternalServerError, + "The operation produced non-finite values. See nonFiniteFindings.", + stopwatch, nonFiniteFindings: findings); + } + + return StatusCode(successStatusCode, response); + } + catch (RequestValidationException ex) + { + logger.LogWarning("[API] {Operation}: validation failed: {Errors}", operationName, string.Join("; ", ex.Errors)); + return Fail(StatusCodes.Status400BadRequest, ex.Message, stopwatch, validationErrors: ex.Errors.ToList()); + } + catch (ResourceNotFoundException ex) + { + logger.LogWarning("[API] {Operation}: {Message}", operationName, ex.Message); + return Fail(StatusCodes.Status404NotFound, ex.Message, stopwatch); + } + catch (UsgsDataNotFoundException ex) + { + logger.LogWarning("[API] {Operation}: {Message}", operationName, ex.Message); + return Fail(StatusCodes.Status404NotFound, ex.Message, stopwatch); + } + catch (ResourceConflictException ex) + { + logger.LogWarning("[API] {Operation}: {Message}", operationName, ex.Message); + return Fail(StatusCodes.Status409Conflict, ex.Message, stopwatch); + } + catch (UsgsUnavailableException ex) + { + logger.LogError(ex, "[API] {Operation}: USGS unavailable", operationName); + return Fail(ex.StatusCode, ex.Message, stopwatch); + } + catch (OperationCanceledException) + { + logger.LogInformation("[API] {Operation}: cancelled by client", operationName); + return Fail(499, "Operation cancelled by client.", stopwatch); + } + catch (ArgumentException ex) + { + logger.LogWarning(ex, "[API] {Operation}: bad request", operationName); + return Fail(StatusCodes.Status400BadRequest, ex.Message, stopwatch); + } + catch (Exception ex) + { + logger.LogError(ex, "[API] {Operation}: unhandled error", operationName); + return Fail(StatusCodes.Status500InternalServerError, $"Internal error: {ex.Message}", stopwatch); + } + } + + /// + /// Runs a synchronous operation through the same pipeline as + /// . + /// + /// The response DTO type. + /// The operation producing the response body. + /// The controller's logger. + /// Short operation name used in log messages. + /// The status code for the success path (200 or 201). + /// The action result carrying the response body. + protected Task> ExecuteAsync( + Func operation, + ILogger logger, + string operationName, + int successStatusCode = StatusCodes.Status200OK) + where TResponse : ResponseBase, new() + { + ArgumentNullException.ThrowIfNull(operation); + return ExecuteAsync(() => Task.FromResult(operation()), logger, operationName, successStatusCode); + } + + /// + /// Builds a failure response body with the shared contract fields populated. + /// + /// The response DTO type. + /// The HTTP status code to return. + /// The client-facing error message. + /// The request stopwatch, used to stamp the elapsed time. + /// Optional individual validation error messages. + /// Optional non-finite audit findings. + /// The failure action result. + private ObjectResult Fail( + int statusCode, + string errorMessage, + Stopwatch stopwatch, + List? validationErrors = null, + List? nonFiniteFindings = null) + where TResponse : ResponseBase, new() + { + var response = new TResponse + { + Success = false, + ErrorMessage = errorMessage, + ValidationErrors = validationErrors, + NonFiniteFindings = nonFiniteFindings, + ComputationTimeMs = stopwatch.ElapsedMilliseconds, + Timestamp = DateTime.UtcNow.ToString("O") + }; + return StatusCode(statusCode, response); + } + } +} diff --git a/src/RMC.BestFit.Api/Controllers/BivariateAnalysisController.cs b/src/RMC.BestFit.Api/Controllers/BivariateAnalysisController.cs new file mode 100644 index 0000000..a97fb15 --- /dev/null +++ b/src/RMC.BestFit.Api/Controllers/BivariateAnalysisController.cs @@ -0,0 +1,174 @@ +using Microsoft.AspNetCore.Mvc; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; + +namespace RMC.BestFit.Api.Controllers +{ + /// + /// Endpoints for Bayesian MCMC bivariate copula analyses over two fitted marginal analyses: + /// create, run synchronously, and retrieve joint-exceedance curves with full posterior + /// uncertainty. Marginals are LIVE references: they must be run before this analysis runs, a + /// marginal mid-run blocks the run (409), and re-running a marginal refreshes this + /// analysis's next run. + /// + [Route("api/analyses/bivariate")] + public class BivariateAnalysisController : ApiControllerBase + { + /// + /// The controller's logger. + /// + private readonly ILogger _logger; + + /// + /// The analysis service shared with the MCP tools. + /// + private readonly IAnalysisService _service; + + /// + /// Constructs the controller. + /// + /// The controller's logger. + /// The analysis service. + /// Thrown when a dependency is null. + public BivariateAnalysisController(ILogger logger, IAnalysisService service) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + /// + /// Creates a bivariate copula analysis over two existing marginal analyses (kinds + /// univariate, bulletin17c, mixture, or pointprocess). The marginals' data are paired by + /// shared time index; at least 10 overlapping non-outlier exact observations are required. + /// + /// The creation request. + /// The created analysis summary, including its id for run/results calls. + /// The analysis was created. + /// A marginal kind, the data overlap, the copula options, or the XY grid is invalid. + /// A marginal analysis does not exist. + [HttpPost] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status404NotFound)] + public Task> Create([FromBody] CreateBivariateAnalysisRequest request) + { + return ExecuteAsync(() => AnalysisMapper.ToResourceResponse(_service.CreateBivariate(request)), + _logger, "analyses.bivariate.create", StatusCodes.Status201Created); + } + + /// + /// Runs the copula estimation synchronously (Bayesian MCMC; typically seconds to a + /// minute) and returns the joint-exceedance results. Both marginals must be estimated + /// first. Disconnecting cancels the run. + /// + /// The analysis id. + /// Cancellation token (client disconnect aborts the estimation). + /// The bivariate results. + /// The run completed; results returned. + /// The configuration failed validation (e.g., a marginal has not been run). + /// No bivariate analysis has the id. + /// The analysis or one of its marginal analyses is currently running. + /// The run was cancelled by the client. + /// The estimation failed; see errorMessage. + [HttpPost("{id:guid}/run")] + [ProducesResponseType(typeof(BivariateResultsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(BivariateResultsResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(BivariateResultsResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(BivariateResultsResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(BivariateResultsResponse), StatusCodes.Status500InternalServerError)] + public Task> Run(Guid id, CancellationToken cancellationToken) + { + return ExecuteAsync(() => _service.RunBivariateAsync(id, cancellationToken), + _logger, "analyses.bivariate.run"); + } + + /// + /// Lists all bivariate analyses. + /// + /// Summaries of every bivariate analysis. + /// The listing. + [HttpGet] + [ProducesResponseType(typeof(AnalysisListResponse), StatusCodes.Status200OK)] + public Task> List() + { + return ExecuteAsync(() => AnalysisMapper.ToListResponse(_service.List(AnalysisKind.Bivariate)), + _logger, "analyses.bivariate.list"); + } + + /// + /// Returns the analysis configuration, validity, and run state. + /// + /// The analysis id. + /// The analysis summary. + /// The analysis. + /// No bivariate analysis has the id. + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status404NotFound)] + public Task> Get(Guid id) + { + return ExecuteAsync(() => AnalysisMapper.ToResourceResponse(_service.Get(id, AnalysisKind.Bivariate)), + _logger, "analyses.bivariate.get"); + } + + /// + /// Returns the stored joint-exceedance results of a previously completed run (no recomputation). + /// + /// The analysis id. + /// The bivariate results. + /// The results. + /// No bivariate analysis has the id, or it has never been run. + [HttpGet("{id:guid}/results")] + [ProducesResponseType(typeof(BivariateResultsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(BivariateResultsResponse), StatusCodes.Status404NotFound)] + public Task> GetResults(Guid id) + { + return ExecuteAsync(() => _service.GetBivariateResults(id), + _logger, "analyses.bivariate.results"); + } + + /// + /// Runs model-layer validation for the analysis without running it (e.g., reports + /// marginals that still require estimation). + /// + /// The analysis id. + /// The validation verdict. + /// The verdict (check isValid). + /// No bivariate analysis has the id. + [HttpGet("{id:guid}/validate")] + [ProducesResponseType(typeof(ValidationResponse), StatusCodes.Status200OK)] + public ActionResult Validate(Guid id) + { + try + { + return Ok(_service.Validate(id, AnalysisKind.Bivariate)); + } + catch (Services.Exceptions.ResourceNotFoundException ex) + { + return NotFound(new ValidationResponse { IsValid = false, Errors = new List { ex.Message } }); + } + } + + /// + /// Deletes the analysis and its stored results. Marginal analyses are not affected. + /// + /// The analysis id. + /// Confirmation of the deletion. + /// The analysis was deleted. + /// No bivariate analysis has the id. + /// The analysis is currently running. + [HttpDelete("{id:guid}")] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status404NotFound)] + public Task> Delete(Guid id) + { + return ExecuteAsync(() => + { + _service.Delete(id, AnalysisKind.Bivariate); + return new DeleteResourceResponse { DeletedId = id, ResourceType = "analysis" }; + }, _logger, "analyses.bivariate.delete"); + } + } +} diff --git a/src/RMC.BestFit.Api/Controllers/Bulletin17CAnalysisController.cs b/src/RMC.BestFit.Api/Controllers/Bulletin17CAnalysisController.cs new file mode 100644 index 0000000..61aa964 --- /dev/null +++ b/src/RMC.BestFit.Api/Controllers/Bulletin17CAnalysisController.cs @@ -0,0 +1,170 @@ +using Microsoft.AspNetCore.Mvc; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; + +namespace RMC.BestFit.Api.Controllers +{ + /// + /// Endpoints for Bulletin 17C flood frequency analyses (USGS guidelines — Expected Moments + /// Algorithm via generalized method of moments with parametric/bootstrap uncertainty): + /// create against an input-data resource, run synchronously, and retrieve the frequency curve + /// with confidence intervals. + /// + [Route("api/analyses/bulletin17c")] + public class Bulletin17CAnalysisController : ApiControllerBase + { + /// + /// The controller's logger. + /// + private readonly ILogger _logger; + + /// + /// The analysis service shared with the MCP tools. + /// + private readonly IAnalysisService _service; + + /// + /// Constructs the controller. + /// + /// The controller's logger. + /// The analysis service. + /// Thrown when a dependency is null. + public Bulletin17CAnalysisController(ILogger logger, IAnalysisService service) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + /// + /// Creates a Bulletin 17C analysis linked to an input-data resource (typically USGS annual + /// peaks). The analysis clones the input data at creation. + /// + /// The creation request. + /// The created analysis summary. + /// The analysis was created. + /// The distribution is not supported by Bulletin 17C, or the ordinates are invalid. + /// The input-data resource does not exist. + [HttpPost] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status404NotFound)] + public Task> Create([FromBody] CreateBulletin17CAnalysisRequest request) + { + return ExecuteAsync(() => AnalysisMapper.ToResourceResponse(_service.CreateBulletin17C(request)), + _logger, "analyses.bulletin17c.create", StatusCodes.Status201Created); + } + + /// + /// Runs the analysis synchronously (GMM fit plus uncertainty quantification; typically + /// seconds) and returns the frequency results. + /// + /// The analysis id. + /// Cancellation token (client disconnect aborts the run). + /// The frequency results. + /// The run completed; results returned. + /// The configuration failed validation. + /// No Bulletin 17C analysis has the id. + /// The analysis is already running. + /// The run was cancelled by the client. + /// The GMM solver failed to find a solution, or the run errored. + [HttpPost("{id:guid}/run")] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status500InternalServerError)] + public Task> Run(Guid id, CancellationToken cancellationToken) + { + return ExecuteAsync(() => _service.RunFrequencyAsync(id, AnalysisKind.Bulletin17C, cancellationToken), + _logger, "analyses.bulletin17c.run"); + } + + /// + /// Lists all Bulletin 17C analyses. + /// + /// Summaries of every Bulletin 17C analysis. + /// The listing. + [HttpGet] + [ProducesResponseType(typeof(AnalysisListResponse), StatusCodes.Status200OK)] + public Task> List() + { + return ExecuteAsync(() => AnalysisMapper.ToListResponse(_service.List(AnalysisKind.Bulletin17C)), + _logger, "analyses.bulletin17c.list"); + } + + /// + /// Returns the analysis configuration, validity, and run state. + /// + /// The analysis id. + /// The analysis summary. + /// The analysis. + /// No Bulletin 17C analysis has the id. + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status404NotFound)] + public Task> Get(Guid id) + { + return ExecuteAsync(() => AnalysisMapper.ToResourceResponse(_service.Get(id, AnalysisKind.Bulletin17C)), + _logger, "analyses.bulletin17c.get"); + } + + /// + /// Returns the stored frequency results of a previously completed run (no recomputation). + /// + /// The analysis id. + /// The frequency results. + /// The results. + /// No Bulletin 17C analysis has the id, or it has never been run. + [HttpGet("{id:guid}/results")] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status404NotFound)] + public Task> GetResults(Guid id) + { + return ExecuteAsync(() => _service.GetFrequencyResults(id, AnalysisKind.Bulletin17C), + _logger, "analyses.bulletin17c.results"); + } + + /// + /// Runs model-layer validation for the analysis without running it. + /// + /// The analysis id. + /// The validation verdict. + /// The verdict (check isValid). + /// No Bulletin 17C analysis has the id. + [HttpGet("{id:guid}/validate")] + [ProducesResponseType(typeof(ValidationResponse), StatusCodes.Status200OK)] + public ActionResult Validate(Guid id) + { + try + { + return Ok(_service.Validate(id, AnalysisKind.Bulletin17C)); + } + catch (Services.Exceptions.ResourceNotFoundException ex) + { + return NotFound(new ValidationResponse { IsValid = false, Errors = new List { ex.Message } }); + } + } + + /// + /// Deletes the analysis and its stored results. + /// + /// The analysis id. + /// Confirmation of the deletion. + /// The analysis was deleted. + /// No Bulletin 17C analysis has the id. + /// The analysis is currently running. + [HttpDelete("{id:guid}")] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status404NotFound)] + public Task> Delete(Guid id) + { + return ExecuteAsync(() => + { + _service.Delete(id, AnalysisKind.Bulletin17C); + return new DeleteResourceResponse { DeletedId = id, ResourceType = "analysis" }; + }, _logger, "analyses.bulletin17c.delete"); + } + } +} diff --git a/src/RMC.BestFit.Api/Controllers/CoincidentFrequencyAnalysisController.cs b/src/RMC.BestFit.Api/Controllers/CoincidentFrequencyAnalysisController.cs new file mode 100644 index 0000000..df8c668 --- /dev/null +++ b/src/RMC.BestFit.Api/Controllers/CoincidentFrequencyAnalysisController.cs @@ -0,0 +1,173 @@ +using Microsoft.AspNetCore.Mvc; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; + +namespace RMC.BestFit.Api.Controllers +{ + /// + /// Endpoints for coincident frequency analyses: integrate a client-supplied response surface + /// Z(x, y) over a fitted bivariate analysis to produce the response's annual exceedance + /// frequency curve. The upstream bivariate (and transitively its marginals) are LIVE + /// references: the bivariate must be run before this analysis runs, any of them mid-run + /// blocks the run (409), and re-running them refreshes this analysis's next run. + /// + [Route("api/analyses/coincidentfrequency")] + public class CoincidentFrequencyAnalysisController : ApiControllerBase + { + /// + /// The controller's logger. + /// + private readonly ILogger _logger; + + /// + /// The analysis service shared with the MCP tools. + /// + private readonly IAnalysisService _service; + + /// + /// Constructs the controller. + /// + /// The controller's logger. + /// The analysis service. + /// Thrown when a dependency is null. + public CoincidentFrequencyAnalysisController(ILogger logger, IAnalysisService service) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + /// + /// Creates a coincident frequency analysis over an existing bivariate analysis plus a + /// tabulated response surface (rows = xValues, columns = yValues, strictly increasing + /// along both axes). + /// + /// The creation request. + /// The created analysis summary, including its id for run/results calls. + /// The analysis was created. + /// The surface shape, ordinates, bin count, or options are invalid. + /// The bivariate analysis does not exist. + [HttpPost] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status404NotFound)] + public Task> Create([FromBody] CreateCoincidentFrequencyAnalysisRequest request) + { + return ExecuteAsync(() => AnalysisMapper.ToResourceResponse(_service.CreateCoincidentFrequency(request)), + _logger, "analyses.coincidentfrequency.create", StatusCodes.Status201Created); + } + + /// + /// Runs the analysis synchronously — no MCMC of its own; it integrates the surface over + /// the upstream posteriors (typically seconds). The bivariate must be estimated first. + /// + /// The analysis id. + /// Cancellation token (client disconnect aborts the aggregation). + /// The coincident frequency results. + /// The run completed; results returned. + /// The configuration failed validation (e.g., the bivariate has not been run). + /// No coincident frequency analysis has the id. + /// The analysis, its bivariate, or a transitive marginal is currently running. + /// The run was cancelled by the client. + /// The aggregation failed; see errorMessage. + [HttpPost("{id:guid}/run")] + [ProducesResponseType(typeof(CoincidentFrequencyResultsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(CoincidentFrequencyResultsResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(CoincidentFrequencyResultsResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(CoincidentFrequencyResultsResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(CoincidentFrequencyResultsResponse), StatusCodes.Status500InternalServerError)] + public Task> Run(Guid id, CancellationToken cancellationToken) + { + return ExecuteAsync(() => _service.RunCoincidentFrequencyAsync(id, cancellationToken), + _logger, "analyses.coincidentfrequency.run"); + } + + /// + /// Lists all coincident frequency analyses. + /// + /// Summaries of every coincident frequency analysis. + /// The listing. + [HttpGet] + [ProducesResponseType(typeof(AnalysisListResponse), StatusCodes.Status200OK)] + public Task> List() + { + return ExecuteAsync(() => AnalysisMapper.ToListResponse(_service.List(AnalysisKind.CoincidentFrequency)), + _logger, "analyses.coincidentfrequency.list"); + } + + /// + /// Returns the analysis configuration, validity, and run state. + /// + /// The analysis id. + /// The analysis summary. + /// The analysis. + /// No coincident frequency analysis has the id. + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status404NotFound)] + public Task> Get(Guid id) + { + return ExecuteAsync(() => AnalysisMapper.ToResourceResponse(_service.Get(id, AnalysisKind.CoincidentFrequency)), + _logger, "analyses.coincidentfrequency.get"); + } + + /// + /// Returns the stored results of a previously completed run (no recomputation). + /// + /// The analysis id. + /// The coincident frequency results. + /// The results. + /// No coincident frequency analysis has the id, or it has never been run. + [HttpGet("{id:guid}/results")] + [ProducesResponseType(typeof(CoincidentFrequencyResultsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(CoincidentFrequencyResultsResponse), StatusCodes.Status404NotFound)] + public Task> GetResults(Guid id) + { + return ExecuteAsync(() => _service.GetCoincidentFrequencyResults(id), + _logger, "analyses.coincidentfrequency.results"); + } + + /// + /// Runs model-layer validation for the analysis without running it (e.g., reports an + /// unestimated bivariate or a non-monotonic response surface). + /// + /// The analysis id. + /// The validation verdict. + /// The verdict (check isValid). + /// No coincident frequency analysis has the id. + [HttpGet("{id:guid}/validate")] + [ProducesResponseType(typeof(ValidationResponse), StatusCodes.Status200OK)] + public ActionResult Validate(Guid id) + { + try + { + return Ok(_service.Validate(id, AnalysisKind.CoincidentFrequency)); + } + catch (Services.Exceptions.ResourceNotFoundException ex) + { + return NotFound(new ValidationResponse { IsValid = false, Errors = new List { ex.Message } }); + } + } + + /// + /// Deletes the analysis and its stored results. The upstream bivariate is not affected. + /// + /// The analysis id. + /// Confirmation of the deletion. + /// The analysis was deleted. + /// No coincident frequency analysis has the id. + /// The analysis is currently running. + [HttpDelete("{id:guid}")] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status404NotFound)] + public Task> Delete(Guid id) + { + return ExecuteAsync(() => + { + _service.Delete(id, AnalysisKind.CoincidentFrequency); + return new DeleteResourceResponse { DeletedId = id, ResourceType = "analysis" }; + }, _logger, "analyses.coincidentfrequency.delete"); + } + } +} diff --git a/src/RMC.BestFit.Api/Controllers/CompetingRisksAnalysisController.cs b/src/RMC.BestFit.Api/Controllers/CompetingRisksAnalysisController.cs new file mode 100644 index 0000000..c39fbf9 --- /dev/null +++ b/src/RMC.BestFit.Api/Controllers/CompetingRisksAnalysisController.cs @@ -0,0 +1,171 @@ +using Microsoft.AspNetCore.Mvc; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; + +namespace RMC.BestFit.Api.Controllers +{ + /// + /// Endpoints for Bayesian MCMC competing risks frequency analyses: create against an + /// input-data resource with 1-3 component distributions (annual maxima modeled as the maximum + /// of independent processes), run synchronously, and retrieve frequency curves with full + /// posterior uncertainty. + /// + [Route("api/analyses/competingrisks")] + public class CompetingRisksAnalysisController : ApiControllerBase + { + /// + /// The controller's logger. + /// + private readonly ILogger _logger; + + /// + /// The analysis service shared with the MCP tools. + /// + private readonly IAnalysisService _service; + + /// + /// Constructs the controller. + /// + /// The controller's logger. + /// The analysis service. + /// Thrown when a dependency is null. + public CompetingRisksAnalysisController(ILogger logger, IAnalysisService service) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + /// + /// Creates a competing risks frequency analysis linked to an input-data resource. The + /// analysis clones the input data, so later edits or deletes of the input-data resource + /// cannot affect it. + /// + /// The creation request. + /// The created analysis summary, including its id for run/results calls. + /// The analysis was created. + /// The component distributions, ordinates, priors, or Bayesian options are invalid. + /// The input-data resource does not exist. + [HttpPost] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status404NotFound)] + public Task> Create([FromBody] CreateCompetingRisksAnalysisRequest request) + { + return ExecuteAsync(() => AnalysisMapper.ToResourceResponse(_service.CreateCompetingRisks(request)), + _logger, "analyses.competingrisks.create", StatusCodes.Status201Created); + } + + /// + /// Runs the analysis synchronously (Bayesian MCMC; typically seconds to a minute) and + /// returns the frequency results. Disconnecting cancels the run. + /// + /// The analysis id. + /// Cancellation token (client disconnect aborts the estimation). + /// The frequency results. + /// The run completed; results returned. + /// The configuration failed validation; see validationErrors. + /// No competing risks analysis has the id. + /// The analysis is already running. + /// The run was cancelled by the client. + /// The estimation failed; see errorMessage. + [HttpPost("{id:guid}/run")] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status500InternalServerError)] + public Task> Run(Guid id, CancellationToken cancellationToken) + { + return ExecuteAsync(() => _service.RunFrequencyAsync(id, AnalysisKind.CompetingRisks, cancellationToken), + _logger, "analyses.competingrisks.run"); + } + + /// + /// Lists all competing risks analyses. + /// + /// Summaries of every competing risks analysis. + /// The listing. + [HttpGet] + [ProducesResponseType(typeof(AnalysisListResponse), StatusCodes.Status200OK)] + public Task> List() + { + return ExecuteAsync(() => AnalysisMapper.ToListResponse(_service.List(AnalysisKind.CompetingRisks)), + _logger, "analyses.competingrisks.list"); + } + + /// + /// Returns the analysis configuration, validity, and run state. + /// + /// The analysis id. + /// The analysis summary. + /// The analysis. + /// No competing risks analysis has the id. + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status404NotFound)] + public Task> Get(Guid id) + { + return ExecuteAsync(() => AnalysisMapper.ToResourceResponse(_service.Get(id, AnalysisKind.CompetingRisks)), + _logger, "analyses.competingrisks.get"); + } + + /// + /// Returns the stored frequency results of a previously completed run (no recomputation). + /// + /// The analysis id. + /// The frequency results. + /// The results. + /// No competing risks analysis has the id, or it has never been run. + [HttpGet("{id:guid}/results")] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status404NotFound)] + public Task> GetResults(Guid id) + { + return ExecuteAsync(() => _service.GetFrequencyResults(id, AnalysisKind.CompetingRisks), + _logger, "analyses.competingrisks.results"); + } + + /// + /// Runs model-layer validation for the analysis without running it. + /// + /// The analysis id. + /// The validation verdict. + /// The verdict (check isValid). + /// No competing risks analysis has the id. + [HttpGet("{id:guid}/validate")] + [ProducesResponseType(typeof(ValidationResponse), StatusCodes.Status200OK)] + public ActionResult Validate(Guid id) + { + try + { + return Ok(_service.Validate(id, AnalysisKind.CompetingRisks)); + } + catch (Services.Exceptions.ResourceNotFoundException ex) + { + return NotFound(new ValidationResponse { IsValid = false, Errors = new List { ex.Message } }); + } + } + + /// + /// Deletes the analysis and its stored results. + /// + /// The analysis id. + /// Confirmation of the deletion. + /// The analysis was deleted. + /// No competing risks analysis has the id. + /// The analysis is currently running. + [HttpDelete("{id:guid}")] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status404NotFound)] + public Task> Delete(Guid id) + { + return ExecuteAsync(() => + { + _service.Delete(id, AnalysisKind.CompetingRisks); + return new DeleteResourceResponse { DeletedId = id, ResourceType = "analysis" }; + }, _logger, "analyses.competingrisks.delete"); + } + } +} diff --git a/src/RMC.BestFit.Api/Controllers/CompositeAnalysisController.cs b/src/RMC.BestFit.Api/Controllers/CompositeAnalysisController.cs new file mode 100644 index 0000000..d9bfce8 --- /dev/null +++ b/src/RMC.BestFit.Api/Controllers/CompositeAnalysisController.cs @@ -0,0 +1,173 @@ +using Microsoft.AspNetCore.Mvc; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; + +namespace RMC.BestFit.Api.Controllers +{ + /// + /// Endpoints for composite analyses that combine already-fitted component analyses by + /// competing risks, mixture weighting, or information-criterion model averaging. Components + /// are LIVE references: every component must have been run before the composite runs, a + /// component mid-run blocks the composite (409), and re-running a component refreshes the + /// composite's next run. + /// + [Route("api/analyses/composite")] + public class CompositeAnalysisController : ApiControllerBase + { + /// + /// The controller's logger. + /// + private readonly ILogger _logger; + + /// + /// The analysis service shared with the MCP tools. + /// + private readonly IAnalysisService _service; + + /// + /// Constructs the controller. + /// + /// The controller's logger. + /// The analysis service. + /// Thrown when a dependency is null. + public CompositeAnalysisController(ILogger logger, IAnalysisService service) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + /// + /// Creates a composite analysis over existing component analyses (univariate, + /// bulletin17c, mixture, pointprocess, or competingrisks kinds; composites cannot nest). + /// Components do not need to be run yet — they must be run before the composite runs. + /// + /// The creation request. + /// The created analysis summary, including its id for run/results calls. + /// The analysis was created. + /// A component kind, mixture weight, ordinate, or option is invalid. + /// A component analysis does not exist. + [HttpPost] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status404NotFound)] + public Task> Create([FromBody] CreateCompositeAnalysisRequest request) + { + return ExecuteAsync(() => AnalysisMapper.ToResourceResponse(_service.CreateComposite(request)), + _logger, "analyses.composite.create", StatusCodes.Status201Created); + } + + /// + /// Runs the composite synchronously — no MCMC of its own; it aggregates the component + /// posteriors (typically a few seconds). Every component must be estimated first. + /// + /// The analysis id. + /// Cancellation token (client disconnect aborts the aggregation). + /// The composite frequency results. + /// The run completed; results returned. + /// The configuration failed validation (e.g., a component has not been run). + /// No composite analysis has the id. + /// The composite or one of its component analyses is currently running. + /// The run was cancelled by the client. + /// The aggregation failed; see errorMessage. + [HttpPost("{id:guid}/run")] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status500InternalServerError)] + public Task> Run(Guid id, CancellationToken cancellationToken) + { + return ExecuteAsync(() => _service.RunFrequencyAsync(id, AnalysisKind.Composite, cancellationToken), + _logger, "analyses.composite.run"); + } + + /// + /// Lists all composite analyses. + /// + /// Summaries of every composite analysis. + /// The listing. + [HttpGet] + [ProducesResponseType(typeof(AnalysisListResponse), StatusCodes.Status200OK)] + public Task> List() + { + return ExecuteAsync(() => AnalysisMapper.ToListResponse(_service.List(AnalysisKind.Composite)), + _logger, "analyses.composite.list"); + } + + /// + /// Returns the analysis configuration, validity, and run state. + /// + /// The analysis id. + /// The analysis summary. + /// The analysis. + /// No composite analysis has the id. + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status404NotFound)] + public Task> Get(Guid id) + { + return ExecuteAsync(() => AnalysisMapper.ToResourceResponse(_service.Get(id, AnalysisKind.Composite)), + _logger, "analyses.composite.get"); + } + + /// + /// Returns the stored composite results of a previously completed run (no recomputation). + /// + /// The analysis id. + /// The composite frequency results. + /// The results. + /// No composite analysis has the id, or it has never been run. + [HttpGet("{id:guid}/results")] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status404NotFound)] + public Task> GetResults(Guid id) + { + return ExecuteAsync(() => _service.GetFrequencyResults(id, AnalysisKind.Composite), + _logger, "analyses.composite.results"); + } + + /// + /// Runs model-layer validation for the analysis without running it (e.g., reports + /// components that still require estimation). + /// + /// The analysis id. + /// The validation verdict. + /// The verdict (check isValid). + /// No composite analysis has the id. + [HttpGet("{id:guid}/validate")] + [ProducesResponseType(typeof(ValidationResponse), StatusCodes.Status200OK)] + public ActionResult Validate(Guid id) + { + try + { + return Ok(_service.Validate(id, AnalysisKind.Composite)); + } + catch (Services.Exceptions.ResourceNotFoundException ex) + { + return NotFound(new ValidationResponse { IsValid = false, Errors = new List { ex.Message } }); + } + } + + /// + /// Deletes the composite and its stored results. Component analyses are not affected. + /// + /// The analysis id. + /// Confirmation of the deletion. + /// The analysis was deleted. + /// No composite analysis has the id. + /// The analysis is currently running. + [HttpDelete("{id:guid}")] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status404NotFound)] + public Task> Delete(Guid id) + { + return ExecuteAsync(() => + { + _service.Delete(id, AnalysisKind.Composite); + return new DeleteResourceResponse { DeletedId = id, ResourceType = "analysis" }; + }, _logger, "analyses.composite.delete"); + } + } +} diff --git a/src/RMC.BestFit.Api/Controllers/DistributionFittingAnalysisController.cs b/src/RMC.BestFit.Api/Controllers/DistributionFittingAnalysisController.cs new file mode 100644 index 0000000..eee7c9a --- /dev/null +++ b/src/RMC.BestFit.Api/Controllers/DistributionFittingAnalysisController.cs @@ -0,0 +1,170 @@ +using Microsoft.AspNetCore.Mvc; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; + +namespace RMC.BestFit.Api.Controllers +{ + /// + /// Endpoints for distribution-fitting analyses: a fast parallel maximum-likelihood fit of + /// many candidate distributions over one input-data resource, ranked by information criteria + /// — a screening step before Bayesian estimation. + /// + [Route("api/analyses/distributionfitting")] + public class DistributionFittingAnalysisController : ApiControllerBase + { + /// + /// The controller's logger. + /// + private readonly ILogger _logger; + + /// + /// The analysis service shared with the MCP tools. + /// + private readonly IAnalysisService _service; + + /// + /// Constructs the controller. + /// + /// The controller's logger. + /// The analysis service. + /// Thrown when a dependency is null. + public DistributionFittingAnalysisController(ILogger logger, IAnalysisService service) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + /// + /// Creates a distribution-fitting analysis linked to an input-data resource. The analysis + /// clones the input data, so later edits or deletes of the input-data resource cannot + /// affect it. + /// + /// The creation request. + /// The created analysis summary, including its id for run/results calls. + /// The analysis was created. + /// A candidate distribution is not supported. + /// The input-data resource does not exist. + [HttpPost] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status404NotFound)] + public Task> Create([FromBody] CreateDistributionFittingAnalysisRequest request) + { + return ExecuteAsync(() => AnalysisMapper.ToResourceResponse(_service.CreateDistributionFitting(request)), + _logger, "analyses.distributionfitting.create", StatusCodes.Status201Created); + } + + /// + /// Runs the fitting synchronously (parallel maximum likelihood; typically seconds) and + /// returns the ranked fits. Disconnecting cancels the run. + /// + /// The analysis id. + /// Cancellation token (client disconnect aborts the fitting loop). + /// The ranked fitting results. + /// The run completed; results returned. + /// The configuration failed validation; see validationErrors. + /// No distribution-fitting analysis has the id. + /// The analysis is already running. + /// The run was cancelled by the client. + /// The fitting failed; see errorMessage. + [HttpPost("{id:guid}/run")] + [ProducesResponseType(typeof(DistributionFittingResultsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(DistributionFittingResultsResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(DistributionFittingResultsResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(DistributionFittingResultsResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(DistributionFittingResultsResponse), StatusCodes.Status500InternalServerError)] + public Task> Run(Guid id, CancellationToken cancellationToken) + { + return ExecuteAsync(() => _service.RunDistributionFittingAsync(id, cancellationToken), + _logger, "analyses.distributionfitting.run"); + } + + /// + /// Lists all distribution-fitting analyses. + /// + /// Summaries of every distribution-fitting analysis. + /// The listing. + [HttpGet] + [ProducesResponseType(typeof(AnalysisListResponse), StatusCodes.Status200OK)] + public Task> List() + { + return ExecuteAsync(() => AnalysisMapper.ToListResponse(_service.List(AnalysisKind.DistributionFitting)), + _logger, "analyses.distributionfitting.list"); + } + + /// + /// Returns the analysis configuration, validity, and run state. + /// + /// The analysis id. + /// The analysis summary. + /// The analysis. + /// No distribution-fitting analysis has the id. + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status404NotFound)] + public Task> Get(Guid id) + { + return ExecuteAsync(() => AnalysisMapper.ToResourceResponse(_service.Get(id, AnalysisKind.DistributionFitting)), + _logger, "analyses.distributionfitting.get"); + } + + /// + /// Returns the stored ranked fits of a previously completed run (no recomputation). + /// + /// The analysis id. + /// The ranked fitting results. + /// The results. + /// No distribution-fitting analysis has the id, or it has never been run. + [HttpGet("{id:guid}/results")] + [ProducesResponseType(typeof(DistributionFittingResultsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(DistributionFittingResultsResponse), StatusCodes.Status404NotFound)] + public Task> GetResults(Guid id) + { + return ExecuteAsync(() => _service.GetDistributionFittingResults(id), + _logger, "analyses.distributionfitting.results"); + } + + /// + /// Runs model-layer validation for the analysis without running it. + /// + /// The analysis id. + /// The validation verdict. + /// The verdict (check isValid). + /// No distribution-fitting analysis has the id. + [HttpGet("{id:guid}/validate")] + [ProducesResponseType(typeof(ValidationResponse), StatusCodes.Status200OK)] + public ActionResult Validate(Guid id) + { + try + { + return Ok(_service.Validate(id, AnalysisKind.DistributionFitting)); + } + catch (Services.Exceptions.ResourceNotFoundException ex) + { + return NotFound(new ValidationResponse { IsValid = false, Errors = new List { ex.Message } }); + } + } + + /// + /// Deletes the analysis and its stored results. + /// + /// The analysis id. + /// Confirmation of the deletion. + /// The analysis was deleted. + /// No distribution-fitting analysis has the id. + /// The analysis is currently running. + [HttpDelete("{id:guid}")] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status404NotFound)] + public Task> Delete(Guid id) + { + return ExecuteAsync(() => + { + _service.Delete(id, AnalysisKind.DistributionFitting); + return new DeleteResourceResponse { DeletedId = id, ResourceType = "analysis" }; + }, _logger, "analyses.distributionfitting.delete"); + } + } +} diff --git a/src/RMC.BestFit.Api/Controllers/InputDataController.cs b/src/RMC.BestFit.Api/Controllers/InputDataController.cs new file mode 100644 index 0000000..97ab659 --- /dev/null +++ b/src/RMC.BestFit.Api/Controllers/InputDataController.cs @@ -0,0 +1,201 @@ +using Microsoft.AspNetCore.Mvc; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Services; + +namespace RMC.BestFit.Api.Controllers +{ + /// + /// Endpoints for creating, inspecting, and deleting input-data resources — the observation + /// sets (systematic records, censored data, perception thresholds) that univariate and + /// Bulletin 17C analyses are fit to. + /// + [Route("api/inputdata")] + public class InputDataController : ApiControllerBase + { + /// + /// The controller's logger. + /// + private readonly ILogger _logger; + + /// + /// The input-data service shared with the MCP tools. + /// + private readonly IInputDataService _service; + + /// + /// Constructs the controller. + /// + /// The controller's logger. + /// The input-data service. + /// Thrown when a dependency is null. + public InputDataController(ILogger logger, IInputDataService service) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + /// + /// Builds an input-data resource from client-supplied observations (systematic record, + /// optionally interval-censored and perception-threshold data). + /// + /// The manual creation request. + /// The created resource summary, including the id to reference when creating analyses. + /// The resource was created. + /// The observations failed validation; see validationErrors. + [HttpPost("manual")] + [ProducesResponseType(typeof(InputDataResourceResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(InputDataResourceResponse), StatusCodes.Status400BadRequest)] + public Task> CreateManual([FromBody] CreateManualInputDataRequest request) + { + return ExecuteAsync(() => + { + var resource = _service.CreateManual(request); + return InputDataMapper.ToResourceResponse(resource); + }, _logger, "inputdata.manual", StatusCodes.Status201Created); + } + + /// + /// Extracts block maxima (e.g., annual maxima by water year) from a linked time-series + /// resource, typically a USGS daily-flow download. + /// + /// The block-maxima creation request. + /// The created resource summary. + /// The resource was created. + /// Extraction produced no observations or the result failed validation. + /// The linked time-series resource does not exist. + [HttpPost("block-max")] + [ProducesResponseType(typeof(InputDataResourceResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(InputDataResourceResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(InputDataResourceResponse), StatusCodes.Status404NotFound)] + public Task> CreateBlockMax([FromBody] CreateBlockMaxInputDataRequest request) + { + return ExecuteAsync(() => + { + var resource = _service.CreateBlockMax(request); + return InputDataMapper.ToResourceResponse(resource); + }, _logger, "inputdata.blockmax", StatusCodes.Status201Created); + } + + /// + /// Extracts independent peaks-over-threshold events from a linked time-series resource. + /// + /// The peaks-over-threshold creation request. + /// The created resource summary (lambda reports the events-per-year rate). + /// The resource was created. + /// Extraction produced no events or the result failed validation. + /// The linked time-series resource does not exist. + [HttpPost("peaks-over-threshold")] + [ProducesResponseType(typeof(InputDataResourceResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(InputDataResourceResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(InputDataResourceResponse), StatusCodes.Status404NotFound)] + public Task> CreatePeaksOverThreshold([FromBody] CreatePotInputDataRequest request) + { + return ExecuteAsync(() => + { + var resource = _service.CreatePeaksOverThreshold(request); + return InputDataMapper.ToResourceResponse(resource); + }, _logger, "inputdata.pot", StatusCodes.Status201Created); + } + + /// + /// Downloads the USGS annual peak-flow file for a site and stores it directly as an + /// input-data resource (no intermediate time-series resource required). + /// + /// The USGS peak download request. + /// Cancellation token. + /// The created resource summary. + /// The resource was created. + /// The site number or series type is invalid. + /// The site returned no peak data. + /// The USGS service returned an error. + /// The USGS service could not be reached. + [HttpPost("usgs-peaks")] + [ProducesResponseType(typeof(InputDataResourceResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(InputDataResourceResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(InputDataResourceResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(InputDataResourceResponse), StatusCodes.Status502BadGateway)] + public Task> CreateFromUsgsPeaks( + [FromBody] CreateUsgsPeaksInputDataRequest request, + CancellationToken cancellationToken) + { + return ExecuteAsync(async () => + { + var resource = await _service.CreateFromUsgsPeaksAsync(request, cancellationToken); + return InputDataMapper.ToResourceResponse(resource); + }, _logger, "inputdata.usgspeaks", StatusCodes.Status201Created); + } + + /// + /// Lists all input-data resources currently held by the server. + /// + /// Summaries of every input-data resource. + /// The listing. + [HttpGet] + [ProducesResponseType(typeof(InputDataListResponse), StatusCodes.Status200OK)] + public Task> List() + { + return ExecuteAsync(() => InputDataMapper.ToListResponse(_service.List()), _logger, "inputdata.list"); + } + + /// + /// Returns an input-data resource summary, optionally with the full observation lists + /// (including computed plotting positions). + /// + /// The resource id. + /// True to include the exact/interval/threshold observation lists. + /// The resource summary and optional observations. + /// The resource. + /// No resource has the id. + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(InputDataResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(InputDataResourceResponse), StatusCodes.Status404NotFound)] + public Task> Get(Guid id, [FromQuery] bool includeData = false) + { + return ExecuteAsync(() => + { + var resource = _service.Get(id); + return InputDataMapper.ToResourceResponse(resource, includeData); + }, _logger, "inputdata.get"); + } + + /// + /// Computes the sample summary statistics of an input-data resource over all observations. + /// + /// The resource id. + /// Summary statistics keyed by measure name. + /// The statistics. + /// No resource has the id. + [HttpGet("{id:guid}/summary-statistics")] + [ProducesResponseType(typeof(SummaryStatisticsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(SummaryStatisticsResponse), StatusCodes.Status404NotFound)] + public Task> GetSummaryStatistics(Guid id) + { + return ExecuteAsync(() => new SummaryStatisticsResponse + { + InputDataId = id, + Statistics = _service.GetSummaryStatistics(id) + }, _logger, "inputdata.summarystatistics"); + } + + /// + /// Deletes an input-data resource. Analyses already created from it are unaffected + /// (they hold their own copies of the data). + /// + /// The resource id. + /// Confirmation of the deletion. + /// The resource was deleted. + /// No resource has the id. + [HttpDelete("{id:guid}")] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status404NotFound)] + public Task> Delete(Guid id) + { + return ExecuteAsync(() => + { + _service.Delete(id); + return new DeleteResourceResponse { DeletedId = id, ResourceType = "inputData" }; + }, _logger, "inputdata.delete"); + } + } +} diff --git a/src/RMC.BestFit.Api/Controllers/MetadataController.cs b/src/RMC.BestFit.Api/Controllers/MetadataController.cs new file mode 100644 index 0000000..0cf7d90 --- /dev/null +++ b/src/RMC.BestFit.Api/Controllers/MetadataController.cs @@ -0,0 +1,91 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Numerics.Data; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Mappers; + +namespace RMC.BestFit.Api.Controllers +{ + /// + /// Discovery endpoints: supported distributions, accepted enum values, and server defaults. + /// MCP agents should consult these before constructing analysis requests. + /// + [Route("api/metadata")] + public class MetadataController : ApiControllerBase + { + /// + /// The controller's logger. + /// + private readonly ILogger _logger; + + /// + /// The configured API limits reported by the defaults endpoint. + /// + private readonly ApiOptions _options; + + /// + /// Constructs the controller. + /// + /// The controller's logger. + /// The configured API limits. + /// Thrown when a dependency is null. + public MetadataController(ILogger logger, IOptions options) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + if (options is null) throw new ArgumentNullException(nameof(options)); + _options = options.Value; + } + + /// + /// Lists the probability distributions available for fitting, with the analysis kinds that + /// support each ("univariate" for Bayesian MCMC, "bulletin17c" for the Bulletin 17C procedure). + /// + /// The distribution listing. + /// The listing. + [HttpGet("distributions")] + [ProducesResponseType(typeof(DistributionsResponse), StatusCodes.Status200OK)] + public Task> GetDistributions() + { + return ExecuteAsync(MetadataMapper.ToDistributionsResponse, _logger, "metadata.distributions"); + } + + /// + /// Lists the accepted string values for every enum-typed request field, in the camelCase + /// form the JSON contract expects. + /// + /// The enum options listing. + /// The listing. + [HttpGet("enums")] + [ProducesResponseType(typeof(EnumOptionsResponse), StatusCodes.Status200OK)] + public Task> GetEnumOptions() + { + return ExecuteAsync(MetadataMapper.ToEnumOptionsResponse, _logger, "metadata.enums"); + } + + /// + /// Reports the server defaults and limits: the default exceedance-probability ordinates + /// frequency curves are evaluated at, and the configured run/store caps. + /// + /// The defaults listing. + /// The defaults. + [HttpGet("defaults")] + [ProducesResponseType(typeof(DefaultsResponse), StatusCodes.Status200OK)] + public Task> GetDefaults() + { + return ExecuteAsync(() => new DefaultsResponse + { + ProbabilityOrdinates = new ProbabilityOrdinates().ToList(), + MaxIterations = _options.MaxIterations, + MaxConcurrentRuns = _options.MaxConcurrentRuns, + MaxResources = _options.MaxResources, + Notes = new List + { + "Probability ordinates are annual exceedance probabilities (AEP); e.g., 0.01 is the 100-year event.", + "Analysis run endpoints are synchronous: the response returns when the run completes.", + "Resources are held in memory only and are lost when the server restarts." + } + }, _logger, "metadata.defaults"); + } + } +} diff --git a/src/RMC.BestFit.Api/Controllers/MixtureAnalysisController.cs b/src/RMC.BestFit.Api/Controllers/MixtureAnalysisController.cs new file mode 100644 index 0000000..74bbf43 --- /dev/null +++ b/src/RMC.BestFit.Api/Controllers/MixtureAnalysisController.cs @@ -0,0 +1,171 @@ +using Microsoft.AspNetCore.Mvc; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; + +namespace RMC.BestFit.Api.Controllers +{ + /// + /// Endpoints for Bayesian MCMC mixture-distribution frequency analyses: create against an + /// input-data resource with 1-3 component distributions, run synchronously, and retrieve + /// frequency curves with full posterior uncertainty. + /// + [Route("api/analyses/mixture")] + public class MixtureAnalysisController : ApiControllerBase + { + /// + /// The controller's logger. + /// + private readonly ILogger _logger; + + /// + /// The analysis service shared with the MCP tools. + /// + private readonly IAnalysisService _service; + + /// + /// Constructs the controller. + /// + /// The controller's logger. + /// The analysis service. + /// Thrown when a dependency is null. + public MixtureAnalysisController(ILogger logger, IAnalysisService service) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + /// + /// Creates a mixture-distribution frequency analysis linked to an input-data resource. + /// The analysis clones the input data, so later edits or deletes of the input-data + /// resource cannot affect it. + /// + /// The creation request. + /// The created analysis summary, including its id for run/results calls. + /// The analysis was created. + /// The component distributions, ordinates, priors, or Bayesian options are invalid. + /// The input-data resource does not exist. + [HttpPost] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status404NotFound)] + public Task> Create([FromBody] CreateMixtureAnalysisRequest request) + { + return ExecuteAsync(() => AnalysisMapper.ToResourceResponse(_service.CreateMixture(request)), + _logger, "analyses.mixture.create", StatusCodes.Status201Created); + } + + /// + /// Runs the analysis synchronously (Bayesian MCMC with expectation-maximization + /// initialization; typically seconds to a minute) and returns the frequency results. + /// Disconnecting cancels the run. + /// + /// The analysis id. + /// Cancellation token (client disconnect aborts the estimation). + /// The frequency results. + /// The run completed; results returned. + /// The configuration failed validation; see validationErrors. + /// No mixture analysis has the id. + /// The analysis is already running. + /// The run was cancelled by the client. + /// The estimation failed; see errorMessage. + [HttpPost("{id:guid}/run")] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status500InternalServerError)] + public Task> Run(Guid id, CancellationToken cancellationToken) + { + return ExecuteAsync(() => _service.RunFrequencyAsync(id, AnalysisKind.Mixture, cancellationToken), + _logger, "analyses.mixture.run"); + } + + /// + /// Lists all mixture analyses. + /// + /// Summaries of every mixture analysis. + /// The listing. + [HttpGet] + [ProducesResponseType(typeof(AnalysisListResponse), StatusCodes.Status200OK)] + public Task> List() + { + return ExecuteAsync(() => AnalysisMapper.ToListResponse(_service.List(AnalysisKind.Mixture)), + _logger, "analyses.mixture.list"); + } + + /// + /// Returns the analysis configuration, validity, and run state. + /// + /// The analysis id. + /// The analysis summary. + /// The analysis. + /// No mixture analysis has the id. + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status404NotFound)] + public Task> Get(Guid id) + { + return ExecuteAsync(() => AnalysisMapper.ToResourceResponse(_service.Get(id, AnalysisKind.Mixture)), + _logger, "analyses.mixture.get"); + } + + /// + /// Returns the stored frequency results of a previously completed run (no recomputation). + /// + /// The analysis id. + /// The frequency results. + /// The results. + /// No mixture analysis has the id, or it has never been run. + [HttpGet("{id:guid}/results")] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status404NotFound)] + public Task> GetResults(Guid id) + { + return ExecuteAsync(() => _service.GetFrequencyResults(id, AnalysisKind.Mixture), + _logger, "analyses.mixture.results"); + } + + /// + /// Runs model-layer validation for the analysis without running it. + /// + /// The analysis id. + /// The validation verdict. + /// The verdict (check isValid). + /// No mixture analysis has the id. + [HttpGet("{id:guid}/validate")] + [ProducesResponseType(typeof(ValidationResponse), StatusCodes.Status200OK)] + public ActionResult Validate(Guid id) + { + try + { + return Ok(_service.Validate(id, AnalysisKind.Mixture)); + } + catch (Services.Exceptions.ResourceNotFoundException ex) + { + return NotFound(new ValidationResponse { IsValid = false, Errors = new List { ex.Message } }); + } + } + + /// + /// Deletes the analysis and its stored results. + /// + /// The analysis id. + /// Confirmation of the deletion. + /// The analysis was deleted. + /// No mixture analysis has the id. + /// The analysis is currently running. + [HttpDelete("{id:guid}")] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status404NotFound)] + public Task> Delete(Guid id) + { + return ExecuteAsync(() => + { + _service.Delete(id, AnalysisKind.Mixture); + return new DeleteResourceResponse { DeletedId = id, ResourceType = "analysis" }; + }, _logger, "analyses.mixture.delete"); + } + } +} diff --git a/src/RMC.BestFit.Api/Controllers/PointProcessAnalysisController.cs b/src/RMC.BestFit.Api/Controllers/PointProcessAnalysisController.cs new file mode 100644 index 0000000..2cf27df --- /dev/null +++ b/src/RMC.BestFit.Api/Controllers/PointProcessAnalysisController.cs @@ -0,0 +1,171 @@ +using Microsoft.AspNetCore.Mvc; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; + +namespace RMC.BestFit.Api.Controllers +{ + /// + /// Endpoints for Bayesian MCMC peaks-over-threshold point process analyses: create against a + /// POT input-data resource, run synchronously, and retrieve annual-exceedance frequency + /// curves with full posterior uncertainty. + /// + [Route("api/analyses/pointprocess")] + public class PointProcessAnalysisController : ApiControllerBase + { + /// + /// The controller's logger. + /// + private readonly ILogger _logger; + + /// + /// The analysis service shared with the MCP tools. + /// + private readonly IAnalysisService _service; + + /// + /// Constructs the controller. + /// + /// The controller's logger. + /// The analysis service. + /// Thrown when a dependency is null. + public PointProcessAnalysisController(ILogger logger, IAnalysisService service) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + /// + /// Creates a point process analysis linked to a peaks-over-threshold input-data resource. + /// The analysis clones the input data; the POT resource's recorded threshold seeds the + /// model unless overridden. + /// + /// The creation request. + /// The created analysis summary, including its id for run/results calls. + /// The analysis was created. + /// The threshold, seasonality, ordinates, priors, or Bayesian options are invalid. + /// The input-data resource does not exist. + [HttpPost] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status404NotFound)] + public Task> Create([FromBody] CreatePointProcessAnalysisRequest request) + { + return ExecuteAsync(() => AnalysisMapper.ToResourceResponse(_service.CreatePointProcess(request)), + _logger, "analyses.pointprocess.create", StatusCodes.Status201Created); + } + + /// + /// Runs the analysis synchronously (Bayesian MCMC over the point process likelihood; + /// typically seconds to a minute) and returns the frequency results. Disconnecting + /// cancels the run. + /// + /// The analysis id. + /// Cancellation token (client disconnect aborts the estimation). + /// The frequency results. + /// The run completed; results returned. + /// The configuration failed validation; see validationErrors. + /// No point process analysis has the id. + /// The analysis is already running. + /// The run was cancelled by the client. + /// The estimation failed; see errorMessage. + [HttpPost("{id:guid}/run")] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status500InternalServerError)] + public Task> Run(Guid id, CancellationToken cancellationToken) + { + return ExecuteAsync(() => _service.RunFrequencyAsync(id, AnalysisKind.PointProcess, cancellationToken), + _logger, "analyses.pointprocess.run"); + } + + /// + /// Lists all point process analyses. + /// + /// Summaries of every point process analysis. + /// The listing. + [HttpGet] + [ProducesResponseType(typeof(AnalysisListResponse), StatusCodes.Status200OK)] + public Task> List() + { + return ExecuteAsync(() => AnalysisMapper.ToListResponse(_service.List(AnalysisKind.PointProcess)), + _logger, "analyses.pointprocess.list"); + } + + /// + /// Returns the analysis configuration, validity, and run state. + /// + /// The analysis id. + /// The analysis summary. + /// The analysis. + /// No point process analysis has the id. + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status404NotFound)] + public Task> Get(Guid id) + { + return ExecuteAsync(() => AnalysisMapper.ToResourceResponse(_service.Get(id, AnalysisKind.PointProcess)), + _logger, "analyses.pointprocess.get"); + } + + /// + /// Returns the stored frequency results of a previously completed run (no recomputation). + /// + /// The analysis id. + /// The frequency results. + /// The results. + /// No point process analysis has the id, or it has never been run. + [HttpGet("{id:guid}/results")] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status404NotFound)] + public Task> GetResults(Guid id) + { + return ExecuteAsync(() => _service.GetFrequencyResults(id, AnalysisKind.PointProcess), + _logger, "analyses.pointprocess.results"); + } + + /// + /// Runs model-layer validation for the analysis without running it. + /// + /// The analysis id. + /// The validation verdict. + /// The verdict (check isValid). + /// No point process analysis has the id. + [HttpGet("{id:guid}/validate")] + [ProducesResponseType(typeof(ValidationResponse), StatusCodes.Status200OK)] + public ActionResult Validate(Guid id) + { + try + { + return Ok(_service.Validate(id, AnalysisKind.PointProcess)); + } + catch (Services.Exceptions.ResourceNotFoundException ex) + { + return NotFound(new ValidationResponse { IsValid = false, Errors = new List { ex.Message } }); + } + } + + /// + /// Deletes the analysis and its stored results. + /// + /// The analysis id. + /// Confirmation of the deletion. + /// The analysis was deleted. + /// No point process analysis has the id. + /// The analysis is currently running. + [HttpDelete("{id:guid}")] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status404NotFound)] + public Task> Delete(Guid id) + { + return ExecuteAsync(() => + { + _service.Delete(id, AnalysisKind.PointProcess); + return new DeleteResourceResponse { DeletedId = id, ResourceType = "analysis" }; + }, _logger, "analyses.pointprocess.delete"); + } + } +} diff --git a/src/RMC.BestFit.Api/Controllers/RatingCurveAnalysisController.cs b/src/RMC.BestFit.Api/Controllers/RatingCurveAnalysisController.cs new file mode 100644 index 0000000..4084d00 --- /dev/null +++ b/src/RMC.BestFit.Api/Controllers/RatingCurveAnalysisController.cs @@ -0,0 +1,171 @@ +using Microsoft.AspNetCore.Mvc; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; + +namespace RMC.BestFit.Api.Controllers +{ + /// + /// Endpoints for Bayesian stage-discharge rating curve analyses: create against stage and + /// discharge measurement time series, run synchronously, and retrieve the fitted piecewise + /// power-law curve with credible intervals over a stage grid. + /// + [Route("api/analyses/ratingcurve")] + public class RatingCurveAnalysisController : ApiControllerBase + { + /// + /// The controller's logger. + /// + private readonly ILogger _logger; + + /// + /// The analysis service shared with the MCP tools. + /// + private readonly IAnalysisService _service; + + /// + /// Constructs the controller. + /// + /// The controller's logger. + /// The analysis service. + /// Thrown when a dependency is null. + public RatingCurveAnalysisController(ILogger logger, IAnalysisService service) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + /// + /// Creates a rating curve analysis linked to a stage and a discharge time-series resource + /// (typically USGS measuredStage and measuredDischarge for the same site). The two series + /// are date-aligned; at least 10 common dates are required. Series are cloned at creation. + /// + /// The creation request. + /// The created analysis summary. + /// The analysis was created. + /// The stage-grid options or Bayesian options are invalid. + /// A referenced time-series resource does not exist. + [HttpPost] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status404NotFound)] + public Task> Create([FromBody] CreateRatingCurveAnalysisRequest request) + { + return ExecuteAsync(() => AnalysisMapper.ToResourceResponse(_service.CreateRatingCurve(request)), + _logger, "analyses.ratingcurve.create", StatusCodes.Status201Created); + } + + /// + /// Runs the analysis synchronously (Bayesian MCMC; typically seconds to a minute) and + /// returns the fitted rating curve with uncertainty. + /// + /// The analysis id. + /// Cancellation token (client disconnect aborts the estimation). + /// The rating curve results. + /// The run completed; results returned. + /// The configuration failed validation (e.g., fewer than 10 aligned observations). + /// No rating curve analysis has the id. + /// The analysis is already running. + /// The run was cancelled by the client. + /// The estimation failed; see errorMessage. + [HttpPost("{id:guid}/run")] + [ProducesResponseType(typeof(RatingCurveResultsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(RatingCurveResultsResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(RatingCurveResultsResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(RatingCurveResultsResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(RatingCurveResultsResponse), StatusCodes.Status500InternalServerError)] + public Task> Run(Guid id, CancellationToken cancellationToken) + { + return ExecuteAsync(() => _service.RunRatingCurveAsync(id, cancellationToken), + _logger, "analyses.ratingcurve.run"); + } + + /// + /// Lists all rating curve analyses. + /// + /// Summaries of every rating curve analysis. + /// The listing. + [HttpGet] + [ProducesResponseType(typeof(AnalysisListResponse), StatusCodes.Status200OK)] + public Task> List() + { + return ExecuteAsync(() => AnalysisMapper.ToListResponse(_service.List(AnalysisKind.RatingCurve)), + _logger, "analyses.ratingcurve.list"); + } + + /// + /// Returns the analysis configuration, validity, and run state. + /// + /// The analysis id. + /// The analysis summary. + /// The analysis. + /// No rating curve analysis has the id. + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status404NotFound)] + public Task> Get(Guid id) + { + return ExecuteAsync(() => AnalysisMapper.ToResourceResponse(_service.Get(id, AnalysisKind.RatingCurve)), + _logger, "analyses.ratingcurve.get"); + } + + /// + /// Returns the stored results of a previously completed run (no recomputation). + /// + /// The analysis id. + /// The rating curve results. + /// The results. + /// No rating curve analysis has the id, or it has never been run. + [HttpGet("{id:guid}/results")] + [ProducesResponseType(typeof(RatingCurveResultsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(RatingCurveResultsResponse), StatusCodes.Status404NotFound)] + public Task> GetResults(Guid id) + { + return ExecuteAsync(() => _service.GetRatingCurveResults(id), + _logger, "analyses.ratingcurve.results"); + } + + /// + /// Runs model-layer validation for the analysis without running it (e.g., checks the + /// minimum of 10 date-aligned observation pairs). + /// + /// The analysis id. + /// The validation verdict. + /// The verdict (check isValid). + /// No rating curve analysis has the id. + [HttpGet("{id:guid}/validate")] + [ProducesResponseType(typeof(ValidationResponse), StatusCodes.Status200OK)] + public ActionResult Validate(Guid id) + { + try + { + return Ok(_service.Validate(id, AnalysisKind.RatingCurve)); + } + catch (Services.Exceptions.ResourceNotFoundException ex) + { + return NotFound(new ValidationResponse { IsValid = false, Errors = new List { ex.Message } }); + } + } + + /// + /// Deletes the analysis and its stored results. + /// + /// The analysis id. + /// Confirmation of the deletion. + /// The analysis was deleted. + /// No rating curve analysis has the id. + /// The analysis is currently running. + [HttpDelete("{id:guid}")] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status404NotFound)] + public Task> Delete(Guid id) + { + return ExecuteAsync(() => + { + _service.Delete(id, AnalysisKind.RatingCurve); + return new DeleteResourceResponse { DeletedId = id, ResourceType = "analysis" }; + }, _logger, "analyses.ratingcurve.delete"); + } + } +} diff --git a/src/RMC.BestFit.Api/Controllers/ResourcesController.cs b/src/RMC.BestFit.Api/Controllers/ResourcesController.cs new file mode 100644 index 0000000..77b0a07 --- /dev/null +++ b/src/RMC.BestFit.Api/Controllers/ResourcesController.cs @@ -0,0 +1,94 @@ +using Microsoft.AspNetCore.Mvc; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Helpers; +using RMC.BestFit.Api.Store; + +namespace RMC.BestFit.Api.Controllers +{ + /// + /// Cross-cutting resource overview so clients (and agents mid-session) can re-orient + /// themselves: every resource id, type, and a one-line detail. + /// + [Route("api/resources")] + public class ResourcesController : ApiControllerBase + { + /// + /// The controller's logger. + /// + private readonly ILogger _logger; + + /// + /// The in-memory resource store. + /// + private readonly IResourceStore _store; + + /// + /// Constructs the controller. + /// + /// The controller's logger. + /// The in-memory resource store. + /// Thrown when a dependency is null. + public ResourcesController(ILogger logger, IResourceStore store) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _store = store ?? throw new ArgumentNullException(nameof(store)); + } + + /// + /// Lists every resource currently held by the server (time series, input data, analyses) + /// with one-line summaries, ordered by creation time within each type. + /// + /// The resource overview. + /// The overview. + [HttpGet] + [ProducesResponseType(typeof(ResourcesOverviewResponse), StatusCodes.Status200OK)] + public Task> GetOverview() + { + return ExecuteAsync(() => + { + var response = new ResourcesOverviewResponse(); + + foreach (var resource in _store.ListTimeSeries()) + { + response.Resources.Add(new ResourceSummaryDto + { + Id = resource.Id, + ResourceType = "timeSeries", + Name = resource.Name, + CreatedUtc = resource.CreatedUtc, + Detail = $"{resource.PointCount} points, {resource.StartDate:yyyy-MM-dd} to {resource.EndDate:yyyy-MM-dd}" + }); + } + + foreach (var resource in _store.ListInputData()) + { + response.Resources.Add(new ResourceSummaryDto + { + Id = resource.Id, + ResourceType = "inputData", + Name = resource.Name, + CreatedUtc = resource.CreatedUtc, + Detail = $"{EnumHelper.ToCamelCase(resource.Method.ToString())}, {resource.DataFrame.ExactSeries.Count} exact observations" + }); + } + + foreach (var resource in _store.ListAnalyses()) + { + response.Resources.Add(new ResourceSummaryDto + { + Id = resource.Id, + ResourceType = "analysis", + Name = resource.Name, + CreatedUtc = resource.CreatedUtc, + Detail = $"{EnumHelper.ToCamelCase(resource.Kind.ToString())}, state {EnumHelper.ToCamelCase(resource.State.ToString())}" + }); + } + + response.TimeSeriesCount = _store.ListTimeSeries().Count; + response.InputDataCount = _store.ListInputData().Count; + response.AnalysisCount = _store.ListAnalyses().Count; + return response; + }, _logger, "resources.overview"); + } + } +} diff --git a/src/RMC.BestFit.Api/Controllers/TimeSeriesAnalysisController.cs b/src/RMC.BestFit.Api/Controllers/TimeSeriesAnalysisController.cs new file mode 100644 index 0000000..fb8df07 --- /dev/null +++ b/src/RMC.BestFit.Api/Controllers/TimeSeriesAnalysisController.cs @@ -0,0 +1,172 @@ +using Microsoft.AspNetCore.Mvc; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; + +namespace RMC.BestFit.Api.Controllers +{ + /// + /// Endpoints for Bayesian MCMC time-series analyses over stored time-series resources. One + /// route covers the AR, MA, ARIMA, and ARIMAX model families via the request's modelType + /// discriminator; fields that do not apply to the chosen family are rejected, never silently + /// ignored. + /// + [Route("api/analyses/timeseries")] + public class TimeSeriesAnalysisController : ApiControllerBase + { + /// + /// The controller's logger. + /// + private readonly ILogger _logger; + + /// + /// The analysis service shared with the MCP tools. + /// + private readonly IAnalysisService _service; + + /// + /// Constructs the controller. + /// + /// The controller's logger. + /// The analysis service. + /// Thrown when a dependency is null. + public TimeSeriesAnalysisController(ILogger logger, IAnalysisService service) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + /// + /// Creates a time-series analysis linked to a time-series resource. The analysis clones + /// the series (and any ARIMAX covariates), so later edits or deletes of the source + /// resources cannot affect it. + /// + /// The creation request. + /// The created analysis summary, including its id for run/results calls. + /// The analysis was created. + /// A field does not apply to the model type, or a value is out of range. + /// The time-series or a covariate resource does not exist. + [HttpPost] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status404NotFound)] + public Task> Create([FromBody] CreateTimeSeriesAnalysisRequest request) + { + return ExecuteAsync(() => AnalysisMapper.ToResourceResponse(_service.CreateTimeSeries(request)), + _logger, "analyses.timeseries.create", StatusCodes.Status201Created); + } + + /// + /// Runs the analysis synchronously (Bayesian MCMC; typically seconds to a minute) and + /// returns the fitted-plus-forecast results. Disconnecting cancels the run. + /// + /// The analysis id. + /// Cancellation token (client disconnect aborts the estimation). + /// The time-series results. + /// The run completed; results returned. + /// The configuration failed validation; see validationErrors. + /// No time-series analysis has the id. + /// The analysis is already running. + /// The run was cancelled by the client. + /// The estimation failed; see errorMessage. + [HttpPost("{id:guid}/run")] + [ProducesResponseType(typeof(TimeSeriesResultsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(TimeSeriesResultsResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(TimeSeriesResultsResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(TimeSeriesResultsResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(TimeSeriesResultsResponse), StatusCodes.Status500InternalServerError)] + public Task> Run(Guid id, CancellationToken cancellationToken) + { + return ExecuteAsync(() => _service.RunTimeSeriesAsync(id, cancellationToken), + _logger, "analyses.timeseries.run"); + } + + /// + /// Lists all time-series analyses (all four model families). + /// + /// Summaries of every time-series analysis. + /// The listing. + [HttpGet] + [ProducesResponseType(typeof(AnalysisListResponse), StatusCodes.Status200OK)] + public Task> List() + { + return ExecuteAsync(() => AnalysisMapper.ToListResponse(_service.List(AnalysisKind.TimeSeries)), + _logger, "analyses.timeseries.list"); + } + + /// + /// Returns the analysis configuration, validity, and run state. + /// + /// The analysis id. + /// The analysis summary. + /// The analysis. + /// No time-series analysis has the id. + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status404NotFound)] + public Task> Get(Guid id) + { + return ExecuteAsync(() => AnalysisMapper.ToResourceResponse(_service.Get(id, AnalysisKind.TimeSeries)), + _logger, "analyses.timeseries.get"); + } + + /// + /// Returns the stored fitted-plus-forecast results of a previously completed run (no + /// recomputation). + /// + /// The analysis id. + /// The time-series results. + /// The results. + /// No time-series analysis has the id, or it has never been run. + [HttpGet("{id:guid}/results")] + [ProducesResponseType(typeof(TimeSeriesResultsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(TimeSeriesResultsResponse), StatusCodes.Status404NotFound)] + public Task> GetResults(Guid id) + { + return ExecuteAsync(() => _service.GetTimeSeriesResults(id), + _logger, "analyses.timeseries.results"); + } + + /// + /// Runs model-layer validation for the analysis without running it. + /// + /// The analysis id. + /// The validation verdict. + /// The verdict (check isValid). + /// No time-series analysis has the id. + [HttpGet("{id:guid}/validate")] + [ProducesResponseType(typeof(ValidationResponse), StatusCodes.Status200OK)] + public ActionResult Validate(Guid id) + { + try + { + return Ok(_service.Validate(id, AnalysisKind.TimeSeries)); + } + catch (Services.Exceptions.ResourceNotFoundException ex) + { + return NotFound(new ValidationResponse { IsValid = false, Errors = new List { ex.Message } }); + } + } + + /// + /// Deletes the analysis and its stored results. + /// + /// The analysis id. + /// Confirmation of the deletion. + /// The analysis was deleted. + /// No time-series analysis has the id. + /// The analysis is currently running. + [HttpDelete("{id:guid}")] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status404NotFound)] + public Task> Delete(Guid id) + { + return ExecuteAsync(() => + { + _service.Delete(id, AnalysisKind.TimeSeries); + return new DeleteResourceResponse { DeletedId = id, ResourceType = "analysis" }; + }, _logger, "analyses.timeseries.delete"); + } + } +} diff --git a/src/RMC.BestFit.Api/Controllers/TimeSeriesController.cs b/src/RMC.BestFit.Api/Controllers/TimeSeriesController.cs new file mode 100644 index 0000000..046dbe2 --- /dev/null +++ b/src/RMC.BestFit.Api/Controllers/TimeSeriesController.cs @@ -0,0 +1,143 @@ +using Microsoft.AspNetCore.Mvc; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Services; + +namespace RMC.BestFit.Api.Controllers +{ + /// + /// Endpoints for creating, inspecting, and deleting time-series resources — the raw data + /// inputs for block-maxima extraction and rating curve analyses. + /// + [Route("api/timeseries")] + public class TimeSeriesController : ApiControllerBase + { + /// + /// The controller's logger. + /// + private readonly ILogger _logger; + + /// + /// The time-series service shared with the MCP tools. + /// + private readonly ITimeSeriesService _service; + + /// + /// Constructs the controller. + /// + /// The controller's logger. + /// The time-series service. + /// Thrown when a dependency is null. + public TimeSeriesController(ILogger logger, ITimeSeriesService service) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + /// + /// Downloads a series from the USGS water services and stores it as a resource. Use + /// dailyDischarge for block-maxima workflows and measuredStage/measuredDischarge pairs for + /// rating curves. + /// + /// The download request. + /// Cancellation token. + /// The created resource summary, including the id to reference in later requests. + /// The resource was created. + /// The site number or series type is invalid. + /// The site returned no data for the series type. + /// The USGS service returned an error. + /// The USGS service could not be reached. + [HttpPost("usgs")] + [ProducesResponseType(typeof(TimeSeriesResourceResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(TimeSeriesResourceResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(TimeSeriesResourceResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(TimeSeriesResourceResponse), StatusCodes.Status502BadGateway)] + public Task> CreateFromUsgs( + [FromBody] CreateUsgsTimeSeriesRequest request, + CancellationToken cancellationToken) + { + return ExecuteAsync(async () => + { + var resource = await _service.CreateFromUsgsAsync(request, cancellationToken); + return TimeSeriesMapper.ToResourceResponse(resource); + }, _logger, "timeseries.usgs", StatusCodes.Status201Created); + } + + /// + /// Builds a series from client-supplied points and stores it as a resource. + /// + /// The manual creation request. + /// The created resource summary. + /// The resource was created. + /// The request is invalid (e.g., no points). + [HttpPost("manual")] + [ProducesResponseType(typeof(TimeSeriesResourceResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(TimeSeriesResourceResponse), StatusCodes.Status400BadRequest)] + public Task> CreateManual([FromBody] CreateManualTimeSeriesRequest request) + { + return ExecuteAsync(() => + { + var resource = _service.CreateManual(request); + return TimeSeriesMapper.ToResourceResponse(resource); + }, _logger, "timeseries.manual", StatusCodes.Status201Created); + } + + /// + /// Lists all time-series resources currently held by the server. + /// + /// Summaries of every time-series resource. + /// The listing. + [HttpGet] + [ProducesResponseType(typeof(TimeSeriesListResponse), StatusCodes.Status200OK)] + public Task> List() + { + return ExecuteAsync(() => TimeSeriesMapper.ToListResponse(_service.List()), _logger, "timeseries.list"); + } + + /// + /// Returns a time-series resource summary, optionally with a page of ordinates. + /// + /// The resource id. + /// True to include ordinate values (paged). + /// Zero-based index of the first point to include. Default 0. + /// Maximum number of points to include. Default 10,000. + /// The resource summary and optional points page. + /// The resource. + /// No resource has the id. + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(TimeSeriesResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(TimeSeriesResourceResponse), StatusCodes.Status404NotFound)] + public Task> Get( + Guid id, + [FromQuery] bool includePoints = false, + [FromQuery] int offset = 0, + [FromQuery] int limit = 10000) + { + return ExecuteAsync(() => + { + var resource = _service.Get(id); + return TimeSeriesMapper.ToResourceResponse(resource, includePoints, offset, limit); + }, _logger, "timeseries.get"); + } + + /// + /// Deletes a time-series resource. Analyses already created from it are unaffected + /// (they hold their own copies of the data). + /// + /// The resource id. + /// Confirmation of the deletion. + /// The resource was deleted. + /// No resource has the id. + [HttpDelete("{id:guid}")] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status404NotFound)] + public Task> Delete(Guid id) + { + return ExecuteAsync(() => + { + _service.Delete(id); + return new DeleteResourceResponse { DeletedId = id, ResourceType = "timeSeries" }; + }, _logger, "timeseries.delete"); + } + } +} diff --git a/src/RMC.BestFit.Api/Controllers/UnivariateAnalysisController.cs b/src/RMC.BestFit.Api/Controllers/UnivariateAnalysisController.cs new file mode 100644 index 0000000..5c4a413 --- /dev/null +++ b/src/RMC.BestFit.Api/Controllers/UnivariateAnalysisController.cs @@ -0,0 +1,169 @@ +using Microsoft.AspNetCore.Mvc; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; + +namespace RMC.BestFit.Api.Controllers +{ + /// + /// Endpoints for Bayesian MCMC univariate frequency analyses: create against an input-data + /// resource, run synchronously, and retrieve frequency curves with full posterior uncertainty. + /// + [Route("api/analyses/univariate")] + public class UnivariateAnalysisController : ApiControllerBase + { + /// + /// The controller's logger. + /// + private readonly ILogger _logger; + + /// + /// The analysis service shared with the MCP tools. + /// + private readonly IAnalysisService _service; + + /// + /// Constructs the controller. + /// + /// The controller's logger. + /// The analysis service. + /// Thrown when a dependency is null. + public UnivariateAnalysisController(ILogger logger, IAnalysisService service) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + /// + /// Creates a univariate frequency analysis linked to an input-data resource. The analysis + /// clones the input data, so later edits or deletes of the input-data resource cannot + /// affect it. Creation validates the configuration and reports messages without rejecting. + /// + /// The creation request. + /// The created analysis summary, including its id for run/results calls. + /// The analysis was created. + /// The distribution, ordinates, or Bayesian options are invalid. + /// The input-data resource does not exist. + [HttpPost] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status404NotFound)] + public Task> Create([FromBody] CreateUnivariateAnalysisRequest request) + { + return ExecuteAsync(() => AnalysisMapper.ToResourceResponse(_service.CreateUnivariate(request)), + _logger, "analyses.univariate.create", StatusCodes.Status201Created); + } + + /// + /// Runs the analysis synchronously (Bayesian MCMC; typically seconds to a minute) and + /// returns the frequency results. Disconnecting cancels the run. + /// + /// The analysis id. + /// Cancellation token (client disconnect aborts the estimation). + /// The frequency results. + /// The run completed; results returned. + /// The configuration failed validation; see validationErrors. + /// No univariate analysis has the id. + /// The analysis is already running. + /// The run was cancelled by the client. + /// The estimation failed; see errorMessage. + [HttpPost("{id:guid}/run")] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status500InternalServerError)] + public Task> Run(Guid id, CancellationToken cancellationToken) + { + return ExecuteAsync(() => _service.RunFrequencyAsync(id, AnalysisKind.Univariate, cancellationToken), + _logger, "analyses.univariate.run"); + } + + /// + /// Lists all univariate analyses. + /// + /// Summaries of every univariate analysis. + /// The listing. + [HttpGet] + [ProducesResponseType(typeof(AnalysisListResponse), StatusCodes.Status200OK)] + public Task> List() + { + return ExecuteAsync(() => AnalysisMapper.ToListResponse(_service.List(AnalysisKind.Univariate)), + _logger, "analyses.univariate.list"); + } + + /// + /// Returns the analysis configuration, validity, and run state. + /// + /// The analysis id. + /// The analysis summary. + /// The analysis. + /// No univariate analysis has the id. + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(AnalysisResourceResponse), StatusCodes.Status404NotFound)] + public Task> Get(Guid id) + { + return ExecuteAsync(() => AnalysisMapper.ToResourceResponse(_service.Get(id, AnalysisKind.Univariate)), + _logger, "analyses.univariate.get"); + } + + /// + /// Returns the stored frequency results of a previously completed run (no recomputation). + /// + /// The analysis id. + /// The frequency results. + /// The results. + /// No univariate analysis has the id, or it has never been run. + [HttpGet("{id:guid}/results")] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(FrequencyResultsResponse), StatusCodes.Status404NotFound)] + public Task> GetResults(Guid id) + { + return ExecuteAsync(() => _service.GetFrequencyResults(id, AnalysisKind.Univariate), + _logger, "analyses.univariate.results"); + } + + /// + /// Runs model-layer validation for the analysis without running it. + /// + /// The analysis id. + /// The validation verdict. + /// The verdict (check isValid). + /// No univariate analysis has the id. + [HttpGet("{id:guid}/validate")] + [ProducesResponseType(typeof(ValidationResponse), StatusCodes.Status200OK)] + public ActionResult Validate(Guid id) + { + try + { + return Ok(_service.Validate(id, AnalysisKind.Univariate)); + } + catch (Services.Exceptions.ResourceNotFoundException ex) + { + return NotFound(new ValidationResponse { IsValid = false, Errors = new List { ex.Message } }); + } + } + + /// + /// Deletes the analysis and its stored results. + /// + /// The analysis id. + /// Confirmation of the deletion. + /// The analysis was deleted. + /// No univariate analysis has the id. + /// The analysis is currently running. + [HttpDelete("{id:guid}")] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(DeleteResourceResponse), StatusCodes.Status404NotFound)] + public Task> Delete(Guid id) + { + return ExecuteAsync(() => + { + _service.Delete(id, AnalysisKind.Univariate); + return new DeleteResourceResponse { DeletedId = id, ResourceType = "analysis" }; + }, _logger, "analyses.univariate.delete"); + } + } +} diff --git a/src/RMC.BestFit.Api/Controllers/WorkflowController.cs b/src/RMC.BestFit.Api/Controllers/WorkflowController.cs new file mode 100644 index 0000000..e7e0f36 --- /dev/null +++ b/src/RMC.BestFit.Api/Controllers/WorkflowController.cs @@ -0,0 +1,115 @@ +using Microsoft.AspNetCore.Mvc; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Services; + +namespace RMC.BestFit.Api.Controllers +{ + /// + /// One-shot USGS workflow endpoints: each call downloads the data, builds the intermediate + /// resources, runs the analysis synchronously, and returns the results together with every + /// created resource id. Step failures are reported in-body (success=false, failedStep) so the + /// ids of already-created resources are preserved for manual resumption. + /// + [Route("api/workflows")] + public class WorkflowController : ApiControllerBase + { + /// + /// The controller's logger. + /// + private readonly ILogger _logger; + + /// + /// The workflow service composing the resource services. + /// + private readonly IWorkflowService _service; + + /// + /// Constructs the controller. + /// + /// The controller's logger. + /// The workflow service. + /// Thrown when a dependency is null. + public WorkflowController(ILogger logger, IWorkflowService service) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + /// + /// Downloads USGS annual peaks, builds input data, runs a univariate Bayesian frequency + /// analysis, and returns the frequency curve — all in one call. + /// + /// The workflow request. + /// Cancellation token (client disconnect aborts the run). + /// The workflow response with resource ids and results. + /// The workflow finished; check success/failedStep for step failures. + /// The workflow was cancelled by the client. + [HttpPost("usgs-peak-frequency")] + [ProducesResponseType(typeof(UsgsFrequencyWorkflowResponse), StatusCodes.Status200OK)] + public Task> RunUsgsPeakFrequency( + [FromBody] UsgsPeakFrequencyWorkflowRequest request, + CancellationToken cancellationToken) + { + return ExecuteAsync(() => _service.RunUsgsPeakFrequencyAsync(request, cancellationToken), + _logger, "workflows.usgspeakfrequency"); + } + + /// + /// Downloads USGS daily flow, extracts block maxima (water-year annual maxima by default), + /// runs a univariate Bayesian frequency analysis, and returns the frequency curve. + /// + /// The workflow request. + /// Cancellation token (client disconnect aborts the run). + /// The workflow response with resource ids and results. + /// The workflow finished; check success/failedStep for step failures. + /// The workflow was cancelled by the client. + [HttpPost("usgs-daily-block-max-frequency")] + [ProducesResponseType(typeof(UsgsFrequencyWorkflowResponse), StatusCodes.Status200OK)] + public Task> RunUsgsBlockMaxFrequency( + [FromBody] UsgsBlockMaxFrequencyWorkflowRequest request, + CancellationToken cancellationToken) + { + return ExecuteAsync(() => _service.RunUsgsBlockMaxFrequencyAsync(request, cancellationToken), + _logger, "workflows.usgsblockmaxfrequency"); + } + + /// + /// Downloads USGS annual peaks, builds input data, runs a Bulletin 17C analysis, and + /// returns the frequency curve with confidence intervals. + /// + /// The workflow request. + /// Cancellation token (client disconnect aborts the run). + /// The workflow response with resource ids and results. + /// The workflow finished; check success/failedStep for step failures. + /// The workflow was cancelled by the client. + [HttpPost("usgs-bulletin17c")] + [ProducesResponseType(typeof(UsgsFrequencyWorkflowResponse), StatusCodes.Status200OK)] + public Task> RunUsgsBulletin17C( + [FromBody] UsgsBulletin17CWorkflowRequest request, + CancellationToken cancellationToken) + { + return ExecuteAsync(() => _service.RunUsgsBulletin17CAsync(request, cancellationToken), + _logger, "workflows.usgsbulletin17c"); + } + + /// + /// Downloads the discrete USGS field measurements of stage and discharge for a site, runs + /// a Bayesian rating curve analysis over the date-aligned pairs, and returns the fitted + /// curve with credible intervals. + /// + /// The workflow request. + /// Cancellation token (client disconnect aborts the run). + /// The workflow response with resource ids and results. + /// The workflow finished; check success/failedStep for step failures. + /// The workflow was cancelled by the client. + [HttpPost("usgs-rating-curve")] + [ProducesResponseType(typeof(UsgsRatingCurveWorkflowResponse), StatusCodes.Status200OK)] + public Task> RunUsgsRatingCurve( + [FromBody] UsgsRatingCurveWorkflowRequest request, + CancellationToken cancellationToken) + { + return ExecuteAsync(() => _service.RunUsgsRatingCurveAsync(request, cancellationToken), + _logger, "workflows.usgsratingcurve"); + } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/AnalysisListResponse.cs b/src/RMC.BestFit.Api/DTOs/Analyses/AnalysisListResponse.cs new file mode 100644 index 0000000..05dddac --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/AnalysisListResponse.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Response body listing analysis resources currently held by the server. + /// + public class AnalysisListResponse : ResponseBase + { + /// + /// The number of listed analyses. + /// + [JsonPropertyName("count")] + public int Count { get; set; } + + /// + /// Summaries of the listed analyses, ordered by creation time. + /// + [JsonPropertyName("analyses")] + public List Analyses { get; set; } = new(); + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/AnalysisResourceResponse.cs b/src/RMC.BestFit.Api/DTOs/Analyses/AnalysisResourceResponse.cs new file mode 100644 index 0000000..90a874f --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/AnalysisResourceResponse.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Response body carrying a single analysis resource summary. + /// + public class AnalysisResourceResponse : ResponseBase + { + /// + /// The analysis resource summary. + /// + [JsonPropertyName("analysis")] + public AnalysisSummaryDto? Analysis { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/AnalysisSummaryDto.cs b/src/RMC.BestFit.Api/DTOs/Analyses/AnalysisSummaryDto.cs new file mode 100644 index 0000000..d87de7e --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/AnalysisSummaryDto.cs @@ -0,0 +1,263 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Summary of an analysis resource: identity, kind, configuration highlights, provenance, + /// current validity, and run state. + /// + public class AnalysisSummaryDto + { + /// + /// The resource id used to run the analysis and fetch its results. + /// + [JsonPropertyName("id")] + public Guid Id { get; set; } + + /// + /// The resource display name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// The resource description, if any. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// UTC timestamp at which the resource was created. + /// + [JsonPropertyName("createdUtc")] + public DateTime CreatedUtc { get; set; } + + /// + /// The analysis kind ("univariate", "bulletin17C", "ratingCurve"). + /// + [JsonPropertyName("kind")] + public string? Kind { get; set; } + + /// + /// The run state ("created", "running", "succeeded", "failed", "cancelled"). + /// + [JsonPropertyName("state")] + public string? State { get; set; } + + /// + /// True when the configuration currently passes model-layer validation. + /// + [JsonPropertyName("isValid")] + public bool IsValid { get; set; } + + /// + /// The validation messages when invalid; null when valid. + /// + [JsonPropertyName("validationMessages")] + public List? ValidationMessages { get; set; } + + /// + /// Non-fatal warnings recorded when the analysis was created (e.g., a Bulletin 17C + /// analysis over input data with uncertain observations, which its algorithm ignores); + /// null when there are none. + /// + [JsonPropertyName("warnings")] + public List? Warnings { get; set; } + + /// + /// The fitted distribution ("logPearsonTypeIII", ...) for univariate and Bulletin 17C + /// analyses; null for rating curves. + /// + [JsonPropertyName("distribution")] + public string? Distribution { get; set; } + + /// + /// The uncertainty method for Bulletin 17C analyses; null for other kinds. + /// + [JsonPropertyName("uncertaintyMethod")] + public string? UncertaintyMethod { get; set; } + + /// + /// The number of rating curve segments; null for other kinds. + /// + [JsonPropertyName("numberOfSegments")] + public int? NumberOfSegments { get; set; } + + /// + /// The component distribution types for mixture and competing risks analyses; null for + /// other kinds. + /// + [JsonPropertyName("componentDistributions")] + public List? ComponentDistributions { get; set; } + + /// + /// True when a mixture analysis models a point mass at zero; null for other kinds. + /// + [JsonPropertyName("isZeroInflated")] + public bool? IsZeroInflated { get; set; } + + /// + /// True when a point process analysis models seasonal event rates; null for other kinds. + /// + [JsonPropertyName("isSeasonal")] + public bool? IsSeasonal { get; set; } + + /// + /// The peaks-over-threshold threshold of a point process analysis; null for other kinds + /// or when not yet derived. + /// + [JsonPropertyName("threshold")] + public double? Threshold { get; set; } + + /// + /// The record span in years of a point process analysis; null for other kinds or when + /// not yet derived. + /// + [JsonPropertyName("totalYears")] + public double? TotalYears { get; set; } + + /// + /// The event rate λ (events per year) of a point process analysis; null for other kinds + /// or when not yet derived. + /// + [JsonPropertyName("lambda")] + public double? Lambda { get; set; } + + /// + /// The composition method of a composite analysis ("competingRisks", "mixture", + /// "modelAverage"); null for other kinds. + /// + [JsonPropertyName("compositeType")] + public string? CompositeType { get; set; } + + /// + /// The model-average weighting method of a composite analysis; null for other kinds or + /// composition methods. + /// + [JsonPropertyName("averageMethod")] + public string? AverageMethod { get; set; } + + /// + /// The competing risks dependence assumption of a composite analysis; null for other + /// kinds or composition methods. + /// + [JsonPropertyName("dependency")] + public string? Dependency { get; set; } + + /// + /// True when a competing risks composite combines as the maximum of its components; null + /// for other kinds or composition methods. + /// + [JsonPropertyName("isMaximum")] + public bool? IsMaximum { get; set; } + + /// + /// The ids of a composite analysis's component analyses, in composite order (provenance — + /// a referenced resource may since have been deleted); null for other kinds. + /// + [JsonPropertyName("componentAnalysisIds")] + public List? ComponentAnalysisIds { get; set; } + + /// + /// The copula family of a bivariate analysis; null for other kinds. + /// + [JsonPropertyName("copulaType")] + public string? CopulaType { get; set; } + + /// + /// The copula estimation method of a bivariate analysis; null for other kinds. + /// + [JsonPropertyName("copulaEstimationMethod")] + public string? CopulaEstimationMethod { get; set; } + + /// + /// The id of the marginal-X analysis of a bivariate analysis (provenance); null for other kinds. + /// + [JsonPropertyName("marginalXAnalysisId")] + public Guid? MarginalXAnalysisId { get; set; } + + /// + /// The id of the marginal-Y analysis of a bivariate analysis (provenance); null for other kinds. + /// + [JsonPropertyName("marginalYAnalysisId")] + public Guid? MarginalYAnalysisId { get; set; } + + /// + /// The id of the upstream bivariate analysis of a coincident frequency analysis + /// (provenance); null for other kinds. + /// + [JsonPropertyName("bivariateAnalysisId")] + public Guid? BivariateAnalysisId { get; set; } + + /// + /// The number of response-magnitude output bins of a coincident frequency analysis; null + /// for other kinds. + /// + [JsonPropertyName("numberOfBins")] + public int? NumberOfBins { get; set; } + + /// + /// The model family of a time-series analysis ("ar", "ma", "arima", "arimax"); null for + /// other kinds. + /// + [JsonPropertyName("timeSeriesModelType")] + public string? TimeSeriesModelType { get; set; } + + /// + /// The id of the source time-series resource of a time-series analysis (provenance); + /// null for other kinds. + /// + [JsonPropertyName("timeSeriesId")] + public Guid? TimeSeriesId { get; set; } + + /// + /// The ids of an ARIMAX analysis's covariate time-series resources (provenance); null + /// for other kinds or model types. + /// + [JsonPropertyName("covariateTimeSeriesIds")] + public List? CovariateTimeSeriesIds { get; set; } + + /// + /// The forecast horizon in time steps of a time-series analysis; null for other kinds. + /// + [JsonPropertyName("forecastingTimeSteps")] + public int? ForecastingTimeSteps { get; set; } + + /// + /// The id of the input-data resource the analysis was created from (univariate and + /// Bulletin 17C). May reference a since-deleted resource. + /// + [JsonPropertyName("inputDataId")] + public Guid? InputDataId { get; set; } + + /// + /// The id of the stage time-series resource (rating curves only). + /// + [JsonPropertyName("stageTimeSeriesId")] + public Guid? StageTimeSeriesId { get; set; } + + /// + /// The id of the discharge time-series resource (rating curves only). + /// + [JsonPropertyName("dischargeTimeSeriesId")] + public Guid? DischargeTimeSeriesId { get; set; } + + /// + /// UTC timestamp of the most recent run, when the analysis has been run. + /// + [JsonPropertyName("lastRunUtc")] + public DateTime? LastRunUtc { get; set; } + + /// + /// Wall-clock duration of the most recent run in milliseconds. + /// + [JsonPropertyName("lastRunMs")] + public long? LastRunMs { get; set; } + + /// + /// The error message of the most recent failed run; null otherwise. + /// + [JsonPropertyName("lastError")] + public string? LastError { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/BayesianOptionsDto.cs b/src/RMC.BestFit.Api/DTOs/Analyses/BayesianOptionsDto.cs new file mode 100644 index 0000000..13bc10e --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/BayesianOptionsDto.cs @@ -0,0 +1,83 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using RMC.BestFit.Estimation; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Optional Bayesian MCMC settings for analysis creation. Every field is nullable: only + /// supplied values are applied, so omitted fields keep the model library's defaults (which + /// scale the simulation to the data). Supplying any sampler/iteration field switches the + /// analysis off its automatic simulation defaults. + /// + public class BayesianOptionsDto + { + /// + /// The MCMC sampler: "demCz", "demCzs" (default, differential evolution with snooker + /// update — the recommended workhorse), "arwmh" (adaptive random walk Metropolis-Hastings), + /// or "nuts" (No-U-Turn Sampler). + /// + [JsonPropertyName("sampler")] + public BayesianAnalysis.SamplerType? Sampler { get; set; } + + /// + /// Total MCMC iterations per chain. Leave null for data-scaled defaults. Larger values + /// improve convergence at proportional runtime cost; capped by the server's maxIterations + /// (see GET api/metadata/defaults). + /// + [Range(100, int.MaxValue)] + [JsonPropertyName("iterations")] + public int? Iterations { get; set; } + + /// + /// Warm-up (burn-in) iterations discarded from the start of each chain. Typically half of + /// iterations. Leave null for defaults. + /// + [Range(0, int.MaxValue)] + [JsonPropertyName("warmupIterations")] + public int? WarmupIterations { get; set; } + + /// + /// Keep every n-th post-warm-up sample to reduce autocorrelation. Leave null for defaults. + /// + [Range(1, int.MaxValue)] + [JsonPropertyName("thinningInterval")] + public int? ThinningInterval { get; set; } + + /// + /// Number of parallel MCMC chains (used for R-hat convergence diagnostics). Leave null for defaults. + /// + [Range(1, 8)] + [JsonPropertyName("numberOfChains")] + public int? NumberOfChains { get; set; } + + /// + /// Pseudo-random number generator seed for reproducible runs. Leave null for the model default. + /// + [JsonPropertyName("prngSeed")] + public int? PrngSeed { get; set; } + + /// + /// Width of the reported credible intervals (e.g., 0.90 for 90% intervals). Leave null for + /// the model default of 0.90. + /// + [Range(0.01, 0.999)] + [JsonPropertyName("credibleIntervalWidth")] + public double? CredibleIntervalWidth { get; set; } + + /// + /// Number of thinned posterior samples retained for post-processing. Leave null for the + /// model default (10,000). + /// + [Range(100, int.MaxValue)] + [JsonPropertyName("outputLength")] + public int? OutputLength { get; set; } + + /// + /// The point estimator used for the reported curve: "posteriorMode" (MAP) or + /// "posteriorMean". Leave null for the model default. + /// + [JsonPropertyName("pointEstimator")] + public BayesianAnalysis.PointEstimateType? PointEstimator { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/BivariateResultsResponse.cs b/src/RMC.BestFit.Api/DTOs/Analyses/BivariateResultsResponse.cs new file mode 100644 index 0000000..a3e4e16 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/BivariateResultsResponse.cs @@ -0,0 +1,159 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Response body carrying bivariate copula results: the fitted copula with posterior + /// summaries, the two marginal identities, and the joint-exceedance curve over the + /// configured (x, y) grid. + /// + public class BivariateResultsResponse : ResponseBase + { + /// + /// The id of the analysis the results belong to. + /// + [JsonPropertyName("analysisId")] + public Guid AnalysisId { get; set; } + + /// + /// The analysis kind ("bivariate"). + /// + [JsonPropertyName("kind")] + public string? Kind { get; set; } + + /// + /// The copula family ("normal", "gumbel", ...). + /// + [JsonPropertyName("copulaType")] + public string? CopulaType { get; set; } + + /// + /// The copula estimation method ("inferenceFromMargins" or "pseudoLikelihood"). + /// + [JsonPropertyName("estimationMethod")] + public string? EstimationMethod { get; set; } + + /// + /// The marginal-X analysis identity. + /// + [JsonPropertyName("marginalX")] + public MarginalInfoDto? MarginalX { get; set; } + + /// + /// The marginal-Y analysis identity. + /// + [JsonPropertyName("marginalY")] + public MarginalInfoDto? MarginalY { get; set; } + + /// + /// The copula parameter point estimates. + /// + [JsonPropertyName("parameters")] + public List Parameters { get; set; } = new(); + + /// + /// Posterior summaries per copula parameter, including convergence diagnostics. + /// + [JsonPropertyName("parameterSummaries")] + public List ParameterSummaries { get; set; } = new(); + + /// + /// The joint-exceedance curve over the configured (x, y) grid. + /// + [JsonPropertyName("jointExceedance")] + public JointExceedanceCurveDto? JointExceedance { get; set; } + + /// + /// Model-fit information criteria for the copula fit. + /// + [JsonPropertyName("informationCriteria")] + public InformationCriteriaDto? InformationCriteria { get; set; } + + /// + /// Run diagnostics (sampler settings, acceptance rates, convergence warnings, timing). + /// + [JsonPropertyName("diagnostics")] + public DiagnosticsDto? Diagnostics { get; set; } + } + + /// + /// The identity of one marginal analysis within a bivariate results response. Kept in this + /// file as a minor supporting DTO tightly coupled to . + /// + public class MarginalInfoDto + { + /// + /// The marginal analysis resource id (provenance — the resource may since have been deleted). + /// + [JsonPropertyName("analysisId")] + public Guid? AnalysisId { get; set; } + + /// + /// The marginal analysis display name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// The marginal analysis kind ("univariate", "bulletin17C", "mixture", "pointProcess"). + /// + [JsonPropertyName("kind")] + public string? Kind { get; set; } + + /// + /// The marginal's fitted distribution type, when a single parent distribution applies. + /// + [JsonPropertyName("distributionType")] + public string? DistributionType { get; set; } + } + + /// + /// The joint-exceedance curve of a bivariate results response: for each grid point, + /// P(X > x AND Y > y) with credible intervals. Kept in this file as a minor supporting + /// DTO tightly coupled to . + /// + public class JointExceedanceCurveDto + { + /// + /// The marginal-X magnitudes of the grid points. + /// + [JsonPropertyName("x")] + public List X { get; set; } = new(); + + /// + /// The marginal-Y magnitudes of the grid points. + /// + [JsonPropertyName("y")] + public List Y { get; set; } = new(); + + /// + /// The joint exceedance probability at the point estimate, per grid point. + /// + [JsonPropertyName("modeProbabilities")] + public List? ModeProbabilities { get; set; } + + /// + /// The posterior-mean joint exceedance probability, per grid point. + /// + [JsonPropertyName("meanProbabilities")] + public List? MeanProbabilities { get; set; } + + /// + /// The lower credible bound of the joint exceedance probability, per grid point. + /// + [JsonPropertyName("ciLower")] + public List? CiLower { get; set; } + + /// + /// The upper credible bound of the joint exceedance probability, per grid point. + /// + [JsonPropertyName("ciUpper")] + public List? CiUpper { get; set; } + + /// + /// The credible interval width the bounds correspond to (e.g., 0.90). + /// + [JsonPropertyName("credibleIntervalWidth")] + public double CredibleIntervalWidth { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/Bulletin17CInfoDto.cs b/src/RMC.BestFit.Api/DTOs/Analyses/Bulletin17CInfoDto.cs new file mode 100644 index 0000000..be82488 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/Bulletin17CInfoDto.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Bulletin 17C-specific run information. + /// + public class Bulletin17CInfoDto + { + /// + /// The uncertainty quantification method that produced the sampled parameter sets + /// ("multivariateNormal", "linkedMultivariateNormal", "bootstrap", "biasCorrectedBootstrap"). + /// + [JsonPropertyName("uncertaintyMethod")] + public string? UncertaintyMethod { get; set; } + + /// + /// Wall-clock time of the generalized-method-of-moments parameter fit, in milliseconds. + /// + [JsonPropertyName("gmmElapsedMs")] + public long? GmmElapsedMs { get; set; } + + /// + /// Wall-clock time of the uncertainty quantification step, in milliseconds. + /// + [JsonPropertyName("uncertaintyElapsedMs")] + public long? UncertaintyElapsedMs { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/CoincidentFrequencyResultsResponse.cs b/src/RMC.BestFit.Api/DTOs/Analyses/CoincidentFrequencyResultsResponse.cs new file mode 100644 index 0000000..47e84ab --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/CoincidentFrequencyResultsResponse.cs @@ -0,0 +1,79 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Response body carrying coincident frequency results: the annual exceedance probability of + /// the response magnitude at each output bin, with credible intervals. + /// + public class CoincidentFrequencyResultsResponse : ResponseBase + { + /// + /// The id of the analysis the results belong to. + /// + [JsonPropertyName("analysisId")] + public Guid AnalysisId { get; set; } + + /// + /// The analysis kind ("coincidentFrequency"). + /// + [JsonPropertyName("kind")] + public string? Kind { get; set; } + + /// + /// The number of response-magnitude output bins. + /// + [JsonPropertyName("numberOfBins")] + public int NumberOfBins { get; set; } + + /// + /// The response magnitudes (Z) of the output bins. + /// + [JsonPropertyName("zValues")] + public List ZValues { get; set; } = new(); + + /// + /// The annual exceedance probability of each bin at the point estimate. + /// + [JsonPropertyName("aepMode")] + public List? AepMode { get; set; } + + /// + /// The posterior-mean annual exceedance probability of each bin. + /// + [JsonPropertyName("aepMean")] + public List? AepMean { get; set; } + + /// + /// The lower credible bound of the annual exceedance probability, per bin. + /// + [JsonPropertyName("ciLower")] + public List? CiLower { get; set; } + + /// + /// The upper credible bound of the annual exceedance probability, per bin. + /// + [JsonPropertyName("ciUpper")] + public List? CiUpper { get; set; } + + /// + /// The credible interval width the bounds correspond to (e.g., 0.90). + /// + [JsonPropertyName("credibleIntervalWidth")] + public double CredibleIntervalWidth { get; set; } + + /// + /// True when the marginal-X posterior chain contributed to the uncertainty bands (the + /// marginal is a plain univariate analysis with an MCMC posterior); false when only the + /// copula chain and point-estimate marginals were used. + /// + [JsonPropertyName("marginalXChainUsed")] + public bool MarginalXChainUsed { get; set; } + + /// + /// True when the marginal-Y posterior chain contributed to the uncertainty bands. + /// + [JsonPropertyName("marginalYChainUsed")] + public bool MarginalYChainUsed { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/CompositeComponentDto.cs b/src/RMC.BestFit.Api/DTOs/Analyses/CompositeComponentDto.cs new file mode 100644 index 0000000..71edd9b --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/CompositeComponentDto.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// One component of a composite analysis: a reference to an existing analysis resource plus + /// its mixture weight, when applicable. + /// + public class CompositeComponentDto + { + /// + /// The id of the component analysis resource. Valid component kinds are univariate, + /// bulletin17c, mixture, pointprocess, and competingrisks; composites cannot nest. + /// The component keeps a LIVE reference: re-running it later changes the composite's + /// next run, and deleting it from the store leaves the composite functional. + /// + [Required] + [JsonPropertyName("analysisId")] + public Guid AnalysisId { get; set; } + + /// + /// The mixture weight for this component, strictly between 0 and 1. Required (per + /// component) when compositeType is "mixture"; the weights must sum to at most 1, with + /// any remainder modeled as a point mass at zero. Ignored for "competingRisks" and + /// computed automatically for "modelAverage". + /// + [JsonPropertyName("weight")] + public double? Weight { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/CompositeInfoDto.cs b/src/RMC.BestFit.Api/DTOs/Analyses/CompositeInfoDto.cs new file mode 100644 index 0000000..3244d0d --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/CompositeInfoDto.cs @@ -0,0 +1,76 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Composite-specific block on the frequency results response: the composition settings and + /// the per-component weights. For model-average composites the weights are computed at run + /// time and are a first-class output. + /// + public class CompositeInfoDto + { + /// + /// The composition method ("competingRisks", "mixture", or "modelAverage"). + /// + [JsonPropertyName("compositeType")] + public string? CompositeType { get; set; } + + /// + /// The model-average weighting method, when compositeType is "modelAverage"; otherwise null. + /// + [JsonPropertyName("averageMethod")] + public string? AverageMethod { get; set; } + + /// + /// The competing risks dependence assumption, when compositeType is "competingRisks"; + /// otherwise null. + /// + [JsonPropertyName("dependency")] + public string? Dependency { get; set; } + + /// + /// True when competing risks combine as the maximum of the component processes; null for + /// other composite types. + /// + [JsonPropertyName("isMaximum")] + public bool? IsMaximum { get; set; } + + /// + /// The components with their current weights, in composite order. + /// + [JsonPropertyName("components")] + public List Components { get; set; } = new(); + } + + /// + /// One component's identity and weight within a composite results response. Kept in this + /// file as a minor supporting DTO tightly coupled to . + /// + public class CompositeComponentInfoDto + { + /// + /// The component analysis resource id (provenance — the resource may since have been deleted). + /// + [JsonPropertyName("analysisId")] + public Guid? AnalysisId { get; set; } + + /// + /// The component analysis display name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// The component analysis kind ("univariate", "bulletin17C", ...). + /// + [JsonPropertyName("kind")] + public string? Kind { get; set; } + + /// + /// The component's weight: client-supplied for mixture composites, computed at run time + /// for model averaging, and unused (0) for competing risks. + /// + [JsonPropertyName("weight")] + public double Weight { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/CreateBivariateAnalysisRequest.cs b/src/RMC.BestFit.Api/DTOs/Analyses/CreateBivariateAnalysisRequest.cs new file mode 100644 index 0000000..bdc5e9c --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/CreateBivariateAnalysisRequest.cs @@ -0,0 +1,81 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Numerics.Distributions.Copulas; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Request body for creating a Bayesian MCMC bivariate copula analysis over two FITTED + /// marginal analyses. The copula parameters are estimated by MCMC; the marginals stay as + /// their own analyses (live references) and must be run before this analysis runs. The two + /// marginals' data are paired by shared time index (at least 10 overlapping non-outlier + /// exact observations are required). + /// + public class CreateBivariateAnalysisRequest + { + /// + /// The id of the marginal-X analysis. Valid kinds: univariate, bulletin17c, mixture, + /// pointprocess. A LIVE reference — re-running the marginal refreshes this analysis's + /// next run. + /// + [Required] + [JsonPropertyName("marginalXAnalysisId")] + public Guid MarginalXAnalysisId { get; set; } + + /// + /// The id of the marginal-Y analysis (same valid kinds; must differ from marginal X). + /// + [Required] + [JsonPropertyName("marginalYAnalysisId")] + public Guid MarginalYAnalysisId { get; set; } + + /// + /// The copula family binding the marginals: "normal" (default), "clayton", "frank", + /// "gumbel", "joe", "aliMikhailHaq", or "studentT". See GET api/metadata/enums. + /// + [JsonPropertyName("copulaType")] + public CopulaType CopulaType { get; set; } = CopulaType.Normal; + + /// + /// How the copula sample is built: "inferenceFromMargins" (default; uses the fitted + /// marginal CDFs) or "pseudoLikelihood" (uses empirical plotting positions). + /// + [JsonPropertyName("estimationMethod")] + public CopulaEstimationMethod? EstimationMethod { get; set; } + + /// + /// The (x, y) evaluation points of the joint-exceedance grid — the results report + /// P(X > x AND Y > y) with credible intervals at each point. At least one point is + /// required; points are sorted by x automatically. + /// + [Required] + [MinLength(1)] + [JsonPropertyName("xyOrdinates")] + public List XyOrdinates { get; set; } = new(); + + /// + /// Optional Bayesian MCMC settings for the copula estimation; omitted fields keep the + /// model defaults. + /// + [JsonPropertyName("bayesianOptions")] + public BayesianOptionsDto? BayesianOptions { get; set; } + + /// + /// Optional informative priors on the copula parameter(s), matched by parameter name. + /// + [JsonPropertyName("parameterPriors")] + public List? ParameterPriors { get; set; } + + /// + /// Optional display name for the analysis. Defaults to "Bivariate: {marginal X} / {marginal Y}". + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Optional description stored with the analysis. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/CreateBulletin17CAnalysisRequest.cs b/src/RMC.BestFit.Api/DTOs/Analyses/CreateBulletin17CAnalysisRequest.cs new file mode 100644 index 0000000..884d229 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/CreateBulletin17CAnalysisRequest.cs @@ -0,0 +1,73 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Numerics.Distributions; +using RMC.BestFit.Analyses; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Request body for creating a Bulletin 17C flood frequency analysis (USGS guidelines: + /// Expected Moments Algorithm via generalized method of moments, with parametric or bootstrap + /// uncertainty) linked to an input-data resource. + /// + public class CreateBulletin17CAnalysisRequest + { + /// + /// The id of the input-data resource to fit — typically USGS annual peaks + /// (POST api/inputdata/usgs-peaks) or manually entered systematic/historical data. + /// + [Required] + [JsonPropertyName("inputDataId")] + public Guid InputDataId { get; set; } + + /// + /// The distribution to fit. Bulletin 17C supports "logPearsonTypeIII" (default, the + /// Bulletin's prescribed distribution), "pearsonTypeIII", "logNormal", "normal", + /// "gammaDistribution", and "exponential". + /// + [JsonPropertyName("distribution")] + public UnivariateDistributionType Distribution { get; set; } = UnivariateDistributionType.LogPearsonTypeIII; + + /// + /// The uncertainty quantification method: "multivariateNormal", "linkedMultivariateNormal", + /// "bootstrap", or "biasCorrectedBootstrap". Leave null for the model default + /// (linkedMultivariateNormal). + /// + [JsonPropertyName("uncertaintyMethod")] + public UncertaintyMethod? UncertaintyMethod { get; set; } + + /// + /// Optional annual exceedance probabilities (AEP) at which the frequency curve is + /// evaluated. Each value must be strictly between 0 and 1. Leave null for the 25 defaults. + /// + [JsonPropertyName("probabilityOrdinates")] + public List? ProbabilityOrdinates { get; set; } + + /// + /// Optional Gaussian penalties on distribution parameters, matched by parameter name — + /// the Bulletin 17C mechanism for regional information. The canonical use is a regional + /// skew: penalize "Skew (of log)" toward the regional value with its mean squared error. + /// + [JsonPropertyName("parameterPenalties")] + public List? ParameterPenalties { get; set; } + + /// + /// Optional Gaussian penalties on quantiles at chosen exceedance probabilities (e.g., a + /// paleoflood-informed 500-year discharge), compared in log10 space by default. + /// + [JsonPropertyName("quantilePenalties")] + public List? QuantilePenalties { get; set; } + + /// + /// Optional display name for the analysis. Defaults to "Bulletin 17C analysis of {input data name}". + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Optional description stored with the analysis. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/CreateCoincidentFrequencyAnalysisRequest.cs b/src/RMC.BestFit.Api/DTOs/Analyses/CreateCoincidentFrequencyAnalysisRequest.cs new file mode 100644 index 0000000..1597642 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/CreateCoincidentFrequencyAnalysisRequest.cs @@ -0,0 +1,84 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using RMC.BestFit.Estimation; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Request body for creating a coincident frequency analysis: integrates a user-supplied + /// response surface Z(x, y) — e.g., pool stage as a function of inflow and starting stage — + /// over the joint distribution of a FITTED bivariate analysis to produce the annual + /// exceedance frequency curve of the response. No MCMC of its own; the upstream bivariate + /// (a LIVE reference) must be run before this analysis runs. + /// + public class CreateCoincidentFrequencyAnalysisRequest + { + /// + /// The id of the upstream bivariate analysis. A LIVE reference — re-running it (or its + /// marginals) refreshes this analysis's next run. + /// + [Required] + [JsonPropertyName("bivariateAnalysisId")] + public Guid BivariateAnalysisId { get; set; } + + /// + /// The strictly ascending marginal-X ordinates of the response surface rows (at least 2). + /// + [Required] + [MinLength(2)] + [JsonPropertyName("xValues")] + public List XValues { get; set; } = new(); + + /// + /// The strictly ascending marginal-Y ordinates of the response surface columns (at least 2). + /// + [Required] + [MinLength(2)] + [JsonPropertyName("yValues")] + public List YValues { get; set; } = new(); + + /// + /// The tabulated response surface: bivariateResponse[i][j] = Z(xValues[i], yValues[j]). + /// Every row must have exactly yValues.Count entries, the row count must equal + /// xValues.Count, and the surface must be strictly increasing along both axes. + /// + [Required] + [JsonPropertyName("bivariateResponse")] + public List> BivariateResponse { get; set; } = new(); + + /// + /// The number of evenly spaced response-magnitude bins the output frequency curve is + /// evaluated at (5-1000). Default 50. + /// + [Range(5, 1000)] + [JsonPropertyName("numberOfBins")] + public int NumberOfBins { get; set; } = 50; + + /// + /// Optional credible interval width for the output uncertainty bands (e.g., 0.90). This + /// analysis accepts no other Bayesian options — it reuses the upstream posteriors. + /// + [Range(0.01, 0.999)] + [JsonPropertyName("credibleIntervalWidth")] + public double? CredibleIntervalWidth { get; set; } + + /// + /// Optional point estimator used for the point-estimate curve: "posteriorMode" or + /// "posteriorMean". Leave null for the model default. + /// + [JsonPropertyName("pointEstimator")] + public BayesianAnalysis.PointEstimateType? PointEstimator { get; set; } + + /// + /// Optional display name for the analysis. Defaults to "Coincident frequency over {bivariate name}". + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Optional description stored with the analysis. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/CreateCompetingRisksAnalysisRequest.cs b/src/RMC.BestFit.Api/DTOs/Analyses/CreateCompetingRisksAnalysisRequest.cs new file mode 100644 index 0000000..dc291c5 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/CreateCompetingRisksAnalysisRequest.cs @@ -0,0 +1,80 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Numerics.Distributions; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Request body for creating a Bayesian MCMC competing risks frequency analysis linked to an + /// input-data resource. Competing risks model each annual maximum as the maximum of 1-3 + /// independent flood-generating processes (e.g., rainfall and snowmelt), each described by + /// its own component distribution. + /// + public class CreateCompetingRisksAnalysisRequest + { + /// + /// The id of the input-data resource to fit (create it first via the api/inputdata endpoints). + /// + [Required] + [JsonPropertyName("inputDataId")] + public Guid InputDataId { get; set; } + + /// + /// The 1-3 component distribution types, one per competing process (e.g., ["gumbel", + /// "generalizedExtremeValue"]). All 15 supported distributions are accepted; see + /// GET api/metadata/distributions. + /// + [Required] + [MinLength(1)] + [MaxLength(3)] + [JsonPropertyName("distributions")] + public List Distributions { get; set; } = new(); + + /// + /// Optional annual exceedance probabilities (AEP) at which the frequency curve is + /// evaluated. Each value must be strictly between 0 and 1. Leave null for the 25 defaults. + /// + [JsonPropertyName("probabilityOrdinates")] + public List? ProbabilityOrdinates { get; set; } + + /// + /// Optional Bayesian MCMC settings; omitted fields keep the model defaults. + /// + [JsonPropertyName("bayesianOptions")] + public BayesianOptionsDto? BayesianOptions { get; set; } + + /// + /// Optional informative priors on individual component parameters, matched by parameter + /// name. Unnamed parameters keep the default flat priors. + /// + [JsonPropertyName("parameterPriors")] + public List? ParameterPriors { get; set; } + + /// + /// Optional priors on quantiles of the competing risks distribution — engineering + /// judgment about flood magnitudes at chosen exceedance probabilities. + /// + [JsonPropertyName("quantilePriors")] + public List? QuantilePriors { get; set; } + + /// + /// True to use the single-quantile-prior formulation (Viglione et al., 2013); false for + /// one prior per parameter. Only meaningful when quantilePriors are supplied. Leave null + /// for the model default. + /// + [JsonPropertyName("useSingleQuantile")] + public bool? UseSingleQuantile { get; set; } + + /// + /// Optional display name for the analysis. Defaults to "Competing risks analysis of {input data name}". + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Optional description stored with the analysis. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/CreateCompositeAnalysisRequest.cs b/src/RMC.BestFit.Api/DTOs/Analyses/CreateCompositeAnalysisRequest.cs new file mode 100644 index 0000000..dcbc5c9 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/CreateCompositeAnalysisRequest.cs @@ -0,0 +1,91 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Numerics.Data.Statistics; +using RMC.BestFit.Analyses; +using RMC.BestFit.Estimation; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Request body for creating a composite analysis over already-created component analyses. + /// A composite runs no MCMC of its own: it combines the component posteriors by competing + /// risks, mixture weighting, or information-criterion model averaging. Every component must + /// have been RUN before the composite runs. + /// + public class CreateCompositeAnalysisRequest + { + /// + /// The component analyses (at least one; at most is practically limited by the + /// composition method). Components are LIVE references — re-running a component later + /// intentionally refreshes the composite's next run. + /// + [Required] + [MinLength(1)] + [JsonPropertyName("components")] + public List Components { get; set; } = new(); + + /// + /// How the components are combined: "competingRisks" (maximum/minimum of independent + /// processes; default), "mixture" (client-supplied weights), or "modelAverage" + /// (information-criterion weights computed at run time). + /// + [JsonPropertyName("compositeType")] + public CompositeType CompositeType { get; set; } = CompositeType.CompetingRisks; + + /// + /// The weighting method for "modelAverage" composites: "aic", "bic", "dic", "waic", + /// "looic", "equal", or "rmse". Leave null for the model default. DIC/WAIC/LOOIC require + /// every component to have an MCMC posterior (a Bulletin 17C component rules them out). + /// + [JsonPropertyName("averageMethod")] + public AverageMethod? AverageMethod { get; set; } + + /// + /// The dependence assumption between competing risks components: "independent" (default), + /// "perfectlyPositive", or "perfectlyNegative". Ignored for other composite types. + /// + [JsonPropertyName("dependency")] + public Probability.DependencyType? Dependency { get; set; } + + /// + /// True (default) to combine competing risks as the maximum of the component processes; + /// false for the minimum. Ignored for other composite types. + /// + [JsonPropertyName("isMaximum")] + public bool IsMaximum { get; set; } = true; + + /// + /// Optional annual exceedance probabilities (AEP) at which the composite frequency curve + /// is evaluated. Each value must be strictly between 0 and 1. Leave null for the 25 defaults. + /// + [JsonPropertyName("probabilityOrdinates")] + public List? ProbabilityOrdinates { get; set; } + + /// + /// Optional credible interval width for the composite uncertainty bands (e.g., 0.90). + /// Composites accept no other Bayesian options — they run no MCMC of their own. + /// + [Range(0.01, 0.999)] + [JsonPropertyName("credibleIntervalWidth")] + public double? CredibleIntervalWidth { get; set; } + + /// + /// Optional point estimator used when assembling the composite from component point + /// estimates: "posteriorMode" or "posteriorMean". Leave null for the model default. + /// + [JsonPropertyName("pointEstimator")] + public BayesianAnalysis.PointEstimateType? PointEstimator { get; set; } + + /// + /// Optional display name for the analysis. Defaults to "Composite of {component names}". + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Optional description stored with the analysis. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/CreateDistributionFittingAnalysisRequest.cs b/src/RMC.BestFit.Api/DTOs/Analyses/CreateDistributionFittingAnalysisRequest.cs new file mode 100644 index 0000000..7773607 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/CreateDistributionFittingAnalysisRequest.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Numerics.Distributions; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Request body for creating a distribution-fitting analysis: a parallel maximum-likelihood + /// fit of many candidate distributions over one input-data resource, ranked by information + /// criteria. This is a fast screening tool (seconds, no MCMC) for choosing distributions to + /// carry into Bayesian analyses. + /// + public class CreateDistributionFittingAnalysisRequest + { + /// + /// The id of the input-data resource to fit (create it first via the api/inputdata endpoints). + /// + [Required] + [JsonPropertyName("inputDataId")] + public Guid InputDataId { get; set; } + + /// + /// Optional subset of candidate distributions to fit. Leave null to fit all 15 supported + /// distributions; see GET api/metadata/distributions. + /// + [JsonPropertyName("distributions")] + public List? Distributions { get; set; } + + /// + /// Optional display name for the analysis. Defaults to "Distribution fitting of {input data name}". + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Optional description stored with the analysis. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/CreateMixtureAnalysisRequest.cs b/src/RMC.BestFit.Api/DTOs/Analyses/CreateMixtureAnalysisRequest.cs new file mode 100644 index 0000000..3b4dedf --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/CreateMixtureAnalysisRequest.cs @@ -0,0 +1,86 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Numerics.Distributions; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Request body for creating a Bayesian MCMC mixture-distribution frequency analysis linked + /// to an input-data resource. Mixtures model samples drawn from multiple populations (e.g., + /// rainfall and snowmelt floods) as a weighted combination of 1-3 component distributions; + /// the component weights are estimated alongside the component parameters. + /// + public class CreateMixtureAnalysisRequest + { + /// + /// The id of the input-data resource to fit (create it first via the api/inputdata endpoints). + /// + [Required] + [JsonPropertyName("inputDataId")] + public Guid InputDataId { get; set; } + + /// + /// The 1-3 component distribution types (e.g., ["gumbel", "logNormal"]). All 15 supported + /// distributions are accepted; see GET api/metadata/distributions. + /// + [Required] + [MinLength(1)] + [MaxLength(3)] + [JsonPropertyName("distributions")] + public List Distributions { get; set; } = new(); + + /// + /// True to add a point mass at zero for records with zero-flow years (the mixture weights + /// then sum to less than one, with the remainder assigned to zero). Default false. + /// + [JsonPropertyName("isZeroInflated")] + public bool IsZeroInflated { get; set; } + + /// + /// Optional annual exceedance probabilities (AEP) at which the frequency curve is + /// evaluated. Each value must be strictly between 0 and 1. Leave null for the 25 defaults. + /// + [JsonPropertyName("probabilityOrdinates")] + public List? ProbabilityOrdinates { get; set; } + + /// + /// Optional Bayesian MCMC settings; omitted fields keep the model defaults. + /// + [JsonPropertyName("bayesianOptions")] + public BayesianOptionsDto? BayesianOptions { get; set; } + + /// + /// Optional informative priors on individual model parameters (component parameters and + /// weights), matched by parameter name. Unnamed parameters keep the default flat priors. + /// + [JsonPropertyName("parameterPriors")] + public List? ParameterPriors { get; set; } + + /// + /// Optional priors on quantiles of the mixture distribution — engineering judgment about + /// flood magnitudes at chosen exceedance probabilities. + /// + [JsonPropertyName("quantilePriors")] + public List? QuantilePriors { get; set; } + + /// + /// True to use the single-quantile-prior formulation (Viglione et al., 2013); false for + /// one prior per parameter. Only meaningful when quantilePriors are supplied. Leave null + /// for the model default. + /// + [JsonPropertyName("useSingleQuantile")] + public bool? UseSingleQuantile { get; set; } + + /// + /// Optional display name for the analysis. Defaults to "Mixture analysis of {input data name}". + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Optional description stored with the analysis. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/CreatePointProcessAnalysisRequest.cs b/src/RMC.BestFit.Api/DTOs/Analyses/CreatePointProcessAnalysisRequest.cs new file mode 100644 index 0000000..6517e63 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/CreatePointProcessAnalysisRequest.cs @@ -0,0 +1,108 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Numerics.Data; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Request body for creating a Bayesian MCMC peaks-over-threshold point process analysis + /// linked to an input-data resource — typically one created by + /// POST api/inputdata/peaks-over-threshold. The point process jointly models event rate and + /// magnitude above the threshold with a Generalized Extreme Value formulation (the + /// distribution is fixed by the method; no distribution field). + /// + public class CreatePointProcessAnalysisRequest + { + /// + /// The id of the input-data resource holding the independent peaks-over-threshold events. + /// When the resource was created by the POT endpoint, its recorded threshold seeds the + /// model automatically. + /// + [Required] + [JsonPropertyName("inputDataId")] + public Guid InputDataId { get; set; } + + /// + /// True to model within-year seasonality of the event rate (seasonal GEV). Default false. + /// + [JsonPropertyName("isSeasonal")] + public bool IsSeasonal { get; set; } + + /// + /// The seasonal time-block window ("waterYear", "calendarYear", ...) used when + /// isSeasonal is true. Leave null for the model default. + /// + [JsonPropertyName("timeBlock")] + public TimeBlockWindow? TimeBlock { get; set; } + + /// + /// The season start month (1-12) used when isSeasonal is true. Leave null for the model + /// default. + /// + [Range(1, 12)] + [JsonPropertyName("startMonth")] + public int? StartMonth { get; set; } + + /// + /// Optional explicit threshold override. Leave null to use the threshold recorded on the + /// POT input-data resource (or the model's data-derived default). + /// + [JsonPropertyName("threshold")] + public double? Threshold { get; set; } + + /// + /// Optional explicit record span in years, used to compute the event rate λ = + /// events / totalYears. Must be greater than 0. Leave null for the model's data-derived + /// value. + /// + [JsonPropertyName("totalYears")] + public double? TotalYears { get; set; } + + /// + /// Optional annual exceedance probabilities (AEP) at which the frequency curve is + /// evaluated. Each value must be strictly between 0 and 1. Leave null for the 25 defaults. + /// + [JsonPropertyName("probabilityOrdinates")] + public List? ProbabilityOrdinates { get; set; } + + /// + /// Optional Bayesian MCMC settings; omitted fields keep the model defaults. + /// + [JsonPropertyName("bayesianOptions")] + public BayesianOptionsDto? BayesianOptions { get; set; } + + /// + /// Optional informative priors on individual model parameters, matched by parameter name. + /// Unnamed parameters keep the default flat priors. + /// + [JsonPropertyName("parameterPriors")] + public List? ParameterPriors { get; set; } + + /// + /// Optional priors on quantiles of the annual-maximum distribution implied by the point + /// process — engineering judgment about flood magnitudes. + /// + [JsonPropertyName("quantilePriors")] + public List? QuantilePriors { get; set; } + + /// + /// True to use the single-quantile-prior formulation (Viglione et al., 2013); false for + /// one prior per parameter. Only meaningful when quantilePriors are supplied. Leave null + /// for the model default. + /// + [JsonPropertyName("useSingleQuantile")] + public bool? UseSingleQuantile { get; set; } + + /// + /// Optional display name for the analysis. Defaults to "Point process analysis of {input data name}". + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Optional description stored with the analysis. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/CreateRatingCurveAnalysisRequest.cs b/src/RMC.BestFit.Api/DTOs/Analyses/CreateRatingCurveAnalysisRequest.cs new file mode 100644 index 0000000..b94651f --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/CreateRatingCurveAnalysisRequest.cs @@ -0,0 +1,83 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Request body for creating a Bayesian stage-discharge rating curve analysis linked to a + /// stage time series and a discharge time series (typically USGS "measuredStage" and + /// "measuredDischarge" field measurements for the same site). The two series are date-aligned + /// internally; at least 10 common dates are required. + /// + public class CreateRatingCurveAnalysisRequest + { + /// + /// The id of the stage (gage height) time-series resource. + /// + [Required] + [JsonPropertyName("stageTimeSeriesId")] + public Guid StageTimeSeriesId { get; set; } + + /// + /// The id of the discharge time-series resource. + /// + [Required] + [JsonPropertyName("dischargeTimeSeriesId")] + public Guid DischargeTimeSeriesId { get; set; } + + /// + /// The number of piecewise power-law segments (hydraulic controls), 1-3. Use 1 for a + /// single channel control; more segments model control changes (e.g., overbank flow). Default 1. + /// + [Range(1, 3)] + [JsonPropertyName("numberOfSegments")] + public int NumberOfSegments { get; set; } = 1; + + /// + /// Optional minimum stage of the output grid. Supply together with maxStage to override + /// the automatic grid (data range with padding). + /// + [JsonPropertyName("minStage")] + public double? MinStage { get; set; } + + /// + /// Optional maximum stage of the output grid. Supply together with minStage. + /// + [JsonPropertyName("maxStage")] + public double? MaxStage { get; set; } + + /// + /// Optional number of stage grid points the discharge curves are evaluated at (2-1000). + /// Leave null for the model default. + /// + [Range(2, 1000)] + [JsonPropertyName("stageBins")] + public int? StageBins { get; set; } + + /// + /// Optional Bayesian MCMC settings; omitted fields keep the model defaults. + /// + [JsonPropertyName("bayesianOptions")] + public BayesianOptionsDto? BayesianOptions { get; set; } + + /// + /// Optional informative priors on individual rating curve parameters, matched by + /// parameter name (e.g., a prior on the offset ξ from a surveyed gage datum). Unnamed + /// parameters keep the default flat priors. + /// + [JsonPropertyName("parameterPriors")] + public List? ParameterPriors { get; set; } + + /// + /// Optional display name for the analysis. Defaults to "Rating curve: {stage name} / {discharge name}". + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Optional description stored with the analysis. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/CreateTimeSeriesAnalysisRequest.cs b/src/RMC.BestFit.Api/DTOs/Analyses/CreateTimeSeriesAnalysisRequest.cs new file mode 100644 index 0000000..2f4ec39 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/CreateTimeSeriesAnalysisRequest.cs @@ -0,0 +1,152 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Models; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Request body for creating a Bayesian MCMC time-series analysis over a stored time series. + /// One route covers four model families discriminated by : "ar" + /// (order p), "ma" (order q), "arima" (p, d, q), and "arimax" (p, d, q plus exogenous + /// covariates, trend, and seasonality). Fields that do not apply to the chosen model type + /// are rejected with 400 — never silently ignored. + /// + public class CreateTimeSeriesAnalysisRequest + { + /// + /// The id of the time-series resource to model (create it first via the api/timeseries + /// endpoints). The series is cloned at creation. + /// + [Required] + [JsonPropertyName("timeSeriesId")] + public Guid TimeSeriesId { get; set; } + + /// + /// The model family: "ar", "ma", "arima", or "arimax". + /// + [Required] + [JsonPropertyName("modelType")] + public TimeSeriesModelType ModelType { get; set; } + + /// + /// The AR or MA order for the "ar"/"ma" model types (default 1). Rejected for + /// "arima"/"arimax" — use pOrder/dOrder/qOrder there. + /// + [Range(1, 10)] + [JsonPropertyName("order")] + public int? Order { get; set; } + + /// + /// The autoregressive order p for "arima"/"arimax" (default 1). Rejected for "ar"/"ma". + /// + [Range(0, 10)] + [JsonPropertyName("pOrder")] + public int? POrder { get; set; } + + /// + /// The differencing order d for "arima"/"arimax" (default 0). Rejected for "ar"/"ma". + /// + [Range(0, 3)] + [JsonPropertyName("dOrder")] + public int? DOrder { get; set; } + + /// + /// The moving-average order q for "arima"/"arimax" (default 0). Rejected for "ar"/"ma". + /// + [Range(0, 10)] + [JsonPropertyName("qOrder")] + public int? QOrder { get; set; } + + /// + /// The number of exogenous covariates b for "arimax" (default 0 — set alongside + /// covariateTimeSeriesIds). Rejected for other model types. + /// + [Range(0, 10)] + [JsonPropertyName("xOrder")] + public int? XOrder { get; set; } + + /// + /// True (default) to include the intercept term μ. + /// + [JsonPropertyName("includeIntercept")] + public bool? IncludeIntercept { get; set; } + + /// + /// Optional variance-stabilizing transform applied before modeling: "none", + /// "logarithmic", "boxCox", or "yeoJohnson". Transform parameters (e.g., the Box-Cox + /// lambda) are fitted automatically. Leave null for the model default. + /// + [JsonPropertyName("transformType")] + public Transform? TransformType { get; set; } + + /// + /// Optional deterministic trend for "arimax": "none", "linear", "quadratic", or "cubic". + /// Rejected for other model types. + /// + [JsonPropertyName("trendType")] + public ARIMAX.Trend? TrendType { get; set; } + + /// + /// True to include a Fourier seasonal component for "arimax" (the seasonal period is + /// inferred from the series interval). Rejected for other model types. + /// + [JsonPropertyName("includeSeasonality")] + public bool? IncludeSeasonality { get; set; } + + /// + /// The ids of the exogenous covariate time-series resources for "arimax", in covariate + /// order. Each series is cloned at creation. Rejected for other model types. + /// + [JsonPropertyName("covariateTimeSeriesIds")] + public List? CovariateTimeSeriesIds { get; set; } + + /// + /// How "arimax" covariates are extended past their observed record for forecasting: + /// "none" (covariates must cover the forecast horizon), "blockBootstrap" (the model + /// default), or "knn". Rejected for other model types. + /// + [JsonPropertyName("covariateExtension")] + public ARIMAX.CovariateExtensionMethod? CovariateExtension { get; set; } + + /// + /// Optional number of time steps used for training (the remainder validates the fit). + /// Leave null for the model default (80% of the series). + /// + [Range(1, int.MaxValue)] + [JsonPropertyName("trainingTimeSteps")] + public int? TrainingTimeSteps { get; set; } + + /// + /// Optional number of time steps to forecast past the end of the observed series (0-100). + /// Leave null for the model default. + /// + [JsonPropertyName("forecastingTimeSteps")] + public int? ForecastingTimeSteps { get; set; } + + /// + /// Optional Bayesian MCMC settings; omitted fields keep the model defaults. + /// + [JsonPropertyName("bayesianOptions")] + public BayesianOptionsDto? BayesianOptions { get; set; } + + /// + /// Optional informative priors on individual model parameters (e.g., the AR coefficient + /// or the scale), matched by parameter name. Unnamed parameters keep the default flat priors. + /// + [JsonPropertyName("parameterPriors")] + public List? ParameterPriors { get; set; } + + /// + /// Optional display name for the analysis. Defaults to "{MODEL} analysis of {series name}". + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Optional description stored with the analysis. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/CreateUnivariateAnalysisRequest.cs b/src/RMC.BestFit.Api/DTOs/Analyses/CreateUnivariateAnalysisRequest.cs new file mode 100644 index 0000000..d8adbb2 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/CreateUnivariateAnalysisRequest.cs @@ -0,0 +1,79 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Numerics.Distributions; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Request body for creating a Bayesian MCMC univariate frequency analysis linked to an + /// input-data resource. The analysis clones the input data at creation, so later changes to + /// the input-data resource cannot affect it. + /// + public class CreateUnivariateAnalysisRequest + { + /// + /// The id of the input-data resource to fit (create it first via the api/inputdata endpoints). + /// + [Required] + [JsonPropertyName("inputDataId")] + public Guid InputDataId { get; set; } + + /// + /// The probability distribution to fit. All 15 supported distributions are listed by + /// GET api/metadata/distributions (e.g., "logPearsonTypeIII", "generalizedExtremeValue", + /// "gumbel", "lnNormal"). Default "logPearsonTypeIII", the U.S. flood-frequency convention. + /// + [JsonPropertyName("distribution")] + public UnivariateDistributionType Distribution { get; set; } = UnivariateDistributionType.LogPearsonTypeIII; + + /// + /// Optional annual exceedance probabilities (AEP) at which the frequency curve is + /// evaluated (e.g., 0.01 is the 100-year event). Each value must be strictly between + /// 0 and 1. Leave null for the 25 default ordinates (see GET api/metadata/defaults). + /// + [JsonPropertyName("probabilityOrdinates")] + public List? ProbabilityOrdinates { get; set; } + + /// + /// Optional Bayesian MCMC settings; omitted fields keep the model defaults. + /// + [JsonPropertyName("bayesianOptions")] + public BayesianOptionsDto? BayesianOptions { get; set; } + + /// + /// Optional informative priors on individual distribution parameters, matched by + /// parameter name (see GET api/metadata/distributions for the names per distribution). + /// Unnamed parameters keep the default flat priors. + /// + [JsonPropertyName("parameterPriors")] + public List? ParameterPriors { get; set; } + + /// + /// Optional priors on quantiles of the parent distribution — engineering judgment about + /// flood magnitudes at chosen exceedance probabilities. Supply one prior per distribution + /// parameter, or a single prior with useSingleQuantile=true. + /// + [JsonPropertyName("quantilePriors")] + public List? QuantilePriors { get; set; } + + /// + /// True to use the single-quantile-prior formulation (Viglione et al., 2013); false for + /// one prior per distribution parameter (Coles and Tawn, 1996). Only meaningful when + /// quantilePriors are supplied. Leave null for the model default (one per parameter). + /// + [JsonPropertyName("useSingleQuantile")] + public bool? UseSingleQuantile { get; set; } + + /// + /// Optional display name for the analysis. Defaults to "{distribution} analysis of {input data name}". + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Optional description stored with the analysis. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/DiagnosticsDto.cs b/src/RMC.BestFit.Api/DTOs/Analyses/DiagnosticsDto.cs new file mode 100644 index 0000000..53aa3ec --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/DiagnosticsDto.cs @@ -0,0 +1,53 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Run diagnostics: sampler configuration, acceptance rates, convergence warnings, and timing. + /// + public class DiagnosticsDto + { + /// + /// The MCMC sampler used ("demCzs", ...); null for non-chain methods (Bulletin 17C). + /// + [JsonPropertyName("sampler")] + public string? Sampler { get; set; } + + /// + /// Total MCMC iterations per chain; null for non-chain methods. + /// + [JsonPropertyName("iterations")] + public int? Iterations { get; set; } + + /// + /// Warm-up iterations discarded per chain; null for non-chain methods. + /// + [JsonPropertyName("warmupIterations")] + public int? WarmupIterations { get; set; } + + /// + /// Number of parallel chains; null for non-chain methods. + /// + [JsonPropertyName("numberOfChains")] + public int? NumberOfChains { get; set; } + + /// + /// Per-chain acceptance rates; null for non-chain methods. + /// + [JsonPropertyName("acceptanceRates")] + public List? AcceptanceRates { get; set; } + + /// + /// Convergence warnings (high R-hat, low effective sample size). An empty list means no + /// convergence concerns were detected; warnings never fail the run. + /// + [JsonPropertyName("convergenceWarnings")] + public List ConvergenceWarnings { get; set; } = new(); + + /// + /// Wall-clock estimation time in milliseconds. + /// + [JsonPropertyName("elapsedMs")] + public long? ElapsedMs { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/DistributionFitDto.cs b/src/RMC.BestFit.Api/DTOs/Analyses/DistributionFitDto.cs new file mode 100644 index 0000000..7b2e37b --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/DistributionFitDto.cs @@ -0,0 +1,67 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// One candidate distribution's maximum-likelihood fit within a distribution-fitting results + /// response: parameters, information criteria, and the fit outcome. + /// + public class DistributionFitDto + { + /// + /// The rank of this fit among the successful fits, 1 = best (lowest AIC). Failed fits are + /// ranked after all successful ones. + /// + [JsonPropertyName("rank")] + public int Rank { get; set; } + + /// + /// The distribution type ("logPearsonTypeIII", "gumbel", ...). + /// + [JsonPropertyName("distribution")] + public string? Distribution { get; set; } + + /// + /// The human-readable distribution name (e.g., "Log-Pearson Type III"). + /// + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + /// + /// The maximum-likelihood parameter estimates in canonical order; null when the fit failed. + /// + [JsonPropertyName("parameters")] + public List? Parameters { get; set; } + + /// + /// The Akaike Information Criterion (lower is better); null when unavailable. + /// + [JsonPropertyName("aic")] + public double? Aic { get; set; } + + /// + /// The Bayesian Information Criterion (lower is better); null when unavailable. + /// + [JsonPropertyName("bic")] + public double? Bic { get; set; } + + /// + /// The root mean square error between plotting positions and the fitted CDF; null when + /// unavailable. + /// + [JsonPropertyName("rmse")] + public double? Rmse { get; set; } + + /// + /// True when the maximum-likelihood fit converged. + /// + [JsonPropertyName("fitSucceeded")] + public bool FitSucceeded { get; set; } + + /// + /// The failure diagnostic when the fit did not converge; null otherwise. + /// + [JsonPropertyName("errorMessage")] + public string? ErrorMessage { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/DistributionFittingResultsResponse.cs b/src/RMC.BestFit.Api/DTOs/Analyses/DistributionFittingResultsResponse.cs new file mode 100644 index 0000000..175a690 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/DistributionFittingResultsResponse.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Response body carrying distribution-fitting results: every candidate distribution's + /// maximum-likelihood fit, ranked by AIC (successful fits first). + /// + public class DistributionFittingResultsResponse : ResponseBase + { + /// + /// The analysis resource id. + /// + [JsonPropertyName("analysisId")] + public Guid AnalysisId { get; set; } + + /// + /// The analysis kind ("distributionFitting"). + /// + [JsonPropertyName("kind")] + public string? Kind { get; set; } + + /// + /// The candidate fits ranked best-first by AIC, with failed fits after all successful ones. + /// + [JsonPropertyName("fits")] + public List Fits { get; set; } = new(); + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/FittedDistributionDto.cs b/src/RMC.BestFit.Api/DTOs/Analyses/FittedDistributionDto.cs new file mode 100644 index 0000000..87eb0d6 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/FittedDistributionDto.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Describes the fitted distribution: its type and point-estimate parameter values. + /// + public class FittedDistributionDto + { + /// + /// The distribution type (camelCase enum name, e.g., "logPearsonTypeIII"). + /// + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// + /// The human-readable distribution name (e.g., "Log-Pearson Type III"). + /// + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + /// + /// The point estimator behind the reported values ("posteriorMode" or "posteriorMean"). + /// + [JsonPropertyName("pointEstimator")] + public string? PointEstimator { get; set; } + + /// + /// The fitted parameter values at the point estimate. + /// + [JsonPropertyName("parameters")] + public List Parameters { get; set; } = new(); + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/FrequencyCurveDto.cs b/src/RMC.BestFit.Api/DTOs/Analyses/FrequencyCurveDto.cs new file mode 100644 index 0000000..cdde334 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/FrequencyCurveDto.cs @@ -0,0 +1,49 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// A frequency curve with uncertainty: quantiles evaluated at each exceedance probability, + /// with point-estimate, posterior-mean, and credible-interval curves aligned index-by-index + /// to . + /// + public class FrequencyCurveDto + { + /// + /// The annual exceedance probabilities (AEP) the curves are evaluated at (e.g., 0.01 is + /// the 100-year event). + /// + [JsonPropertyName("probabilities")] + public List Probabilities { get; set; } = new(); + + /// + /// Quantiles of the point-estimate (mode/computed) curve, one per probability. + /// + [JsonPropertyName("modeCurve")] + public List? ModeCurve { get; set; } + + /// + /// Quantiles of the posterior-mean (predictive) curve, one per probability. + /// + [JsonPropertyName("meanCurve")] + public List? MeanCurve { get; set; } + + /// + /// Lower credible-interval bound, one per probability. + /// + [JsonPropertyName("ciLower")] + public List? CiLower { get; set; } + + /// + /// Upper credible-interval bound, one per probability. + /// + [JsonPropertyName("ciUpper")] + public List? CiUpper { get; set; } + + /// + /// The width of the reported credible interval (e.g., 0.90 for 90% intervals). + /// + [JsonPropertyName("credibleIntervalWidth")] + public double CredibleIntervalWidth { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/FrequencyResultsResponse.cs b/src/RMC.BestFit.Api/DTOs/Analyses/FrequencyResultsResponse.cs new file mode 100644 index 0000000..443b340 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/FrequencyResultsResponse.cs @@ -0,0 +1,70 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Response body carrying the frequency analysis results of a frequency-kind analysis + /// (univariate, Bulletin 17C, mixture, point process, competing risks, or composite): the + /// frequency curve with uncertainty, the fitted distribution, parameter summaries, + /// information criteria, and run diagnostics. + /// + public class FrequencyResultsResponse : ResponseBase + { + /// + /// The id of the analysis the results belong to. + /// + [JsonPropertyName("analysisId")] + public Guid AnalysisId { get; set; } + + /// + /// The analysis kind ("univariate" or "bulletin17C"). + /// + [JsonPropertyName("kind")] + public string? Kind { get; set; } + + /// + /// The fitted distribution and its point-estimate parameter values. + /// + [JsonPropertyName("fittedDistribution")] + public FittedDistributionDto? FittedDistribution { get; set; } + + /// + /// The frequency curve with credible intervals, aligned to the configured exceedance + /// probabilities. + /// + [JsonPropertyName("frequencyCurve")] + public FrequencyCurveDto? FrequencyCurve { get; set; } + + /// + /// Posterior (or sampled) summaries per parameter, including convergence diagnostics + /// for MCMC fits. + /// + [JsonPropertyName("parameterSummaries")] + public List ParameterSummaries { get; set; } = new(); + + /// + /// Model-fit information criteria. + /// + [JsonPropertyName("informationCriteria")] + public InformationCriteriaDto? InformationCriteria { get; set; } + + /// + /// Run diagnostics (sampler settings, acceptance rates, convergence warnings, timing). + /// + [JsonPropertyName("diagnostics")] + public DiagnosticsDto? Diagnostics { get; set; } + + /// + /// Bulletin 17C-specific information; null for other kinds. + /// + [JsonPropertyName("bulletin17C")] + public Bulletin17CInfoDto? Bulletin17C { get; set; } + + /// + /// Composite-specific information (composition settings and per-component weights); + /// null for other kinds. + /// + [JsonPropertyName("composite")] + public CompositeInfoDto? Composite { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/InformationCriteriaDto.cs b/src/RMC.BestFit.Api/DTOs/Analyses/InformationCriteriaDto.cs new file mode 100644 index 0000000..e3ef3bb --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/InformationCriteriaDto.cs @@ -0,0 +1,66 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Model-fit information criteria and error measures. Lower is better for all criteria; + /// values undefined for the estimation method are null. + /// + public class InformationCriteriaDto + { + /// + /// Akaike information criterion. + /// + [JsonPropertyName("aic")] + public double? Aic { get; set; } + + /// + /// Bayesian information criterion. + /// + [JsonPropertyName("bic")] + public double? Bic { get; set; } + + /// + /// Deviance information criterion. + /// + [JsonPropertyName("dic")] + public double? Dic { get; set; } + + /// + /// Widely applicable information criterion (Bayesian MCMC only). + /// + [JsonPropertyName("waic")] + public double? Waic { get; set; } + + /// + /// Effective number of parameters behind WAIC (Bayesian MCMC only). + /// + [JsonPropertyName("waicPD")] + public double? WaicPD { get; set; } + + /// + /// Leave-one-out cross-validation information criterion with Pareto-smoothed importance + /// sampling (Bayesian MCMC only). + /// + [JsonPropertyName("looic")] + public double? Looic { get; set; } + + /// + /// Standard error of LOOIC (Bayesian MCMC only). + /// + [JsonPropertyName("looicSE")] + public double? LooicSE { get; set; } + + /// + /// Root mean square error of the fit against plotting positions. + /// + [JsonPropertyName("rmse")] + public double? Rmse { get; set; } + + /// + /// Effective record length in years (Bulletin 17C). + /// + [JsonPropertyName("erl")] + public double? Erl { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/ParameterPenaltyDto.cs b/src/RMC.BestFit.Api/DTOs/Analyses/ParameterPenaltyDto.cs new file mode 100644 index 0000000..e156807 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/ParameterPenaltyDto.cs @@ -0,0 +1,50 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// A Gaussian penalty on a distribution parameter in a Bulletin 17C analysis — the Bulletin's + /// mechanism for incorporating prior (typically regional) information into the Expected + /// Moments Algorithm fit. The canonical use is a regional skew: penalize the station skew + /// toward the regional value with strength inversely proportional to its mean squared error. + /// + /// + /// The penalty adds 0.5 × (parameter − mean)² / (mse × n) to the GMM objective, where n is + /// the total record length. With useLog=true the penalty is computed in log space via the + /// delta method (appropriate for strictly positive parameters). + /// + public class ParameterPenaltyDto + { + /// + /// The name of the parameter the penalty applies to, matched case-insensitively against + /// the fitted distribution's parameter names (e.g., "Skew (of log)" for a Log-Pearson + /// Type III regional skew). The names per distribution are listed by + /// GET api/metadata/distributions. + /// + [Required] + [JsonPropertyName("parameterName")] + public string ParameterName { get; set; } = string.Empty; + + /// + /// The prior mean for the parameter in real space (e.g., the regional skew value). + /// + [JsonPropertyName("mean")] + public double Mean { get; set; } + + /// + /// The mean squared error (variance) of the prior mean. Must be greater than 0; smaller + /// values pull the estimate more strongly toward the mean (e.g., the regional skew MSE + /// from Bulletin 17B/17C skew maps). + /// + [JsonPropertyName("mse")] + public double Mse { get; set; } + + /// + /// True to compute the penalty in log space via the delta method — use for strictly + /// positive parameters. Requires mean > 0. Default false (real space). + /// + [JsonPropertyName("useLog")] + public bool UseLog { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/ParameterPriorDto.cs b/src/RMC.BestFit.Api/DTOs/Analyses/ParameterPriorDto.cs new file mode 100644 index 0000000..f63e688 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/ParameterPriorDto.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// An informative prior on a single model parameter for Bayesian estimation. Parameters not + /// named in the request keep the model's default flat (uniform) prior, so partial + /// specification is supported. + /// + /// + /// Supplying any parameter prior switches the model off its automatic flat-prior defaults for + /// the whole analysis (the named parameters get the supplied priors; the rest keep the flat + /// priors already in place). Prior densities are evaluated in real parameter space. + /// + public class ParameterPriorDto + { + /// + /// The name of the parameter the prior applies to, matched case-insensitively against the + /// model's parameter names. The canonical names per distribution are listed by + /// GET api/metadata/distributions (e.g., Log-Pearson Type III: "Mean (of log)", + /// "Std Dev (of log)", "Skew (of log)"). + /// + [Required] + [JsonPropertyName("parameterName")] + public string ParameterName { get; set; } = string.Empty; + + /// + /// The prior distribution for the parameter (e.g., a normal prior on a regional skew). + /// + [Required] + [JsonPropertyName("distribution")] + public DistributionSpecDto? Distribution { get; set; } + + /// + /// True to hold the parameter fixed at its current value during estimation (it is not + /// sampled). Leave null or false to estimate the parameter. Default null (estimated). + /// + [JsonPropertyName("isFixed")] + public bool? IsFixed { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/ParameterSummaryDto.cs b/src/RMC.BestFit.Api/DTOs/Analyses/ParameterSummaryDto.cs new file mode 100644 index 0000000..b48d543 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/ParameterSummaryDto.cs @@ -0,0 +1,60 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Posterior (or sampled) summary of one model parameter, including MCMC convergence + /// diagnostics where applicable. + /// + public class ParameterSummaryDto + { + /// + /// The parameter name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// The posterior (or sampled) mean. + /// + [JsonPropertyName("mean")] + public double? Mean { get; set; } + + /// + /// The posterior (or sampled) median. + /// + [JsonPropertyName("median")] + public double? Median { get; set; } + + /// + /// The posterior (or sampled) standard deviation. + /// + [JsonPropertyName("standardDeviation")] + public double? StandardDeviation { get; set; } + + /// + /// The lower credible-interval bound of the parameter. + /// + [JsonPropertyName("lowerCI")] + public double? LowerCI { get; set; } + + /// + /// The upper credible-interval bound of the parameter. + /// + [JsonPropertyName("upperCI")] + public double? UpperCI { get; set; } + + /// + /// The Gelman-Rubin convergence diagnostic (values near 1.0 indicate convergence; above + /// ~1.1 warrants more iterations). Null for non-chain methods (Bulletin 17C). + /// + [JsonPropertyName("rhat")] + public double? Rhat { get; set; } + + /// + /// The effective sample size of the posterior draws. Null for non-chain methods. + /// + [JsonPropertyName("ess")] + public double? Ess { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/ParameterValueDto.cs b/src/RMC.BestFit.Api/DTOs/Analyses/ParameterValueDto.cs new file mode 100644 index 0000000..d37f054 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/ParameterValueDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// A fitted model parameter: its name and point-estimate value. + /// + public class ParameterValueDto + { + /// + /// The parameter name (e.g., "Mu", "Sigma", or rating-curve names like the offset and exponents). + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// The point-estimate value of the parameter (per the configured point estimator). + /// + [JsonPropertyName("value")] + public double Value { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/QuantilePenaltyDto.cs b/src/RMC.BestFit.Api/DTOs/Analyses/QuantilePenaltyDto.cs new file mode 100644 index 0000000..2ea86ff --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/QuantilePenaltyDto.cs @@ -0,0 +1,50 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// A Gaussian penalty on a quantile of the fitted distribution in a Bulletin 17C analysis — + /// incorporates prior information about a flood magnitude at a specific annual exceedance + /// probability (e.g., a paleoflood-informed 500-year discharge) into the Expected Moments + /// Algorithm fit. + /// + /// + /// The penalty adds 0.5 × (quantile − mean)² / (mse × n) to the GMM objective, where n is the + /// total record length. With useLog10=true (the default, appropriate for flood discharges) + /// the quantile and mean are compared in log10 space, so mean and mse must be supplied in + /// log10 units. + /// + public class QuantilePenaltyDto + { + /// + /// The annual exceedance probability of the penalized quantile, strictly between 0 and 1 + /// (e.g., 0.002 for the 500-year event). + /// + [Range(0d, 1d)] + [JsonPropertyName("aep")] + public double Aep { get; set; } + + /// + /// The prior mean for the quantile — in log10 units when useLog10 is true (the default), + /// otherwise in real units. + /// + [JsonPropertyName("mean")] + public double Mean { get; set; } + + /// + /// The mean squared error (variance) of the prior mean, in the same (log10 or real) units + /// as the mean. Must be greater than 0; smaller values pull the fitted quantile more + /// strongly toward the mean. + /// + [JsonPropertyName("mse")] + public double Mse { get; set; } + + /// + /// True (default) to compare the quantile and mean in log10 space — the convention for + /// flood discharges. Set false to penalize in real space. + /// + [JsonPropertyName("useLog10")] + public bool UseLog10 { get; set; } = true; + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/QuantilePriorDto.cs b/src/RMC.BestFit.Api/DTOs/Analyses/QuantilePriorDto.cs new file mode 100644 index 0000000..fe30738 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/QuantilePriorDto.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// A prior distribution on a quantile of the parent distribution at a given annual exceedance + /// probability — the mechanism for incorporating engineering judgment about flood magnitudes + /// (e.g., "the 100-year flow is around 50,000 cfs, give or take") into Bayesian estimation. + /// + /// + /// Two formulations are supported via the request-level useSingleQuantile flag: a single + /// quantile prior (Viglione et al., 2013) or one prior per parent-distribution parameter + /// (Coles and Tawn, 1996). Supply priors at distinct exceedance probabilities. + /// + public class QuantilePriorDto + { + /// + /// The annual exceedance probability of the quantile the prior applies to, strictly + /// between 0 and 1 (e.g., 0.01 for the 100-year event). + /// + [Range(0d, 1d)] + [JsonPropertyName("alpha")] + public double Alpha { get; set; } + + /// + /// The prior distribution for the quantile magnitude (e.g., a log-normal prior centered + /// on the judged 100-year flow). + /// + [Required] + [JsonPropertyName("distribution")] + public DistributionSpecDto? Distribution { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/RatingCurveDto.cs b/src/RMC.BestFit.Api/DTOs/Analyses/RatingCurveDto.cs new file mode 100644 index 0000000..090fbc1 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/RatingCurveDto.cs @@ -0,0 +1,66 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// A fitted stage-discharge rating curve with uncertainty: discharge evaluated over a stage + /// grid, with point-estimate, posterior-mean, and credible-interval curves aligned + /// index-by-index to . + /// + public class RatingCurveDto + { + /// + /// The stage grid the discharge curves are evaluated at. + /// + [JsonPropertyName("stages")] + public List Stages { get; set; } = new(); + + /// + /// Discharge of the point-estimate curve, one per stage. + /// + [JsonPropertyName("modeCurve")] + public List? ModeCurve { get; set; } + + /// + /// Discharge of the posterior-mean curve, one per stage. + /// + [JsonPropertyName("meanCurve")] + public List? MeanCurve { get; set; } + + /// + /// Lower credible-interval discharge bound, one per stage. + /// + [JsonPropertyName("ciLower")] + public List? CiLower { get; set; } + + /// + /// Upper credible-interval discharge bound, one per stage. + /// + [JsonPropertyName("ciUpper")] + public List? CiUpper { get; set; } + + /// + /// The width of the reported credible interval (e.g., 0.90 for 90% intervals). + /// + [JsonPropertyName("credibleIntervalWidth")] + public double CredibleIntervalWidth { get; set; } + + /// + /// The minimum stage of the output grid. + /// + [JsonPropertyName("minStage")] + public double? MinStage { get; set; } + + /// + /// The maximum stage of the output grid. + /// + [JsonPropertyName("maxStage")] + public double? MaxStage { get; set; } + + /// + /// The number of stage grid points. + /// + [JsonPropertyName("stageBins")] + public int? StageBins { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/RatingCurveResultsResponse.cs b/src/RMC.BestFit.Api/DTOs/Analyses/RatingCurveResultsResponse.cs new file mode 100644 index 0000000..650656e --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/RatingCurveResultsResponse.cs @@ -0,0 +1,66 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Response body carrying the results of a rating curve analysis: the fitted stage-discharge + /// curve with uncertainty, the fitted parameters, and run diagnostics. + /// + public class RatingCurveResultsResponse : ResponseBase + { + /// + /// The id of the analysis the results belong to. + /// + [JsonPropertyName("analysisId")] + public Guid AnalysisId { get; set; } + + /// + /// The analysis kind (always "ratingCurve"). + /// + [JsonPropertyName("kind")] + public string? Kind { get; set; } + + /// + /// The fitted rating curve with credible intervals over the stage grid. + /// + [JsonPropertyName("ratingCurve")] + public RatingCurveDto? RatingCurve { get; set; } + + /// + /// The number of piecewise power-law segments in the fitted model. + /// + [JsonPropertyName("numberOfSegments")] + public int NumberOfSegments { get; set; } + + /// + /// The number of date-aligned (stage, discharge) observation pairs used in the fit. + /// + [JsonPropertyName("alignedObservationCount")] + public int AlignedObservationCount { get; set; } + + /// + /// The fitted model parameters at the point estimate (offset, log-coefficients, + /// exponents, breakpoints, error scale). + /// + [JsonPropertyName("parameters")] + public List Parameters { get; set; } = new(); + + /// + /// Posterior summaries per parameter, including convergence diagnostics. + /// + [JsonPropertyName("parameterSummaries")] + public List ParameterSummaries { get; set; } = new(); + + /// + /// Model-fit information criteria. + /// + [JsonPropertyName("informationCriteria")] + public InformationCriteriaDto? InformationCriteria { get; set; } + + /// + /// Run diagnostics (sampler settings, acceptance rates, convergence warnings, timing). + /// + [JsonPropertyName("diagnostics")] + public DiagnosticsDto? Diagnostics { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/TimeSeriesResultsResponse.cs b/src/RMC.BestFit.Api/DTOs/Analyses/TimeSeriesResultsResponse.cs new file mode 100644 index 0000000..dcc1130 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/TimeSeriesResultsResponse.cs @@ -0,0 +1,158 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Response body carrying time-series analysis results: the fitted-plus-forecast curve with + /// credible intervals, parameter posteriors, and information criteria. The curve spans the + /// observed series followed by the forecast horizon. + /// + public class TimeSeriesResultsResponse : ResponseBase + { + /// + /// The id of the analysis the results belong to. + /// + [JsonPropertyName("analysisId")] + public Guid AnalysisId { get; set; } + + /// + /// The analysis kind ("timeSeries"). + /// + [JsonPropertyName("kind")] + public string? Kind { get; set; } + + /// + /// The model family ("ar", "ma", "arima", "arimax"). + /// + [JsonPropertyName("modelType")] + public string? ModelType { get; set; } + + /// + /// The variance-stabilizing transform applied before modeling. + /// + [JsonPropertyName("transformType")] + public string? TransformType { get; set; } + + /// + /// The observed series length in time steps. + /// + [JsonPropertyName("dataLength")] + public int DataLength { get; set; } + + /// + /// The number of time steps used for training. + /// + [JsonPropertyName("trainingTimeSteps")] + public int TrainingTimeSteps { get; set; } + + /// + /// The number of forecast steps past the end of the observed series. + /// + [JsonPropertyName("forecastingTimeSteps")] + public int ForecastingTimeSteps { get; set; } + + /// + /// The AR/MA order for "ar"/"ma" models; null otherwise. + /// + [JsonPropertyName("order")] + public int? Order { get; set; } + + /// + /// The autoregressive order p for "arima"/"arimax" models; null otherwise. + /// + [JsonPropertyName("pOrder")] + public int? POrder { get; set; } + + /// + /// The differencing order d for "arima"/"arimax" models; null otherwise. + /// + [JsonPropertyName("dOrder")] + public int? DOrder { get; set; } + + /// + /// The moving-average order q for "arima"/"arimax" models; null otherwise. + /// + [JsonPropertyName("qOrder")] + public int? QOrder { get; set; } + + /// + /// The exogenous covariate order b for "arimax" models; null otherwise. + /// + [JsonPropertyName("xOrder")] + public int? XOrder { get; set; } + + /// + /// The fitted-plus-forecast curve with credible intervals. + /// + [JsonPropertyName("curve")] + public TimeSeriesCurveDto? Curve { get; set; } + + /// + /// The model parameter point estimates. + /// + [JsonPropertyName("parameters")] + public List Parameters { get; set; } = new(); + + /// + /// Posterior summaries per parameter, including convergence diagnostics. + /// + [JsonPropertyName("parameterSummaries")] + public List ParameterSummaries { get; set; } = new(); + + /// + /// Model-fit information criteria (computed on the training period). + /// + [JsonPropertyName("informationCriteria")] + public InformationCriteriaDto? InformationCriteria { get; set; } + + /// + /// Run diagnostics (sampler settings, acceptance rates, convergence warnings, timing). + /// + [JsonPropertyName("diagnostics")] + public DiagnosticsDto? Diagnostics { get; set; } + } + + /// + /// The fitted-plus-forecast curve of a time-series results response, aligned by time index. + /// Kept in this file as a minor supporting DTO tightly coupled to + /// . + /// + public class TimeSeriesCurveDto + { + /// + /// The time indices of the curve points (observed steps followed by forecast steps). + /// + [JsonPropertyName("timeIndices")] + public List TimeIndices { get; set; } = new(); + + /// + /// The predicted values at the point estimate, per time index. + /// + [JsonPropertyName("modeCurve")] + public List? ModeCurve { get; set; } + + /// + /// The posterior-mean predicted values, per time index. + /// + [JsonPropertyName("meanCurve")] + public List? MeanCurve { get; set; } + + /// + /// The lower credible bound, per time index. + /// + [JsonPropertyName("ciLower")] + public List? CiLower { get; set; } + + /// + /// The upper credible bound, per time index. + /// + [JsonPropertyName("ciUpper")] + public List? CiUpper { get; set; } + + /// + /// The credible interval width the bounds correspond to (e.g., 0.90). + /// + [JsonPropertyName("credibleIntervalWidth")] + public double CredibleIntervalWidth { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Analyses/XyOrdinateDto.cs b/src/RMC.BestFit.Api/DTOs/Analyses/XyOrdinateDto.cs new file mode 100644 index 0000000..238fadc --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Analyses/XyOrdinateDto.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// One (x, y) evaluation point of a bivariate analysis's joint-exceedance grid: the joint + /// probability P(X > x AND Y > y) is evaluated at each point. + /// + public class XyOrdinateDto + { + /// + /// The marginal-X magnitude of the evaluation point. + /// + [JsonPropertyName("x")] + public double X { get; set; } + + /// + /// The marginal-Y magnitude of the evaluation point. + /// + [JsonPropertyName("y")] + public double Y { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Common/ApiInfoDto.cs b/src/RMC.BestFit.Api/DTOs/Common/ApiInfoDto.cs new file mode 100644 index 0000000..87b1e14 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Common/ApiInfoDto.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Body of the GET api/info endpoint describing the service and its capabilities. + /// + public class ApiInfoDto + { + /// + /// The service name. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = "RMC-BestFit API"; + + /// + /// The API assembly version. + /// + [JsonPropertyName("version")] + public string? Version { get; set; } + + /// + /// A short description of what the service provides. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// The feature areas currently exposed by the service (e.g., "timeseries", "inputdata"). + /// + [JsonPropertyName("features")] + public List Features { get; set; } = new(); + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Common/DeleteResourceResponse.cs b/src/RMC.BestFit.Api/DTOs/Common/DeleteResourceResponse.cs new file mode 100644 index 0000000..456c55a --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Common/DeleteResourceResponse.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Response body confirming the deletion of a server-side resource. + /// + public class DeleteResourceResponse : ResponseBase + { + /// + /// The id of the resource that was deleted. + /// + [JsonPropertyName("deletedId")] + public Guid DeletedId { get; set; } + + /// + /// The type of the deleted resource ("timeSeries", "inputData", or "analysis"). + /// + [JsonPropertyName("resourceType")] + public string? ResourceType { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Common/DistributionSpecDto.cs b/src/RMC.BestFit.Api/DTOs/Common/DistributionSpecDto.cs new file mode 100644 index 0000000..8aa0853 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Common/DistributionSpecDto.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Numerics.Distributions; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// A fully specified univariate probability distribution: a type plus its parameter values in + /// the distribution's canonical order. Used wherever a request supplies a distribution as an + /// input — per-observation measurement-error distributions (uncertain data), parameter priors, + /// and quantile priors. + /// + /// + /// The canonical parameter order and names for each type are listed by + /// GET api/metadata/distributions (e.g., normal = [mean, standard deviation]; + /// triangular = [min, most likely, max]). Parameter values are validated by the model layer; + /// invalid values (e.g., a non-positive standard deviation) are rejected with HTTP 400. + /// + public class DistributionSpecDto + { + /// + /// The distribution type (camelCase string, e.g., "normal", "logNormal", "triangular", + /// "uniform", "pertPercentile"). Accepted values are listed by GET api/metadata/enums + /// under "priorDistributions". Default "normal". + /// + [JsonPropertyName("type")] + public UnivariateDistributionType Type { get; set; } = UnivariateDistributionType.Normal; + + /// + /// The parameter values in the distribution's canonical order (see + /// GET api/metadata/distributions for the names and order per type). The count must match + /// the distribution's parameter count exactly. + /// + [Required] + [JsonPropertyName("parameters")] + public List Parameters { get; set; } = new(); + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Common/HealthCheckDto.cs b/src/RMC.BestFit.Api/DTOs/Common/HealthCheckDto.cs new file mode 100644 index 0000000..cba2076 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Common/HealthCheckDto.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Body of the detailed health-check endpoint. + /// + public class HealthCheckDto + { + /// + /// The health status string ("healthy" when the host is serving requests). + /// + [JsonPropertyName("status")] + public string Status { get; set; } = "healthy"; + + /// + /// UTC timestamp at which the health check was evaluated. + /// + [JsonPropertyName("timestamp")] + public DateTime Timestamp { get; set; } + + /// + /// The API assembly version. + /// + [JsonPropertyName("version")] + public string? Version { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Common/ResourceSummaryDto.cs b/src/RMC.BestFit.Api/DTOs/Common/ResourceSummaryDto.cs new file mode 100644 index 0000000..e3cff92 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Common/ResourceSummaryDto.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// A one-line summary of any server-side resource, used by the cross-cutting resources + /// overview so agents can re-orient themselves mid-session. + /// + public class ResourceSummaryDto + { + /// + /// The resource id. + /// + [JsonPropertyName("id")] + public Guid Id { get; set; } + + /// + /// The resource type ("timeSeries", "inputData", or "analysis"). + /// + [JsonPropertyName("resourceType")] + public string? ResourceType { get; set; } + + /// + /// The resource display name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// UTC timestamp at which the resource was created. + /// + [JsonPropertyName("createdUtc")] + public DateTime CreatedUtc { get; set; } + + /// + /// A short type-specific detail line (e.g., point count for a time series, record length + /// for input data, kind and run state for an analysis). + /// + [JsonPropertyName("detail")] + public string? Detail { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Common/ResourcesOverviewResponse.cs b/src/RMC.BestFit.Api/DTOs/Common/ResourcesOverviewResponse.cs new file mode 100644 index 0000000..046ac4c --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Common/ResourcesOverviewResponse.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Response body of the cross-cutting GET api/resources endpoint listing every resource in the store. + /// + public class ResourcesOverviewResponse : ResponseBase + { + /// + /// The number of time-series resources currently held. + /// + [JsonPropertyName("timeSeriesCount")] + public int TimeSeriesCount { get; set; } + + /// + /// The number of input-data resources currently held. + /// + [JsonPropertyName("inputDataCount")] + public int InputDataCount { get; set; } + + /// + /// The number of analysis resources currently held. + /// + [JsonPropertyName("analysisCount")] + public int AnalysisCount { get; set; } + + /// + /// One-line summaries of every resource, ordered by creation time. + /// + [JsonPropertyName("resources")] + public List Resources { get; set; } = new(); + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Common/ResponseBase.cs b/src/RMC.BestFit.Api/DTOs/Common/ResponseBase.cs new file mode 100644 index 0000000..7692b8c --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Common/ResponseBase.cs @@ -0,0 +1,54 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Base class for every REST/MCP response body, carrying the shared success/error/diagnostic + /// contract used across the API (mirrors the Dam Screening Tool API response conventions). + /// + public abstract class ResponseBase + { + /// + /// True when the operation completed successfully; false when any error occurred. + /// + [JsonPropertyName("success")] + public bool Success { get; set; } = true; + + /// + /// A human-readable description of the failure when is false; otherwise null. + /// + [JsonPropertyName("errorMessage")] + public string? ErrorMessage { get; set; } + + /// + /// Individual validation error messages when the request failed model-layer validation; otherwise null. + /// + [JsonPropertyName("validationErrors")] + public List? ValidationErrors { get; set; } + + /// + /// Non-fatal validation warnings (e.g., convergence diagnostics, partial block years); otherwise null. + /// + [JsonPropertyName("validationWarnings")] + public List? ValidationWarnings { get; set; } + + /// + /// Wall-clock time the server spent handling the request, in milliseconds. + /// + [JsonPropertyName("computationTimeMs")] + public long? ComputationTimeMs { get; set; } + + /// + /// UTC timestamp of the response in ISO 8601 round-trip ("O") format. + /// + [JsonPropertyName("timestamp")] + public string? Timestamp { get; set; } + + /// + /// Paths of ±Infinity values detected in the response by the finite auditor, when the + /// server rejected its own response as non-finite; otherwise null. + /// + [JsonPropertyName("nonFiniteFindings")] + public List? NonFiniteFindings { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Common/ValidationResponse.cs b/src/RMC.BestFit.Api/DTOs/Common/ValidationResponse.cs new file mode 100644 index 0000000..76cf425 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Common/ValidationResponse.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Response body of the validate endpoints: the model-layer validation verdict for an + /// analysis configuration, without running the analysis. + /// + public class ValidationResponse + { + /// + /// True when the configuration passes model-layer validation and the analysis can run. + /// + [JsonPropertyName("isValid")] + public bool IsValid { get; set; } + + /// + /// The validation error messages when invalid; empty when valid. + /// + [JsonPropertyName("errors")] + public List Errors { get; set; } = new(); + + /// + /// Non-fatal validation warnings, when any. + /// + [JsonPropertyName("warnings")] + public List Warnings { get; set; } = new(); + } +} diff --git a/src/RMC.BestFit.Api/DTOs/InputData/BlockOptionsDto.cs b/src/RMC.BestFit.Api/DTOs/InputData/BlockOptionsDto.cs new file mode 100644 index 0000000..8c33dc1 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/InputData/BlockOptionsDto.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Echo of the block-maxima extraction options an input-data resource was created with. + /// + public class BlockOptionsDto + { + /// + /// The block window ("waterYear", "calendarYear", "customYear", "quarter", "month"). + /// + [JsonPropertyName("timeBlock")] + public string? TimeBlock { get; set; } + + /// + /// The function computed over each block ("maximum", "minimum", ...). + /// + [JsonPropertyName("blockFunction")] + public string? BlockFunction { get; set; } + + /// + /// The smoothing function applied before extraction ("none", "movingAverage", ...). + /// + [JsonPropertyName("smoothingFunction")] + public string? SmoothingFunction { get; set; } + + /// + /// The starting month of the water/custom year window. + /// + [JsonPropertyName("startMonth")] + public int? StartMonth { get; set; } + + /// + /// The ending month of the custom year window. + /// + [JsonPropertyName("endMonth")] + public int? EndMonth { get; set; } + + /// + /// The smoothing period in time steps. + /// + [JsonPropertyName("period")] + public int? Period { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/InputData/CreateBlockMaxInputDataRequest.cs b/src/RMC.BestFit.Api/DTOs/InputData/CreateBlockMaxInputDataRequest.cs new file mode 100644 index 0000000..2b8554c --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/InputData/CreateBlockMaxInputDataRequest.cs @@ -0,0 +1,79 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Numerics.Data; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Request body for creating an input-data resource by extracting block maxima (e.g., annual + /// maxima by water year) from an existing time-series resource, typically a USGS daily-flow download. + /// + public class CreateBlockMaxInputDataRequest + { + /// + /// The id of the time-series resource to extract block maxima from (create it first via + /// POST api/timeseries/usgs or api/timeseries/manual). + /// + [Required] + [JsonPropertyName("timeSeriesId")] + public Guid TimeSeriesId { get; set; } + + /// + /// Optional display name for the resource. Defaults to "Block maxima of {source name}". + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Optional description stored with the resource. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// The block window over which the function is computed. "waterYear" (default, Oct-Sep in + /// the U.S., controlled by startMonth), "calendarYear", "customYear" (startMonth-endMonth), + /// "quarter", or "month". + /// + [JsonPropertyName("timeBlock")] + public TimeBlockWindow TimeBlock { get; set; } = TimeBlockWindow.WaterYear; + + /// + /// The function computed over each block. "maximum" (default) for flood frequency; other + /// options include minimum, mean, and sum depending on the application. + /// + [JsonPropertyName("blockFunction")] + public BlockFunctionType BlockFunction { get; set; } = BlockFunctionType.Maximum; + + /// + /// Optional smoothing applied to the source series before block extraction (e.g., a moving + /// average for n-day flows). Default "none". + /// + [JsonPropertyName("smoothingFunction")] + public SmoothingFunctionType SmoothingFunction { get; set; } = SmoothingFunctionType.None; + + /// + /// The starting month of the water/custom year window (1-12). Default 10 (October, the + /// U.S. water year convention). + /// + [Range(1, 12)] + [JsonPropertyName("startMonth")] + public int StartMonth { get; set; } = 10; + + /// + /// The ending month of the custom year window (1-12). Only used when timeBlock is + /// "customYear". Default 9 (September). + /// + [Range(1, 12)] + [JsonPropertyName("endMonth")] + public int EndMonth { get; set; } = 9; + + /// + /// The smoothing period in time steps (e.g., 7 for a 7-day moving average of a daily + /// series). Only used when smoothingFunction is not "none". Default 1. + /// + [Range(1, int.MaxValue)] + [JsonPropertyName("period")] + public int Period { get; set; } = 1; + } +} diff --git a/src/RMC.BestFit.Api/DTOs/InputData/CreateManualInputDataRequest.cs b/src/RMC.BestFit.Api/DTOs/InputData/CreateManualInputDataRequest.cs new file mode 100644 index 0000000..826b7a3 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/InputData/CreateManualInputDataRequest.cs @@ -0,0 +1,79 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Request body for creating an input-data resource from client-supplied observations + /// (systematic record, optionally with uncertain, interval-censored, and perception-threshold + /// data). + /// + public class CreateManualInputDataRequest + { + /// + /// Optional display name for the resource. Defaults to "Manual input data". + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Optional description stored with the resource. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// The exact (uncensored) observations, e.g., annual peak discharges by water year. + /// + [Required] + [MinLength(1)] + [JsonPropertyName("exactData")] + public List ExactData { get; set; } = new(); + + /// + /// Optional uncertain observations whose magnitudes are known only as measurement-error + /// distributions (e.g., paleoflood estimates). Bayesian estimation propagates the full + /// distributions through the likelihood. An uncertain observation may not share a time + /// index with an exact observation. + /// + [JsonPropertyName("uncertainData")] + public List? UncertainData { get; set; } + + /// + /// Optional interval-censored observations (magnitude known only within bounds). + /// + [JsonPropertyName("intervalData")] + public List? IntervalData { get; set; } + + /// + /// Optional perception-threshold records describing historical periods when only floods + /// above a threshold would have been observed. + /// + [JsonPropertyName("thresholdData")] + public List? ThresholdData { get; set; } + + /// + /// The plotting-position parameter "a" in the general formula p = (i - a) / (n + 1 - 2a). + /// 0 = Weibull (default), 0.375 = Blom, 0.40 = Cunnane, 0.44 = Gringorten, 0.5 = Hazen. + /// Affects display positions only, not the fit. + /// + [Range(0.0, 0.5)] + [JsonPropertyName("plottingParameter")] + public double? PlottingParameter { get; set; } + + /// + /// Optional low-outlier threshold: exact observations at or below this magnitude are + /// flagged as low outliers and censored during fitting. + /// + [JsonPropertyName("lowOutlierThreshold")] + public double? LowOutlierThreshold { get; set; } + + /// + /// The average number of events per time index (λ). Leave null for annual-maximum data + /// (computed from the record, ≈1); set explicitly for peaks-over-threshold data entered + /// manually (events per year). + /// + [JsonPropertyName("lambda")] + public double? Lambda { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/InputData/CreatePotInputDataRequest.cs b/src/RMC.BestFit.Api/DTOs/InputData/CreatePotInputDataRequest.cs new file mode 100644 index 0000000..588b173 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/InputData/CreatePotInputDataRequest.cs @@ -0,0 +1,72 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Numerics.Data; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Request body for creating an input-data resource by extracting independent + /// peaks-over-threshold (POT) events from an existing time-series resource. + /// + public class CreatePotInputDataRequest + { + /// + /// The id of the time-series resource to extract peaks from (create it first via + /// POST api/timeseries/usgs or api/timeseries/manual). + /// + [Required] + [JsonPropertyName("timeSeriesId")] + public Guid TimeSeriesId { get; set; } + + /// + /// Optional display name for the resource. Defaults to "POT of {source name}". + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Optional description stored with the resource. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// The threshold magnitude; peaks above this value are recorded as events. Choose it so + /// the average number of events per year (λ, reported on the created resource) is roughly + /// 1-3 for typical POT analyses. + /// + [Required] + [JsonPropertyName("threshold")] + public double Threshold { get; set; } + + /// + /// The minimum number of time steps between recorded peaks, used to enforce event + /// independence (e.g., 7 for daily data requires a week between peaks). Default 1. + /// + [Range(1, int.MaxValue)] + [JsonPropertyName("minStepsBetweenPeaks")] + public int MinStepsBetweenPeaks { get; set; } = 1; + + /// + /// Optional smoothing applied to the source series before peak extraction. Default "none". + /// + [JsonPropertyName("smoothingFunction")] + public SmoothingFunctionType SmoothingFunction { get; set; } = SmoothingFunctionType.None; + + /// + /// The smoothing period in time steps. Only used when smoothingFunction is not "none". Default 1. + /// + [Range(1, int.MaxValue)] + [JsonPropertyName("period")] + public int Period { get; set; } = 1; + + /// + /// Optional explicit average number of events per year (λ). When omitted, the model layer + /// computes λ from the extracted peaks (events divided by the span of peak years). Set this + /// to events divided by the full record length when peak-less years at the record edges + /// should count toward the rate. + /// + [JsonPropertyName("lambda")] + public double? Lambda { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/InputData/CreateUsgsPeaksInputDataRequest.cs b/src/RMC.BestFit.Api/DTOs/InputData/CreateUsgsPeaksInputDataRequest.cs new file mode 100644 index 0000000..9f05ca2 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/InputData/CreateUsgsPeaksInputDataRequest.cs @@ -0,0 +1,40 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Numerics.Data; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Request body for creating an input-data resource by downloading the annual peak-flow file + /// directly from the USGS (no intermediate time-series resource required). + /// + public class CreateUsgsPeaksInputDataRequest + { + /// + /// The 8-digit USGS surface-water site number (e.g., "01646500"). + /// + [Required] + [JsonPropertyName("siteNumber")] + public string SiteNumber { get; set; } = string.Empty; + + /// + /// The peak series to download: "peakDischarge" (default) or "peakStage". Other series + /// types are rejected — use POST api/timeseries/usgs followed by api/inputdata/block-max + /// for daily records. + /// + [JsonPropertyName("seriesType")] + public TimeSeriesDownload.TimeSeriesType SeriesType { get; set; } = TimeSeriesDownload.TimeSeriesType.PeakDischarge; + + /// + /// Optional display name for the resource. Defaults to "USGS {siteNumber} peaks". + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Optional description stored with the resource. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/InputData/ExactObservationDto.cs b/src/RMC.BestFit.Api/DTOs/InputData/ExactObservationDto.cs new file mode 100644 index 0000000..e9d0310 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/InputData/ExactObservationDto.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// An exact (uncensored) observation. In requests, supply either (the + /// integer time index, typically the water year) or (from which the + /// year is taken). In responses, carries the computed value. + /// + public class ExactObservationDto + { + /// + /// The integer time index of the observation, typically the water year (e.g., 1996). + /// Required in requests unless is supplied. + /// + [JsonPropertyName("index")] + public int? Index { get; set; } + + /// + /// The date-time of the observation; its year is used as the time index. Alternative to + /// in requests. + /// + [JsonPropertyName("dateTime")] + public DateTime? DateTime { get; set; } + + /// + /// The observed magnitude (e.g., peak discharge in cfs). + /// + [JsonPropertyName("value")] + public double Value { get; set; } + + /// + /// True to mark the observation as a low outlier excluded from fitting (e.g., by the + /// Multiple Grubbs-Beck test in the desktop application). Default false. + /// + [JsonPropertyName("isLowOutlier")] + public bool IsLowOutlier { get; set; } + + /// + /// The computed plotting position (exceedance probability) of the observation. Populated + /// in responses; ignored in requests (positions are always recomputed from the data frame's + /// plotting parameter). + /// + [JsonPropertyName("plottingPosition")] + public double? PlottingPosition { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/InputData/InputDataListResponse.cs b/src/RMC.BestFit.Api/DTOs/InputData/InputDataListResponse.cs new file mode 100644 index 0000000..d872e3a --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/InputData/InputDataListResponse.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Response body listing all input-data resources currently held by the server. + /// + public class InputDataListResponse : ResponseBase + { + /// + /// The number of input-data resources. + /// + [JsonPropertyName("count")] + public int Count { get; set; } + + /// + /// Summaries of every input-data resource, ordered by creation time. + /// + [JsonPropertyName("inputData")] + public List InputData { get; set; } = new(); + } +} diff --git a/src/RMC.BestFit.Api/DTOs/InputData/InputDataResourceResponse.cs b/src/RMC.BestFit.Api/DTOs/InputData/InputDataResourceResponse.cs new file mode 100644 index 0000000..1cefad0 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/InputData/InputDataResourceResponse.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Response body carrying a single input-data resource summary, optionally with the full + /// observation lists (includeData=true). + /// + public class InputDataResourceResponse : ResponseBase + { + /// + /// The input-data resource summary. + /// + [JsonPropertyName("inputData")] + public InputDataSummaryDto? InputData { get; set; } + + /// + /// The exact observations with computed plotting positions, when requested; otherwise null. + /// + [JsonPropertyName("exactData")] + public List? ExactData { get; set; } + + /// + /// The uncertain observations with their measurement-error distributions, when requested + /// and present; otherwise null. + /// + [JsonPropertyName("uncertainData")] + public List? UncertainData { get; set; } + + /// + /// The interval-censored observations, when requested and present; otherwise null. + /// + [JsonPropertyName("intervalData")] + public List? IntervalData { get; set; } + + /// + /// The perception-threshold records, when requested and present; otherwise null. + /// + [JsonPropertyName("thresholdData")] + public List? ThresholdData { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/InputData/InputDataSummaryDto.cs b/src/RMC.BestFit.Api/DTOs/InputData/InputDataSummaryDto.cs new file mode 100644 index 0000000..1298153 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/InputData/InputDataSummaryDto.cs @@ -0,0 +1,123 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Summary of an input-data resource: identity, provenance, record composition, and the + /// settings that shape the likelihood (lambda, plotting parameter, low-outlier threshold). + /// + public class InputDataSummaryDto + { + /// + /// The resource id used to reference this input data when creating analyses. + /// + [JsonPropertyName("id")] + public Guid Id { get; set; } + + /// + /// The resource display name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// The resource description, if any. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// UTC timestamp at which the resource was created. + /// + [JsonPropertyName("createdUtc")] + public DateTime CreatedUtc { get; set; } + + /// + /// How the exact series was created ("manual", "blockMaxima", "peaksOverThreshold", + /// "usgsPeakDischarge", "usgsPeakStage"). + /// + [JsonPropertyName("method")] + public string? Method { get; set; } + + /// + /// The id of the source time-series resource, when the data was derived from one. + /// May reference a since-deleted resource. + /// + [JsonPropertyName("sourceTimeSeriesId")] + public Guid? SourceTimeSeriesId { get; set; } + + /// + /// The USGS site number, when the data was downloaded directly from the USGS peak-flow file. + /// + [JsonPropertyName("usgsSiteNumber")] + public string? UsgsSiteNumber { get; set; } + + /// + /// The total record length in time indices (systematic record plus censored periods). + /// + [JsonPropertyName("recordLength")] + public int RecordLength { get; set; } + + /// + /// The number of exact observations. + /// + [JsonPropertyName("exactCount")] + public int ExactCount { get; set; } + + /// + /// The number of uncertain observations (magnitudes described by measurement-error + /// distributions). + /// + [JsonPropertyName("uncertainCount")] + public int UncertainCount { get; set; } + + /// + /// The number of interval-censored observations. + /// + [JsonPropertyName("intervalCount")] + public int IntervalCount { get; set; } + + /// + /// The number of perception-threshold records. + /// + [JsonPropertyName("thresholdCount")] + public int ThresholdCount { get; set; } + + /// + /// The average number of events per time index (λ). ≈1 for annual maxima; the events-per-year + /// rate for peaks-over-threshold data. + /// + [JsonPropertyName("lambda")] + public double Lambda { get; set; } + + /// + /// The plotting-position parameter "a" (0 = Weibull, 0.375 = Blom, 0.44 = Gringorten, ...). + /// + [JsonPropertyName("plottingParameter")] + public double PlottingParameter { get; set; } + + /// + /// The low-outlier threshold, when one is set; observations at or below it are censored. + /// + [JsonPropertyName("lowOutlierThreshold")] + public double? LowOutlierThreshold { get; set; } + + /// + /// The number of exact observations flagged as low outliers. + /// + [JsonPropertyName("lowOutlierCount")] + public int LowOutlierCount { get; set; } + + /// + /// The block-maxima extraction options, when method is "blockMaxima"; otherwise null. + /// + [JsonPropertyName("blockOptions")] + public BlockOptionsDto? BlockOptions { get; set; } + + /// + /// The peaks-over-threshold extraction options, when method is "peaksOverThreshold"; otherwise null. + /// + [JsonPropertyName("potOptions")] + public PotOptionsDto? PotOptions { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/InputData/IntervalObservationDto.cs b/src/RMC.BestFit.Api/DTOs/InputData/IntervalObservationDto.cs new file mode 100644 index 0000000..b0cb15e --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/InputData/IntervalObservationDto.cs @@ -0,0 +1,43 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// An interval-censored observation: the true magnitude is known only to lie between + /// and (e.g., a historical flood bracketed + /// by high-water marks). + /// + public class IntervalObservationDto + { + /// + /// The integer time index of the observation, typically the water year. + /// + [JsonPropertyName("index")] + public int Index { get; set; } + + /// + /// The lower bound of the interval. + /// + [JsonPropertyName("lowerBound")] + public double LowerBound { get; set; } + + /// + /// The upper bound of the interval. + /// + [JsonPropertyName("upperBound")] + public double UpperBound { get; set; } + + /// + /// The representative value used for plotting. Optional in requests; defaults to the + /// interval midpoint. + /// + [JsonPropertyName("value")] + public double? Value { get; set; } + + /// + /// The computed plotting position of the observation. Populated in responses; ignored in requests. + /// + [JsonPropertyName("plottingPosition")] + public double? PlottingPosition { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/InputData/PotOptionsDto.cs b/src/RMC.BestFit.Api/DTOs/InputData/PotOptionsDto.cs new file mode 100644 index 0000000..2f9442a --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/InputData/PotOptionsDto.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Echo of the peaks-over-threshold extraction options an input-data resource was created with. + /// + public class PotOptionsDto + { + /// + /// The threshold magnitude above which peaks were recorded. + /// + [JsonPropertyName("threshold")] + public double? Threshold { get; set; } + + /// + /// The minimum number of time steps enforced between independent peaks. + /// + [JsonPropertyName("minStepsBetweenPeaks")] + public int? MinStepsBetweenPeaks { get; set; } + + /// + /// The smoothing function applied before extraction ("none", "movingAverage", ...). + /// + [JsonPropertyName("smoothingFunction")] + public string? SmoothingFunction { get; set; } + + /// + /// The smoothing period in time steps. + /// + [JsonPropertyName("period")] + public int? Period { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/InputData/SummaryStatisticsResponse.cs b/src/RMC.BestFit.Api/DTOs/InputData/SummaryStatisticsResponse.cs new file mode 100644 index 0000000..a8d4830 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/InputData/SummaryStatisticsResponse.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Response body carrying the sample summary statistics of an input-data resource + /// (record length, mean, standard deviation, skew, and related measures computed by the model layer). + /// + public class SummaryStatisticsResponse : ResponseBase + { + /// + /// The id of the input-data resource the statistics describe. + /// + [JsonPropertyName("inputDataId")] + public Guid InputDataId { get; set; } + + /// + /// The summary statistics keyed by measure name, as computed by the model layer over all + /// data (exact, interval, and threshold observations). Values may be NaN when a measure is + /// undefined for the data set. + /// + [JsonPropertyName("statistics")] + public Dictionary Statistics { get; set; } = new(); + } +} diff --git a/src/RMC.BestFit.Api/DTOs/InputData/ThresholdObservationDto.cs b/src/RMC.BestFit.Api/DTOs/InputData/ThresholdObservationDto.cs new file mode 100644 index 0000000..e5591d4 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/InputData/ThresholdObservationDto.cs @@ -0,0 +1,52 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// A perception-threshold (censored) record: over the window from to + /// , floods exceeding would have been observed, and + /// of them were. Years below the threshold are treated as censored. + /// Used to incorporate historical and paleoflood information. + /// + public class ThresholdObservationDto + { + /// + /// The first time index (water year) of the threshold window. + /// + [JsonPropertyName("startIndex")] + public int StartIndex { get; set; } + + /// + /// The last time index (water year) of the threshold window. + /// + [JsonPropertyName("endIndex")] + public int EndIndex { get; set; } + + /// + /// The perception threshold magnitude: floods above this value would have been recorded + /// during the window. + /// + [JsonPropertyName("value")] + public double Value { get; set; } + + /// + /// The number of observations above the threshold during the window. These are typically + /// entered separately as exact or interval observations. Default 0. + /// + [JsonPropertyName("numberAbove")] + public int NumberAbove { get; set; } + + /// + /// The number of censored years below the threshold. Computed by the model layer from the + /// window duration and overlapping observations; populated in responses and ignored in requests. + /// + [JsonPropertyName("numberBelow")] + public int? NumberBelow { get; set; } + + /// + /// The computed plotting position of the threshold. Populated in responses; ignored in requests. + /// + [JsonPropertyName("plottingPosition")] + public double? PlottingPosition { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/InputData/UncertainObservationDto.cs b/src/RMC.BestFit.Api/DTOs/InputData/UncertainObservationDto.cs new file mode 100644 index 0000000..14b0662 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/InputData/UncertainObservationDto.cs @@ -0,0 +1,60 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// An uncertain observation: the magnitude is known only as a probability distribution + /// describing its measurement error (e.g., a paleoflood estimate expressed as a triangular or + /// log-normal distribution). Bayesian estimation propagates the full distribution through the + /// likelihood; the distribution's mean serves as the nominal value for plotting positions and + /// summary statistics. + /// + /// + /// In requests, supply either or plus the + /// measurement-error . An uncertain observation may not share a time + /// index with an exact observation. In responses, and + /// carry the computed nominal value and plotting position. + /// Note the Bulletin 17C analysis does not use uncertain observations (its Expected Moments + /// Algorithm has no measurement-error likelihood); use the univariate analysis instead. + /// + public class UncertainObservationDto + { + /// + /// The integer time index of the observation, typically the water year (e.g., 1875 for a + /// historical flood). Required in requests unless is supplied. + /// + [JsonPropertyName("index")] + public int? Index { get; set; } + + /// + /// The date-time of the observation; its year is used as the time index. Alternative to + /// in requests. + /// + [JsonPropertyName("dateTime")] + public DateTime? DateTime { get; set; } + + /// + /// The measurement-error distribution of the observed magnitude (e.g., triangular with + /// [min, most likely, max] from a paleoflood study, or normal with [best estimate, + /// standard error]). + /// + [Required] + [JsonPropertyName("distribution")] + public DistributionSpecDto? Distribution { get; set; } + + /// + /// The nominal magnitude (the mean of the measurement-error distribution). Populated in + /// responses; ignored in requests. + /// + [JsonPropertyName("value")] + public double? Value { get; set; } + + /// + /// The computed plotting position (exceedance probability) of the observation. Populated + /// in responses; ignored in requests. + /// + [JsonPropertyName("plottingPosition")] + public double? PlottingPosition { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Metadata/DefaultsResponse.cs b/src/RMC.BestFit.Api/DTOs/Metadata/DefaultsResponse.cs new file mode 100644 index 0000000..4ae364b --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Metadata/DefaultsResponse.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Response body describing the server defaults and limits that shape analysis requests. + /// + public class DefaultsResponse : ResponseBase + { + /// + /// The default exceedance-probability ordinates (annual exceedance probabilities, AEP) at + /// which frequency curves are evaluated when a request does not supply its own. + /// + [JsonPropertyName("probabilityOrdinates")] + public List ProbabilityOrdinates { get; set; } = new(); + + /// + /// The maximum number of MCMC iterations a run request may specify. + /// + [JsonPropertyName("maxIterations")] + public int MaxIterations { get; set; } + + /// + /// The maximum number of analyses the server will run concurrently; additional run + /// requests queue until a slot frees up. + /// + [JsonPropertyName("maxConcurrentRuns")] + public int MaxConcurrentRuns { get; set; } + + /// + /// The maximum total number of resources the in-memory store will hold. + /// + [JsonPropertyName("maxResources")] + public int MaxResources { get; set; } + + /// + /// Human-readable notes on conventions (probability convention, run behavior, persistence). + /// + [JsonPropertyName("notes")] + public List Notes { get; set; } = new(); + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Metadata/DistributionInfoDto.cs b/src/RMC.BestFit.Api/DTOs/Metadata/DistributionInfoDto.cs new file mode 100644 index 0000000..847162b --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Metadata/DistributionInfoDto.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Describes one probability distribution available for fitting, including which analysis + /// kinds support it. + /// + public class DistributionInfoDto + { + /// + /// The camelCase enum name accepted by create-analysis requests (e.g., "logPearsonTypeIII"). + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// The human-readable distribution name (e.g., "Log-Pearson Type III"). + /// + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + /// + /// The analysis kinds that accept this distribution ("univariate" and/or "bulletin17c"). + /// + [JsonPropertyName("supportedBy")] + public List SupportedBy { get; set; } = new(); + + /// + /// The canonical parameter names in order (e.g., ["Mean (of log)", "Std Dev (of log)", + /// "Skew (of log)"]) — the order distribution specs supply parameter values in, and the + /// names parameter priors and Bulletin 17C parameter penalties are matched against. + /// + [JsonPropertyName("parameterNames")] + public List ParameterNames { get; set; } = new(); + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Metadata/DistributionsResponse.cs b/src/RMC.BestFit.Api/DTOs/Metadata/DistributionsResponse.cs new file mode 100644 index 0000000..848ce0a --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Metadata/DistributionsResponse.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Response body listing the probability distributions available for fitting. + /// + public class DistributionsResponse : ResponseBase + { + /// + /// The available distributions with the analysis kinds that support each. + /// + [JsonPropertyName("distributions")] + public List Distributions { get; set; } = new(); + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Metadata/EnumOptionsResponse.cs b/src/RMC.BestFit.Api/DTOs/Metadata/EnumOptionsResponse.cs new file mode 100644 index 0000000..fc0954e --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Metadata/EnumOptionsResponse.cs @@ -0,0 +1,134 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Response body listing the accepted string values for every enum-typed request field, in the + /// camelCase form the JSON contract expects. Agents should consult this before constructing requests. + /// + public class EnumOptionsResponse : ResponseBase + { + /// + /// MCMC sampler types accepted by bayesianOptions.sampler. + /// + [JsonPropertyName("samplers")] + public List Samplers { get; set; } = new(); + + /// + /// Point estimator types accepted by bayesianOptions.pointEstimator. + /// + [JsonPropertyName("pointEstimators")] + public List PointEstimators { get; set; } = new(); + + /// + /// Uncertainty quantification methods accepted by Bulletin 17C create requests. + /// + [JsonPropertyName("uncertaintyMethods")] + public List UncertaintyMethods { get; set; } = new(); + + /// + /// Time-block windows accepted by block-maxima input-data requests. + /// + [JsonPropertyName("timeBlockWindows")] + public List TimeBlockWindows { get; set; } = new(); + + /// + /// Block functions accepted by block-maxima input-data requests. + /// + [JsonPropertyName("blockFunctions")] + public List BlockFunctions { get; set; } = new(); + + /// + /// Smoothing functions accepted by block-maxima and peaks-over-threshold requests. + /// + [JsonPropertyName("smoothingFunctions")] + public List SmoothingFunctions { get; set; } = new(); + + /// + /// USGS series types accepted by time-series and USGS-peaks input-data requests. + /// + [JsonPropertyName("usgsSeriesTypes")] + public List UsgsSeriesTypes { get; set; } = new(); + + /// + /// Recording intervals accepted by manual time-series requests. + /// + [JsonPropertyName("timeIntervals")] + public List TimeIntervals { get; set; } = new(); + + /// + /// The analysis kinds exposed by the API. + /// + [JsonPropertyName("analysisKinds")] + public List AnalysisKinds { get; set; } = new(); + + /// + /// The input-data creation methods exposed by the API. + /// + [JsonPropertyName("inputDataMethods")] + public List InputDataMethods { get; set; } = new(); + + /// + /// Distribution types accepted in distribution specs (uncertain-observation + /// measurement-error distributions, parameter priors, and quantile priors) — every type + /// the distribution factory can construct, a superset of the fitting distributions. + /// + [JsonPropertyName("priorDistributions")] + public List PriorDistributions { get; set; } = new(); + + /// + /// Composition methods accepted by composite-analysis create requests. + /// + [JsonPropertyName("compositeTypes")] + public List CompositeTypes { get; set; } = new(); + + /// + /// Model-average weighting methods accepted by composite-analysis create requests. + /// + [JsonPropertyName("averageMethods")] + public List AverageMethods { get; set; } = new(); + + /// + /// Competing risks dependence assumptions accepted by composite-analysis create requests. + /// + [JsonPropertyName("dependencyTypes")] + public List DependencyTypes { get; set; } = new(); + + /// + /// Copula families accepted by bivariate-analysis create requests. + /// + [JsonPropertyName("copulaTypes")] + public List CopulaTypes { get; set; } = new(); + + /// + /// Copula estimation methods accepted by bivariate-analysis create requests + /// ("fullLikelihood" is listed for completeness but rejected by the bivariate analysis). + /// + [JsonPropertyName("copulaEstimationMethods")] + public List CopulaEstimationMethods { get; set; } = new(); + + /// + /// Time-series model families accepted by time-series create requests. + /// + [JsonPropertyName("timeSeriesModelTypes")] + public List TimeSeriesModelTypes { get; set; } = new(); + + /// + /// Variance-stabilizing transforms accepted by time-series create requests. + /// + [JsonPropertyName("transformTypes")] + public List TransformTypes { get; set; } = new(); + + /// + /// Deterministic trend types accepted by ARIMAX create requests. + /// + [JsonPropertyName("trendTypes")] + public List TrendTypes { get; set; } = new(); + + /// + /// Covariate extension methods accepted by ARIMAX create requests. + /// + [JsonPropertyName("covariateExtensions")] + public List CovariateExtensions { get; set; } = new(); + } +} diff --git a/src/RMC.BestFit.Api/DTOs/TimeSeries/CreateManualTimeSeriesRequest.cs b/src/RMC.BestFit.Api/DTOs/TimeSeries/CreateManualTimeSeriesRequest.cs new file mode 100644 index 0000000..186dd42 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/TimeSeries/CreateManualTimeSeriesRequest.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Numerics.Data; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Request body for creating a time-series resource from client-supplied points. + /// + public class CreateManualTimeSeriesRequest + { + /// + /// Optional display name for the resource. Defaults to "Manual time series". + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Optional description stored with the resource. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// The recording interval of the series. Use "oneDay" for daily records; use "irregular" + /// for discrete measurements that do not fall on a fixed interval (e.g., field measurements + /// for a rating curve). + /// + [JsonPropertyName("timeInterval")] + public TimeInterval TimeInterval { get; set; } = TimeInterval.OneDay; + + /// + /// The ordinates of the series. Points are sorted by date-time before the series is built. + /// Use NaN values to mark missing observations. + /// + [Required] + [MinLength(1)] + [JsonPropertyName("points")] + public List Points { get; set; } = new(); + } +} diff --git a/src/RMC.BestFit.Api/DTOs/TimeSeries/CreateUsgsTimeSeriesRequest.cs b/src/RMC.BestFit.Api/DTOs/TimeSeries/CreateUsgsTimeSeriesRequest.cs new file mode 100644 index 0000000..1ab6149 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/TimeSeries/CreateUsgsTimeSeriesRequest.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Numerics.Data; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Request body for creating a time-series resource by downloading data from the USGS water services. + /// + public class CreateUsgsTimeSeriesRequest + { + /// + /// The 8-digit USGS surface-water site number (e.g., "01646500" for the Potomac River at + /// Little Falls). The first 2 digits are the part number and the following 6 the + /// downstream-order number. + /// + [Required] + [JsonPropertyName("siteNumber")] + public string SiteNumber { get; set; } = string.Empty; + + /// + /// The series to download. Use "dailyDischarge"/"dailyStage" for daily mean records (input + /// to block-maxima extraction), "peakDischarge"/"peakStage" for the annual peak-flow file, + /// and "measuredDischarge"/"measuredStage" for discrete field measurements (the inputs to a + /// rating curve analysis). + /// + [JsonPropertyName("seriesType")] + public TimeSeriesDownload.TimeSeriesType SeriesType { get; set; } = TimeSeriesDownload.TimeSeriesType.DailyDischarge; + + /// + /// Optional display name for the resource. Defaults to "USGS {siteNumber} {seriesType}". + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Optional description stored with the resource. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/TimeSeries/TimeSeriesListResponse.cs b/src/RMC.BestFit.Api/DTOs/TimeSeries/TimeSeriesListResponse.cs new file mode 100644 index 0000000..e7280c7 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/TimeSeries/TimeSeriesListResponse.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Response body listing all time-series resources currently held by the server. + /// + public class TimeSeriesListResponse : ResponseBase + { + /// + /// The number of time-series resources. + /// + [JsonPropertyName("count")] + public int Count { get; set; } + + /// + /// Summaries of every time-series resource, ordered by creation time. + /// + [JsonPropertyName("timeSeries")] + public List TimeSeries { get; set; } = new(); + } +} diff --git a/src/RMC.BestFit.Api/DTOs/TimeSeries/TimeSeriesPointDto.cs b/src/RMC.BestFit.Api/DTOs/TimeSeries/TimeSeriesPointDto.cs new file mode 100644 index 0000000..43417b8 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/TimeSeries/TimeSeriesPointDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// A single time-series ordinate (date-time and value). + /// + public class TimeSeriesPointDto + { + /// + /// The date-time of the ordinate. + /// + [JsonPropertyName("dateTime")] + public DateTime DateTime { get; set; } + + /// + /// The observed value at the ordinate. NaN marks a missing observation. + /// + [JsonPropertyName("value")] + public double Value { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/TimeSeries/TimeSeriesResourceResponse.cs b/src/RMC.BestFit.Api/DTOs/TimeSeries/TimeSeriesResourceResponse.cs new file mode 100644 index 0000000..228730f --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/TimeSeries/TimeSeriesResourceResponse.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Response body carrying a single time-series resource summary, optionally with a page of points. + /// + public class TimeSeriesResourceResponse : ResponseBase + { + /// + /// The time-series resource summary. + /// + [JsonPropertyName("timeSeries")] + public TimeSeriesSummaryDto? TimeSeries { get; set; } + + /// + /// A page of ordinates when the request asked for points (includePoints=true); otherwise null. + /// Use the offset/limit query parameters to page through large series. + /// + [JsonPropertyName("points")] + public List? Points { get; set; } + + /// + /// The zero-based offset of the first returned point within the full series, when points + /// are included; otherwise null. + /// + [JsonPropertyName("pointsOffset")] + public int? PointsOffset { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/TimeSeries/TimeSeriesSummaryDto.cs b/src/RMC.BestFit.Api/DTOs/TimeSeries/TimeSeriesSummaryDto.cs new file mode 100644 index 0000000..80305ae --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/TimeSeries/TimeSeriesSummaryDto.cs @@ -0,0 +1,85 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Summary of a time-series resource: identity, provenance, and extent. Point values are + /// returned separately (and paged) because series can hold millions of ordinates. + /// + public class TimeSeriesSummaryDto + { + /// + /// The resource id used to reference this time series in later requests + /// (e.g., block-maxima input data or rating curve creation). + /// + [JsonPropertyName("id")] + public Guid Id { get; set; } + + /// + /// The resource display name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// The resource description, if any. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// UTC timestamp at which the resource was created. + /// + [JsonPropertyName("createdUtc")] + public DateTime CreatedUtc { get; set; } + + /// + /// How the resource was created ("usgs" or "manual"). + /// + [JsonPropertyName("source")] + public string? Source { get; set; } + + /// + /// The USGS site number the series was downloaded from, when source is "usgs". + /// + [JsonPropertyName("usgsSiteNumber")] + public string? UsgsSiteNumber { get; set; } + + /// + /// The USGS series type that was downloaded (e.g., "dailyDischarge", "measuredStage"), + /// when source is "usgs". + /// + [JsonPropertyName("seriesType")] + public string? SeriesType { get; set; } + + /// + /// The recording interval of the series (e.g., "oneDay", "irregular"). + /// + [JsonPropertyName("timeInterval")] + public string? TimeInterval { get; set; } + + /// + /// The number of ordinates in the series. + /// + [JsonPropertyName("pointCount")] + public int PointCount { get; set; } + + /// + /// The number of ordinates whose value is NaN (missing observations). + /// + [JsonPropertyName("missingCount")] + public int MissingCount { get; set; } + + /// + /// The date of the first ordinate, or null when the series is empty. + /// + [JsonPropertyName("startDate")] + public DateTime? StartDate { get; set; } + + /// + /// The date of the last ordinate, or null when the series is empty. + /// + [JsonPropertyName("endDate")] + public DateTime? EndDate { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Workflows/UsgsBlockMaxFrequencyWorkflowRequest.cs b/src/RMC.BestFit.Api/DTOs/Workflows/UsgsBlockMaxFrequencyWorkflowRequest.cs new file mode 100644 index 0000000..ef68653 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Workflows/UsgsBlockMaxFrequencyWorkflowRequest.cs @@ -0,0 +1,92 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Numerics.Data; +using Numerics.Distributions; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Request body for the one-shot USGS daily-flow block-maxima frequency workflow: download the + /// daily record, extract block maxima (water-year annual maxima by default), create a + /// univariate analysis, run it, and return the frequency results — all in one call. + /// + public class UsgsBlockMaxFrequencyWorkflowRequest + { + /// + /// The 8-digit USGS surface-water site number. + /// + [Required] + [JsonPropertyName("siteNumber")] + public string SiteNumber { get; set; } = string.Empty; + + /// + /// The daily series to download: "dailyDischarge" (default) or "dailyStage". + /// + [JsonPropertyName("seriesType")] + public TimeSeriesDownload.TimeSeriesType SeriesType { get; set; } = TimeSeriesDownload.TimeSeriesType.DailyDischarge; + + /// + /// The block window. Default "waterYear". + /// + [JsonPropertyName("timeBlock")] + public TimeBlockWindow TimeBlock { get; set; } = TimeBlockWindow.WaterYear; + + /// + /// The function computed over each block. Default "maximum". + /// + [JsonPropertyName("blockFunction")] + public BlockFunctionType BlockFunction { get; set; } = BlockFunctionType.Maximum; + + /// + /// Optional smoothing applied before extraction (e.g., "movingAverage" with a period for + /// n-day flows). Default "none". + /// + [JsonPropertyName("smoothingFunction")] + public SmoothingFunctionType SmoothingFunction { get; set; } = SmoothingFunctionType.None; + + /// + /// The starting month of the water/custom year window (1-12). Default 10. + /// + [Range(1, 12)] + [JsonPropertyName("startMonth")] + public int StartMonth { get; set; } = 10; + + /// + /// The ending month of the custom year window (1-12); only used for "customYear". Default 9. + /// + [Range(1, 12)] + [JsonPropertyName("endMonth")] + public int EndMonth { get; set; } = 9; + + /// + /// The smoothing period in time steps. Default 1. + /// + [Range(1, int.MaxValue)] + [JsonPropertyName("period")] + public int Period { get; set; } = 1; + + /// + /// The distribution to fit. Default "logPearsonTypeIII". + /// + [JsonPropertyName("distribution")] + public UnivariateDistributionType Distribution { get; set; } = UnivariateDistributionType.LogPearsonTypeIII; + + /// + /// Optional annual exceedance probabilities for the frequency curve; null for the 25 defaults. + /// + [JsonPropertyName("probabilityOrdinates")] + public List? ProbabilityOrdinates { get; set; } + + /// + /// Optional Bayesian MCMC settings; omitted fields keep the model defaults. + /// + [JsonPropertyName("bayesianOptions")] + public BayesianOptionsDto? BayesianOptions { get; set; } + + /// + /// Optional base name for the created resources. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Workflows/UsgsBulletin17CWorkflowRequest.cs b/src/RMC.BestFit.Api/DTOs/Workflows/UsgsBulletin17CWorkflowRequest.cs new file mode 100644 index 0000000..e400654 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Workflows/UsgsBulletin17CWorkflowRequest.cs @@ -0,0 +1,53 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Numerics.Data; +using Numerics.Distributions; +using RMC.BestFit.Analyses; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Request body for the one-shot USGS Bulletin 17C workflow: download the annual peak-flow + /// file, build input data, create a Bulletin 17C analysis, run it, and return the frequency + /// results — all in one call. + /// + public class UsgsBulletin17CWorkflowRequest + { + /// + /// The 8-digit USGS surface-water site number. + /// + [Required] + [JsonPropertyName("siteNumber")] + public string SiteNumber { get; set; } = string.Empty; + + /// + /// The peak series to download: "peakDischarge" (default) or "peakStage". + /// + [JsonPropertyName("seriesType")] + public TimeSeriesDownload.TimeSeriesType SeriesType { get; set; } = TimeSeriesDownload.TimeSeriesType.PeakDischarge; + + /// + /// The distribution to fit (Bulletin 17C set). Default "logPearsonTypeIII". + /// + [JsonPropertyName("distribution")] + public UnivariateDistributionType Distribution { get; set; } = UnivariateDistributionType.LogPearsonTypeIII; + + /// + /// The uncertainty method; null for the model default (linkedMultivariateNormal). + /// + [JsonPropertyName("uncertaintyMethod")] + public UncertaintyMethod? UncertaintyMethod { get; set; } + + /// + /// Optional annual exceedance probabilities for the frequency curve; null for the 25 defaults. + /// + [JsonPropertyName("probabilityOrdinates")] + public List? ProbabilityOrdinates { get; set; } + + /// + /// Optional base name for the created resources. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Workflows/UsgsFrequencyWorkflowResponse.cs b/src/RMC.BestFit.Api/DTOs/Workflows/UsgsFrequencyWorkflowResponse.cs new file mode 100644 index 0000000..86613b7 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Workflows/UsgsFrequencyWorkflowResponse.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Response body of the frequency workflow endpoints. On step failure, success is false, the + /// failed step is named, and the ids of resources created by the completed steps are preserved + /// so the client can inspect intermediates and resume manually. + /// + public class UsgsFrequencyWorkflowResponse : ResponseBase + { + /// + /// The step that failed ("downloadTimeSeries", "createInputData", "createAnalysis", + /// "runAnalysis"); null when the workflow succeeded. + /// + [JsonPropertyName("failedStep")] + public string? FailedStep { get; set; } + + /// + /// The id of the created time-series resource (block-maxima workflow only); may be set + /// even when a later step failed. + /// + [JsonPropertyName("timeSeriesId")] + public Guid? TimeSeriesId { get; set; } + + /// + /// The id of the created input-data resource; may be set even when a later step failed. + /// + [JsonPropertyName("inputDataId")] + public Guid? InputDataId { get; set; } + + /// + /// The id of the created analysis; may be set even when the run failed (the analysis can + /// be rerun via POST api/analyses/.../{id}/run). + /// + [JsonPropertyName("analysisId")] + public Guid? AnalysisId { get; set; } + + /// + /// The frequency results when the workflow completed; null on failure. + /// + [JsonPropertyName("results")] + public FrequencyResultsResponse? Results { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Workflows/UsgsPeakFrequencyWorkflowRequest.cs b/src/RMC.BestFit.Api/DTOs/Workflows/UsgsPeakFrequencyWorkflowRequest.cs new file mode 100644 index 0000000..cacae4a --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Workflows/UsgsPeakFrequencyWorkflowRequest.cs @@ -0,0 +1,52 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Numerics.Data; +using Numerics.Distributions; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Request body for the one-shot USGS peak-flow frequency workflow: download the annual + /// peak-flow file, build input data, create a univariate analysis, run it, and return the + /// frequency results — all in one call. + /// + public class UsgsPeakFrequencyWorkflowRequest + { + /// + /// The 8-digit USGS surface-water site number (e.g., "01646500"). + /// + [Required] + [JsonPropertyName("siteNumber")] + public string SiteNumber { get; set; } = string.Empty; + + /// + /// The peak series to download: "peakDischarge" (default) or "peakStage". + /// + [JsonPropertyName("seriesType")] + public TimeSeriesDownload.TimeSeriesType SeriesType { get; set; } = TimeSeriesDownload.TimeSeriesType.PeakDischarge; + + /// + /// The distribution to fit. Default "logPearsonTypeIII". + /// + [JsonPropertyName("distribution")] + public UnivariateDistributionType Distribution { get; set; } = UnivariateDistributionType.LogPearsonTypeIII; + + /// + /// Optional annual exceedance probabilities for the frequency curve; null for the 25 defaults. + /// + [JsonPropertyName("probabilityOrdinates")] + public List? ProbabilityOrdinates { get; set; } + + /// + /// Optional Bayesian MCMC settings; omitted fields keep the model defaults. + /// + [JsonPropertyName("bayesianOptions")] + public BayesianOptionsDto? BayesianOptions { get; set; } + + /// + /// Optional base name for the created resources. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Workflows/UsgsRatingCurveWorkflowRequest.cs b/src/RMC.BestFit.Api/DTOs/Workflows/UsgsRatingCurveWorkflowRequest.cs new file mode 100644 index 0000000..d46c789 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Workflows/UsgsRatingCurveWorkflowRequest.cs @@ -0,0 +1,58 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Request body for the one-shot USGS rating curve workflow: download the discrete field + /// measurements of stage and discharge for a site, create a rating curve analysis over the + /// date-aligned pairs, run it, and return the fitted curve — all in one call. + /// + public class UsgsRatingCurveWorkflowRequest + { + /// + /// The 8-digit USGS surface-water site number. + /// + [Required] + [JsonPropertyName("siteNumber")] + public string SiteNumber { get; set; } = string.Empty; + + /// + /// The number of piecewise power-law segments (1-3). Default 1. + /// + [Range(1, 3)] + [JsonPropertyName("numberOfSegments")] + public int NumberOfSegments { get; set; } = 1; + + /// + /// Optional minimum stage of the output grid (supply with maxStage). + /// + [JsonPropertyName("minStage")] + public double? MinStage { get; set; } + + /// + /// Optional maximum stage of the output grid (supply with minStage). + /// + [JsonPropertyName("maxStage")] + public double? MaxStage { get; set; } + + /// + /// Optional number of stage grid points (2-1000); null for the model default. + /// + [Range(2, 1000)] + [JsonPropertyName("stageBins")] + public int? StageBins { get; set; } + + /// + /// Optional Bayesian MCMC settings; omitted fields keep the model defaults. + /// + [JsonPropertyName("bayesianOptions")] + public BayesianOptionsDto? BayesianOptions { get; set; } + + /// + /// Optional base name for the created resources. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/DTOs/Workflows/UsgsRatingCurveWorkflowResponse.cs b/src/RMC.BestFit.Api/DTOs/Workflows/UsgsRatingCurveWorkflowResponse.cs new file mode 100644 index 0000000..49b6293 --- /dev/null +++ b/src/RMC.BestFit.Api/DTOs/Workflows/UsgsRatingCurveWorkflowResponse.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.DTOs +{ + /// + /// Response body of the rating curve workflow endpoint. On step failure, success is false, the + /// failed step is named, and the ids of resources created by the completed steps are preserved. + /// + public class UsgsRatingCurveWorkflowResponse : ResponseBase + { + /// + /// The step that failed ("downloadStage", "downloadDischarge", "createAnalysis", + /// "runAnalysis"); null when the workflow succeeded. + /// + [JsonPropertyName("failedStep")] + public string? FailedStep { get; set; } + + /// + /// The id of the created stage time-series resource; may be set even when a later step failed. + /// + [JsonPropertyName("stageTimeSeriesId")] + public Guid? StageTimeSeriesId { get; set; } + + /// + /// The id of the created discharge time-series resource; may be set even when a later step failed. + /// + [JsonPropertyName("dischargeTimeSeriesId")] + public Guid? DischargeTimeSeriesId { get; set; } + + /// + /// The id of the created analysis; may be set even when the run failed. + /// + [JsonPropertyName("analysisId")] + public Guid? AnalysisId { get; set; } + + /// + /// The rating curve results when the workflow completed; null on failure. + /// + [JsonPropertyName("results")] + public RatingCurveResultsResponse? Results { get; set; } + } +} diff --git a/src/RMC.BestFit.Api/Helpers/AnalysisRunHelper.cs b/src/RMC.BestFit.Api/Helpers/AnalysisRunHelper.cs new file mode 100644 index 0000000..700ed8c --- /dev/null +++ b/src/RMC.BestFit.Api/Helpers/AnalysisRunHelper.cs @@ -0,0 +1,126 @@ +using RMC.BestFit.Analyses; +using RMC.BestFit.Api.Store; + +namespace RMC.BestFit.Api.Helpers +{ + /// + /// Orchestrates a single synchronous analysis run against the model layer, normalizing the + /// three analyses' divergent failure semantics into thrown exceptions the controller layer + /// maps to status codes. + /// + /// + /// Verified model behavior this helper encodes: UnivariateAnalysis.RunAsync and + /// RatingCurveAnalysis.RunAsync swallow exceptions and report the outcome only through + /// the AnalysisCompleted event; Bulletin17CAnalysis.RunAsync fires no events, + /// rethrows exceptions, catches cancellation itself, and returns silently with + /// IsEstimated == false when the GMM solver fails. Cancellation for every kind goes + /// through AnalysisBase.CancelAnalysis(). + /// + public static class AnalysisRunHelper + { + /// + /// Runs model-layer validation for the resource's analysis, combined with API-level rules + /// the model cannot see: a bivariate analysis's marginals live in OTHER resources, so the + /// model's own Validate cannot know whether they have been estimated. + /// + /// The analysis resource. + /// The validity flag and validation messages. + public static (bool IsValid, List Messages) Validate(AnalysisResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + var (isValid, messages) = resource.Analysis.Validate(); + + if (resource.Kind == AnalysisKind.Bivariate) + { + if (resource.MarginalXResource is { } marginalX && !marginalX.Analysis.IsEstimated) + { + isValid = false; + messages.Add($"Error: The marginal X analysis '{marginalX.Name}' requires estimation. Run it first (POST .../{marginalX.Id}/run)."); + } + if (resource.MarginalYResource is { } marginalY && !marginalY.Analysis.IsEstimated) + { + isValid = false; + messages.Add($"Error: The marginal Y analysis '{marginalY.Name}' requires estimation. Run it first (POST .../{marginalY.Id}/run)."); + } + } + + return (isValid, messages); + } + + /// + /// Executes the analysis run and throws when it did not produce an estimated fit. + /// + /// The analysis resource to run. + /// Client cancellation; wired to CancelAnalysis() for the duration of the run. + /// A task completing when the run has succeeded. + /// Thrown when the run was cancelled (mapped to HTTP 499). + /// Thrown when the run failed (mapped to HTTP 500 with the model's error message). + public static async Task ExecuteAsync(AnalysisResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + var analysis = resource.Analysis; + + // CancelAnalysis() is the model's real cancellation path: it cancels the analysis-level + // token source and (for MCMC kinds) the sampler simulation. + using var registration = cancellationToken.Register(static state => ((AnalysisBase)state!).CancelAnalysis(), analysis); + + if (resource.Kind == AnalysisKind.Bulletin17C) + { + // B17C rethrows run errors and swallows cancellation internally; detect the latter + // from the request token, and the silent GMM failure from IsEstimated. + await analysis.RunAsync(null); + cancellationToken.ThrowIfCancellationRequested(); + if (!analysis.IsEstimated) + { + throw new InvalidOperationException( + "The GMM solver failed to find a solution for the Bulletin 17C distribution fit. " + + "Check the input data (record length, low outliers, zero flows) and try a different distribution or uncertainty method."); + } + return; + } + + // Univariate and rating curve analyses swallow exceptions and report only through the + // AnalysisCompleted event; missing that subscription would turn failures into silent + // 200-with-no-results responses. + AnalysisRunCompletedEventArgs? completion = null; + void OnCompleted(object? sender, AnalysisRunCompletedEventArgs e) => completion = e; + analysis.AnalysisCompleted += OnCompleted; + try + { + await analysis.RunAsync(null); + } + finally + { + analysis.AnalysisCompleted -= OnCompleted; + } + + ThrowIfRunFailed(completion, analysis.IsEstimated, cancellationToken); + } + + /// + /// Converts a captured run-completion outcome into the exception contract: cancellation, + /// the model's error, or a generic no-fit failure. No-op when the run succeeded. + /// + /// The captured AnalysisCompleted event args, or null when the event never fired. + /// The analysis's estimated flag after the run. + /// The client cancellation token observed during the run. + /// Thrown when the run was cancelled. + /// Thrown when the run failed. + public static void ThrowIfRunFailed(AnalysisRunCompletedEventArgs? completion, bool isEstimated, CancellationToken cancellationToken) + { + if (completion?.Cancelled == true || cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException("The analysis run was cancelled."); + } + if (completion?.Error != null) + { + throw new InvalidOperationException($"The analysis run failed: {completion.Error.Message}", completion.Error); + } + if (completion == null || !completion.Succeeded || !isEstimated) + { + throw new InvalidOperationException( + "The analysis run did not produce an estimated fit. Check the input data and estimation settings, then rerun."); + } + } + } +} diff --git a/src/RMC.BestFit.Api/Helpers/EnumHelper.cs b/src/RMC.BestFit.Api/Helpers/EnumHelper.cs new file mode 100644 index 0000000..bfb24df --- /dev/null +++ b/src/RMC.BestFit.Api/Helpers/EnumHelper.cs @@ -0,0 +1,69 @@ +using System.Text.Json; + +namespace RMC.BestFit.Api.Helpers +{ + /// + /// Helpers for presenting enum values in the API's camelCase JSON contract. + /// + /// + /// The API serializes enums as camelCase strings via + /// . The metadata/discovery + /// endpoints list the accepted values for each enum so MCP agents can construct valid requests; + /// these helpers keep that listing consistent with the serializer's naming policy. + /// + public static class EnumHelper + { + /// + /// Converts a single enum member name to the camelCase form used on the wire. + /// + /// The enum member name (e.g., "WaterYear"). + /// The camelCase form (e.g., "waterYear"). + public static string ToCamelCase(string name) + { + return JsonNamingPolicy.CamelCase.ConvertName(name); + } + + /// + /// Returns the camelCase names of all members of the enum type, in declaration order. + /// + /// The enum type to enumerate. + /// A list of camelCase member names matching the API's JSON contract. + public static List CamelCaseNames() where TEnum : struct, Enum + { + return Enum.GetNames().Select(ToCamelCase).ToList(); + } + + /// + /// Parses a camelCase (or any-case) enum string from an MCP tool parameter, falling back + /// to a default when the value is null or blank. + /// + /// The enum type to parse. + /// The string value, or null/blank for the default. + /// The value used when is null or blank. + /// The parsed enum value. + /// Thrown when the value does not name an enum member; the message lists the accepted values. + public static TEnum ParseOrDefault(string? value, TEnum defaultValue) where TEnum : struct, Enum + { + if (string.IsNullOrWhiteSpace(value)) return defaultValue; + if (Enum.TryParse(value, ignoreCase: true, out var parsed)) return parsed; + throw new ArgumentException( + $"'{value}' is not a valid {typeof(TEnum).Name}. Accepted values: {string.Join(", ", CamelCaseNames())}."); + } + + /// + /// Parses a camelCase (or any-case) enum string from an MCP tool parameter, returning null + /// when the value is null or blank (so a model-layer default stays authoritative). + /// + /// The enum type to parse. + /// The string value, or null/blank for null. + /// The parsed enum value, or null. + /// Thrown when the value does not name an enum member; the message lists the accepted values. + public static TEnum? ParseOrNull(string? value) where TEnum : struct, Enum + { + if (string.IsNullOrWhiteSpace(value)) return null; + if (Enum.TryParse(value, ignoreCase: true, out var parsed)) return parsed; + throw new ArgumentException( + $"'{value}' is not a valid {typeof(TEnum).Name}. Accepted values: {string.Join(", ", CamelCaseNames())}."); + } + } +} diff --git a/src/RMC.BestFit.Api/Helpers/ResponseFiniteAuditor.cs b/src/RMC.BestFit.Api/Helpers/ResponseFiniteAuditor.cs new file mode 100644 index 0000000..554dde3 --- /dev/null +++ b/src/RMC.BestFit.Api/Helpers/ResponseFiniteAuditor.cs @@ -0,0 +1,151 @@ +using System.Collections; +using System.Reflection; + +namespace RMC.BestFit.Api.Helpers +{ + /// + /// Walks a response DTO graph and reports any values that are + /// or . + /// + /// + /// Ported from the Dam Screening Tool API (SystemResponse.Api). NaN values are intentionally + /// NOT flagged — NaN is a legitimate marker for missing observations in hydrologic time series + /// (e.g., USGS gap fill) and serializes safely under + /// . + /// Infinity, by contrast, indicates a computation defect and should fail loudly before + /// serialization rather than mid-stream. + /// + public static class ResponseFiniteAuditor + { + /// + /// Maximum recursion depth. Value-type property chains cannot be cycle-broken by reference + /// identity (e.g., returns a fresh + /// forever), so a hard depth cap backstops any leaf case the switch below misses. + /// + private const int MaxDepth = 64; + + /// + /// Recursively audits for ±Infinity double values. + /// + /// The response object graph to audit. May be null. + /// A list of "path = value" strings, empty if every double is finite or NaN. + public static List Audit(object? root) + { + var findings = new List(); + if (root == null) return findings; + var visited = new HashSet(ReferenceEqualityComparer.Instance); + Walk(root, rootPath: root.GetType().Name, findings, visited, depth: 0); + return findings; + } + + /// + /// Recursively walks one node of the object graph, appending findings for any + /// non-finite doubles and descending into dictionaries, arrays, sequences, and + /// public instance properties. + /// + /// The current node value. + /// The dotted/indexed path from the root to this node, used in findings. + /// The accumulating list of findings. + /// Reference-equality set used to break cycles on reference types. + /// The current recursion depth, capped at . + private static void Walk(object? value, string rootPath, List findings, HashSet visited, int depth) + { + if (value == null || depth > MaxDepth) return; + + // Break cycles on reference types. + if (!value.GetType().IsValueType) + { + if (!visited.Add(value)) return; + } + + switch (value) + { + case double d: + if (double.IsInfinity(d)) findings.Add($"{rootPath} = {d}"); + return; + + case float f: + if (float.IsInfinity(f)) findings.Add($"{rootPath} = {f}"); + return; + + // Leaf values with no double content. Date/time structs are critical here: their + // properties return fresh value-type instances (DateTime.Date → DateTime), which + // the reference-identity cycle guard cannot break. + case string: + case bool: + case char: + case decimal: + case DateTime: + case DateTimeOffset: + case TimeSpan: + case DateOnly: + case TimeOnly: + case Guid: + return; + } + + var type = value.GetType(); + if (type.IsPrimitive || type.IsEnum) return; + + if (value is IDictionary dict) + { + foreach (DictionaryEntry entry in dict) + { + Walk(entry.Value, $"{rootPath}[{entry.Key}]", findings, visited, depth + 1); + } + return; + } + + if (value is Array arr) + { + if (arr.Rank == 1) + { + for (int i = 0; i < arr.Length; i++) + { + Walk(arr.GetValue(i), $"{rootPath}[{i}]", findings, visited, depth + 1); + } + } + else if (arr.Rank == 2) + { + int rows = arr.GetLength(0); + int cols = arr.GetLength(1); + for (int r = 0; r < rows; r++) + { + for (int c = 0; c < cols; c++) + { + Walk(arr.GetValue(r, c), $"{rootPath}[{r},{c}]", findings, visited, depth + 1); + } + } + } + return; + } + + if (value is IEnumerable seq) + { + int i = 0; + foreach (var item in seq) + { + Walk(item, $"{rootPath}[{i++}]", findings, visited, depth + 1); + } + return; + } + + foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (prop.GetIndexParameters().Length > 0) continue; + object? propValue; + try + { + propValue = prop.GetValue(value); + } + catch + { + // Property getters on foreign types may throw (e.g., not-yet-computed state); + // skip them rather than failing the audit. + continue; + } + Walk(propValue, $"{rootPath}.{prop.Name}", findings, visited, depth + 1); + } + } + } +} diff --git a/src/RMC.BestFit.Api/Mappers/AnalysisMapper.cs b/src/RMC.BestFit.Api/Mappers/AnalysisMapper.cs new file mode 100644 index 0000000..bd0e8b4 --- /dev/null +++ b/src/RMC.BestFit.Api/Mappers/AnalysisMapper.cs @@ -0,0 +1,161 @@ +using Numerics.Distributions; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Helpers; +using RMC.BestFit.Api.Store; + +namespace RMC.BestFit.Api.Mappers +{ + /// + /// Maps analysis resources to their summary/response DTOs. + /// + public static class AnalysisMapper + { + /// + /// Maps a resource to its summary DTO, running model-layer validation to report current validity. + /// + /// The analysis resource. + /// The summary DTO. + public static AnalysisSummaryDto ToSummary(AnalysisResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + var (isValid, messages) = AnalysisRunHelper.Validate(resource); + + var summary = new AnalysisSummaryDto + { + Id = resource.Id, + Name = resource.Name, + Description = resource.Description, + CreatedUtc = resource.CreatedUtc, + Kind = EnumHelper.ToCamelCase(resource.Kind.ToString()), + State = EnumHelper.ToCamelCase(resource.State.ToString()), + IsValid = isValid, + ValidationMessages = isValid ? null : messages, + Warnings = resource.CreationWarnings.Count > 0 ? resource.CreationWarnings.ToList() : null, + InputDataId = resource.InputDataId, + StageTimeSeriesId = resource.StageTimeSeriesId, + DischargeTimeSeriesId = resource.DischargeTimeSeriesId, + LastRunUtc = resource.LastRunUtc, + LastRunMs = resource.LastRunMs, + LastError = resource.LastError + }; + + switch (resource.Kind) + { + case AnalysisKind.Univariate: + summary.Distribution = EnumHelper.ToCamelCase(resource.Univariate!.UnivariateDistribution.DistributionType.ToString()); + break; + case AnalysisKind.Bulletin17C: + summary.Distribution = EnumHelper.ToCamelCase(resource.Bulletin17C!.Bulletin17CDistribution.DistributionType.ToString()); + summary.UncertaintyMethod = EnumHelper.ToCamelCase(resource.Bulletin17C.UncertaintyMethod.ToString()); + break; + case AnalysisKind.RatingCurve: + summary.NumberOfSegments = resource.RatingCurve!.RatingCurve.NumberOfSegments; + break; + case AnalysisKind.Mixture: + var mixture = resource.Mixture!.MixtureDistribution; + summary.ComponentDistributions = (mixture.Mixture?.Distributions ?? Array.Empty()) + .Select(d => EnumHelper.ToCamelCase(d.Type.ToString())) + .ToList(); + summary.IsZeroInflated = mixture.IsZeroInflated; + break; + case AnalysisKind.PointProcess: + var pointProcess = resource.PointProcess!.PointProcess; + summary.IsSeasonal = pointProcess.IsSeasonal; + summary.Threshold = NanToNull(pointProcess.Threshold); + summary.TotalYears = NanToNull(pointProcess.TotalYears); + summary.Lambda = NanToNull(pointProcess.Lambda); + break; + case AnalysisKind.CompetingRisks: + var competingRisks = resource.CompetingRisks!.CompetingRisksDistribution; + summary.ComponentDistributions = competingRisks.CompetingRisks is null + ? new List() + : competingRisks.CompetingRisks.Distributions + .Select(d => EnumHelper.ToCamelCase(d.Type.ToString())) + .ToList(); + break; + case AnalysisKind.Composite: + var composite = resource.Composite!; + summary.CompositeType = EnumHelper.ToCamelCase(composite.CompositeDistributionType.ToString()); + if (composite.CompositeDistributionType == RMC.BestFit.Analyses.CompositeType.ModelAverage) + { + summary.AverageMethod = EnumHelper.ToCamelCase(composite.ModelAverageMethod.ToString()); + } + if (composite.CompositeDistributionType == RMC.BestFit.Analyses.CompositeType.CompetingRisks) + { + summary.Dependency = EnumHelper.ToCamelCase(composite.Dependency.ToString()); + summary.IsMaximum = composite.IsMaximum; + } + summary.ComponentAnalysisIds = resource.ComponentAnalysisIds; + break; + case AnalysisKind.DistributionFitting: + summary.ComponentDistributions = resource.DistributionFitting!.DistributionList + .Select(d => EnumHelper.ToCamelCase(d.Type.ToString())) + .ToList(); + break; + case AnalysisKind.Bivariate: + var bivariate = resource.Bivariate!.BivariateDistribution; + summary.CopulaType = EnumHelper.ToCamelCase(bivariate.CopulaType.ToString()); + summary.CopulaEstimationMethod = EnumHelper.ToCamelCase(bivariate.CopulaEstimationMethod.ToString()); + summary.MarginalXAnalysisId = resource.MarginalXAnalysisId; + summary.MarginalYAnalysisId = resource.MarginalYAnalysisId; + break; + case AnalysisKind.CoincidentFrequency: + summary.BivariateAnalysisId = resource.BivariateAnalysisId; + summary.NumberOfBins = resource.CoincidentFrequency!.NumberOfBins; + break; + case AnalysisKind.TimeSeries: + summary.TimeSeriesModelType = EnumHelper.ToCamelCase(resource.TimeSeriesModel!.Value.ToString()); + summary.TimeSeriesId = resource.TimeSeriesId; + summary.CovariateTimeSeriesIds = resource.CovariateTimeSeriesIds; + summary.ForecastingTimeSteps = resource.TimeSeriesModel switch + { + TimeSeriesModelType.Ar => resource.Ar!.ForecastingTimeSteps, + TimeSeriesModelType.Ma => resource.Ma!.ForecastingTimeSteps, + TimeSeriesModelType.Arima => resource.Arima!.ForecastingTimeSteps, + TimeSeriesModelType.Arimax => resource.Arimax!.ForecastingTimeSteps, + _ => null + }; + break; + default: + break; + } + + return summary; + } + + /// + /// Converts NaN to null so not-yet-derived numeric settings are omitted from the JSON. + /// + /// The value to convert. + /// Null when NaN; otherwise the value. + private static double? NanToNull(double value) + { + return double.IsNaN(value) ? null : value; + } + + /// + /// Maps a resource to a single-resource response. + /// + /// The analysis resource. + /// The response DTO. + public static AnalysisResourceResponse ToResourceResponse(AnalysisResource resource) + { + return new AnalysisResourceResponse { Analysis = ToSummary(resource) }; + } + + /// + /// Maps a list of resources to the list response. + /// + /// The analysis resources ordered by creation time. + /// The list response DTO. + public static AnalysisListResponse ToListResponse(IReadOnlyList resources) + { + ArgumentNullException.ThrowIfNull(resources); + return new AnalysisListResponse + { + Count = resources.Count, + Analyses = resources.Select(ToSummary).ToList() + }; + } + } +} diff --git a/src/RMC.BestFit.Api/Mappers/BayesianOptionsMapper.cs b/src/RMC.BestFit.Api/Mappers/BayesianOptionsMapper.cs new file mode 100644 index 0000000..db76020 --- /dev/null +++ b/src/RMC.BestFit.Api/Mappers/BayesianOptionsMapper.cs @@ -0,0 +1,60 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Estimation; + +namespace RMC.BestFit.Api.Mappers +{ + /// + /// Applies client-supplied Bayesian MCMC options onto a model-layer + /// . Only non-null fields are assigned, so omitted fields keep + /// the model defaults; supplying any simulation-shaping field disables the model's automatic + /// simulation defaults (which would otherwise overwrite the assignments at run time). + /// + public static class BayesianOptionsMapper + { + /// + /// Applies the options onto the Bayesian analysis. + /// + /// The model-layer Bayesian analysis to configure. + /// The client-supplied options; null applies nothing. + /// The server's iteration cap; requests above it are rejected. + /// Thrown when is null. + /// Thrown when iterations exceed the server cap or warm-up is not below iterations. + public static void Apply(BayesianAnalysis bayesianAnalysis, BayesianOptionsDto? options, int maxIterations) + { + ArgumentNullException.ThrowIfNull(bayesianAnalysis); + if (options == null) return; + + if (options.Iterations.HasValue && options.Iterations.Value > maxIterations) + { + throw new ArgumentException( + $"bayesianOptions.iterations ({options.Iterations.Value}) exceeds the server cap of {maxIterations}. " + + "Run endpoints are synchronous; see GET api/metadata/defaults for the configured limits."); + } + if (options.Iterations.HasValue && options.WarmupIterations.HasValue && + options.WarmupIterations.Value >= options.Iterations.Value) + { + throw new ArgumentException("bayesianOptions.warmupIterations must be less than bayesianOptions.iterations."); + } + + // Supplying any simulation-shaping field takes the run off the model's automatic + // defaults; otherwise the model would overwrite these assignments when the run starts. + bool shapesSimulation = options.Sampler.HasValue || options.Iterations.HasValue || + options.WarmupIterations.HasValue || options.ThinningInterval.HasValue || + options.NumberOfChains.HasValue || options.OutputLength.HasValue; + if (shapesSimulation) + { + bayesianAnalysis.UseSimulationDefaults = false; + } + + if (options.Sampler.HasValue) bayesianAnalysis.Type = options.Sampler.Value; + if (options.Iterations.HasValue) bayesianAnalysis.Iterations = options.Iterations.Value; + if (options.WarmupIterations.HasValue) bayesianAnalysis.WarmupIterations = options.WarmupIterations.Value; + if (options.ThinningInterval.HasValue) bayesianAnalysis.ThinningInterval = options.ThinningInterval.Value; + if (options.NumberOfChains.HasValue) bayesianAnalysis.NumberOfChains = options.NumberOfChains.Value; + if (options.PrngSeed.HasValue) bayesianAnalysis.PRNGSeed = options.PrngSeed.Value; + if (options.CredibleIntervalWidth.HasValue) bayesianAnalysis.CredibleIntervalWidth = options.CredibleIntervalWidth.Value; + if (options.OutputLength.HasValue) bayesianAnalysis.OutputLength = options.OutputLength.Value; + if (options.PointEstimator.HasValue) bayesianAnalysis.PointEstimator = options.PointEstimator.Value; + } + } +} diff --git a/src/RMC.BestFit.Api/Mappers/BivariateResultsMapper.cs b/src/RMC.BestFit.Api/Mappers/BivariateResultsMapper.cs new file mode 100644 index 0000000..9e29552 --- /dev/null +++ b/src/RMC.BestFit.Api/Mappers/BivariateResultsMapper.cs @@ -0,0 +1,146 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Helpers; +using RMC.BestFit.Api.Store; + +namespace RMC.BestFit.Api.Mappers +{ + /// + /// Maps bivariate copula and coincident frequency results to their response DTOs. Both + /// results carry [n, 2] confidence intervals (lower, upper) — per XY grid point for + /// the bivariate joint exceedance, per response-magnitude bin for the coincident frequency + /// curve. + /// + public static class BivariateResultsMapper + { + /// + /// Builds the joint-exceedance results response for a bivariate analysis. + /// + /// The bivariate analysis resource. + /// The results response. + /// Thrown when the resource is not a bivariate analysis. + /// Thrown when the analysis has never been run. + public static BivariateResultsResponse ToBivariateResults(AnalysisResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + if (resource.Kind != AnalysisKind.Bivariate) + { + throw new ArgumentException($"Analysis kind '{resource.Kind}' does not produce bivariate results.", nameof(resource)); + } + var analysis = resource.Bivariate!; + var results = analysis.AnalysisResults + ?? throw new InvalidOperationException("The analysis has no results. Run it first (POST .../run)."); + var distribution = analysis.BivariateDistribution; + var parameterNames = distribution.Parameters.Select(p => p.DisplayName).ToList(); + + var curve = new JointExceedanceCurveDto + { + ModeProbabilities = results.ModeCurve?.ToList(), + MeanProbabilities = results.MeanCurve?.ToList(), + CredibleIntervalWidth = analysis.BayesianAnalysis.CredibleIntervalWidth + }; + foreach (var ordinate in analysis.XYOrdinates) + { + curve.X.Add(ordinate.X); + curve.Y.Add(ordinate.Y?.Mean ?? double.NaN); + } + if (results.ConfidenceIntervals is { } intervals && intervals.GetLength(1) >= 2) + { + int rows = intervals.GetLength(0); + var lower = new List(rows); + var upper = new List(rows); + for (int i = 0; i < rows; i++) + { + lower.Add(intervals[i, 0]); + upper.Add(intervals[i, 1]); + } + curve.CiLower = lower; + curve.CiUpper = upper; + } + + return new BivariateResultsResponse + { + AnalysisId = resource.Id, + Kind = EnumHelper.ToCamelCase(resource.Kind.ToString()), + CopulaType = EnumHelper.ToCamelCase(distribution.CopulaType.ToString()), + EstimationMethod = EnumHelper.ToCamelCase(distribution.CopulaEstimationMethod.ToString()), + MarginalX = ToMarginalInfo(resource.MarginalXResource, resource.MarginalXAnalysisId), + MarginalY = ToMarginalInfo(resource.MarginalYResource, resource.MarginalYAnalysisId), + Parameters = distribution.Parameters + .Select(p => new ParameterValueDto { Name = p.DisplayName, Value = p.Value }) + .ToList(), + ParameterSummaries = ResultsMapper.BuildParameterSummaries(parameterNames, analysis.BayesianAnalysis.Results, includeChainDiagnostics: true), + JointExceedance = curve, + InformationCriteria = ResultsMapper.BuildInformationCriteria(results, analysis.BayesianAnalysis), + Diagnostics = ResultsMapper.BuildMcmcDiagnostics(analysis.BayesianAnalysis, parameterNames) + }; + } + + /// + /// Builds the response frequency-curve results for a coincident frequency analysis. + /// + /// The coincident frequency analysis resource. + /// The results response. + /// Thrown when the resource is not a coincident frequency analysis. + /// Thrown when the analysis has never been run. + public static CoincidentFrequencyResultsResponse ToCoincidentFrequencyResults(AnalysisResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + if (resource.Kind != AnalysisKind.CoincidentFrequency) + { + throw new ArgumentException($"Analysis kind '{resource.Kind}' does not produce coincident frequency results.", nameof(resource)); + } + var analysis = resource.CoincidentFrequency!; + var results = analysis.AnalysisResults + ?? throw new InvalidOperationException("The analysis has no results. Run it first (POST .../run)."); + + var response = new CoincidentFrequencyResultsResponse + { + AnalysisId = resource.Id, + Kind = EnumHelper.ToCamelCase(resource.Kind.ToString()), + NumberOfBins = analysis.NumberOfBins, + ZValues = analysis.ZOutputValues?.ToList() ?? new List(), + AepMode = results.ModeCurve?.ToList(), + AepMean = results.MeanCurve?.ToList(), + CredibleIntervalWidth = analysis.BayesianAnalysis.CredibleIntervalWidth, + MarginalXChainUsed = analysis.MarginalXChain != null, + MarginalYChainUsed = analysis.MarginalYChain != null + }; + if (results.ConfidenceIntervals is { } intervals && intervals.GetLength(1) >= 2) + { + int rows = intervals.GetLength(0); + var lower = new List(rows); + var upper = new List(rows); + for (int i = 0; i < rows; i++) + { + lower.Add(intervals[i, 0]); + upper.Add(intervals[i, 1]); + } + response.CiLower = lower; + response.CiUpper = upper; + } + return response; + } + + /// + /// Builds the identity block for one marginal of a bivariate results response. + /// + /// The LIVE marginal resource, or null when unavailable. + /// The provenance id recorded at creation. + /// The marginal info DTO. + private static MarginalInfoDto ToMarginalInfo(AnalysisResource? marginal, Guid? analysisId) + { + var info = new MarginalInfoDto { AnalysisId = analysisId }; + if (marginal == null) return info; + + info.Name = marginal.Name; + info.Kind = EnumHelper.ToCamelCase(marginal.Kind.ToString()); + info.DistributionType = marginal.Kind switch + { + AnalysisKind.Univariate => EnumHelper.ToCamelCase(marginal.Univariate!.UnivariateDistribution.DistributionType.ToString()), + AnalysisKind.Bulletin17C => EnumHelper.ToCamelCase(marginal.Bulletin17C!.Bulletin17CDistribution.DistributionType.ToString()), + _ => null + }; + return info; + } + } +} diff --git a/src/RMC.BestFit.Api/Mappers/DistributionFittingResultsMapper.cs b/src/RMC.BestFit.Api/Mappers/DistributionFittingResultsMapper.cs new file mode 100644 index 0000000..f65e1c6 --- /dev/null +++ b/src/RMC.BestFit.Api/Mappers/DistributionFittingResultsMapper.cs @@ -0,0 +1,103 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Helpers; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Models; + +namespace RMC.BestFit.Api.Mappers +{ + /// + /// Maps distribution-fitting results (per-candidate maximum-likelihood fits with information + /// criteria) to the ranked results response. + /// + public static class DistributionFittingResultsMapper + { + /// + /// Builds the ranked results response for a distribution-fitting analysis. Successful + /// fits are ranked by AIC ascending (NaN AIC after finite); failed fits follow. + /// + /// The distribution-fitting analysis resource. + /// The results response. + /// Thrown when the resource is not a distribution-fitting analysis. + /// Thrown when the analysis has never been run. + public static DistributionFittingResultsResponse ToResults(AnalysisResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + if (resource.Kind != AnalysisKind.DistributionFitting) + { + throw new ArgumentException($"Analysis kind '{resource.Kind}' does not produce distribution-fitting results.", nameof(resource)); + } + var analysis = resource.DistributionFitting!; + if (!analysis.IsEstimated) + { + throw new InvalidOperationException("The analysis has no results. Run it first (POST .../run)."); + } + + var response = new DistributionFittingResultsResponse + { + AnalysisId = resource.Id, + Kind = EnumHelper.ToCamelCase(resource.Kind.ToString()), + Fits = ToRankedFits(analysis.FittedDistributions) + }; + return response; + } + + /// + /// Ranks candidate fits — successful fits by AIC ascending (NaN AIC after finite), failed + /// fits after all successful ones — and maps them to DTOs with 1-based ranks. + /// + /// The model-layer fitted distributions. + /// The ranked fit DTOs. + public static List ToRankedFits(IEnumerable fits) + { + ArgumentNullException.ThrowIfNull(fits); + var ordered = fits + .OrderByDescending(f => f.FitSucceeded) + .ThenBy(f => double.IsNaN(f.AIC) ? double.PositiveInfinity : f.AIC) + .ToList(); + + var result = new List(ordered.Count); + for (int i = 0; i < ordered.Count; i++) + { + result.Add(ToFit(ordered[i], rank: i + 1)); + } + return result; + } + + /// + /// Maps one candidate fit to its DTO. + /// + /// The model-layer fitted distribution. + /// The 1-based rank in the ordered listing. + /// The fit DTO. + private static DistributionFitDto ToFit(FittedDistribution fit, int rank) + { + var distribution = fit.Distribution; + return new DistributionFitDto + { + Rank = rank, + Distribution = distribution is null ? null : EnumHelper.ToCamelCase(distribution.Type.ToString()), + DisplayName = distribution is null ? null : MetadataMapper.ToDisplayName(distribution.Type), + Parameters = distribution is null || !fit.FitSucceeded + ? null + : distribution.ParameterNames + .Zip(distribution.GetParameters, (name, value) => new ParameterValueDto { Name = name, Value = value }) + .ToList(), + Aic = NanToNull(fit.AIC), + Bic = NanToNull(fit.BIC), + Rmse = NanToNull(fit.RMSE), + FitSucceeded = fit.FitSucceeded, + ErrorMessage = string.IsNullOrWhiteSpace(fit.ErrorMessage) ? null : fit.ErrorMessage + }; + } + + /// + /// Converts NaN to null so inapplicable criteria are omitted from the JSON. + /// + /// The value to convert. + /// Null when NaN; otherwise the value. + private static double? NanToNull(double value) + { + return double.IsNaN(value) ? null : value; + } + } +} diff --git a/src/RMC.BestFit.Api/Mappers/DistributionSpecMapper.cs b/src/RMC.BestFit.Api/Mappers/DistributionSpecMapper.cs new file mode 100644 index 0000000..32a1987 --- /dev/null +++ b/src/RMC.BestFit.Api/Mappers/DistributionSpecMapper.cs @@ -0,0 +1,77 @@ +using Numerics.Distributions; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Helpers; + +namespace RMC.BestFit.Api.Mappers +{ + /// + /// Materializes payloads into model-layer + /// instances (and back, for response echoes). Used by + /// uncertain-data observations, parameter priors, and quantile priors. + /// + public static class DistributionSpecMapper + { + /// + /// Builds and validates a distribution from its spec. + /// + /// The client-supplied distribution spec. + /// The request field the spec came from (e.g., "parameterPriors[0].distribution"), used in error messages. + /// The constructed distribution with the supplied parameters applied. + /// + /// Thrown when the spec is missing, the type cannot be constructed, the parameter count + /// does not match the distribution's canonical parameter list, or the parameter values are + /// invalid for the distribution (all mapped to HTTP 400). + /// + public static UnivariateDistributionBase ToDistribution(DistributionSpecDto? spec, string context) + { + if (spec == null) + { + throw new ArgumentException($"{context}: a distribution spec ({{ type, parameters }}) is required."); + } + + UnivariateDistributionBase distribution; + try + { + distribution = UnivariateDistributionFactory.CreateDistribution(spec.Type); + } + catch (Exception ex) + { + throw new ArgumentException( + $"{context}: the distribution type '{EnumHelper.ToCamelCase(spec.Type.ToString())}' cannot be used here. " + + "See GET api/metadata/enums (priorDistributions) for the accepted types.", ex); + } + + if (spec.Parameters == null || spec.Parameters.Count != distribution.NumberOfParameters) + { + throw new ArgumentException( + $"{context}: the {distribution.DisplayName} distribution requires exactly {distribution.NumberOfParameters} " + + $"parameter(s) in order [{string.Join(", ", distribution.ParameterNames)}], but {spec.Parameters?.Count ?? 0} were supplied."); + } + + var invalid = distribution.ValidateParameters(spec.Parameters, throwException: false); + if (invalid != null) + { + throw new ArgumentException($"{context}: {invalid.Message}"); + } + distribution.SetParameters(spec.Parameters); + return distribution; + } + + /// + /// Builds the response echo of a distribution: its type and current parameter values in + /// canonical order. + /// + /// The model-layer distribution. + /// The spec DTO describing the distribution. + /// Thrown when is null. + public static DistributionSpecDto ToSpec(UnivariateDistributionBase distribution) + { + ArgumentNullException.ThrowIfNull(distribution); + return new DistributionSpecDto + { + Type = distribution.Type, + Parameters = distribution.GetParameters.ToList() + }; + } + } +} diff --git a/src/RMC.BestFit.Api/Mappers/InputDataMapper.cs b/src/RMC.BestFit.Api/Mappers/InputDataMapper.cs new file mode 100644 index 0000000..02475bd --- /dev/null +++ b/src/RMC.BestFit.Api/Mappers/InputDataMapper.cs @@ -0,0 +1,215 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Helpers; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Models; + +namespace RMC.BestFit.Api.Mappers +{ + /// + /// Maps input-data resources to their response DTOs. + /// + public static class InputDataMapper + { + /// + /// Maps a resource to its summary DTO. + /// + /// The input-data resource. + /// The summary DTO. + public static InputDataSummaryDto ToSummary(InputDataResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + var dataFrame = resource.DataFrame; + + int lowOutlierCount = 0; + foreach (ExactData data in dataFrame.ExactSeries) + { + if (data.IsLowOutlier) lowOutlierCount++; + } + + return new InputDataSummaryDto + { + Id = resource.Id, + Name = resource.Name, + Description = resource.Description, + CreatedUtc = resource.CreatedUtc, + Method = EnumHelper.ToCamelCase(resource.Method.ToString()), + SourceTimeSeriesId = resource.SourceTimeSeriesId, + UsgsSiteNumber = resource.UsgsSiteNumber, + RecordLength = dataFrame.TotalRecordLength(), + ExactCount = dataFrame.ExactSeries.Count, + UncertainCount = dataFrame.UncertainSeries.Count, + IntervalCount = dataFrame.IntervalSeries.Count, + ThresholdCount = dataFrame.ThresholdSeries.Count, + Lambda = dataFrame.Lambda, + PlottingParameter = dataFrame.PlottingParameter, + LowOutlierThreshold = double.IsNaN(dataFrame.LowOutlierThreshold) ? null : dataFrame.LowOutlierThreshold, + LowOutlierCount = lowOutlierCount, + BlockOptions = ToBlockOptions(resource), + PotOptions = ToPotOptions(resource) + }; + } + + /// + /// Maps a resource to a single-resource response, optionally including the observation lists. + /// + /// The input-data resource. + /// True to include the exact/interval/threshold observation lists. + /// The response DTO. + public static InputDataResourceResponse ToResourceResponse(InputDataResource resource, bool includeData = false) + { + ArgumentNullException.ThrowIfNull(resource); + var response = new InputDataResourceResponse { InputData = ToSummary(resource) }; + if (includeData) + { + response.ExactData = ToExactObservations(resource.DataFrame); + if (resource.DataFrame.UncertainSeries.Count > 0) response.UncertainData = ToUncertainObservations(resource.DataFrame); + if (resource.DataFrame.IntervalSeries.Count > 0) response.IntervalData = ToIntervalObservations(resource.DataFrame); + if (resource.DataFrame.ThresholdSeries.Count > 0) response.ThresholdData = ToThresholdObservations(resource.DataFrame); + } + return response; + } + + /// + /// Maps a list of resources to the list response. + /// + /// The input-data resources ordered by creation time. + /// The list response DTO. + public static InputDataListResponse ToListResponse(IReadOnlyList resources) + { + ArgumentNullException.ThrowIfNull(resources); + return new InputDataListResponse + { + Count = resources.Count, + InputData = resources.Select(ToSummary).ToList() + }; + } + + /// + /// Extracts the exact observations (with computed plotting positions) from a data frame. + /// + /// The data frame. + /// The exact observation DTOs. + public static List ToExactObservations(DataFrame dataFrame) + { + ArgumentNullException.ThrowIfNull(dataFrame); + var list = new List(dataFrame.ExactSeries.Count); + foreach (ExactData data in dataFrame.ExactSeries) + { + list.Add(new ExactObservationDto + { + Index = data.Index, + Value = data.Value, + IsLowOutlier = data.IsLowOutlier, + PlottingPosition = data.PlottingPosition + }); + } + return list; + } + + /// + /// Extracts the uncertain observations (with their measurement-error distributions, + /// nominal values, and computed plotting positions) from a data frame. + /// + /// The data frame. + /// The uncertain observation DTOs. + public static List ToUncertainObservations(DataFrame dataFrame) + { + ArgumentNullException.ThrowIfNull(dataFrame); + var list = new List(dataFrame.UncertainSeries.Count); + foreach (UncertainData data in dataFrame.UncertainSeries) + { + list.Add(new UncertainObservationDto + { + Index = data.Index, + Distribution = DistributionSpecMapper.ToSpec(data.Distribution), + Value = data.Value, + PlottingPosition = data.PlottingPosition + }); + } + return list; + } + + /// + /// Extracts the interval-censored observations from a data frame. + /// + /// The data frame. + /// The interval observation DTOs. + public static List ToIntervalObservations(DataFrame dataFrame) + { + ArgumentNullException.ThrowIfNull(dataFrame); + var list = new List(dataFrame.IntervalSeries.Count); + foreach (IntervalData data in dataFrame.IntervalSeries) + { + list.Add(new IntervalObservationDto + { + Index = data.Index, + LowerBound = data.LowerValue, + UpperBound = data.UpperValue, + Value = data.Value, + PlottingPosition = data.PlottingPosition + }); + } + return list; + } + + /// + /// Extracts the perception-threshold records from a data frame. + /// + /// The data frame. + /// The threshold observation DTOs. + public static List ToThresholdObservations(DataFrame dataFrame) + { + ArgumentNullException.ThrowIfNull(dataFrame); + var list = new List(dataFrame.ThresholdSeries.Count); + foreach (ThresholdData data in dataFrame.ThresholdSeries) + { + list.Add(new ThresholdObservationDto + { + StartIndex = data.StartIndex, + EndIndex = data.EndIndex, + Value = data.Value, + NumberAbove = data.NumberAbove, + NumberBelow = data.NumberBelow, + PlottingPosition = data.PlottingPosition + }); + } + return list; + } + + /// + /// Builds the block-options echo for a block-maxima resource, or null for other methods. + /// + /// The input-data resource. + /// The block options DTO, or null. + private static BlockOptionsDto? ToBlockOptions(InputDataResource resource) + { + if (resource.Method != InputDataMethod.BlockMaxima) return null; + return new BlockOptionsDto + { + TimeBlock = resource.TimeBlock.HasValue ? EnumHelper.ToCamelCase(resource.TimeBlock.Value.ToString()) : null, + BlockFunction = resource.BlockFunction.HasValue ? EnumHelper.ToCamelCase(resource.BlockFunction.Value.ToString()) : null, + SmoothingFunction = resource.SmoothingFunction.HasValue ? EnumHelper.ToCamelCase(resource.SmoothingFunction.Value.ToString()) : null, + StartMonth = resource.StartMonth, + EndMonth = resource.EndMonth, + Period = resource.Period + }; + } + + /// + /// Builds the POT-options echo for a peaks-over-threshold resource, or null for other methods. + /// + /// The input-data resource. + /// The POT options DTO, or null. + private static PotOptionsDto? ToPotOptions(InputDataResource resource) + { + if (resource.Method != InputDataMethod.PeaksOverThreshold) return null; + return new PotOptionsDto + { + Threshold = resource.Threshold, + MinStepsBetweenPeaks = resource.MinStepsBetweenPeaks, + SmoothingFunction = resource.SmoothingFunction.HasValue ? EnumHelper.ToCamelCase(resource.SmoothingFunction.Value.ToString()) : null, + Period = resource.Period + }; + } + } +} diff --git a/src/RMC.BestFit.Api/Mappers/MetadataMapper.cs b/src/RMC.BestFit.Api/Mappers/MetadataMapper.cs new file mode 100644 index 0000000..3eb656e --- /dev/null +++ b/src/RMC.BestFit.Api/Mappers/MetadataMapper.cs @@ -0,0 +1,163 @@ +using System.Text; +using Numerics.Data; +using Numerics.Distributions; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Helpers; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Estimation; +using RMC.BestFit.Analyses; +using RMC.BestFit.Models; + +namespace RMC.BestFit.Api.Mappers +{ + /// + /// Builds the discovery/metadata responses that let API clients (and MCP agents in particular) + /// enumerate the accepted enum values and supported distributions before constructing requests. + /// + public static class MetadataMapper + { + /// + /// Builds the distributions listing: every distribution type supported by the univariate + /// or Bulletin 17C analysis, with display names and the kinds that support it. + /// + /// The distributions response. + public static DistributionsResponse ToDistributionsResponse() + { + var response = new DistributionsResponse(); + foreach (var type in Enum.GetValues()) + { + bool univariate = UnivariateDistribution.IsSupportedDistributionType(type); + bool bulletin17C = Bulletin17CDistribution.IsSupportedDistributionType(type); + bool mixture = MixtureModel.IsSupportedDistributionType(type); + bool competingRisks = CompetingRisksModel.IsSupportedDistributionType(type); + bool pointProcess = PointProcessModel.IsSupportedDistributionType(type); + if (!univariate && !bulletin17C && !mixture && !competingRisks && !pointProcess) continue; + + var info = new DistributionInfoDto + { + Name = EnumHelper.ToCamelCase(type.ToString()), + DisplayName = ToDisplayName(type), + ParameterNames = UnivariateDistributionFactory.CreateDistribution(type).ParameterNames.ToList() + }; + if (univariate) info.SupportedBy.Add("univariate"); + if (bulletin17C) info.SupportedBy.Add("bulletin17c"); + if (mixture) info.SupportedBy.Add("mixture"); + if (pointProcess) info.SupportedBy.Add("pointprocess"); + if (competingRisks) info.SupportedBy.Add("competingrisks"); + // The fitting screen accepts exactly the univariate candidate set. + if (univariate) info.SupportedBy.Add("distributionfitting"); + response.Distributions.Add(info); + } + return response; + } + + /// + /// Builds the enum-options listing covering every enum-typed request field in the API. + /// + /// The enum options response. + public static EnumOptionsResponse ToEnumOptionsResponse() + { + return new EnumOptionsResponse + { + Samplers = EnumHelper.CamelCaseNames(), + PointEstimators = EnumHelper.CamelCaseNames(), + UncertaintyMethods = EnumHelper.CamelCaseNames(), + TimeBlockWindows = EnumHelper.CamelCaseNames(), + BlockFunctions = EnumHelper.CamelCaseNames(), + SmoothingFunctions = EnumHelper.CamelCaseNames(), + UsgsSeriesTypes = EnumHelper.CamelCaseNames(), + TimeIntervals = EnumHelper.CamelCaseNames(), + AnalysisKinds = EnumHelper.CamelCaseNames(), + InputDataMethods = EnumHelper.CamelCaseNames(), + PriorDistributions = _priorDistributionNames.Value, + CompositeTypes = EnumHelper.CamelCaseNames(), + AverageMethods = EnumHelper.CamelCaseNames(), + DependencyTypes = EnumHelper.CamelCaseNames(), + CopulaTypes = EnumHelper.CamelCaseNames(), + CopulaEstimationMethods = EnumHelper.CamelCaseNames(), + TimeSeriesModelTypes = EnumHelper.CamelCaseNames(), + // Fully qualified: Numerics.Data.Transform also exists (documented ambiguity). + TransformTypes = EnumHelper.CamelCaseNames(), + TrendTypes = EnumHelper.CamelCaseNames(), + CovariateExtensions = EnumHelper.CamelCaseNames() + }; + } + + /// + /// The camelCase names of every distribution type the factory can construct, computed + /// once. Factory support is the operational definition of what a distribution spec + /// (uncertain observation, parameter prior, quantile prior) may name. + /// + private static readonly Lazy> _priorDistributionNames = new(() => + { + var names = new List(); + foreach (var type in Enum.GetValues()) + { + try + { + if (!UnivariateDistributionFactory.TryCreateDistribution(type, out _)) + { + continue; + } + names.Add(EnumHelper.ToCamelCase(type.ToString())); + } + catch (ArgumentOutOfRangeException) + { + // Not constructible from a bare type (e.g., composite types that require + // component distributions) — excluded from the spec list. + continue; + } + } + return names; + }); + + /// + /// Returns the human-readable display name for a distribution type, matching the names + /// used in the RMC-BestFit desktop application and documentation. + /// + /// The distribution type. + /// The display name. + public static string ToDisplayName(UnivariateDistributionType type) + { + return type switch + { + UnivariateDistributionType.Exponential => "Exponential", + UnivariateDistributionType.GammaDistribution => "Gamma", + UnivariateDistributionType.GeneralizedExtremeValue => "Generalized Extreme Value", + UnivariateDistributionType.GeneralizedLogistic => "Generalized Logistic", + UnivariateDistributionType.GeneralizedNormal => "Generalized Normal", + UnivariateDistributionType.GeneralizedPareto => "Generalized Pareto", + UnivariateDistributionType.Gumbel => "Gumbel", + UnivariateDistributionType.KappaFour => "Kappa Four", + UnivariateDistributionType.LnNormal => "Ln-Normal", + UnivariateDistributionType.Logistic => "Logistic", + UnivariateDistributionType.LogNormal => "Log-Normal", + UnivariateDistributionType.LogPearsonTypeIII => "Log-Pearson Type III", + UnivariateDistributionType.Normal => "Normal", + UnivariateDistributionType.PearsonTypeIII => "Pearson Type III", + UnivariateDistributionType.Weibull => "Weibull", + _ => SplitPascalCase(type.ToString()) + }; + } + + /// + /// Fallback display-name formatter: inserts spaces at the case boundaries of a PascalCase + /// enum member name (e.g., "GeneralizedExtremeValue" → "Generalized Extreme Value"). + /// + /// The PascalCase enum member name. + /// The spaced display name. + private static string SplitPascalCase(string name) + { + var builder = new StringBuilder(name.Length + 8); + for (int i = 0; i < name.Length; i++) + { + if (i > 0 && char.IsUpper(name[i]) && !char.IsUpper(name[i - 1])) + { + builder.Append(' '); + } + builder.Append(name[i]); + } + return builder.ToString(); + } + } +} diff --git a/src/RMC.BestFit.Api/Mappers/PriorMapper.cs b/src/RMC.BestFit.Api/Mappers/PriorMapper.cs new file mode 100644 index 0000000..f5b9ba3 --- /dev/null +++ b/src/RMC.BestFit.Api/Mappers/PriorMapper.cs @@ -0,0 +1,291 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Models; + +namespace RMC.BestFit.Api.Mappers +{ + /// + /// Applies client-supplied Bayesian prior information onto model-layer objects: informative + /// parameter priors (any Bayesian model), quantile priors (univariate-family models), and + /// Bulletin 17C parameter/quantile penalties. Only supplied entries are applied; everything + /// else keeps the model defaults. + /// + public static class PriorMapper + { + /// + /// Applies informative parameter priors onto a model's parameters, matched by name. + /// + /// The model whose parameters receive the priors. + /// The client-supplied priors; null or empty applies nothing. + /// Thrown when is null. + /// + /// Thrown when a parameter name does not match any model parameter, is named twice, or a + /// prior distribution spec is invalid (all mapped to HTTP 400). + /// + /// + /// Supplying any prior sets to false FIRST, + /// because data-frame property changes rebuild the parameter list (wiping priors) whenever + /// that flag is true; setting it false has no side effects. Parameters not named keep the + /// flat priors already in place, so partial specification works. + /// + public static void ApplyParameterPriors(ModelBase model, List? priors) + { + ArgumentNullException.ThrowIfNull(model); + if (priors == null || priors.Count == 0) return; + + model.UseDefaultFlatPriors = false; + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < priors.Count; i++) + { + var dto = priors[i]; + if (string.IsNullOrWhiteSpace(dto.ParameterName)) + { + throw new ArgumentException($"parameterPriors[{i}]: parameterName is required. " + + $"Valid names: {DescribeParameterNames(model)}."); + } + if (!seen.Add(dto.ParameterName)) + { + throw new ArgumentException($"parameterPriors[{i}]: parameter '{dto.ParameterName}' is named more than once."); + } + + var matches = model.Parameters.Where(p => NameMatches(p.DisplayName, dto.ParameterName) || NameMatches(p.Name, dto.ParameterName)).ToList(); + if (matches.Count == 0) + { + throw new ArgumentException( + $"parameterPriors[{i}]: no model parameter is named '{dto.ParameterName}'. " + + $"Valid names: {DescribeParameterNames(model)}."); + } + if (matches.Count > 1) + { + throw new ArgumentException( + $"parameterPriors[{i}]: '{dto.ParameterName}' matches more than one parameter. " + + $"Use the full name. Valid names: {DescribeParameterNames(model)}."); + } + var parameter = matches[0]; + + parameter.PriorDistribution = DistributionSpecMapper.ToDistribution(dto.Distribution, $"parameterPriors[{i}].distribution"); + if (dto.IsFixed.HasValue) + { + parameter.IsFixed = dto.IsFixed.Value; + } + } + } + + /// + /// Applies quantile priors onto a univariate-family model. + /// + /// The model implementing . + /// The client-supplied quantile priors; null or empty applies nothing. + /// + /// True for the single-quantile formulation (Viglione et al., 2013); false or null for one + /// prior per parent-distribution parameter (Coles and Tawn, 1996; the model default). + /// + /// Thrown when is null. + /// + /// Thrown when an exceedance probability is outside (0, 1) or a prior distribution spec is + /// invalid (mapped to HTTP 400). + /// + /// + /// Assignment order matters: and + /// re-seed default priors from their + /// setters, so the client's list is assigned + /// LAST (its setter processes the priors into likelihood form itself). + /// + public static void ApplyQuantilePriors(IQuantilePriors model, List? priors, bool? useSingleQuantile) + { + ArgumentNullException.ThrowIfNull(model); + if (priors == null || priors.Count == 0) return; + + var list = new List(priors.Count); + for (int i = 0; i < priors.Count; i++) + { + var dto = priors[i]; + if (double.IsNaN(dto.Alpha) || dto.Alpha <= 0d || dto.Alpha >= 1d) + { + throw new ArgumentException( + $"quantilePriors[{i}]: alpha must be strictly between 0 and 1 (annual exceedance probability)."); + } + list.Add(new QuantilePrior(dto.Alpha, DistributionSpecMapper.ToDistribution(dto.Distribution, $"quantilePriors[{i}].distribution"))); + } + + model.EnableQuantilePriors = true; + if (useSingleQuantile.HasValue) + { + model.UseSingleQuantile = useSingleQuantile.Value; + } + model.QuantilePriors = list; + } + + /// + /// Applies Bulletin 17C parameter and quantile penalties onto the distribution. + /// + /// The Bulletin 17C distribution to receive the penalties. + /// The client-supplied parameter penalties; null or empty applies nothing. + /// The client-supplied quantile penalties; null or empty applies nothing. + /// Thrown when is null. + /// + /// Thrown when a parameter name does not match, a name is repeated, an exceedance + /// probability is outside (0, 1), an MSE is not positive, or a log-space penalty has a + /// non-positive mean (all mapped to HTTP 400). + /// + /// + /// Parameter penalties FILL the distribution's pre-created entries in place: the model + /// keeps ParameterPenalties index-aligned 1:1 with Parameters (the GMM + /// penalty function pairs them by index), so the collection is never replaced. Quantile + /// penalties are self-contained (each carries its own AEP); their collection contents are + /// replaced in place because the model exposes no public setter. + /// + public static void ApplyPenalties( + Bulletin17CDistribution distribution, + List? parameterPenalties, + List? quantilePenalties) + { + ArgumentNullException.ThrowIfNull(distribution); + + if (parameterPenalties is { Count: > 0 }) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < parameterPenalties.Count; i++) + { + var dto = parameterPenalties[i]; + if (string.IsNullOrWhiteSpace(dto.ParameterName)) + { + throw new ArgumentException($"parameterPenalties[{i}]: parameterName is required. " + + $"Valid names: {DescribePenaltyNames(distribution)}."); + } + if (!seen.Add(dto.ParameterName)) + { + throw new ArgumentException($"parameterPenalties[{i}]: parameter '{dto.ParameterName}' is named more than once."); + } + ValidatePenaltyNumbers(dto.Mean, dto.Mse, dto.UseLog, $"parameterPenalties[{i}]", "useLog"); + + var matches = distribution.ParameterPenalties.Where(p => NameMatches(p.Name, dto.ParameterName)).ToList(); + if (matches.Count == 0) + { + throw new ArgumentException( + $"parameterPenalties[{i}]: no distribution parameter is named '{dto.ParameterName}'. " + + $"Valid names: {DescribePenaltyNames(distribution)}."); + } + if (matches.Count > 1) + { + throw new ArgumentException( + $"parameterPenalties[{i}]: '{dto.ParameterName}' matches more than one parameter. " + + $"Use the full name. Valid names: {DescribePenaltyNames(distribution)}."); + } + var entry = matches[0]; + + entry.Mean = dto.Mean; + entry.MSE = dto.Mse; + entry.UseLog = dto.UseLog; + entry.Enabled = true; + } + } + + if (quantilePenalties is { Count: > 0 }) + { + var list = new List(quantilePenalties.Count); + for (int i = 0; i < quantilePenalties.Count; i++) + { + var dto = quantilePenalties[i]; + if (double.IsNaN(dto.Aep) || dto.Aep <= 0d || dto.Aep >= 1d) + { + throw new ArgumentException( + $"quantilePenalties[{i}]: aep must be strictly between 0 and 1 (annual exceedance probability)."); + } + ValidatePenaltyNumbers(dto.Mean, dto.Mse, dto.UseLog10, $"quantilePenalties[{i}]", "useLog10"); + + list.Add(new QuantilePenalty + { + Enabled = true, + AEP = dto.Aep, + Mean = dto.Mean, + MSE = dto.Mse, + UseLog10 = dto.UseLog10 + }); + } + // The collection setter is private (the model pre-creates one disabled entry in + // its constructors), so replace the contents in place. PropertyChanged wiring on + // the new entries is irrelevant here: analysis resources are immutable after + // creation, so nothing edits penalties later. + distribution.QuantilePenalties.Clear(); + distribution.QuantilePenalties.AddRange(list); + } + } + + /// + /// Validates the numeric fields shared by both penalty kinds. + /// + /// The prior mean. + /// The prior mean squared error. + /// The log-space flag value. + /// The request field the penalty came from, used in error messages. + /// The JSON name of the log-space flag ("useLog" or "useLog10"). + /// Thrown when a value is invalid. + private static void ValidatePenaltyNumbers(double mean, double mse, bool useLog, string context, string logFlagName) + { + if (!double.IsFinite(mean)) + { + throw new ArgumentException($"{context}: mean must be a finite number."); + } + if (!double.IsFinite(mse) || mse <= 0d) + { + throw new ArgumentException($"{context}: mse must be a finite number greater than 0."); + } + if (useLog && mean <= 0d) + { + throw new ArgumentException($"{context}: mean must be greater than 0 when {logFlagName} is true."); + } + } + + /// + /// Compares a model parameter name against a client-supplied name, case-insensitively. + /// The model's names carry a trailing short-form group (e.g., "Skew (of log) (γ)"); the + /// comparison also accepts the name without it so clients need not type Greek letters. + /// + /// The model-side parameter name. + /// The client-supplied name. + /// True when the names match. + private static bool NameMatches(string candidate, string clientName) + { + return string.Equals(candidate, clientName, StringComparison.OrdinalIgnoreCase) || + string.Equals(StripShortForm(candidate), clientName, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Removes a trailing short-form marker like " (γ)" from a parameter name. Only a final + /// parenthesized group of at most three characters is treated as a short form, so + /// semantic groups like "(of log)" are never stripped. + /// + /// The parameter name. + /// The name without its short-form marker, or the name unchanged. + private static string StripShortForm(string name) + { + int open = name.LastIndexOf(" (", StringComparison.Ordinal); + if (open > 0 && name.EndsWith(')') && name.Length - open - 3 <= 3) + { + return name[..open]; + } + return name; + } + + /// + /// Lists the model's parameter display names for error messages. + /// + /// The model. + /// The quoted, comma-separated display names. + private static string DescribeParameterNames(ModelBase model) + { + return string.Join(", ", model.Parameters.Select(p => $"'{p.DisplayName}'")); + } + + /// + /// Lists the distribution's penalty entry names for error messages. + /// + /// The Bulletin 17C distribution. + /// The quoted, comma-separated penalty names. + private static string DescribePenaltyNames(Bulletin17CDistribution distribution) + { + return string.Join(", ", distribution.ParameterPenalties.Select(p => $"'{p.Name}'")); + } + } +} diff --git a/src/RMC.BestFit.Api/Mappers/ResultsMapper.cs b/src/RMC.BestFit.Api/Mappers/ResultsMapper.cs new file mode 100644 index 0000000..a24d194 --- /dev/null +++ b/src/RMC.BestFit.Api/Mappers/ResultsMapper.cs @@ -0,0 +1,530 @@ +using Numerics.Distributions; +using Numerics.Sampling.MCMC; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Helpers; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Estimation; + +namespace RMC.BestFit.Api.Mappers +{ + /// + /// Maps model-layer results (, MCMC posterior + /// summaries, information criteria) to the API's flat, agent-friendly results DTOs. + /// + /// + /// The confidence-interval array shape differs by analysis: univariate and Bulletin 17C + /// produce [p, 2] (lower, upper per probability ordinate) while the rating curve + /// produces [n, 3] (stage bin, lower, upper per grid point). NaN diagnostics are + /// converted to nulls so clients never have to parse named floating-point literals for + /// values that simply do not apply. + /// + public static class ResultsMapper + { + /// + /// R-hat threshold above which a convergence warning is emitted. + /// + private const double RhatWarningThreshold = 1.1; + + /// + /// Effective-sample-size threshold below which a convergence warning is emitted. + /// + private const double EssWarningThreshold = 400d; + + /// + /// Builds the frequency results response for a univariate or Bulletin 17C analysis. + /// + /// The analysis resource; its analysis must be estimated with results available. + /// The results response. + /// Thrown when the resource is a rating curve analysis. + /// Thrown when the analysis has no results. + public static FrequencyResultsResponse ToFrequencyResults(AnalysisResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + return resource.Kind switch + { + AnalysisKind.Univariate => ToUnivariateResults(resource), + AnalysisKind.Bulletin17C => ToBulletin17CResults(resource), + AnalysisKind.Mixture => ToMixtureResults(resource), + AnalysisKind.PointProcess => ToPointProcessResults(resource), + AnalysisKind.CompetingRisks => ToCompetingRisksResults(resource), + AnalysisKind.Composite => ToCompositeResults(resource), + _ => throw new ArgumentException($"Analysis kind '{resource.Kind}' does not produce frequency results.", nameof(resource)) + }; + } + + /// + /// Builds the results response for a univariate MCMC analysis. + /// + /// The univariate analysis resource. + /// The results response. + /// Thrown when the analysis has no results. + private static FrequencyResultsResponse ToUnivariateResults(AnalysisResource resource) + { + var analysis = resource.Univariate!; + var distribution = analysis.UnivariateDistribution; + return ToMcmcFrequencyResults( + resource, + analysis.AnalysisResults, + analysis.BayesianAnalysis, + analysis.ProbabilityOrdinates.ToList(), + distribution.Parameters, + EnumHelper.ToCamelCase(distribution.DistributionType.ToString()), + MetadataMapper.ToDisplayName(distribution.DistributionType)); + } + + /// + /// Builds the results response for a mixture-distribution MCMC analysis. + /// + /// The mixture analysis resource. + /// The results response. + /// Thrown when the analysis has no results. + private static FrequencyResultsResponse ToMixtureResults(AnalysisResource resource) + { + var analysis = resource.Mixture!; + var model = analysis.MixtureDistribution; + string components = string.Join(", ", (model.Mixture?.Distributions ?? Array.Empty()) + .Select(d => MetadataMapper.ToDisplayName(d.Type))); + return ToMcmcFrequencyResults( + resource, + analysis.AnalysisResults, + analysis.BayesianAnalysis, + analysis.ProbabilityOrdinates.ToList(), + model.Parameters, + "mixture", + components.Length > 0 ? $"Mixture of {components}" : "Mixture"); + } + + /// + /// Builds the results response for a peaks-over-threshold point process MCMC analysis. + /// + /// The point process analysis resource. + /// The results response. + /// Thrown when the analysis has no results. + private static FrequencyResultsResponse ToPointProcessResults(AnalysisResource resource) + { + var analysis = resource.PointProcess!; + return ToMcmcFrequencyResults( + resource, + analysis.AnalysisResults, + analysis.BayesianAnalysis, + analysis.ProbabilityOrdinates.ToList(), + analysis.PointProcess.Parameters, + "pointProcess", + "Point Process (GEV)"); + } + + /// + /// Builds the results response for a competing risks MCMC analysis. + /// + /// The competing risks analysis resource. + /// The results response. + /// Thrown when the analysis has no results. + private static FrequencyResultsResponse ToCompetingRisksResults(AnalysisResource resource) + { + var analysis = resource.CompetingRisks!; + var model = analysis.CompetingRisksDistribution; + string components = model.CompetingRisks is null + ? string.Empty + : string.Join(", ", model.CompetingRisks.Distributions.Select(d => MetadataMapper.ToDisplayName(d.Type))); + return ToMcmcFrequencyResults( + resource, + analysis.AnalysisResults, + analysis.BayesianAnalysis, + analysis.ProbabilityOrdinates.ToList(), + model.Parameters, + "competingRisks", + components.Length > 0 ? $"Competing Risks of {components}" : "Competing Risks"); + } + + /// + /// Builds the results response for a composite analysis. Composites run no MCMC of their + /// own — the response carries the aggregated curve, component weights (a first-class + /// output for model averaging), and the fit criteria the aggregation produced; there is + /// no single fitted distribution and no chain diagnostics. + /// + /// The composite analysis resource. + /// The results response. + /// Thrown when the analysis has no results. + private static FrequencyResultsResponse ToCompositeResults(AnalysisResource resource) + { + var analysis = resource.Composite!; + var results = analysis.AnalysisResults + ?? throw new InvalidOperationException("The analysis has no results. Run it first (POST .../run)."); + + var components = new List(analysis.Analyses.Count); + for (int i = 0; i < analysis.Analyses.Count; i++) + { + var component = analysis.Analyses[i]; + Guid? componentId = resource.ComponentAnalysisIds != null && i < resource.ComponentAnalysisIds.Count + ? resource.ComponentAnalysisIds[i] + : null; + var componentResource = resource.ComponentResources != null && i < resource.ComponentResources.Count + ? resource.ComponentResources[i] + : null; + components.Add(new CompositeComponentInfoDto + { + AnalysisId = componentId, + Name = componentResource?.Name, + Kind = componentResource == null ? null : EnumHelper.ToCamelCase(componentResource.Kind.ToString()), + Weight = component.Weight + }); + } + + return new FrequencyResultsResponse + { + AnalysisId = resource.Id, + Kind = EnumHelper.ToCamelCase(resource.Kind.ToString()), + FittedDistribution = null, + FrequencyCurve = BuildFrequencyCurve(results, analysis.ProbabilityOrdinates.ToList(), analysis.BayesianAnalysis.CredibleIntervalWidth), + ParameterSummaries = new List(), + InformationCriteria = BuildInformationCriteria(results, bayesianAnalysis: null), + Diagnostics = new DiagnosticsDto(), + Composite = new CompositeInfoDto + { + CompositeType = EnumHelper.ToCamelCase(analysis.CompositeDistributionType.ToString()), + AverageMethod = analysis.CompositeDistributionType == RMC.BestFit.Analyses.CompositeType.ModelAverage + ? EnumHelper.ToCamelCase(analysis.ModelAverageMethod.ToString()) + : null, + Dependency = analysis.CompositeDistributionType == RMC.BestFit.Analyses.CompositeType.CompetingRisks + ? EnumHelper.ToCamelCase(analysis.Dependency.ToString()) + : null, + IsMaximum = analysis.CompositeDistributionType == RMC.BestFit.Analyses.CompositeType.CompetingRisks + ? analysis.IsMaximum + : null, + Components = components + } + }; + } + + /// + /// Shared response assembly for the MCMC frequency-curve kinds (univariate, mixture, + /// point process, competing risks): fitted parameters at the point estimate, the + /// AEP-aligned curve, posterior summaries with chain diagnostics, and information criteria. + /// + /// The analysis resource. + /// The analysis results; null means never run. + /// The Bayesian analysis carrying the chain and settings. + /// The exceedance-probability ordinates. + /// The model parameters in canonical order. + /// The wire name for the fitted distribution type. + /// The human-readable fitted distribution name. + /// The results response. + /// Thrown when the analysis has no results. + private static FrequencyResultsResponse ToMcmcFrequencyResults( + AnalysisResource resource, + UncertaintyAnalysisResults? results, + BayesianAnalysis bayesian, + IReadOnlyList probabilities, + IReadOnlyList parameters, + string typeName, + string displayName) + { + if (results == null) + { + throw new InvalidOperationException("The analysis has no results. Run it first (POST .../run)."); + } + // DisplayName, not Name: stationary models blank the short name and keep the + // user-facing identifier (the same one priors are matched against) in DisplayName. + var parameterNames = parameters.Select(p => p.DisplayName).ToList(); + + return new FrequencyResultsResponse + { + AnalysisId = resource.Id, + Kind = EnumHelper.ToCamelCase(resource.Kind.ToString()), + FittedDistribution = new FittedDistributionDto + { + Type = typeName, + DisplayName = displayName, + PointEstimator = EnumHelper.ToCamelCase(bayesian.PointEstimator.ToString()), + Parameters = parameters + .Select(p => new ParameterValueDto { Name = p.DisplayName, Value = p.Value }) + .ToList() + }, + FrequencyCurve = BuildFrequencyCurve(results, probabilities, bayesian.CredibleIntervalWidth), + ParameterSummaries = BuildParameterSummaries(parameterNames, bayesian.Results, includeChainDiagnostics: true), + InformationCriteria = BuildInformationCriteria(results, bayesian), + Diagnostics = BuildMcmcDiagnostics(bayesian, parameterNames) + }; + } + + /// + /// Builds the results response for a Bulletin 17C analysis. + /// + /// The Bulletin 17C analysis resource. + /// The results response. + /// Thrown when the analysis has no results. + private static FrequencyResultsResponse ToBulletin17CResults(AnalysisResource resource) + { + var analysis = resource.Bulletin17C!; + var results = analysis.AnalysisResults + ?? throw new InvalidOperationException("The analysis has no results. Run it first (POST .../run)."); + var distribution = analysis.Bulletin17CDistribution; + var parameterNames = distribution.Parameters.Select(p => p.Name).ToList(); + + return new FrequencyResultsResponse + { + AnalysisId = resource.Id, + Kind = EnumHelper.ToCamelCase(resource.Kind.ToString()), + FittedDistribution = new FittedDistributionDto + { + Type = EnumHelper.ToCamelCase(distribution.DistributionType.ToString()), + DisplayName = MetadataMapper.ToDisplayName(distribution.DistributionType), + PointEstimator = "gmm", + Parameters = distribution.Parameters + .Select(p => new ParameterValueDto { Name = p.Name, Value = p.Value }) + .ToList() + }, + FrequencyCurve = BuildFrequencyCurve(results, analysis.ProbabilityOrdinates.ToList(), analysis.BayesianAnalysis.CredibleIntervalWidth), + ParameterSummaries = BuildParameterSummaries(parameterNames, analysis.BayesianAnalysis.Results, includeChainDiagnostics: false), + InformationCriteria = BuildInformationCriteria(results, bayesianAnalysis: null), + Diagnostics = new DiagnosticsDto + { + ElapsedMs = (long?)analysis.ElapsedTime?.TotalMilliseconds + }, + Bulletin17C = new Bulletin17CInfoDto + { + UncertaintyMethod = EnumHelper.ToCamelCase(analysis.UncertaintyMethod.ToString()), + GmmElapsedMs = (long?)analysis.GMMElapsedTime?.TotalMilliseconds, + UncertaintyElapsedMs = (long?)analysis.UncertaintyElapsedTime?.TotalMilliseconds + } + }; + } + + /// + /// Builds the results response for a rating curve analysis. + /// + /// The rating curve analysis resource. + /// The results response. + /// Thrown when the resource is not a rating curve analysis. + /// Thrown when the analysis has no results. + public static RatingCurveResultsResponse ToRatingCurveResults(AnalysisResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + if (resource.Kind != AnalysisKind.RatingCurve) + { + throw new ArgumentException($"Analysis kind '{resource.Kind}' does not produce rating curve results.", nameof(resource)); + } + + var analysis = resource.RatingCurve!; + var results = analysis.AnalysisResults + ?? throw new InvalidOperationException("The analysis has no results. Run it first (POST .../run)."); + var model = analysis.RatingCurve; + var parameterNames = model.Parameters.Select(p => p.Name).ToList(); + + return new RatingCurveResultsResponse + { + AnalysisId = resource.Id, + Kind = EnumHelper.ToCamelCase(resource.Kind.ToString()), + RatingCurve = BuildRatingCurve(results, analysis.BayesianAnalysis.CredibleIntervalWidth, analysis.MinStage, analysis.MaxStage, analysis.StageBins), + NumberOfSegments = model.NumberOfSegments, + AlignedObservationCount = model.GetAlignedObservations().Count, + Parameters = model.Parameters + .Select(p => new ParameterValueDto { Name = p.Name, Value = p.Value }) + .ToList(), + ParameterSummaries = BuildParameterSummaries(parameterNames, analysis.BayesianAnalysis.Results, includeChainDiagnostics: true), + InformationCriteria = BuildInformationCriteria(results, analysis.BayesianAnalysis), + Diagnostics = BuildMcmcDiagnostics(analysis.BayesianAnalysis, parameterNames) + }; + } + + /// + /// Builds a frequency curve from an uncertainty results object whose confidence intervals + /// have the [p, 2] (lower, upper) shape. + /// + /// The uncertainty analysis results. + /// The exceedance probabilities the curves are aligned to. + /// The credible interval width (e.g., 0.90). + /// The frequency curve DTO. + public static FrequencyCurveDto BuildFrequencyCurve(UncertaintyAnalysisResults results, IReadOnlyList probabilities, double credibleIntervalWidth) + { + ArgumentNullException.ThrowIfNull(results); + ArgumentNullException.ThrowIfNull(probabilities); + + var curve = new FrequencyCurveDto + { + Probabilities = probabilities.ToList(), + ModeCurve = results.ModeCurve?.ToList(), + MeanCurve = results.MeanCurve?.ToList(), + CredibleIntervalWidth = credibleIntervalWidth + }; + + if (results.ConfidenceIntervals is { } intervals && intervals.GetLength(1) >= 2) + { + // [p, 2]: column 0 = lower, column 1 = upper. + int rows = intervals.GetLength(0); + var lower = new List(rows); + var upper = new List(rows); + for (int i = 0; i < rows; i++) + { + lower.Add(intervals[i, 0]); + upper.Add(intervals[i, 1]); + } + curve.CiLower = lower; + curve.CiUpper = upper; + } + + return curve; + } + + /// + /// Builds a rating curve from an uncertainty results object whose confidence intervals + /// have the [n, 3] (stage bin, lower, upper) shape. + /// + /// The uncertainty analysis results. + /// The credible interval width (e.g., 0.90). + /// The configured minimum stage of the grid. + /// The configured maximum stage of the grid. + /// The configured number of stage bins. + /// The rating curve DTO. + public static RatingCurveDto BuildRatingCurve(UncertaintyAnalysisResults results, double credibleIntervalWidth, double? minStage = null, double? maxStage = null, int? stageBins = null) + { + ArgumentNullException.ThrowIfNull(results); + + var curve = new RatingCurveDto + { + ModeCurve = results.ModeCurve?.ToList(), + MeanCurve = results.MeanCurve?.ToList(), + CredibleIntervalWidth = credibleIntervalWidth, + MinStage = minStage, + MaxStage = maxStage, + StageBins = stageBins + }; + + if (results.ConfidenceIntervals is { } intervals && intervals.GetLength(1) >= 3) + { + // [n, 3]: column 0 = stage bin, column 1 = lower, column 2 = upper. + int rows = intervals.GetLength(0); + var stages = new List(rows); + var lower = new List(rows); + var upper = new List(rows); + for (int i = 0; i < rows; i++) + { + stages.Add(intervals[i, 0]); + lower.Add(intervals[i, 1]); + upper.Add(intervals[i, 2]); + } + curve.Stages = stages; + curve.CiLower = lower; + curve.CiUpper = upper; + } + + return curve; + } + + /// + /// Builds per-parameter posterior summaries from MCMC results, pairing names with + /// summary statistics by index. + /// + /// The model parameter names, in model order. + /// The MCMC (or sampled parameter set) results; null yields an empty list. + /// True to report R-hat/ESS; false (Bulletin 17C sampled sets) reports them as null. + /// The parameter summaries. + public static List BuildParameterSummaries(IReadOnlyList parameterNames, MCMCResults? results, bool includeChainDiagnostics) + { + ArgumentNullException.ThrowIfNull(parameterNames); + var summaries = new List(); + if (results?.ParameterResults == null) return summaries; + + for (int i = 0; i < results.ParameterResults.Length; i++) + { + var statistics = results.ParameterResults[i].SummaryStatistics; + summaries.Add(new ParameterSummaryDto + { + Name = i < parameterNames.Count ? parameterNames[i] : $"parameter{i}", + Mean = NanToNull(statistics.Mean), + Median = NanToNull(statistics.Median), + StandardDeviation = NanToNull(statistics.StandardDeviation), + LowerCI = NanToNull(statistics.LowerCI), + UpperCI = NanToNull(statistics.UpperCI), + Rhat = includeChainDiagnostics ? NanToNull(statistics.Rhat) : null, + Ess = includeChainDiagnostics ? NanToNull(statistics.ESS) : null + }); + } + return summaries; + } + + /// + /// Scans MCMC parameter results for convergence concerns: R-hat above + /// or effective sample size below + /// . Warnings never fail a run. + /// + /// The MCMC results; null yields no warnings. + /// The model parameter names, in model order. + /// The warning messages, empty when no concerns were detected. + public static List BuildConvergenceWarnings(MCMCResults? results, IReadOnlyList parameterNames) + { + ArgumentNullException.ThrowIfNull(parameterNames); + var warnings = new List(); + if (results?.ParameterResults == null) return warnings; + + for (int i = 0; i < results.ParameterResults.Length; i++) + { + var statistics = results.ParameterResults[i].SummaryStatistics; + string name = i < parameterNames.Count ? parameterNames[i] : $"parameter{i}"; + if (!double.IsNaN(statistics.Rhat) && statistics.Rhat > RhatWarningThreshold) + { + warnings.Add($"R-hat for '{name}' is {statistics.Rhat:F3} (> {RhatWarningThreshold}); the chains may not have converged. Consider more iterations or warm-up."); + } + if (!double.IsNaN(statistics.ESS) && statistics.ESS < EssWarningThreshold) + { + warnings.Add($"Effective sample size for '{name}' is {statistics.ESS:F0} (< {EssWarningThreshold}); posterior summaries may be noisy. Consider more iterations or less thinning."); + } + } + return warnings; + } + + /// + /// Builds the diagnostics block for an MCMC-based analysis. + /// + /// The Bayesian analysis carrying sampler settings and results. + /// The model parameter names for warning messages. + /// The diagnostics DTO. + public static DiagnosticsDto BuildMcmcDiagnostics(BayesianAnalysis bayesianAnalysis, IReadOnlyList parameterNames) + { + return new DiagnosticsDto + { + Sampler = EnumHelper.ToCamelCase(bayesianAnalysis.Type.ToString()), + Iterations = bayesianAnalysis.Iterations, + WarmupIterations = bayesianAnalysis.WarmupIterations, + NumberOfChains = bayesianAnalysis.NumberOfChains, + AcceptanceRates = bayesianAnalysis.Results?.AcceptanceRates?.ToList(), + ConvergenceWarnings = BuildConvergenceWarnings(bayesianAnalysis.Results, parameterNames), + ElapsedMs = (long?)bayesianAnalysis.ElapsedTime?.TotalMilliseconds + }; + } + + /// + /// Builds the information-criteria block, merging the fit criteria on the results object + /// with the MCMC-only criteria on the Bayesian analysis (when applicable). + /// + /// The uncertainty analysis results carrying AIC/BIC/DIC/RMSE/ERL. + /// The Bayesian analysis carrying WAIC/LOOIC, or null for non-MCMC fits. + /// The information criteria DTO. + public static InformationCriteriaDto BuildInformationCriteria(UncertaintyAnalysisResults results, BayesianAnalysis? bayesianAnalysis) + { + return new InformationCriteriaDto + { + Aic = NanToNull(results.AIC), + Bic = NanToNull(results.BIC), + Dic = NanToNull(results.DIC), + Rmse = NanToNull(results.RMSE), + Erl = NanToNull(results.ERL), + Waic = bayesianAnalysis == null ? null : NanToNull(bayesianAnalysis.WAIC), + WaicPD = bayesianAnalysis == null ? null : NanToNull(bayesianAnalysis.WAIC_pD), + Looic = bayesianAnalysis == null ? null : NanToNull(bayesianAnalysis.LOOIC), + LooicSE = bayesianAnalysis == null ? null : NanToNull(bayesianAnalysis.LOOIC_SE) + }; + } + + /// + /// Converts NaN to null so diagnostics that do not apply are omitted from the JSON rather + /// than serialized as named floating-point literals. + /// + /// The value to convert. + /// Null when NaN; otherwise the value. + private static double? NanToNull(double value) + { + return double.IsNaN(value) ? null : value; + } + } +} diff --git a/src/RMC.BestFit.Api/Mappers/TimeSeriesMapper.cs b/src/RMC.BestFit.Api/Mappers/TimeSeriesMapper.cs new file mode 100644 index 0000000..f024f88 --- /dev/null +++ b/src/RMC.BestFit.Api/Mappers/TimeSeriesMapper.cs @@ -0,0 +1,84 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Helpers; +using RMC.BestFit.Api.Store; + +namespace RMC.BestFit.Api.Mappers +{ + /// + /// Maps time-series resources to their response DTOs. + /// + public static class TimeSeriesMapper + { + /// + /// Maps a resource to its summary DTO. + /// + /// The time-series resource. + /// The summary DTO. + public static TimeSeriesSummaryDto ToSummary(TimeSeriesResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + return new TimeSeriesSummaryDto + { + Id = resource.Id, + Name = resource.Name, + Description = resource.Description, + CreatedUtc = resource.CreatedUtc, + Source = EnumHelper.ToCamelCase(resource.Source.ToString()), + UsgsSiteNumber = resource.UsgsSiteNumber, + SeriesType = resource.UsgsSeriesType.HasValue ? EnumHelper.ToCamelCase(resource.UsgsSeriesType.Value.ToString()) : null, + TimeInterval = EnumHelper.ToCamelCase(resource.TimeSeries.TimeInterval.ToString()), + PointCount = resource.PointCount, + MissingCount = resource.MissingCount, + StartDate = resource.StartDate, + EndDate = resource.EndDate + }; + } + + /// + /// Maps a resource to a single-resource response, optionally including a page of points. + /// + /// The time-series resource. + /// True to include ordinate values in the response. + /// Zero-based index of the first point to include. + /// Maximum number of points to include. + /// The response DTO. + public static TimeSeriesResourceResponse ToResourceResponse(TimeSeriesResource resource, bool includePoints = false, int offset = 0, int limit = 10000) + { + ArgumentNullException.ThrowIfNull(resource); + var response = new TimeSeriesResourceResponse { TimeSeries = ToSummary(resource) }; + if (includePoints) + { + offset = Math.Max(0, offset); + limit = Math.Max(0, limit); + var points = new List(); + int end = Math.Min(resource.TimeSeries.Count, offset + limit); + for (int i = offset; i < end; i++) + { + points.Add(new TimeSeriesPointDto + { + DateTime = resource.TimeSeries[i].Index, + Value = resource.TimeSeries[i].Value + }); + } + response.Points = points; + response.PointsOffset = offset; + } + return response; + } + + /// + /// Maps a list of resources to the list response. + /// + /// The time-series resources ordered by creation time. + /// The list response DTO. + public static TimeSeriesListResponse ToListResponse(IReadOnlyList resources) + { + ArgumentNullException.ThrowIfNull(resources); + return new TimeSeriesListResponse + { + Count = resources.Count, + TimeSeries = resources.Select(ToSummary).ToList() + }; + } + } +} diff --git a/src/RMC.BestFit.Api/Mappers/TimeSeriesResultsMapper.cs b/src/RMC.BestFit.Api/Mappers/TimeSeriesResultsMapper.cs new file mode 100644 index 0000000..0d65ee0 --- /dev/null +++ b/src/RMC.BestFit.Api/Mappers/TimeSeriesResultsMapper.cs @@ -0,0 +1,165 @@ +using Numerics.Distributions; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Helpers; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Estimation; +using RMC.BestFit.Models; + +namespace RMC.BestFit.Api.Mappers +{ + /// + /// Maps time-series analysis results to the response DTO. All four model families share the + /// results shape: ModeCurve/MeanCurve span the observed series plus the + /// forecast horizon, and ConfidenceIntervals is [n, 3] (time index, lower, + /// upper). + /// + public static class TimeSeriesResultsMapper + { + /// + /// Builds the results response for a time-series analysis of any model family. + /// + /// The time-series analysis resource. + /// The results response. + /// Thrown when the resource is not a time-series analysis. + /// Thrown when the analysis has never been run. + public static TimeSeriesResultsResponse ToResults(AnalysisResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + if (resource.Kind != AnalysisKind.TimeSeries) + { + throw new ArgumentException($"Analysis kind '{resource.Kind}' does not produce time-series results.", nameof(resource)); + } + + // One extraction switch: results object, estimator, parameters, and the per-family + // settings echo. The curve/summary assembly below is family-independent. + UncertaintyAnalysisResults? results; + BayesianAnalysis bayesian; + IReadOnlyList parameters; + string transform; + int dataLength; + int trainingSteps; + int forecastSteps; + int? order = null, pOrder = null, dOrder = null, qOrder = null, xOrder = null; + + switch (resource.TimeSeriesModel) + { + case TimeSeriesModelType.Ar: + { + var analysis = resource.Ar!; + var model = analysis.AutoRegressive; + results = analysis.AnalysisResults; + bayesian = analysis.BayesianAnalysis; + parameters = model.Parameters; + transform = model.TransformType.ToString(); + dataLength = model.TimeSeries?.Count ?? 0; + trainingSteps = model.TrainingTimeSteps; + forecastSteps = analysis.ForecastingTimeSteps; + order = model.Order; + break; + } + case TimeSeriesModelType.Ma: + { + var analysis = resource.Ma!; + var model = analysis.MovingAverage; + results = analysis.AnalysisResults; + bayesian = analysis.BayesianAnalysis; + parameters = model.Parameters; + transform = model.TransformType.ToString(); + dataLength = model.TimeSeries?.Count ?? 0; + trainingSteps = model.TrainingTimeSteps; + forecastSteps = analysis.ForecastingTimeSteps; + order = model.Order; + break; + } + case TimeSeriesModelType.Arima: + { + var analysis = resource.Arima!; + var model = analysis.ARIMA; + results = analysis.AnalysisResults; + bayesian = analysis.BayesianAnalysis; + parameters = model.Parameters; + transform = model.TransformType.ToString(); + dataLength = model.TimeSeries?.Count ?? 0; + trainingSteps = model.TrainingTimeSteps; + forecastSteps = analysis.ForecastingTimeSteps; + pOrder = model.POrder; + dOrder = model.DOrder; + qOrder = model.QOrder; + break; + } + case TimeSeriesModelType.Arimax: + { + var analysis = resource.Arimax!; + var model = analysis.ARIMAX; + results = analysis.AnalysisResults; + bayesian = analysis.BayesianAnalysis; + parameters = model.Parameters; + transform = model.TransformType.ToString(); + dataLength = model.TimeSeries?.Count ?? 0; + trainingSteps = model.TrainingTimeSteps; + forecastSteps = analysis.ForecastingTimeSteps; + pOrder = model.AROrderP; + dOrder = model.DiffOrderD; + qOrder = model.MAOrderQ; + xOrder = model.XOrderB; + break; + } + default: + throw new ArgumentException("The time-series resource has no model discriminator.", nameof(resource)); + } + + if (results == null) + { + throw new InvalidOperationException("The analysis has no results. Run it first (POST .../run)."); + } + var parameterNames = parameters.Select(p => p.DisplayName).ToList(); + + var curve = new TimeSeriesCurveDto + { + ModeCurve = results.ModeCurve?.ToList(), + MeanCurve = results.MeanCurve?.ToList(), + CredibleIntervalWidth = bayesian.CredibleIntervalWidth + }; + if (results.ConfidenceIntervals is { } intervals && intervals.GetLength(1) >= 3) + { + // [n, 3]: column 0 = time index, column 1 = lower, column 2 = upper. + int rows = intervals.GetLength(0); + var indices = new List(rows); + var lower = new List(rows); + var upper = new List(rows); + for (int i = 0; i < rows; i++) + { + indices.Add(intervals[i, 0]); + lower.Add(intervals[i, 1]); + upper.Add(intervals[i, 2]); + } + curve.TimeIndices = indices; + curve.CiLower = lower; + curve.CiUpper = upper; + } + + return new TimeSeriesResultsResponse + { + AnalysisId = resource.Id, + Kind = EnumHelper.ToCamelCase(resource.Kind.ToString()), + ModelType = EnumHelper.ToCamelCase(resource.TimeSeriesModel.ToString()!), + TransformType = EnumHelper.ToCamelCase(transform), + DataLength = dataLength, + TrainingTimeSteps = trainingSteps, + ForecastingTimeSteps = forecastSteps, + Order = order, + POrder = pOrder, + DOrder = dOrder, + QOrder = qOrder, + XOrder = xOrder, + Curve = curve, + Parameters = parameters + .Select(p => new ParameterValueDto { Name = p.DisplayName, Value = p.Value }) + .ToList(), + ParameterSummaries = ResultsMapper.BuildParameterSummaries(parameterNames, bayesian.Results, includeChainDiagnostics: true), + InformationCriteria = ResultsMapper.BuildInformationCriteria(results, bayesian), + Diagnostics = ResultsMapper.BuildMcmcDiagnostics(bayesian, parameterNames) + }; + } + } +} diff --git a/src/RMC.BestFit.Api/Mcp/AdvancedAnalysisTools.cs b/src/RMC.BestFit.Api/Mcp/AdvancedAnalysisTools.cs new file mode 100644 index 0000000..fab4712 --- /dev/null +++ b/src/RMC.BestFit.Api/Mcp/AdvancedAnalysisTools.cs @@ -0,0 +1,502 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; +using Numerics.Data; +using Numerics.Distributions; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Helpers; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Services; + +namespace RMC.BestFit.Api.Mcp +{ + /// + /// MCP tools for creating the advanced analysis kinds (mixture, point process, competing + /// risks, and later composite/bivariate/time-series kinds). Created analyses are run with the + /// shared run_analysis tool. + /// + [McpServerToolType] + public class AdvancedAnalysisTools + { + /// + /// The analysis service shared with the REST controllers. + /// + private readonly IAnalysisService _service; + + /// + /// Constructs the tools with their service dependency. + /// + /// The analysis service. + /// Thrown when the service is null. + public AdvancedAnalysisTools(IAnalysisService service) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + /// + /// Creates a mixture-distribution frequency analysis. + /// + /// The input-data resource id. + /// The 1-3 component distribution names. + /// True to model a point mass at zero. + /// Optional AEP ordinates. + /// Optional MCMC iterations. + /// Optional warm-up iterations. + /// Optional chain count. + /// Optional PRNG seed for reproducibility. + /// Optional credible interval width. + /// Optional sampler name. + /// Optional point estimator name. + /// Optional informative parameter priors. + /// Optional quantile priors. + /// Optional single-quantile-prior formulation flag. + /// Optional display name. + /// JSON with the created analysis summary including its id. + [McpServerTool(Name = "create_mixture_analysis")] + [Description("Create a Bayesian MCMC mixture-distribution frequency analysis over an input-data resource — models samples from multiple flood-generating populations as a weighted combination of 1-3 component distributions with estimated weights. Returns the analysis id to pass to run_analysis.")] + public string CreateMixtureAnalysis( + [Description("Id of the input-data resource to fit.")] Guid inputDataId, + [Description("1-3 component distributions, e.g. ['gumbel','logNormal'] (see get_metadata for names).")] string[] distributions, + [Description("True to add a point mass at zero for zero-flow years. Default false.")] bool isZeroInflated = false, + [Description("Optional AEP ordinates, each strictly between 0 and 1. Sorted automatically.")] double[]? probabilityOrdinates = null, + [Description("Optional total MCMC iterations per chain (server-capped).")] int? iterations = null, + [Description("Optional warm-up iterations (must be below iterations).")] int? warmupIterations = null, + [Description("Optional number of parallel chains (1-8).")] int? numberOfChains = null, + [Description("Optional PRNG seed for reproducible runs.")] int? prngSeed = null, + [Description("Optional credible interval width, e.g. 0.90.")] double? credibleIntervalWidth = null, + [Description("Optional sampler: demCzs (default), demCz, arwmh, or nuts.")] string? sampler = null, + [Description("Optional point estimator: posteriorMode or posteriorMean.")] string? pointEstimator = null, + [Description("Optional informative parameter priors: array of { parameterName, distribution: { type, parameters }, isFixed? }.")] List? parameterPriors = null, + [Description("Optional quantile priors: array of { alpha (AEP), distribution: { type, parameters } }.")] List? quantilePriors = null, + [Description("True for the single-quantile-prior formulation (Viglione et al. 2013).")] bool? useSingleQuantile = null, + [Description("Optional display name for the analysis.")] string? name = null) + { + var resource = _service.CreateMixture(new CreateMixtureAnalysisRequest + { + InputDataId = inputDataId, + Distributions = ParseDistributions(distributions), + IsZeroInflated = isZeroInflated, + ProbabilityOrdinates = probabilityOrdinates?.ToList(), + BayesianOptions = AnalysisTools.BuildBayesianOptions(iterations, warmupIterations, numberOfChains, prngSeed, credibleIntervalWidth, sampler, pointEstimator), + ParameterPriors = parameterPriors, + QuantilePriors = quantilePriors, + UseSingleQuantile = useSingleQuantile, + Name = name + }); + return McpJson.Serialize(AnalysisMapper.ToResourceResponse(resource)); + } + + /// + /// Creates a peaks-over-threshold point process analysis. + /// + /// The POT input-data resource id. + /// True to model seasonal event rates. + /// Optional seasonal time-block window name. + /// Optional season start month (1-12). + /// Optional explicit threshold override. + /// Optional explicit record span in years. + /// Optional AEP ordinates. + /// Optional MCMC iterations. + /// Optional warm-up iterations. + /// Optional chain count. + /// Optional PRNG seed for reproducibility. + /// Optional credible interval width. + /// Optional sampler name. + /// Optional point estimator name. + /// Optional informative parameter priors. + /// Optional quantile priors. + /// Optional single-quantile-prior formulation flag. + /// Optional display name. + /// JSON with the created analysis summary including its id. + [McpServerTool(Name = "create_pointprocess_analysis")] + [Description("Create a Bayesian MCMC peaks-over-threshold point process analysis over a POT input-data resource (create it with create_inputdata_pot). Jointly models event rate and magnitude with a GEV formulation; the POT resource's threshold seeds the model unless overridden. Returns the analysis id to pass to run_analysis.")] + public string CreatePointProcessAnalysis( + [Description("Id of the peaks-over-threshold input-data resource to fit.")] Guid inputDataId, + [Description("True to model within-year seasonality of the event rate. Default false.")] bool isSeasonal = false, + [Description("Seasonal time-block window (waterYear, calendarYear, ...); only with isSeasonal=true.")] string? timeBlock = null, + [Description("Season start month 1-12; only with isSeasonal=true.")] int? startMonth = null, + [Description("Optional explicit threshold; omit to use the POT resource's recorded threshold.")] double? threshold = null, + [Description("Optional explicit record span in years (> 0) for the event rate λ = events/totalYears.")] double? totalYears = null, + [Description("Optional AEP ordinates, each strictly between 0 and 1. Sorted automatically.")] double[]? probabilityOrdinates = null, + [Description("Optional total MCMC iterations per chain (server-capped).")] int? iterations = null, + [Description("Optional warm-up iterations (must be below iterations).")] int? warmupIterations = null, + [Description("Optional number of parallel chains (1-8).")] int? numberOfChains = null, + [Description("Optional PRNG seed for reproducible runs.")] int? prngSeed = null, + [Description("Optional credible interval width, e.g. 0.90.")] double? credibleIntervalWidth = null, + [Description("Optional sampler: demCzs (default), demCz, arwmh, or nuts.")] string? sampler = null, + [Description("Optional point estimator: posteriorMode or posteriorMean.")] string? pointEstimator = null, + [Description("Optional informative parameter priors: array of { parameterName, distribution: { type, parameters }, isFixed? }.")] List? parameterPriors = null, + [Description("Optional quantile priors: array of { alpha (AEP), distribution: { type, parameters } }.")] List? quantilePriors = null, + [Description("True for the single-quantile-prior formulation (Viglione et al. 2013).")] bool? useSingleQuantile = null, + [Description("Optional display name for the analysis.")] string? name = null) + { + var resource = _service.CreatePointProcess(new CreatePointProcessAnalysisRequest + { + InputDataId = inputDataId, + IsSeasonal = isSeasonal, + TimeBlock = EnumHelper.ParseOrNull(timeBlock), + StartMonth = startMonth, + Threshold = threshold, + TotalYears = totalYears, + ProbabilityOrdinates = probabilityOrdinates?.ToList(), + BayesianOptions = AnalysisTools.BuildBayesianOptions(iterations, warmupIterations, numberOfChains, prngSeed, credibleIntervalWidth, sampler, pointEstimator), + ParameterPriors = parameterPriors, + QuantilePriors = quantilePriors, + UseSingleQuantile = useSingleQuantile, + Name = name + }); + return McpJson.Serialize(AnalysisMapper.ToResourceResponse(resource)); + } + + /// + /// Creates a competing risks frequency analysis. + /// + /// The input-data resource id. + /// The 1-3 component distribution names. + /// Optional AEP ordinates. + /// Optional MCMC iterations. + /// Optional warm-up iterations. + /// Optional chain count. + /// Optional PRNG seed for reproducibility. + /// Optional credible interval width. + /// Optional sampler name. + /// Optional point estimator name. + /// Optional informative parameter priors. + /// Optional quantile priors. + /// Optional single-quantile-prior formulation flag. + /// Optional display name. + /// JSON with the created analysis summary including its id. + [McpServerTool(Name = "create_competingrisks_analysis")] + [Description("Create a Bayesian MCMC competing risks frequency analysis over an input-data resource — models each annual maximum as the maximum of 1-3 independent flood-generating processes, each with its own component distribution. Returns the analysis id to pass to run_analysis.")] + public string CreateCompetingRisksAnalysis( + [Description("Id of the input-data resource to fit.")] Guid inputDataId, + [Description("1-3 component distributions, one per competing process (see get_metadata for names).")] string[] distributions, + [Description("Optional AEP ordinates, each strictly between 0 and 1. Sorted automatically.")] double[]? probabilityOrdinates = null, + [Description("Optional total MCMC iterations per chain (server-capped).")] int? iterations = null, + [Description("Optional warm-up iterations (must be below iterations).")] int? warmupIterations = null, + [Description("Optional number of parallel chains (1-8).")] int? numberOfChains = null, + [Description("Optional PRNG seed for reproducible runs.")] int? prngSeed = null, + [Description("Optional credible interval width, e.g. 0.90.")] double? credibleIntervalWidth = null, + [Description("Optional sampler: demCzs (default), demCz, arwmh, or nuts.")] string? sampler = null, + [Description("Optional point estimator: posteriorMode or posteriorMean.")] string? pointEstimator = null, + [Description("Optional informative parameter priors: array of { parameterName, distribution: { type, parameters }, isFixed? }.")] List? parameterPriors = null, + [Description("Optional quantile priors: array of { alpha (AEP), distribution: { type, parameters } }.")] List? quantilePriors = null, + [Description("True for the single-quantile-prior formulation (Viglione et al. 2013).")] bool? useSingleQuantile = null, + [Description("Optional display name for the analysis.")] string? name = null) + { + var resource = _service.CreateCompetingRisks(new CreateCompetingRisksAnalysisRequest + { + InputDataId = inputDataId, + Distributions = ParseDistributions(distributions), + ProbabilityOrdinates = probabilityOrdinates?.ToList(), + BayesianOptions = AnalysisTools.BuildBayesianOptions(iterations, warmupIterations, numberOfChains, prngSeed, credibleIntervalWidth, sampler, pointEstimator), + ParameterPriors = parameterPriors, + QuantilePriors = quantilePriors, + UseSingleQuantile = useSingleQuantile, + Name = name + }); + return McpJson.Serialize(AnalysisMapper.ToResourceResponse(resource)); + } + + /// + /// Creates a composite analysis over existing component analyses. + /// + /// The component analysis ids. + /// The composition method name. + /// Mixture weights aligned with the component ids. + /// Optional model-average weighting method name. + /// Optional competing risks dependence assumption name. + /// True to combine competing risks as the maximum. + /// Optional AEP ordinates. + /// Optional credible interval width. + /// Optional point estimator name. + /// Optional display name. + /// JSON with the created analysis summary including its id. + [McpServerTool(Name = "create_composite_analysis")] + [Description("Create a composite analysis over already-created component analyses (univariate, bulletin17c, mixture, pointprocess, or competingrisks kinds). Combines component posteriors by competing risks, mixture weights, or information-criterion model averaging — no MCMC of its own. Components must be RUN before the composite runs; components are LIVE references (re-running one refreshes the composite's next run). Returns the analysis id to pass to run_analysis.")] + public string CreateCompositeAnalysis( + [Description("Ids of the component analyses, in order.")] Guid[] componentAnalysisIds, + [Description("Composition method: competingRisks (default), mixture, or modelAverage.")] string? compositeType = null, + [Description("Mixture weights aligned with componentAnalysisIds (each strictly 0-1, sum ≤ 1). Required for mixture; ignored otherwise.")] double[]? weights = null, + [Description("Model-average weighting: aic, bic, dic, waic, looic, equal, or rmse. Only with compositeType=modelAverage.")] string? averageMethod = null, + [Description("Competing risks dependence: independent (default), perfectlyPositive, or perfectlyNegative.")] string? dependency = null, + [Description("True (default) for the maximum of competing processes; false for the minimum.")] bool isMaximum = true, + [Description("Optional AEP ordinates, each strictly between 0 and 1. Sorted automatically.")] double[]? probabilityOrdinates = null, + [Description("Optional credible interval width for the composite bands, e.g. 0.90.")] double? credibleIntervalWidth = null, + [Description("Optional point estimator: posteriorMode or posteriorMean.")] string? pointEstimator = null, + [Description("Optional display name for the analysis.")] string? name = null) + { + if (componentAnalysisIds == null || componentAnalysisIds.Length == 0) + { + throw new ArgumentException("At least one component analysis id is required."); + } + if (weights != null && weights.Length != componentAnalysisIds.Length) + { + throw new ArgumentException("weights must align one-to-one with componentAnalysisIds."); + } + + var components = new List(componentAnalysisIds.Length); + for (int i = 0; i < componentAnalysisIds.Length; i++) + { + components.Add(new CompositeComponentDto + { + AnalysisId = componentAnalysisIds[i], + Weight = weights?[i] + }); + } + + var resource = _service.CreateComposite(new CreateCompositeAnalysisRequest + { + Components = components, + CompositeType = EnumHelper.ParseOrDefault(compositeType, RMC.BestFit.Analyses.CompositeType.CompetingRisks), + AverageMethod = EnumHelper.ParseOrNull(averageMethod), + Dependency = EnumHelper.ParseOrNull(dependency), + IsMaximum = isMaximum, + ProbabilityOrdinates = probabilityOrdinates?.ToList(), + CredibleIntervalWidth = credibleIntervalWidth, + PointEstimator = EnumHelper.ParseOrNull(pointEstimator), + Name = name + }); + return McpJson.Serialize(AnalysisMapper.ToResourceResponse(resource)); + } + + /// + /// Creates a distribution-fitting analysis (parallel MLE screen). + /// + /// The input-data resource id. + /// Optional candidate subset names. + /// Optional display name. + /// JSON with the created analysis summary including its id. + [McpServerTool(Name = "create_distributionfitting_analysis")] + [Description("Create a distribution-fitting analysis: a fast parallel maximum-likelihood fit of candidate distributions over an input-data resource, ranked by AIC/BIC/RMSE — a screening step (seconds, no MCMC) before Bayesian analyses. Omit distributions to fit all 15. Returns the analysis id to pass to run_analysis.")] + public string CreateDistributionFittingAnalysis( + [Description("Id of the input-data resource to fit.")] Guid inputDataId, + [Description("Optional candidate subset, e.g. ['logPearsonTypeIII','generalizedExtremeValue']; omit for all 15.")] string[]? distributions = null, + [Description("Optional display name for the analysis.")] string? name = null) + { + var resource = _service.CreateDistributionFitting(new CreateDistributionFittingAnalysisRequest + { + InputDataId = inputDataId, + Distributions = distributions == null || distributions.Length == 0 ? null : ParseDistributions(distributions), + Name = name + }); + return McpJson.Serialize(AnalysisMapper.ToResourceResponse(resource)); + } + + /// + /// Creates a bivariate copula analysis over two fitted marginal analyses. + /// + /// The marginal-X analysis id. + /// The marginal-Y analysis id. + /// The x magnitudes of the joint-exceedance grid. + /// The y magnitudes of the grid, aligned with xOrdinates. + /// Optional copula family name. + /// Optional copula estimation method name. + /// Optional MCMC iterations. + /// Optional warm-up iterations. + /// Optional chain count. + /// Optional PRNG seed for reproducibility. + /// Optional credible interval width. + /// Optional sampler name. + /// Optional point estimator name. + /// Optional informative copula-parameter priors. + /// Optional display name. + /// JSON with the created analysis summary including its id. + [McpServerTool(Name = "create_bivariate_analysis")] + [Description("Create a Bayesian MCMC bivariate copula analysis over two FITTED marginal analyses (kinds univariate/bulletin17c/mixture/pointprocess; the marginals' data pair by shared time index, ≥10 overlapping observations required). Marginals are LIVE references and must be RUN before this analysis runs. The results report joint exceedance P(X>x AND Y>y) at each grid point. Returns the analysis id to pass to run_analysis.")] + public string CreateBivariateAnalysis( + [Description("Id of the marginal-X analysis.")] Guid marginalXAnalysisId, + [Description("Id of the marginal-Y analysis (must differ from marginal X).")] Guid marginalYAnalysisId, + [Description("X magnitudes of the joint-exceedance evaluation grid.")] double[] xOrdinates, + [Description("Y magnitudes aligned one-to-one with xOrdinates.")] double[] yOrdinates, + [Description("Copula family: normal (default), clayton, frank, gumbel, joe, aliMikhailHaq, or studentT.")] string? copulaType = null, + [Description("Copula estimation: inferenceFromMargins (default) or pseudoLikelihood.")] string? estimationMethod = null, + [Description("Optional total MCMC iterations per chain (server-capped).")] int? iterations = null, + [Description("Optional warm-up iterations (must be below iterations).")] int? warmupIterations = null, + [Description("Optional number of parallel chains (1-8).")] int? numberOfChains = null, + [Description("Optional PRNG seed for reproducible runs.")] int? prngSeed = null, + [Description("Optional credible interval width, e.g. 0.90.")] double? credibleIntervalWidth = null, + [Description("Optional sampler: demCzs (default), demCz, arwmh, or nuts.")] string? sampler = null, + [Description("Optional point estimator: posteriorMode or posteriorMean.")] string? pointEstimator = null, + [Description("Optional informative priors on the copula parameter(s): array of { parameterName, distribution: { type, parameters }, isFixed? }.")] List? parameterPriors = null, + [Description("Optional display name for the analysis.")] string? name = null) + { + if (xOrdinates == null || yOrdinates == null || xOrdinates.Length == 0 || xOrdinates.Length != yOrdinates.Length) + { + throw new ArgumentException("xOrdinates and yOrdinates must be non-empty and align one-to-one."); + } + var points = new List(xOrdinates.Length); + for (int i = 0; i < xOrdinates.Length; i++) + { + points.Add(new XyOrdinateDto { X = xOrdinates[i], Y = yOrdinates[i] }); + } + + var resource = _service.CreateBivariate(new CreateBivariateAnalysisRequest + { + MarginalXAnalysisId = marginalXAnalysisId, + MarginalYAnalysisId = marginalYAnalysisId, + CopulaType = EnumHelper.ParseOrDefault(copulaType, Numerics.Distributions.Copulas.CopulaType.Normal), + EstimationMethod = EnumHelper.ParseOrNull(estimationMethod), + XyOrdinates = points, + BayesianOptions = AnalysisTools.BuildBayesianOptions(iterations, warmupIterations, numberOfChains, prngSeed, credibleIntervalWidth, sampler, pointEstimator), + ParameterPriors = parameterPriors, + Name = name + }); + return McpJson.Serialize(AnalysisMapper.ToResourceResponse(resource)); + } + + /// + /// Creates a coincident frequency analysis over a fitted bivariate analysis. + /// + /// The upstream bivariate analysis id. + /// The strictly ascending surface row ordinates. + /// The strictly ascending surface column ordinates. + /// The response surface as a JSON 2-D array. + /// The number of output response-magnitude bins. + /// Optional credible interval width. + /// Optional point estimator name. + /// Optional display name. + /// JSON with the created analysis summary including its id. + [McpServerTool(Name = "create_coincidentfrequency_analysis")] + [Description("Create a coincident frequency analysis: integrate a tabulated response surface Z(x,y) (e.g., pool stage from inflow and starting stage) over a FITTED bivariate analysis to get the response's annual exceedance frequency curve. The bivariate is a LIVE reference and must be RUN before this analysis runs. Returns the analysis id to pass to run_analysis.")] + public string CreateCoincidentFrequencyAnalysis( + [Description("Id of the fitted bivariate analysis.")] Guid bivariateAnalysisId, + [Description("Strictly ascending x ordinates of the surface rows (≥2).")] double[] xValues, + [Description("Strictly ascending y ordinates of the surface columns (≥2).")] double[] yValues, + [Description("Response surface as a JSON 2-D array, row i = xValues[i]: e.g. '[[10.1,10.5],[11.2,11.8]]'. Must be strictly increasing along both axes.")] string bivariateResponseJson, + [Description("Number of output response-magnitude bins (5-1000). Default 50.")] int numberOfBins = 50, + [Description("Optional credible interval width, e.g. 0.90.")] double? credibleIntervalWidth = null, + [Description("Optional point estimator: posteriorMode or posteriorMean.")] string? pointEstimator = null, + [Description("Optional display name for the analysis.")] string? name = null) + { + List> surface; + try + { + surface = System.Text.Json.JsonSerializer.Deserialize>>(bivariateResponseJson) + ?? throw new ArgumentException("bivariateResponseJson deserialized to null."); + } + catch (System.Text.Json.JsonException ex) + { + throw new ArgumentException( + $"bivariateResponseJson is not a valid JSON 2-D array of numbers: {ex.Message}", ex); + } + + var resource = _service.CreateCoincidentFrequency(new CreateCoincidentFrequencyAnalysisRequest + { + BivariateAnalysisId = bivariateAnalysisId, + XValues = xValues?.ToList() ?? new List(), + YValues = yValues?.ToList() ?? new List(), + BivariateResponse = surface, + NumberOfBins = numberOfBins, + CredibleIntervalWidth = credibleIntervalWidth, + PointEstimator = EnumHelper.ParseOrNull(pointEstimator), + Name = name + }); + return McpJson.Serialize(AnalysisMapper.ToResourceResponse(resource)); + } + + /// + /// Creates a time-series analysis (AR, MA, ARIMA, or ARIMAX per modelType). + /// + /// The source time-series resource id. + /// The model family name. + /// The AR/MA order for ar/ma. + /// The AR order p for arima/arimax. + /// The differencing order d for arima/arimax. + /// The MA order q for arima/arimax. + /// The covariate order b for arimax. + /// Optional intercept flag. + /// Optional transform name. + /// Optional arimax trend name. + /// Optional arimax seasonality flag. + /// Optional arimax covariate resource ids. + /// Optional arimax covariate extension name. + /// Optional training window in time steps. + /// Optional forecast horizon (0-100). + /// Optional MCMC iterations. + /// Optional warm-up iterations. + /// Optional chain count. + /// Optional PRNG seed for reproducibility. + /// Optional credible interval width. + /// Optional sampler name. + /// Optional point estimator name. + /// Optional informative parameter priors. + /// Optional display name. + /// JSON with the created analysis summary including its id. + [McpServerTool(Name = "create_timeseries_analysis")] + [Description("Create a Bayesian MCMC time-series analysis over a stored time series (the series is cloned). modelType picks the family: 'ar' (order p), 'ma' (order q), 'arima' (pOrder/dOrder/qOrder), or 'arimax' (+ covariates, trend, seasonality). Fields that do not apply to the chosen family are rejected. Returns the analysis id to pass to run_analysis.")] + public string CreateTimeSeriesAnalysis( + [Description("Id of the time-series resource to model.")] Guid timeSeriesId, + [Description("Model family: ar, ma, arima, or arimax.")] string modelType, + [Description("AR/MA order for ar/ma (default 1). Rejected for arima/arimax.")] int? order = null, + [Description("AR order p for arima/arimax (default 1). Rejected for ar/ma.")] int? pOrder = null, + [Description("Differencing order d for arima/arimax (default 0). Rejected for ar/ma.")] int? dOrder = null, + [Description("MA order q for arima/arimax (default 0). Rejected for ar/ma.")] int? qOrder = null, + [Description("Covariate order b for arimax (default 0). Rejected otherwise.")] int? xOrder = null, + [Description("Include the intercept term μ. Default true.")] bool? includeIntercept = null, + [Description("Transform: none, logarithmic, boxCox, or yeoJohnson (parameters fitted automatically).")] string? transformType = null, + [Description("ARIMAX trend: none, linear, quadratic, or cubic. Rejected otherwise.")] string? trendType = null, + [Description("True to include an ARIMAX Fourier seasonal component. Rejected otherwise.")] bool? includeSeasonality = null, + [Description("ARIMAX covariate time-series resource ids, in order (each cloned). Rejected otherwise.")] Guid[]? covariateTimeSeriesIds = null, + [Description("ARIMAX covariate extension: none, blockBootstrap (default), or knn. Rejected otherwise.")] string? covariateExtension = null, + [Description("Optional training window in time steps (default 80% of the series).")] int? trainingTimeSteps = null, + [Description("Optional forecast horizon past the series end (0-100).")] int? forecastingTimeSteps = null, + [Description("Optional total MCMC iterations per chain (server-capped).")] int? iterations = null, + [Description("Optional warm-up iterations (must be below iterations).")] int? warmupIterations = null, + [Description("Optional number of parallel chains (1-8).")] int? numberOfChains = null, + [Description("Optional PRNG seed for reproducible runs.")] int? prngSeed = null, + [Description("Optional credible interval width, e.g. 0.90.")] double? credibleIntervalWidth = null, + [Description("Optional sampler: demCzs (default), demCz, arwmh, or nuts.")] string? sampler = null, + [Description("Optional point estimator: posteriorMode or posteriorMean.")] string? pointEstimator = null, + [Description("Optional informative parameter priors: array of { parameterName, distribution: { type, parameters }, isFixed? }.")] List? parameterPriors = null, + [Description("Optional display name for the analysis.")] string? name = null) + { + var parsedModelType = EnumHelper.ParseOrNull(modelType) + ?? throw new ArgumentException("modelType is required: ar, ma, arima, or arimax."); + + var resource = _service.CreateTimeSeries(new CreateTimeSeriesAnalysisRequest + { + TimeSeriesId = timeSeriesId, + ModelType = parsedModelType, + Order = order, + POrder = pOrder, + DOrder = dOrder, + QOrder = qOrder, + XOrder = xOrder, + IncludeIntercept = includeIntercept, + TransformType = EnumHelper.ParseOrNull(transformType), + TrendType = EnumHelper.ParseOrNull(trendType), + IncludeSeasonality = includeSeasonality, + CovariateTimeSeriesIds = covariateTimeSeriesIds?.ToList(), + CovariateExtension = EnumHelper.ParseOrNull(covariateExtension), + TrainingTimeSteps = trainingTimeSteps, + ForecastingTimeSteps = forecastingTimeSteps, + BayesianOptions = AnalysisTools.BuildBayesianOptions(iterations, warmupIterations, numberOfChains, prngSeed, credibleIntervalWidth, sampler, pointEstimator), + ParameterPriors = parameterPriors, + Name = name + }); + return McpJson.Serialize(AnalysisMapper.ToResourceResponse(resource)); + } + + /// + /// Parses a component-distribution name array into typed values. Blank entries are + /// rejected rather than silently defaulted. + /// + /// The camelCase distribution names. + /// The parsed distribution types. + /// Thrown when the array is null/empty, an entry is blank, or a name is not a distribution type; the message lists the accepted values. + private static List ParseDistributions(string[] distributions) + { + if (distributions == null || distributions.Length == 0) + { + throw new ArgumentException("At least one component distribution name is required."); + } + var parsed = new List(distributions.Length); + foreach (var entry in distributions) + { + parsed.Add(EnumHelper.ParseOrNull(entry) + ?? throw new ArgumentException("Component distribution names cannot be blank.")); + } + return parsed; + } + } +} diff --git a/src/RMC.BestFit.Api/Mcp/AnalysisTools.cs b/src/RMC.BestFit.Api/Mcp/AnalysisTools.cs new file mode 100644 index 0000000..62fa5ef --- /dev/null +++ b/src/RMC.BestFit.Api/Mcp/AnalysisTools.cs @@ -0,0 +1,279 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; +using Numerics.Distributions; +using RMC.BestFit.Analyses; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Helpers; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Estimation; + +namespace RMC.BestFit.Api.Mcp +{ + /// + /// MCP tools for creating, running, and inspecting analyses. Run tools are synchronous and + /// may take seconds to about a minute for MCMC analyses. + /// + [McpServerToolType] + public class AnalysisTools + { + /// + /// The analysis service shared with the REST controllers. + /// + private readonly IAnalysisService _service; + + /// + /// Constructs the tools with their service dependency. + /// + /// The analysis service. + public AnalysisTools(IAnalysisService service) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + /// + /// Creates a univariate Bayesian MCMC frequency analysis. + /// + /// The input-data resource id. + /// The distribution name. + /// Optional AEP ordinates. + /// Optional MCMC iterations. + /// Optional warm-up iterations. + /// Optional chain count. + /// Optional PRNG seed for reproducibility. + /// Optional credible interval width. + /// Optional sampler name. + /// Optional point estimator name. + /// Optional informative parameter priors. + /// Optional quantile priors. + /// Optional single-quantile-prior formulation flag. + /// Optional display name. + /// JSON with the created analysis summary including its id. + [McpServerTool(Name = "create_univariate_analysis")] + [Description("Create a Bayesian MCMC univariate frequency analysis over an input-data resource (the data is cloned at creation). Returns the analysis id to pass to run_analysis. Probability ordinates are annual exceedance probabilities (AEP, 0-1); omit for the 25 defaults. Omit MCMC settings for data-scaled defaults. Supports informative parameter priors and quantile priors (engineering judgment).")] + public string CreateUnivariateAnalysis( + [Description("Id of the input-data resource to fit.")] Guid inputDataId, + [Description("Distribution: logPearsonTypeIII (default), generalizedExtremeValue, gumbel, lnNormal, weibull, ... (see get_metadata).")] string? distribution = null, + [Description("Optional AEP ordinates, each strictly between 0 and 1 (e.g., 0.01 = 100-year event). Sorted automatically.")] double[]? probabilityOrdinates = null, + [Description("Optional total MCMC iterations per chain (server-capped).")] int? iterations = null, + [Description("Optional warm-up iterations (must be below iterations).")] int? warmupIterations = null, + [Description("Optional number of parallel chains (1-8).")] int? numberOfChains = null, + [Description("Optional PRNG seed for reproducible runs.")] int? prngSeed = null, + [Description("Optional credible interval width, e.g. 0.90.")] double? credibleIntervalWidth = null, + [Description("Optional sampler: demCzs (default), demCz, arwmh, or nuts.")] string? sampler = null, + [Description("Optional point estimator: posteriorMode or posteriorMean.")] string? pointEstimator = null, + [Description("Optional informative parameter priors: array of { parameterName, distribution: { type, parameters }, isFixed? }. Parameter names per distribution come from get_metadata. Unnamed parameters keep flat priors.")] List? parameterPriors = null, + [Description("Optional quantile priors (engineering judgment about flood magnitudes): array of { alpha (AEP), distribution: { type, parameters } }. Supply one per distribution parameter, or one total with useSingleQuantile=true.")] List? quantilePriors = null, + [Description("True for the single-quantile-prior formulation (Viglione et al. 2013); false/omit for one prior per parameter (Coles and Tawn 1996).")] bool? useSingleQuantile = null, + [Description("Optional display name for the analysis.")] string? name = null) + { + var resource = _service.CreateUnivariate(new CreateUnivariateAnalysisRequest + { + InputDataId = inputDataId, + Distribution = EnumHelper.ParseOrDefault(distribution, UnivariateDistributionType.LogPearsonTypeIII), + ProbabilityOrdinates = probabilityOrdinates?.ToList(), + BayesianOptions = BuildBayesianOptions(iterations, warmupIterations, numberOfChains, prngSeed, credibleIntervalWidth, sampler, pointEstimator), + ParameterPriors = parameterPriors, + QuantilePriors = quantilePriors, + UseSingleQuantile = useSingleQuantile, + Name = name + }); + return McpJson.Serialize(AnalysisMapper.ToResourceResponse(resource)); + } + + /// + /// Creates a Bulletin 17C flood frequency analysis. + /// + /// The input-data resource id. + /// The distribution name (Bulletin 17C set). + /// Optional uncertainty method name. + /// Optional AEP ordinates. + /// Optional parameter penalties (e.g., regional skew). + /// Optional quantile penalties. + /// Optional display name. + /// JSON with the created analysis summary including its id. + [McpServerTool(Name = "create_bulletin17c_analysis")] + [Description("Create a Bulletin 17C flood frequency analysis (USGS guidelines; Expected Moments Algorithm with parametric/bootstrap uncertainty) over an input-data resource — typically USGS annual peaks. Supports regional-skew parameter penalties and quantile penalties. Returns the analysis id to pass to run_analysis.")] + public string CreateBulletin17CAnalysis( + [Description("Id of the input-data resource to fit.")] Guid inputDataId, + [Description("Distribution: logPearsonTypeIII (default), pearsonTypeIII, logNormal, normal, gammaDistribution, or exponential.")] string? distribution = null, + [Description("Uncertainty method: linkedMultivariateNormal (default), multivariateNormal, bootstrap, or biasCorrectedBootstrap.")] string? uncertaintyMethod = null, + [Description("Optional AEP ordinates, each strictly between 0 and 1. Sorted automatically.")] double[]? probabilityOrdinates = null, + [Description("Optional parameter penalties: array of { parameterName, mean, mse, useLog? }. Canonical use: regional skew — { parameterName: 'Skew (of log)', mean: regionalSkew, mse: regionalSkewMSE }.")] List? parameterPenalties = null, + [Description("Optional quantile penalties: array of { aep, mean, mse, useLog10? } — mean/mse in log10 units when useLog10 (default true).")] List? quantilePenalties = null, + [Description("Optional display name for the analysis.")] string? name = null) + { + var resource = _service.CreateBulletin17C(new CreateBulletin17CAnalysisRequest + { + InputDataId = inputDataId, + Distribution = EnumHelper.ParseOrDefault(distribution, UnivariateDistributionType.LogPearsonTypeIII), + UncertaintyMethod = EnumHelper.ParseOrNull(uncertaintyMethod), + ProbabilityOrdinates = probabilityOrdinates?.ToList(), + ParameterPenalties = parameterPenalties, + QuantilePenalties = quantilePenalties, + Name = name + }); + return McpJson.Serialize(AnalysisMapper.ToResourceResponse(resource)); + } + + /// + /// Creates a Bayesian rating curve analysis. + /// + /// The stage time-series resource id. + /// The discharge time-series resource id. + /// The number of power-law segments (1-3). + /// Optional minimum stage of the output grid. + /// Optional maximum stage of the output grid. + /// Optional number of stage grid points. + /// Optional PRNG seed for reproducibility. + /// Optional informative parameter priors. + /// Optional display name. + /// JSON with the created analysis summary including its id. + [McpServerTool(Name = "create_ratingcurve_analysis")] + [Description("Create a Bayesian stage-discharge rating curve analysis over two time-series resources (typically USGS measuredStage and measuredDischarge for the same site). The series are date-aligned; at least 10 common dates are required. Returns the analysis id to pass to run_analysis.")] + public string CreateRatingCurveAnalysis( + [Description("Id of the stage (gage height) time-series resource.")] Guid stageTimeSeriesId, + [Description("Id of the discharge time-series resource.")] Guid dischargeTimeSeriesId, + [Description("Number of piecewise power-law segments (1-3). Default 1.")] int numberOfSegments = 1, + [Description("Optional minimum stage of the output grid (supply with maxStage).")] double? minStage = null, + [Description("Optional maximum stage of the output grid (supply with minStage).")] double? maxStage = null, + [Description("Optional number of stage grid points (2-1000).")] int? stageBins = null, + [Description("Optional PRNG seed for reproducible runs.")] int? prngSeed = null, + [Description("Optional informative parameter priors: array of { parameterName, distribution: { type, parameters }, isFixed? } (e.g., a prior on the offset from a surveyed gage datum).")] List? parameterPriors = null, + [Description("Optional display name for the analysis.")] string? name = null) + { + var resource = _service.CreateRatingCurve(new CreateRatingCurveAnalysisRequest + { + StageTimeSeriesId = stageTimeSeriesId, + DischargeTimeSeriesId = dischargeTimeSeriesId, + NumberOfSegments = numberOfSegments, + MinStage = minStage, + MaxStage = maxStage, + StageBins = stageBins, + BayesianOptions = prngSeed.HasValue ? new BayesianOptionsDto { PrngSeed = prngSeed } : null, + ParameterPriors = parameterPriors, + Name = name + }); + return McpJson.Serialize(AnalysisMapper.ToResourceResponse(resource)); + } + + /// + /// Runs an analysis synchronously and returns its results. + /// + /// The analysis id. + /// Cancellation token supplied by the MCP host. + /// JSON with the frequency or rating curve results depending on the analysis kind. + [McpServerTool(Name = "run_analysis")] + [Description("Run an analysis synchronously and return its results (frequency curve for univariate/Bulletin 17C, stage-discharge curve for rating curves). MCMC runs typically take seconds to about a minute. Rerunning replaces prior results.")] + public async Task RunAnalysis( + [Description("The analysis id from a create_*_analysis tool.")] Guid analysisId, + CancellationToken cancellationToken = default) + { + var resource = _service.Get(analysisId); + if (resource.Kind == AnalysisKind.RatingCurve) + { + return McpJson.Serialize(await _service.RunRatingCurveAsync(analysisId, cancellationToken)); + } + if (resource.Kind == AnalysisKind.DistributionFitting) + { + return McpJson.Serialize(await _service.RunDistributionFittingAsync(analysisId, cancellationToken)); + } + if (resource.Kind == AnalysisKind.Bivariate) + { + return McpJson.Serialize(await _service.RunBivariateAsync(analysisId, cancellationToken)); + } + if (resource.Kind == AnalysisKind.CoincidentFrequency) + { + return McpJson.Serialize(await _service.RunCoincidentFrequencyAsync(analysisId, cancellationToken)); + } + if (resource.Kind == AnalysisKind.TimeSeries) + { + return McpJson.Serialize(await _service.RunTimeSeriesAsync(analysisId, cancellationToken)); + } + return McpJson.Serialize(await _service.RunFrequencyAsync(analysisId, expectedKind: null, cancellationToken)); + } + + /// + /// Returns the stored results of a previously run analysis. + /// + /// The analysis id. + /// JSON with the stored results. + [McpServerTool(Name = "get_analysis_results")] + [Description("Get the stored results of a previously run analysis (no recomputation). Fails if the analysis has never been run — call run_analysis first.")] + public string GetAnalysisResults( + [Description("The analysis id.")] Guid analysisId) + { + var resource = _service.Get(analysisId); + if (resource.Kind == AnalysisKind.RatingCurve) + { + return McpJson.Serialize(_service.GetRatingCurveResults(analysisId)); + } + if (resource.Kind == AnalysisKind.DistributionFitting) + { + return McpJson.Serialize(_service.GetDistributionFittingResults(analysisId)); + } + if (resource.Kind == AnalysisKind.Bivariate) + { + return McpJson.Serialize(_service.GetBivariateResults(analysisId)); + } + if (resource.Kind == AnalysisKind.CoincidentFrequency) + { + return McpJson.Serialize(_service.GetCoincidentFrequencyResults(analysisId)); + } + if (resource.Kind == AnalysisKind.TimeSeries) + { + return McpJson.Serialize(_service.GetTimeSeriesResults(analysisId)); + } + return McpJson.Serialize(_service.GetFrequencyResults(analysisId)); + } + + /// + /// Validates an analysis configuration without running it. + /// + /// The analysis id. + /// JSON with the validation verdict. + [McpServerTool(Name = "validate_analysis")] + [Description("Validate an analysis configuration without running it (e.g., check a rating curve has at least 10 date-aligned observation pairs).")] + public string ValidateAnalysis( + [Description("The analysis id.")] Guid analysisId) + { + return McpJson.Serialize(_service.Validate(analysisId)); + } + + /// + /// Composes a Bayesian options DTO from the flat tool parameters, or returns null when + /// none were supplied. Shared with . + /// + /// Optional MCMC iterations. + /// Optional warm-up iterations. + /// Optional chain count. + /// Optional PRNG seed. + /// Optional credible interval width. + /// Optional sampler name. + /// Optional point estimator name. + /// The options DTO, or null. + internal static BayesianOptionsDto? BuildBayesianOptions( + int? iterations, int? warmupIterations, int? numberOfChains, int? prngSeed, + double? credibleIntervalWidth, string? sampler, string? pointEstimator) + { + var samplerValue = EnumHelper.ParseOrNull(sampler); + var estimatorValue = EnumHelper.ParseOrNull(pointEstimator); + if (iterations == null && warmupIterations == null && numberOfChains == null && prngSeed == null && + credibleIntervalWidth == null && samplerValue == null && estimatorValue == null) + { + return null; + } + return new BayesianOptionsDto + { + Iterations = iterations, + WarmupIterations = warmupIterations, + NumberOfChains = numberOfChains, + PrngSeed = prngSeed, + CredibleIntervalWidth = credibleIntervalWidth, + Sampler = samplerValue, + PointEstimator = estimatorValue + }; + } + } +} diff --git a/src/RMC.BestFit.Api/Mcp/InputDataTools.cs b/src/RMC.BestFit.Api/Mcp/InputDataTools.cs new file mode 100644 index 0000000..15c5ad4 --- /dev/null +++ b/src/RMC.BestFit.Api/Mcp/InputDataTools.cs @@ -0,0 +1,178 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; +using Numerics.Data; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Helpers; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Services; + +namespace RMC.BestFit.Api.Mcp +{ + /// + /// MCP tools for creating and inspecting input-data resources (the observation sets analyses + /// are fit to). + /// + [McpServerToolType] + public class InputDataTools + { + /// + /// The input-data service shared with the REST controllers. + /// + private readonly IInputDataService _service; + + /// + /// Constructs the tools with their service dependency. + /// + /// The input-data service. + public InputDataTools(IInputDataService service) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + /// + /// Downloads the USGS annual peak-flow file directly into an input-data resource. + /// + /// The 8-digit USGS site number. + /// peakDischarge (default) or peakStage. + /// Optional display name. + /// Cancellation token supplied by the MCP host. + /// JSON with the created resource summary including its id. + [McpServerTool(Name = "create_inputdata_usgs_peaks")] + [Description("Download the USGS annual peak-flow file for a site directly into an input-data resource (no intermediate time series needed). Returns the inputData id to pass to create_univariate_analysis or create_bulletin17c_analysis.")] + public async Task CreateInputDataUsgsPeaks( + [Description("8-digit USGS surface-water site number.")] string siteNumber, + [Description("peakDischarge (default) or peakStage.")] string? seriesType = null, + [Description("Optional display name for the resource.")] string? name = null, + CancellationToken cancellationToken = default) + { + var resource = await _service.CreateFromUsgsPeaksAsync(new CreateUsgsPeaksInputDataRequest + { + SiteNumber = siteNumber, + SeriesType = EnumHelper.ParseOrDefault(seriesType, TimeSeriesDownload.TimeSeriesType.PeakDischarge), + Name = name + }, cancellationToken); + return McpJson.Serialize(InputDataMapper.ToResourceResponse(resource)); + } + + /// + /// Extracts block maxima from a stored time series into an input-data resource. + /// + /// The source time-series resource id. + /// The block window name. + /// The block function name. + /// The smoothing function name. + /// Starting month of the water/custom year. + /// Ending month of the custom year. + /// Smoothing period in time steps. + /// Optional display name. + /// JSON with the created resource summary including its id. + [McpServerTool(Name = "create_inputdata_block_max")] + [Description("Extract block maxima (water-year annual maxima by default) from a stored daily time series into an input-data resource. Returns the inputData id to pass to analysis creation tools.")] + public string CreateInputDataBlockMax( + [Description("Id of the source time-series resource (from usgs_download_timeseries or create_manual_timeseries).")] Guid timeSeriesId, + [Description("Block window: waterYear (default), calendarYear, customYear, quarter, or month.")] string? timeBlock = null, + [Description("Function over each block: maximum (default), minimum, ...")] string? blockFunction = null, + [Description("Smoothing before extraction: none (default), movingAverage, movingSum, difference. Use movingAverage with period 7 for 7-day flows.")] string? smoothingFunction = null, + [Description("Starting month of the water/custom year (1-12). Default 10 (October).")] int startMonth = 10, + [Description("Ending month of the custom year (1-12); only used for customYear. Default 9.")] int endMonth = 9, + [Description("Smoothing period in time steps. Default 1.")] int period = 1, + [Description("Optional display name for the resource.")] string? name = null) + { + var resource = _service.CreateBlockMax(new CreateBlockMaxInputDataRequest + { + TimeSeriesId = timeSeriesId, + TimeBlock = EnumHelper.ParseOrDefault(timeBlock, TimeBlockWindow.WaterYear), + BlockFunction = EnumHelper.ParseOrDefault(blockFunction, BlockFunctionType.Maximum), + SmoothingFunction = EnumHelper.ParseOrDefault(smoothingFunction, SmoothingFunctionType.None), + StartMonth = startMonth, + EndMonth = endMonth, + Period = period, + Name = name + }); + return McpJson.Serialize(InputDataMapper.ToResourceResponse(resource)); + } + + /// + /// Extracts independent peaks-over-threshold events from a stored time series. + /// + /// The source time-series resource id. + /// The threshold magnitude. + /// Minimum steps between independent peaks. + /// Optional explicit events-per-year rate. + /// Optional display name. + /// JSON with the created resource summary including its id. + [McpServerTool(Name = "create_inputdata_pot")] + [Description("Extract independent peaks-over-threshold events from a stored time series into an input-data resource. Choose the threshold so lambda (events per year, reported back) is roughly 1-3. Returns the inputData id.")] + public string CreateInputDataPot( + [Description("Id of the source time-series resource.")] Guid timeSeriesId, + [Description("Threshold magnitude; peaks above it are recorded as events.")] double threshold, + [Description("Minimum time steps between independent peaks (e.g., 7 for daily data). Default 1.")] int minStepsBetweenPeaks = 1, + [Description("Optional explicit events-per-year rate; omit to use the model-computed value.")] double? lambda = null, + [Description("Optional display name for the resource.")] string? name = null) + { + var resource = _service.CreatePeaksOverThreshold(new CreatePotInputDataRequest + { + TimeSeriesId = timeSeriesId, + Threshold = threshold, + MinStepsBetweenPeaks = minStepsBetweenPeaks, + Lambda = lambda, + Name = name + }); + return McpJson.Serialize(InputDataMapper.ToResourceResponse(resource)); + } + + /// + /// Builds an input-data resource from explicit observations. + /// + /// The exact observations. + /// Optional uncertain observations with measurement-error distributions. + /// Optional interval-censored observations. + /// Optional perception-threshold records. + /// Optional plotting-position parameter. + /// Optional low-outlier threshold. + /// Optional events-per-year rate. + /// Optional display name. + /// JSON with the created resource summary including its id. + [McpServerTool(Name = "create_inputdata_manual")] + [Description("Create an input-data resource from explicit observations: exactData is required (each { index: waterYear, value }), optionally with uncertain observations ({ index, distribution: { type, parameters } } — e.g., paleoflood estimates as measurement-error distributions), interval-censored observations ({ index, lowerBound, upperBound }), and perception thresholds ({ startIndex, endIndex, value, numberAbove }) for historical floods. Returns the inputData id.")] + public string CreateInputDataManual( + [Description("Exact observations: array of { index (water year) or dateTime, value, isLowOutlier? }.")] List exactData, + [Description("Optional uncertain observations: array of { index (water year) or dateTime, distribution: { type, parameters } }. Example distribution: { type: 'triangular', parameters: [min, mostLikely, max] }. See get_metadata for parameter orders. May not share an index with an exact observation.")] List? uncertainData = null, + [Description("Optional interval-censored observations: array of { index, lowerBound, upperBound, value? }.")] List? intervalData = null, + [Description("Optional perception-threshold records: array of { startIndex, endIndex, value, numberAbove }.")] List? thresholdData = null, + [Description("Plotting-position parameter a: 0 Weibull (default), 0.375 Blom, 0.44 Gringorten, 0.5 Hazen.")] double? plottingParameter = null, + [Description("Optional low-outlier threshold; observations at or below it are censored.")] double? lowOutlierThreshold = null, + [Description("Optional events-per-year rate (lambda); omit for annual data (≈1).")] double? lambda = null, + [Description("Optional display name for the resource.")] string? name = null) + { + var resource = _service.CreateManual(new CreateManualInputDataRequest + { + ExactData = exactData, + UncertainData = uncertainData, + IntervalData = intervalData, + ThresholdData = thresholdData, + PlottingParameter = plottingParameter, + LowOutlierThreshold = lowOutlierThreshold, + Lambda = lambda, + Name = name + }); + return McpJson.Serialize(InputDataMapper.ToResourceResponse(resource)); + } + + /// + /// Returns an input-data resource summary, optionally with the observation lists. + /// + /// The resource id. + /// True to include the observation lists. + /// JSON with the resource summary and optional observations. + [McpServerTool(Name = "get_inputdata")] + [Description("Get an input-data resource summary by id, optionally with its observations and computed plotting positions.")] + public string GetInputData( + [Description("The input-data resource id.")] Guid id, + [Description("True to include the exact/interval/threshold observation lists. Default false.")] bool includeData = false) + { + var resource = _service.Get(id); + return McpJson.Serialize(InputDataMapper.ToResourceResponse(resource, includeData)); + } + } +} diff --git a/src/RMC.BestFit.Api/Mcp/McpJson.cs b/src/RMC.BestFit.Api/Mcp/McpJson.cs new file mode 100644 index 0000000..3e64621 --- /dev/null +++ b/src/RMC.BestFit.Api/Mcp/McpJson.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace RMC.BestFit.Api.Mcp +{ + /// + /// JSON serialization for MCP tool results, using exactly the same wire options as the REST + /// controllers so both surfaces share one contract (camelCase, string enums, ignore-null, + /// named floating-point literals). + /// + public static class McpJson + { + /// + /// The serializer options matching the REST wire contract configured in Program.cs. + /// + public static readonly JsonSerializerOptions Options = CreateOptions(); + + /// + /// Builds the serializer options. + /// + /// The configured options. + private static JsonSerializerOptions CreateOptions() + { + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, + WriteIndented = false + }; + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + return options; + } + + /// + /// Serializes a response DTO to the JSON string returned as the MCP tool result. + /// + /// The DTO type. + /// The DTO to serialize. + /// The JSON text. + public static string Serialize(T value) + { + return JsonSerializer.Serialize(value, Options); + } + } +} diff --git a/src/RMC.BestFit.Api/Mcp/MetadataTools.cs b/src/RMC.BestFit.Api/Mcp/MetadataTools.cs new file mode 100644 index 0000000..e626537 --- /dev/null +++ b/src/RMC.BestFit.Api/Mcp/MetadataTools.cs @@ -0,0 +1,149 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Helpers; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Store; + +namespace RMC.BestFit.Api.Mcp +{ + /// + /// MCP tools for discovery: supported distributions, accepted enum values, server defaults, + /// and the cross-cutting resource overview. Agents should call these before constructing + /// analysis requests. + /// + [McpServerToolType] + public class MetadataTools + { + /// + /// The in-memory resource store (for the resource overview). + /// + private readonly IResourceStore _store; + + /// + /// The configured API limits. + /// + private readonly Configuration.ApiOptions _options; + + /// + /// Constructs the tools with their service dependencies. + /// + /// The in-memory resource store. + /// The configured API limits. + public MetadataTools(IResourceStore store, Microsoft.Extensions.Options.IOptions options) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + _options = (options ?? throw new ArgumentNullException(nameof(options))).Value; + } + + /// + /// Lists the supported distributions, accepted enum values, and server defaults. + /// + /// JSON with distributions (name/displayName/supportedBy), enum option lists, default AEP ordinates, and server limits. + [McpServerTool(Name = "get_metadata")] + [Description("Get the RMC-BestFit metadata: supported probability distributions (with which analysis kinds accept each), every accepted enum value (samplers, uncertainty methods, time blocks, USGS series types, ...), the default annual-exceedance-probability ordinates, and server limits. Call this before constructing analysis requests.")] + public string GetMetadata() + { + var payload = new + { + distributions = MetadataMapper.ToDistributionsResponse().Distributions, + enums = MetadataMapper.ToEnumOptionsResponse(), + defaults = new DefaultsResponse + { + ProbabilityOrdinates = new Numerics.Data.ProbabilityOrdinates().ToList(), + MaxIterations = _options.MaxIterations, + MaxConcurrentRuns = _options.MaxConcurrentRuns, + MaxResources = _options.MaxResources, + Notes = new List + { + "Probability ordinates are annual exceedance probabilities (AEP); e.g., 0.01 is the 100-year event.", + "Run tools are synchronous and may take seconds to about a minute for MCMC analyses.", + "Resources are held in memory only and are lost when the server restarts." + } + } + }; + return McpJson.Serialize(payload); + } + + /// + /// Lists every resource currently held by the server. + /// + /// JSON with per-type counts and one-line summaries (id, type, name, detail). + [McpServerTool(Name = "list_resources")] + [Description("List every resource on the server (time series, input data, analyses) with ids and one-line summaries. Use to re-orient mid-session or find an id you created earlier.")] + public string ListResources() + { + var response = new ResourcesOverviewResponse(); + foreach (var resource in _store.ListTimeSeries()) + { + response.Resources.Add(new ResourceSummaryDto + { + Id = resource.Id, + ResourceType = "timeSeries", + Name = resource.Name, + CreatedUtc = resource.CreatedUtc, + Detail = $"{resource.PointCount} points, {resource.StartDate:yyyy-MM-dd} to {resource.EndDate:yyyy-MM-dd}" + }); + } + foreach (var resource in _store.ListInputData()) + { + response.Resources.Add(new ResourceSummaryDto + { + Id = resource.Id, + ResourceType = "inputData", + Name = resource.Name, + CreatedUtc = resource.CreatedUtc, + Detail = $"{EnumHelper.ToCamelCase(resource.Method.ToString())}, {resource.DataFrame.ExactSeries.Count} exact observations" + }); + } + foreach (var resource in _store.ListAnalyses()) + { + response.Resources.Add(new ResourceSummaryDto + { + Id = resource.Id, + ResourceType = "analysis", + Name = resource.Name, + CreatedUtc = resource.CreatedUtc, + Detail = $"{EnumHelper.ToCamelCase(resource.Kind.ToString())}, state {EnumHelper.ToCamelCase(resource.State.ToString())}" + }); + } + response.TimeSeriesCount = _store.ListTimeSeries().Count; + response.InputDataCount = _store.ListInputData().Count; + response.AnalysisCount = _store.ListAnalyses().Count; + return McpJson.Serialize(response); + } + + /// + /// Deletes a resource of any type by id. + /// + /// The resource type: "timeSeries", "inputData", or "analysis". + /// The resource id. + /// JSON confirming the deletion. + /// Thrown when the resource type is not recognized. + /// Thrown when no resource of that type has the id. + [McpServerTool(Name = "delete_resource")] + [Description("Delete a resource by type and id. Analyses created from a deleted resource are unaffected (they hold their own copies of the data). Use to free store capacity.")] + public string DeleteResource( + [Description("Resource type: timeSeries, inputData, or analysis.")] string resourceType, + [Description("The resource id.")] Guid id) + { + bool deleted = resourceType?.Trim().ToLowerInvariant() switch + { + "timeseries" => _store.DeleteTimeSeries(id), + "inputdata" => _store.DeleteInputData(id), + "analysis" => _store.DeleteAnalysis(id), + _ => throw new ArgumentException($"'{resourceType}' is not a valid resource type. Accepted values: timeSeries, inputData, analysis.") + }; + if (!deleted) + { + throw new Services.Exceptions.ResourceNotFoundException(resourceType!, id); + } + return McpJson.Serialize(new DeleteResourceResponse + { + DeletedId = id, + ResourceType = resourceType, + Timestamp = DateTime.UtcNow.ToString("O") + }); + } + } +} diff --git a/src/RMC.BestFit.Api/Mcp/TimeSeriesTools.cs b/src/RMC.BestFit.Api/Mcp/TimeSeriesTools.cs new file mode 100644 index 0000000..9c9f79a --- /dev/null +++ b/src/RMC.BestFit.Api/Mcp/TimeSeriesTools.cs @@ -0,0 +1,99 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; +using Numerics.Data; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Helpers; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Services; + +namespace RMC.BestFit.Api.Mcp +{ + /// + /// MCP tools for creating and inspecting time-series resources. + /// + [McpServerToolType] + public class TimeSeriesTools + { + /// + /// The time-series service shared with the REST controllers. + /// + private readonly ITimeSeriesService _service; + + /// + /// Constructs the tools with their service dependency. + /// + /// The time-series service. + public TimeSeriesTools(ITimeSeriesService service) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + /// + /// Downloads a series from the USGS water services and stores it as a resource. + /// + /// The 8-digit USGS site number. + /// The series type name. + /// Optional display name. + /// Cancellation token supplied by the MCP host. + /// JSON with the created resource summary including its id. + [McpServerTool(Name = "usgs_download_timeseries")] + [Description("Download a time series from the USGS water services and store it as a resource. Returns the resource id to pass to later tools. Use seriesType dailyDischarge/dailyStage for block-maxima workflows, measuredStage/measuredDischarge pairs for rating curves, and peakDischarge/peakStage for annual peaks (or use create_inputdata_usgs_peaks directly).")] + public async Task UsgsDownloadTimeSeries( + [Description("8-digit USGS surface-water site number, e.g. 01646500.")] string siteNumber, + [Description("Series type: dailyDischarge (default), dailyStage, instantaneousDischarge, instantaneousStage, peakDischarge, peakStage, measuredDischarge, or measuredStage.")] string? seriesType = null, + [Description("Optional display name for the resource.")] string? name = null, + CancellationToken cancellationToken = default) + { + var resource = await _service.CreateFromUsgsAsync(new CreateUsgsTimeSeriesRequest + { + SiteNumber = siteNumber, + SeriesType = EnumHelper.ParseOrDefault(seriesType, TimeSeriesDownload.TimeSeriesType.DailyDischarge), + Name = name + }, cancellationToken); + return McpJson.Serialize(TimeSeriesMapper.ToResourceResponse(resource)); + } + + /// + /// Builds a time series from supplied points and stores it as a resource. + /// + /// The ordinates (dateTime + value per point). + /// The recording interval name. + /// Optional display name. + /// JSON with the created resource summary including its id. + [McpServerTool(Name = "create_manual_timeseries")] + [Description("Create a time-series resource from explicit points (each with dateTime and value; NaN marks missing). Use timeInterval oneDay for daily records or irregular for discrete measurements. Returns the resource id.")] + public string CreateManualTimeSeries( + [Description("The points: array of { dateTime, value }. Sorted by date automatically.")] List points, + [Description("Recording interval: oneDay (default), oneHour, irregular, ...")] string? timeInterval = null, + [Description("Optional display name for the resource.")] string? name = null) + { + var resource = _service.CreateManual(new CreateManualTimeSeriesRequest + { + Points = points, + TimeInterval = EnumHelper.ParseOrDefault(timeInterval, TimeInterval.OneDay), + Name = name + }); + return McpJson.Serialize(TimeSeriesMapper.ToResourceResponse(resource)); + } + + /// + /// Returns a time-series resource summary, optionally with a page of points. + /// + /// The resource id. + /// True to include ordinate values. + /// Zero-based index of the first point. + /// Maximum number of points returned. + /// JSON with the resource summary and optional points page. + [McpServerTool(Name = "get_timeseries")] + [Description("Get a time-series resource summary by id, optionally with a page of its points (keep limit modest — daily records span decades).")] + public string GetTimeSeries( + [Description("The time-series resource id.")] Guid id, + [Description("True to include point values. Default false.")] bool includePoints = false, + [Description("Zero-based index of the first point to return. Default 0.")] int offset = 0, + [Description("Maximum number of points to return. Default 1000.")] int limit = 1000) + { + var resource = _service.Get(id); + return McpJson.Serialize(TimeSeriesMapper.ToResourceResponse(resource, includePoints, offset, limit)); + } + } +} diff --git a/src/RMC.BestFit.Api/Mcp/WorkflowTools.cs b/src/RMC.BestFit.Api/Mcp/WorkflowTools.cs new file mode 100644 index 0000000..640a44d --- /dev/null +++ b/src/RMC.BestFit.Api/Mcp/WorkflowTools.cs @@ -0,0 +1,162 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; +using Numerics.Data; +using Numerics.Distributions; +using RMC.BestFit.Analyses; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Helpers; +using RMC.BestFit.Api.Services; + +namespace RMC.BestFit.Api.Mcp +{ + /// + /// MCP tools for the one-shot USGS workflows: each downloads the data, builds the intermediate + /// resources, runs the analysis, and returns the results plus every created resource id. On a + /// step failure the response reports success=false and the failed step while preserving the + /// ids of resources already created. + /// + [McpServerToolType] + public class WorkflowTools + { + /// + /// The workflow service shared with the REST controllers. + /// + private readonly IWorkflowService _service; + + /// + /// Constructs the tools with their service dependency. + /// + /// The workflow service. + public WorkflowTools(IWorkflowService service) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + /// + /// Runs the USGS peak-flow frequency workflow. + /// + /// The 8-digit USGS site number. + /// The distribution name. + /// Optional AEP ordinates. + /// Optional PRNG seed. + /// Optional base name for the created resources. + /// Cancellation token supplied by the MCP host. + /// JSON with the created resource ids and the frequency results. + [McpServerTool(Name = "run_usgs_peak_frequency_workflow")] + [Description("One call: download the USGS annual peak-flow file for a site, build input data, run a univariate Bayesian frequency analysis, and return the frequency curve with uncertainty plus the created resource ids. Synchronous — may take up to about a minute.")] + public async Task RunUsgsPeakFrequencyWorkflow( + [Description("8-digit USGS surface-water site number, e.g. 01646500.")] string siteNumber, + [Description("Distribution: logPearsonTypeIII (default), generalizedExtremeValue, ... (see get_metadata).")] string? distribution = null, + [Description("Optional AEP ordinates, each strictly between 0 and 1.")] double[]? probabilityOrdinates = null, + [Description("Optional PRNG seed for reproducible runs.")] int? prngSeed = null, + [Description("Optional base name for the created resources.")] string? name = null, + CancellationToken cancellationToken = default) + { + var response = await _service.RunUsgsPeakFrequencyAsync(new UsgsPeakFrequencyWorkflowRequest + { + SiteNumber = siteNumber, + Distribution = EnumHelper.ParseOrDefault(distribution, UnivariateDistributionType.LogPearsonTypeIII), + ProbabilityOrdinates = probabilityOrdinates?.ToList(), + BayesianOptions = prngSeed.HasValue ? new BayesianOptionsDto { PrngSeed = prngSeed } : null, + Name = name + }, cancellationToken); + return McpJson.Serialize(response); + } + + /// + /// Runs the USGS daily-flow block-maxima frequency workflow. + /// + /// The 8-digit USGS site number. + /// The distribution name. + /// The block window name. + /// The smoothing function name. + /// The smoothing period in days. + /// Optional AEP ordinates. + /// Optional PRNG seed. + /// Optional base name for the created resources. + /// Cancellation token supplied by the MCP host. + /// JSON with the created resource ids and the frequency results. + [McpServerTool(Name = "run_usgs_daily_block_max_frequency_workflow")] + [Description("One call: download the USGS daily-flow record for a site, extract water-year annual maxima (or another block), run a univariate Bayesian frequency analysis, and return the frequency curve plus the created resource ids (timeSeriesId, inputDataId, analysisId). Synchronous.")] + public async Task RunUsgsDailyBlockMaxFrequencyWorkflow( + [Description("8-digit USGS surface-water site number.")] string siteNumber, + [Description("Distribution: logPearsonTypeIII (default), generalizedExtremeValue, ...")] string? distribution = null, + [Description("Block window: waterYear (default), calendarYear, quarter, month.")] string? timeBlock = null, + [Description("Smoothing before extraction: none (default) or movingAverage (with period, e.g. 7 for 7-day flows).")] string? smoothingFunction = null, + [Description("Smoothing period in days. Default 1.")] int period = 1, + [Description("Optional AEP ordinates, each strictly between 0 and 1.")] double[]? probabilityOrdinates = null, + [Description("Optional PRNG seed for reproducible runs.")] int? prngSeed = null, + [Description("Optional base name for the created resources.")] string? name = null, + CancellationToken cancellationToken = default) + { + var response = await _service.RunUsgsBlockMaxFrequencyAsync(new UsgsBlockMaxFrequencyWorkflowRequest + { + SiteNumber = siteNumber, + Distribution = EnumHelper.ParseOrDefault(distribution, UnivariateDistributionType.LogPearsonTypeIII), + TimeBlock = EnumHelper.ParseOrDefault(timeBlock, TimeBlockWindow.WaterYear), + SmoothingFunction = EnumHelper.ParseOrDefault(smoothingFunction, SmoothingFunctionType.None), + Period = period, + ProbabilityOrdinates = probabilityOrdinates?.ToList(), + BayesianOptions = prngSeed.HasValue ? new BayesianOptionsDto { PrngSeed = prngSeed } : null, + Name = name + }, cancellationToken); + return McpJson.Serialize(response); + } + + /// + /// Runs the USGS Bulletin 17C workflow. + /// + /// The 8-digit USGS site number. + /// Optional uncertainty method name. + /// Optional AEP ordinates. + /// Optional base name for the created resources. + /// Cancellation token supplied by the MCP host. + /// JSON with the created resource ids and the frequency results. + [McpServerTool(Name = "run_usgs_bulletin17c_workflow")] + [Description("One call: download the USGS annual peak-flow file for a site, build input data, run a Bulletin 17C flood frequency analysis (USGS guidelines), and return the frequency curve with confidence intervals plus the created resource ids. Synchronous.")] + public async Task RunUsgsBulletin17CWorkflow( + [Description("8-digit USGS surface-water site number.")] string siteNumber, + [Description("Uncertainty method: linkedMultivariateNormal (default), multivariateNormal, bootstrap, biasCorrectedBootstrap.")] string? uncertaintyMethod = null, + [Description("Optional AEP ordinates, each strictly between 0 and 1.")] double[]? probabilityOrdinates = null, + [Description("Optional base name for the created resources.")] string? name = null, + CancellationToken cancellationToken = default) + { + var response = await _service.RunUsgsBulletin17CAsync(new UsgsBulletin17CWorkflowRequest + { + SiteNumber = siteNumber, + UncertaintyMethod = EnumHelper.ParseOrNull(uncertaintyMethod), + ProbabilityOrdinates = probabilityOrdinates?.ToList(), + Name = name + }, cancellationToken); + return McpJson.Serialize(response); + } + + /// + /// Runs the USGS rating curve workflow. + /// + /// The 8-digit USGS site number. + /// The number of power-law segments (1-3). + /// Optional PRNG seed. + /// Optional base name for the created resources. + /// Cancellation token supplied by the MCP host. + /// JSON with the created resource ids and the rating curve results. + [McpServerTool(Name = "run_usgs_rating_curve_workflow")] + [Description("One call: download the discrete USGS field measurements of stage and discharge for a site, run a Bayesian rating curve analysis over the date-aligned pairs, and return the fitted stage-discharge curve with credible intervals plus the created resource ids. Synchronous.")] + public async Task RunUsgsRatingCurveWorkflow( + [Description("8-digit USGS surface-water site number.")] string siteNumber, + [Description("Number of piecewise power-law segments (1-3). Default 1.")] int numberOfSegments = 1, + [Description("Optional PRNG seed for reproducible runs.")] int? prngSeed = null, + [Description("Optional base name for the created resources.")] string? name = null, + CancellationToken cancellationToken = default) + { + var response = await _service.RunUsgsRatingCurveAsync(new UsgsRatingCurveWorkflowRequest + { + SiteNumber = siteNumber, + NumberOfSegments = numberOfSegments, + BayesianOptions = prngSeed.HasValue ? new BayesianOptionsDto { PrngSeed = prngSeed } : null, + Name = name + }, cancellationToken); + return McpJson.Serialize(response); + } + } +} diff --git a/src/RMC.BestFit.Api/Program.cs b/src/RMC.BestFit.Api/Program.cs new file mode 100644 index 0000000..00d1a53 --- /dev/null +++ b/src/RMC.BestFit.Api/Program.cs @@ -0,0 +1,162 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Services; +using RMC.BestFit.Api.Store; + +var builder = WebApplication.CreateBuilder(args); + +// ============================================================================= +// Service Configuration +// ============================================================================= + +// Configure JSON serialization +builder.Services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + options.JsonSerializerOptions.WriteIndented = builder.Environment.IsDevelopment(); + + // Seatbelt against mid-stream serialization crashes: NaN is a legitimate marker for + // missing hydrologic observations (e.g., USGS gap fill) and is emitted as the JSON string + // literal "NaN". ±Infinity is caught earlier by the ResponseFiniteAuditor; this option is + // the last line of defense. Clients must enable the matching flag on their deserializer. + options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals; + }); + +// Configure OpenAPI +builder.Services.AddOpenApi(); + +// Configure CORS +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowAll", policy => + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader()); + + // More restrictive policy for production + options.AddPolicy("Production", policy => + policy.WithOrigins( + builder.Configuration.GetSection("Cors:AllowedOrigins").Get() + ?? new[] { "https://localhost" }) + .AllowAnyMethod() + .AllowAnyHeader()); +}); + +// Bind the API limits (resource cap, run throttle, iteration cap) +builder.Services.Configure(builder.Configuration.GetSection(ApiOptions.SectionName)); + +// Register the resource store and the services shared by REST controllers and MCP tools. +// Everything is a singleton: all state lives in the store, and services are stateless facades. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// MCP server: same service layer as the REST controllers, exposed as tools over the streamable +// HTTP transport. Stateless mode is correct here because all state lives in the app-singleton +// resource store, so resource ids remain valid across MCP sessions and the REST/MCP boundary. +builder.Services.AddMcpServer() + .WithHttpTransport(options => options.Stateless = true) + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools(); + +// Add health checks +builder.Services.AddHealthChecks(); + +// ============================================================================= +// Application Configuration +// ============================================================================= + +var app = builder.Build(); + +// Configure error handling +if (app.Environment.IsDevelopment()) +{ + app.UseDeveloperExceptionPage(); +} +else +{ + app.UseExceptionHandler("/error"); + app.UseHsts(); +} + +// Configure OpenAPI endpoint +if (app.Environment.IsDevelopment() || app.Configuration.GetValue("EnableSwagger")) +{ + app.MapOpenApi(); +} + +// HTTPS redirection is skipped in Development: local MCP clients connect over plain HTTP and a +// redirect would break the streamable HTTP transport. +if (!app.Environment.IsDevelopment()) +{ + app.UseHttpsRedirection(); +} + +app.UseCors(app.Environment.IsDevelopment() ? "AllowAll" : "Production"); +app.UseAuthorization(); + +// Map controllers +app.MapControllers(); + +// MCP endpoint (streamable HTTP transport) +app.MapMcp("/mcp"); + +// ============================================================================= +// Health and Info Endpoints +// ============================================================================= + +// Health check endpoint +app.MapHealthChecks("/health"); + +// Detailed health check with version info +app.MapGet("/health/detailed", () => Results.Ok(new HealthCheckDto +{ + Status = "healthy", + Timestamp = DateTime.UtcNow, + Version = typeof(Program).Assembly.GetName().Version?.ToString() +})) +.WithName("DetailedHealthCheck") +.WithTags("Health") +.Produces(StatusCodes.Status200OK); + +// Service info endpoint describing the exposed feature areas +app.MapGet("/api/info", () => Results.Ok(new ApiInfoDto +{ + Name = "RMC-BestFit API", + Version = typeof(Program).Assembly.GetName().Version?.ToString(), + Description = "REST API and MCP server for Bayesian flood-frequency analysis with RMC-BestFit.", + Features = new List { "timeseries", "inputdata", "analyses", "workflows", "mcp", "metadata", "resources" } +})) +.WithName("ApiInfo") +.WithTags("Info") +.Produces(StatusCodes.Status200OK); + +// Error handler endpoint +app.MapGet("/error", () => Results.Problem( + title: "An error occurred", + statusCode: StatusCodes.Status500InternalServerError)) +.ExcludeFromDescription(); + +// ============================================================================= +// Run Application +// ============================================================================= + +app.Run(); + +/// +/// Marker partial class making the top-level-statement entry point visible to the integration +/// test host (WebApplicationFactory<Program>). +/// +public partial class Program { } diff --git a/src/RMC.BestFit.Api/Properties/launchSettings.json b/src/RMC.BestFit.Api/Properties/launchSettings.json new file mode 100644 index 0000000..93a88fc --- /dev/null +++ b/src/RMC.BestFit.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5210", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7210;http://localhost:5210", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/RMC.BestFit.Api/RMC.BestFit.Api.csproj b/src/RMC.BestFit.Api/RMC.BestFit.Api.csproj new file mode 100644 index 0000000..4d6afc6 --- /dev/null +++ b/src/RMC.BestFit.Api/RMC.BestFit.Api.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + true + RMC.BestFit.Api + REST API and MCP server for the Bayesian estimation and fitting software (RMC-BestFit). + U.S. Army Corps of Engineers + RMC-BestFit + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.Api/Services/AnalysisService.Creates.cs b/src/RMC.BestFit.Api/Services/AnalysisService.Creates.cs new file mode 100644 index 0000000..ce3174e --- /dev/null +++ b/src/RMC.BestFit.Api/Services/AnalysisService.Creates.cs @@ -0,0 +1,707 @@ +using Numerics.Data; +using Numerics.Distributions; +using RMC.BestFit.Analyses; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Services.Exceptions; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Models; + +namespace RMC.BestFit.Api.Services +{ + /// + /// Creation methods for the Phase-4 analysis kinds. Kept in a separate partial file so the + /// core run/lookup orchestration in AnalysisService.cs stays readable as the kind count grows. + /// + public partial class AnalysisService + { + /// + public AnalysisResource CreateMixture(CreateMixtureAnalysisRequest request) + { + ArgumentNullException.ThrowIfNull(request); + var input = _store.GetInputData(request.InputDataId) + ?? throw new ResourceNotFoundException("input data", request.InputDataId); + ValidateComponentDistributions(request.Distributions, MixtureModel.IsSupportedDistributionType, "mixture"); + + var model = new MixtureModel(input.DataFrame.Clone(), request.Distributions, request.IsZeroInflated); + var analysis = new MixtureAnalysis(model); + ApplyProbabilityOrdinates(request.ProbabilityOrdinates, analysis.ProbabilityOrdinates); + BayesianOptionsMapper.Apply(analysis.BayesianAnalysis, request.BayesianOptions, _options.MaxIterations); + // Priors are applied last: the model is fully configured, so nothing after this point + // triggers the default-parameter rebuild that would wipe them. + PriorMapper.ApplyParameterPriors(model, request.ParameterPriors); + PriorMapper.ApplyQuantilePriors(model, request.QuantilePriors, request.UseSingleQuantile); + + var resource = new AnalysisResource + { + Name = string.IsNullOrWhiteSpace(request.Name) ? $"Mixture analysis of {input.Name}" : request.Name, + Description = request.Description, + Kind = AnalysisKind.Mixture, + Mixture = analysis, + InputDataId = input.Id + }; + return _store.AddAnalysis(resource); + } + + /// + public AnalysisResource CreatePointProcess(CreatePointProcessAnalysisRequest request) + { + ArgumentNullException.ThrowIfNull(request); + var input = _store.GetInputData(request.InputDataId) + ?? throw new ResourceNotFoundException("input data", request.InputDataId); + + if (!request.IsSeasonal && (request.TimeBlock.HasValue || request.StartMonth.HasValue)) + { + throw new ArgumentException("timeBlock and startMonth apply only when isSeasonal is true. Remove them or set isSeasonal."); + } + if (request.TotalYears is <= 0d) + { + throw new ArgumentException("totalYears must be greater than 0."); + } + + // Construction order matters and mirrors the desktop application: the DataFrame setter + // processes the threshold series and seeds data-derived defaults; seasonality swaps the + // internal GEV distribution wholesale, so it must follow the data assignment and + // precede any threshold seeding. + var model = new PointProcessModel(); + model.DataFrame = input.DataFrame.Clone(); + if (request.IsSeasonal) + { + model.IsSeasonal = true; + if (request.TimeBlock.HasValue) model.TimeBlock = request.TimeBlock.Value; + if (request.StartMonth.HasValue) model.StartMonth = request.StartMonth.Value; + } + + // A POT-created input-data resource records the extraction threshold; reuse it so the + // model's threshold matches the events unless the client overrides explicitly. + if (!request.Threshold.HasValue && input.Method == InputDataMethod.PeaksOverThreshold && input.Threshold.HasValue) + { + model.SetDefaultThresholdAndTotalYears(input.Threshold, forceTotalYears: true); + } + if (request.Threshold.HasValue || request.TotalYears.HasValue) + { + model.UseDefaults = false; + if (request.Threshold.HasValue) model.Threshold = request.Threshold.Value; + if (request.TotalYears.HasValue) model.TotalYears = request.TotalYears.Value; + } + + var analysis = new PointProcessAnalysis(model); + ApplyProbabilityOrdinates(request.ProbabilityOrdinates, analysis.ProbabilityOrdinates); + BayesianOptionsMapper.Apply(analysis.BayesianAnalysis, request.BayesianOptions, _options.MaxIterations); + PriorMapper.ApplyParameterPriors(model, request.ParameterPriors); + PriorMapper.ApplyQuantilePriors(model, request.QuantilePriors, request.UseSingleQuantile); + + var resource = new AnalysisResource + { + Name = string.IsNullOrWhiteSpace(request.Name) ? $"Point process analysis of {input.Name}" : request.Name, + Description = request.Description, + Kind = AnalysisKind.PointProcess, + PointProcess = analysis, + InputDataId = input.Id + }; + return _store.AddAnalysis(resource); + } + + /// + public AnalysisResource CreateCompetingRisks(CreateCompetingRisksAnalysisRequest request) + { + ArgumentNullException.ThrowIfNull(request); + var input = _store.GetInputData(request.InputDataId) + ?? throw new ResourceNotFoundException("input data", request.InputDataId); + ValidateComponentDistributions(request.Distributions, CompetingRisksModel.IsSupportedDistributionType, "competing risks"); + + var model = new CompetingRisksModel(input.DataFrame.Clone(), request.Distributions); + var analysis = new CompetingRiskAnalysis(model); + ApplyProbabilityOrdinates(request.ProbabilityOrdinates, analysis.ProbabilityOrdinates); + BayesianOptionsMapper.Apply(analysis.BayesianAnalysis, request.BayesianOptions, _options.MaxIterations); + PriorMapper.ApplyParameterPriors(model, request.ParameterPriors); + PriorMapper.ApplyQuantilePriors(model, request.QuantilePriors, request.UseSingleQuantile); + + var resource = new AnalysisResource + { + Name = string.IsNullOrWhiteSpace(request.Name) ? $"Competing risks analysis of {input.Name}" : request.Name, + Description = request.Description, + Kind = AnalysisKind.CompetingRisks, + CompetingRisks = analysis, + InputDataId = input.Id + }; + return _store.AddAnalysis(resource); + } + + /// + public AnalysisResource CreateComposite(CreateCompositeAnalysisRequest request) + { + ArgumentNullException.ThrowIfNull(request); + if (request.Components == null || request.Components.Count == 0) + { + throw new ArgumentException("At least one component analysis is required."); + } + + // Mixture weights are user inputs; the other composite types ignore or compute them. + if (request.CompositeType == CompositeType.Mixture) + { + double sum = 0d; + for (int i = 0; i < request.Components.Count; i++) + { + double? weight = request.Components[i].Weight; + if (!weight.HasValue || double.IsNaN(weight.Value) || weight.Value <= 0d || weight.Value >= 1d) + { + throw new ArgumentException( + $"components[{i}]: mixture composites require a weight strictly between 0 and 1 for every component."); + } + sum += weight.Value; + } + if (sum > 1d + 1e-12) + { + throw new ArgumentException( + $"The mixture weights sum to {sum:G6}; they must sum to at most 1 (any remainder becomes a point mass at zero)."); + } + } + + var componentResources = new List(request.Components.Count); + var weighted = new List(request.Components.Count); + foreach (var component in request.Components) + { + var componentResource = _store.GetAnalysis(component.AnalysisId) + ?? throw new ResourceNotFoundException("component analysis", component.AnalysisId); + componentResources.Add(componentResource); + // The WeightedUnivariateAnalysis setter rejects composite children itself; this + // guard turns the remaining non-univariate kinds into a clear client error. + weighted.Add(new WeightedUnivariateAnalysis( + GetUnivariateAnalysis(componentResource), component.Weight ?? 0d)); + } + + var analysis = new CompositeAnalysis(weighted) + { + CompositeDistributionType = request.CompositeType, + IsMaximum = request.IsMaximum + }; + if (request.AverageMethod.HasValue) analysis.ModelAverageMethod = request.AverageMethod.Value; + if (request.Dependency.HasValue) analysis.Dependency = request.Dependency.Value; + ApplyProbabilityOrdinates(request.ProbabilityOrdinates, analysis.ProbabilityOrdinates); + if (request.CredibleIntervalWidth.HasValue) analysis.BayesianAnalysis.CredibleIntervalWidth = request.CredibleIntervalWidth.Value; + if (request.PointEstimator.HasValue) analysis.BayesianAnalysis.PointEstimator = request.PointEstimator.Value; + + var resource = new AnalysisResource + { + Name = string.IsNullOrWhiteSpace(request.Name) + ? $"Composite of {string.Join(", ", componentResources.Select(r => r.Name))}" + : request.Name, + Description = request.Description, + Kind = AnalysisKind.Composite, + Composite = analysis, + ComponentResources = componentResources, + ComponentAnalysisIds = componentResources.Select(r => r.Id).ToList() + }; + return _store.AddAnalysis(resource); + } + + /// + public AnalysisResource CreateDistributionFitting(CreateDistributionFittingAnalysisRequest request) + { + ArgumentNullException.ThrowIfNull(request); + var input = _store.GetInputData(request.InputDataId) + ?? throw new ResourceNotFoundException("input data", request.InputDataId); + + var analysis = new FittingAnalysis(input.DataFrame.Clone()); + if (request.Distributions is { Count: > 0 }) + { + foreach (var type in request.Distributions) + { + if (!UnivariateDistribution.IsSupportedDistributionType(type)) + { + throw new ArgumentException( + $"Distribution '{type}' is not supported by the distribution fitting analysis. See GET api/metadata/distributions."); + } + } + // The candidate list defaults to all 15 supported distributions; a client subset + // replaces its contents (the property setter is private). + analysis.DistributionList.Clear(); + foreach (var type in request.Distributions.Distinct()) + { + analysis.DistributionList.Add(Numerics.Distributions.UnivariateDistributionFactory.CreateDistribution(type)); + } + } + + var resource = new AnalysisResource + { + Name = string.IsNullOrWhiteSpace(request.Name) ? $"Distribution fitting of {input.Name}" : request.Name, + Description = request.Description, + Kind = AnalysisKind.DistributionFitting, + DistributionFitting = analysis, + InputDataId = input.Id + }; + return _store.AddAnalysis(resource); + } + + /// + public AnalysisResource CreateBivariate(CreateBivariateAnalysisRequest request) + { + ArgumentNullException.ThrowIfNull(request); + if (request.MarginalXAnalysisId == request.MarginalYAnalysisId) + { + throw new ArgumentException("The two marginal analyses must be distinct resources."); + } + if (request.EstimationMethod == Numerics.Distributions.Copulas.CopulaEstimationMethod.FullLikelihood) + { + throw new ArgumentException( + "estimationMethod 'fullLikelihood' is not supported by the bivariate analysis. Use 'inferenceFromMargins' or 'pseudoLikelihood'."); + } + if (request.XyOrdinates == null || request.XyOrdinates.Count == 0) + { + throw new ArgumentException("At least one xy evaluation point is required (the joint-exceedance grid)."); + } + + var marginalX = _store.GetAnalysis(request.MarginalXAnalysisId) + ?? throw new ResourceNotFoundException("marginal X analysis", request.MarginalXAnalysisId); + var marginalY = _store.GetAnalysis(request.MarginalYAnalysisId) + ?? throw new ResourceNotFoundException("marginal Y analysis", request.MarginalYAnalysisId); + var modelX = GetMarginalModel(marginalX, "marginal X"); + var modelY = GetMarginalModel(marginalY, "marginal Y"); + + if (modelX.IsNonstationary || modelY.IsNonstationary) + { + throw new ArgumentException("Nonstationary marginals are not supported by the bivariate analysis."); + } + int overlap = CountMatchedExactPairs(modelX.DataFrame, modelY.DataFrame); + if (overlap < 10) + { + throw new ArgumentException( + $"The two marginals share only {overlap} overlapping non-outlier exact observations (matched by time index); at least 10 are required to fit a copula."); + } + + var distribution = new BivariateDistribution(modelX, modelY, request.CopulaType); + if (request.EstimationMethod.HasValue) + { + distribution.CopulaEstimationMethod = request.EstimationMethod.Value; + } + var analysis = new BivariateAnalysis(distribution) + { + // Sorted by x so the model's ascending-ordered pair container accepts the grid + // regardless of client ordering. Y ordinates are deterministic point values, + // matching the model's own placeholder construction. + XYOrdinates = new UncertainOrderedPairedData( + request.XyOrdinates + .OrderBy(p => p.X) + .Select(p => new UncertainOrdinate(p.X, new Deterministic(p.Y))) + .ToList(), + false, SortOrder.Ascending, false, SortOrder.Ascending, + Numerics.Distributions.UnivariateDistributionType.Deterministic) + }; + BayesianOptionsMapper.Apply(analysis.BayesianAnalysis, request.BayesianOptions, _options.MaxIterations); + PriorMapper.ApplyParameterPriors(distribution, request.ParameterPriors); + + var resource = new AnalysisResource + { + Name = string.IsNullOrWhiteSpace(request.Name) ? $"Bivariate: {marginalX.Name} / {marginalY.Name}" : request.Name, + Description = request.Description, + Kind = AnalysisKind.Bivariate, + Bivariate = analysis, + MarginalXResource = marginalX, + MarginalYResource = marginalY, + ComponentResources = new List { marginalX, marginalY }, + MarginalXAnalysisId = marginalX.Id, + MarginalYAnalysisId = marginalY.Id + }; + return _store.AddAnalysis(resource); + } + + /// + public AnalysisResource CreateCoincidentFrequency(CreateCoincidentFrequencyAnalysisRequest request) + { + ArgumentNullException.ThrowIfNull(request); + var bivariate = _store.GetAnalysis(request.BivariateAnalysisId); + if (bivariate == null || bivariate.Kind != AnalysisKind.Bivariate) + { + throw new ResourceNotFoundException("bivariate analysis", request.BivariateAnalysisId); + } + if (request.NumberOfBins is < 5 or > 1000) + { + throw new ArgumentException("numberOfBins must be between 5 and 1000."); + } + + var surface = ToResponseSurface(request); + var analysis = new CoincidentFrequencyAnalysis( + bivariate.Bivariate!, + request.XValues.ToArray(), + request.YValues.ToArray(), + surface) + { + NumberOfBins = request.NumberOfBins + }; + if (request.CredibleIntervalWidth.HasValue) analysis.BayesianAnalysis.CredibleIntervalWidth = request.CredibleIntervalWidth.Value; + if (request.PointEstimator.HasValue) analysis.BayesianAnalysis.PointEstimator = request.PointEstimator.Value; + + // Locks cover the bivariate plus its transitive marginals: a marginal re-running + // mid-CFA would mutate the shared marginal model this analysis reads. + var components = new List { bivariate }; + if (bivariate.MarginalXResource != null) components.Add(bivariate.MarginalXResource); + if (bivariate.MarginalYResource != null) components.Add(bivariate.MarginalYResource); + + var resource = new AnalysisResource + { + Name = string.IsNullOrWhiteSpace(request.Name) ? $"Coincident frequency over {bivariate.Name}" : request.Name, + Description = request.Description, + Kind = AnalysisKind.CoincidentFrequency, + CoincidentFrequency = analysis, + BivariateResource = bivariate, + ComponentResources = components, + BivariateAnalysisId = bivariate.Id + }; + return _store.AddAnalysis(resource); + } + + /// + public AnalysisResource CreateTimeSeries(CreateTimeSeriesAnalysisRequest request) + { + ArgumentNullException.ThrowIfNull(request); + var source = _store.GetTimeSeries(request.TimeSeriesId) + ?? throw new ResourceNotFoundException("time series", request.TimeSeriesId); + + RejectFieldsForModelType(request); + // The model layer silently clamps the forecast horizon to 0-100; range-check here so + // an out-of-range request is an error rather than a silent adjustment. + if (request.ForecastingTimeSteps is < 0 or > 100) + { + throw new ArgumentException("forecastingTimeSteps must be between 0 and 100."); + } + + var series = CloneSeries(source.TimeSeries); + bool includeIntercept = request.IncludeIntercept ?? true; + + AnalysisResource resource; + switch (request.ModelType) + { + case TimeSeriesModelType.Ar: + { + var model = new AutoRegressive(series, request.Order ?? 1, includeIntercept); + var analysis = new ARAnalysis(model); + ConfigureTimeSeriesAnalysis(request, () => { if (request.TransformType.HasValue) model.TransformType = request.TransformType.Value; }, + setTraining: v => { model.UseDefaultTrainingSteps = false; model.TrainingTimeSteps = v; }, + setForecast: v => analysis.ForecastingTimeSteps = v, + analysis.BayesianAnalysis, model, request.ParameterPriors); + resource = new AnalysisResource + { + Name = DefaultTimeSeriesName(request, "AR", source.Name), + Description = request.Description, + Kind = AnalysisKind.TimeSeries, + TimeSeriesModel = TimeSeriesModelType.Ar, + Ar = analysis, + TimeSeriesId = source.Id + }; + break; + } + case TimeSeriesModelType.Ma: + { + var model = new MovingAverage(series, request.Order ?? 1, includeIntercept); + var analysis = new MAAnalysis(model); + ConfigureTimeSeriesAnalysis(request, () => { if (request.TransformType.HasValue) model.TransformType = request.TransformType.Value; }, + setTraining: v => { model.UseDefaultTrainingSteps = false; model.TrainingTimeSteps = v; }, + setForecast: v => analysis.ForecastingTimeSteps = v, + analysis.BayesianAnalysis, model, request.ParameterPriors); + resource = new AnalysisResource + { + Name = DefaultTimeSeriesName(request, "MA", source.Name), + Description = request.Description, + Kind = AnalysisKind.TimeSeries, + TimeSeriesModel = TimeSeriesModelType.Ma, + Ma = analysis, + TimeSeriesId = source.Id + }; + break; + } + case TimeSeriesModelType.Arima: + { + var model = new ARIMA(series, request.POrder ?? 1, request.DOrder ?? 0, request.QOrder ?? 0, includeIntercept); + var analysis = new ARIMAAnalysis(model); + ConfigureTimeSeriesAnalysis(request, () => { if (request.TransformType.HasValue) model.TransformType = request.TransformType.Value; }, + setTraining: v => { model.UseDefaultTrainingSteps = false; model.TrainingTimeSteps = v; }, + setForecast: v => analysis.ForecastingTimeSteps = v, + analysis.BayesianAnalysis, model, request.ParameterPriors); + resource = new AnalysisResource + { + Name = DefaultTimeSeriesName(request, "ARIMA", source.Name), + Description = request.Description, + Kind = AnalysisKind.TimeSeries, + TimeSeriesModel = TimeSeriesModelType.Arima, + Arima = analysis, + TimeSeriesId = source.Id + }; + break; + } + case TimeSeriesModelType.Arimax: + { + var model = new ARIMAX(series) + { + AROrderP = request.POrder ?? 1, + DiffOrderD = request.DOrder ?? 0, + MAOrderQ = request.QOrder ?? 0, + XOrderB = request.XOrder ?? 0, + IncludeIntercept = includeIntercept + }; + if (request.TrendType.HasValue) model.TrendType = request.TrendType.Value; + if (request.IncludeSeasonality.HasValue) model.IncludeSeasonality = request.IncludeSeasonality.Value; + if (request.CovariateExtension.HasValue) model.CovariateExtension = request.CovariateExtension.Value; + + var covariateIds = new List(); + if (request.CovariateTimeSeriesIds is { Count: > 0 }) + { + var covariates = new List(request.CovariateTimeSeriesIds.Count); + foreach (var covariateId in request.CovariateTimeSeriesIds) + { + var covariateSource = _store.GetTimeSeries(covariateId) + ?? throw new ResourceNotFoundException("covariate time series", covariateId); + covariates.Add(CloneSeries(covariateSource.TimeSeries)); + covariateIds.Add(covariateSource.Id); + } + model.SetCovariates(covariates); + } + + var analysis = new ARIMAXAnalysis(model); + ConfigureTimeSeriesAnalysis(request, () => { if (request.TransformType.HasValue) model.TransformType = request.TransformType.Value; }, + setTraining: v => { model.UseDefaultTrainingSteps = false; model.TrainingTimeSteps = v; }, + setForecast: v => analysis.ForecastingTimeSteps = v, + analysis.BayesianAnalysis, model, request.ParameterPriors); + resource = new AnalysisResource + { + Name = DefaultTimeSeriesName(request, "ARIMAX", source.Name), + Description = request.Description, + Kind = AnalysisKind.TimeSeries, + TimeSeriesModel = TimeSeriesModelType.Arimax, + Arimax = analysis, + TimeSeriesId = source.Id, + CovariateTimeSeriesIds = covariateIds.Count > 0 ? covariateIds : null + }; + break; + } + default: + throw new ArgumentException($"Unknown time-series model type '{request.ModelType}'."); + } + return _store.AddAnalysis(resource); + } + + /// + /// Rejects request fields that do not apply to the chosen time-series model type, naming + /// every offending field — never silently ignored. + /// + /// The creation request. + /// Thrown when any inapplicable field is set. + private static void RejectFieldsForModelType(CreateTimeSeriesAnalysisRequest request) + { + var offending = new List(); + bool isArOrMa = request.ModelType is TimeSeriesModelType.Ar or TimeSeriesModelType.Ma; + bool isArimax = request.ModelType == TimeSeriesModelType.Arimax; + + if (isArOrMa) + { + if (request.POrder.HasValue) offending.Add(nameof(request.POrder)); + if (request.DOrder.HasValue) offending.Add(nameof(request.DOrder)); + if (request.QOrder.HasValue) offending.Add(nameof(request.QOrder)); + } + else if (request.Order.HasValue) + { + offending.Add(nameof(request.Order)); + } + if (!isArimax) + { + if (request.XOrder.HasValue) offending.Add(nameof(request.XOrder)); + if (request.TrendType.HasValue) offending.Add(nameof(request.TrendType)); + if (request.IncludeSeasonality.HasValue) offending.Add(nameof(request.IncludeSeasonality)); + if (request.CovariateTimeSeriesIds is { Count: > 0 }) offending.Add(nameof(request.CovariateTimeSeriesIds)); + if (request.CovariateExtension.HasValue) offending.Add(nameof(request.CovariateExtension)); + } + + if (offending.Count > 0) + { + string fields = string.Join(", ", offending.Select(EnumHelperCamel)); + throw new ArgumentException( + $"The following fields do not apply to modelType '{EnumHelperCamel(request.ModelType.ToString())}' and must be omitted: {fields}."); + } + } + + /// + /// Applies the shared time-series configuration in the safe order: transform and training + /// window first, then the forecast horizon, MCMC options, and priors LAST (order setters + /// rebuild the parameter list). + /// + /// The creation request. + /// Applies the transform onto the concrete model. + /// Applies an explicit training window onto the concrete model. + /// Applies the forecast horizon onto the concrete analysis. + /// The analysis's Bayesian estimator. + /// The concrete model, for prior application. + /// The client-supplied parameter priors. + private void ConfigureTimeSeriesAnalysis( + CreateTimeSeriesAnalysisRequest request, + Action applyTransform, + Action setTraining, + Action setForecast, + Estimation.BayesianAnalysis bayesianAnalysis, + ModelBase model, + List? priors) + { + applyTransform(); + if (request.TrainingTimeSteps.HasValue) + { + setTraining(request.TrainingTimeSteps.Value); + } + if (request.ForecastingTimeSteps.HasValue) + { + setForecast(request.ForecastingTimeSteps.Value); + } + BayesianOptionsMapper.Apply(bayesianAnalysis, request.BayesianOptions, _options.MaxIterations); + PriorMapper.ApplyParameterPriors(model, priors); + } + + /// + /// Builds the default display name for a time-series analysis. + /// + /// The creation request. + /// The model family label (e.g., "ARIMA"). + /// The source series display name. + /// The display name. + private static string DefaultTimeSeriesName(CreateTimeSeriesAnalysisRequest request, string modelLabel, string? sourceName) + { + return string.IsNullOrWhiteSpace(request.Name) ? $"{modelLabel} analysis of {sourceName}" : request.Name; + } + + /// + /// Converts a PascalCase field or enum name to the camelCase wire form for error messages. + /// + /// The PascalCase name. + /// The camelCase form. + private static string EnumHelperCamel(string name) + { + return Helpers.EnumHelper.ToCamelCase(name); + } + + /// + /// Converts the jagged response surface of a coincident frequency request into the + /// model's rectangular array, validating the shape. + /// + /// The creation request. + /// The rectangular surface, indexed [x row, y column]. + /// Thrown when the row count, a row length, or a value is inconsistent with the ordinates. + private static double[,] ToResponseSurface(CreateCoincidentFrequencyAnalysisRequest request) + { + if (request.BivariateResponse == null || request.BivariateResponse.Count != request.XValues.Count) + { + throw new ArgumentException( + $"bivariateResponse must have exactly one row per xValues entry ({request.XValues.Count}), but has {request.BivariateResponse?.Count ?? 0}."); + } + var surface = new double[request.XValues.Count, request.YValues.Count]; + for (int i = 0; i < request.BivariateResponse.Count; i++) + { + var row = request.BivariateResponse[i]; + if (row == null || row.Count != request.YValues.Count) + { + throw new ArgumentException( + $"bivariateResponse[{i}] must have exactly one entry per yValues entry ({request.YValues.Count}), but has {row?.Count ?? 0}."); + } + for (int j = 0; j < row.Count; j++) + { + surface[i, j] = row[j]; + } + } + return surface; + } + + /// + /// Extracts the model-layer marginal model from an analysis resource for use in a + /// bivariate distribution. Competing risks and composite analyses expose no marginal + /// model and are rejected. + /// + /// The marginal analysis resource. + /// The role name used in error messages ("marginal X" or "marginal Y"). + /// The marginal model. + /// Thrown when the resource's kind cannot serve as a bivariate marginal. + private static IUnivariateModel GetMarginalModel(AnalysisResource resource, string role) + { + return resource.Kind switch + { + AnalysisKind.Univariate => resource.Univariate!.UnivariateDistribution, + AnalysisKind.Bulletin17C => resource.Bulletin17C!.Bulletin17CDistribution, + AnalysisKind.Mixture => resource.Mixture!.MixtureDistribution, + AnalysisKind.PointProcess => resource.PointProcess!.PointProcess, + _ => throw new ArgumentException( + $"An analysis of kind '{resource.Kind}' cannot be the {role} of a bivariate analysis. " + + "Valid marginal kinds: univariate, bulletin17c, mixture, pointprocess.") + }; + } + + /// + /// Counts the non-low-outlier exact observations the two marginals share by time index — + /// the pairs the copula sample is built from (mirrors the model's two-pointer merge). + /// + /// The marginal-X data frame. + /// The marginal-Y data frame. + /// The matched pair count. + private static int CountMatchedExactPairs(DataFrame frameX, DataFrame frameY) + { + var indexesX = new HashSet(); + foreach (ExactData data in frameX.ExactSeries) + { + if (!data.IsLowOutlier) indexesX.Add(data.Index); + } + int count = 0; + foreach (ExactData data in frameY.ExactSeries) + { + if (!data.IsLowOutlier && indexesX.Contains(data.Index)) count++; + } + return count; + } + + /// + /// Extracts the model-layer univariate-analysis interface from a component resource for + /// use as a composite component. + /// + /// The component analysis resource. + /// The component viewed as . + /// Thrown when the resource's kind cannot serve as a composite component. + private static IUnivariateAnalysis GetUnivariateAnalysis(AnalysisResource resource) + { + return resource.Kind switch + { + AnalysisKind.Univariate => resource.Univariate!, + AnalysisKind.Bulletin17C => resource.Bulletin17C!, + AnalysisKind.Mixture => resource.Mixture!, + AnalysisKind.PointProcess => resource.PointProcess!, + AnalysisKind.CompetingRisks => resource.CompetingRisks!, + _ => throw new ArgumentException( + $"An analysis of kind '{resource.Kind}' cannot be a composite component. " + + "Valid component kinds: univariate, bulletin17c, mixture, pointprocess, competingrisks.") + }; + } + + /// + /// Validates a component-distribution list: 1-3 entries, each supported by the target + /// model family. + /// + /// The client-supplied component types. + /// The model family's support predicate. + /// The family name used in error messages (e.g., "mixture"). + /// Thrown when the list is empty, too long, or names an unsupported type. + private static void ValidateComponentDistributions( + List distributions, + Func isSupported, + string familyName) + { + if (distributions == null || distributions.Count == 0) + { + throw new ArgumentException($"At least one component distribution is required for a {familyName} analysis."); + } + if (distributions.Count > 3) + { + throw new ArgumentException($"A {familyName} analysis supports at most 3 component distributions."); + } + foreach (var type in distributions) + { + if (!isSupported(type)) + { + throw new ArgumentException( + $"Distribution '{type}' is not supported by the {familyName} analysis. See GET api/metadata/distributions."); + } + } + } + } +} diff --git a/src/RMC.BestFit.Api/Services/AnalysisService.cs b/src/RMC.BestFit.Api/Services/AnalysisService.cs new file mode 100644 index 0000000..fea8181 --- /dev/null +++ b/src/RMC.BestFit.Api/Services/AnalysisService.cs @@ -0,0 +1,593 @@ +using System.Diagnostics; +using Microsoft.Extensions.Options; +using Numerics.Data; +using Numerics.Distributions; +using RMC.BestFit.Analyses; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Helpers; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Services.Exceptions; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Models; + +namespace RMC.BestFit.Api.Services +{ + /// + /// Default implementation. Analyses clone their inputs at + /// creation (data frames via XElement round-trip, time series via ordinate copy) so deleting + /// or replacing upstream resources can never corrupt an existing fit. Runs are serialized per + /// analysis (single-entry lock → HTTP 409 on conflict) and throttled globally + /// () because each MCMC run parallelizes internally. + /// + public partial class AnalysisService : IAnalysisService + { + /// + /// The in-memory resource store. + /// + private readonly IResourceStore _store; + + /// + /// The configured API limits (iteration cap, run throttle). + /// + private readonly ApiOptions _options; + + /// + /// Global throttle bounding concurrent estimation runs across all analyses. + /// + private readonly SemaphoreSlim _runThrottle; + + /// + /// Constructs the service. + /// + /// The in-memory resource store. + /// The configured API limits. + /// Thrown when a dependency is null. + public AnalysisService(IResourceStore store, IOptions options) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + if (options is null) throw new ArgumentNullException(nameof(options)); + _options = options.Value; + _runThrottle = new SemaphoreSlim(Math.Max(1, _options.MaxConcurrentRuns), Math.Max(1, _options.MaxConcurrentRuns)); + } + + /// + public AnalysisResource CreateUnivariate(CreateUnivariateAnalysisRequest request) + { + ArgumentNullException.ThrowIfNull(request); + var input = _store.GetInputData(request.InputDataId) + ?? throw new ResourceNotFoundException("input data", request.InputDataId); + + if (!UnivariateDistribution.IsSupportedDistributionType(request.Distribution)) + { + throw new ArgumentException( + $"Distribution '{request.Distribution}' is not supported by the univariate analysis. See GET api/metadata/distributions."); + } + + var distribution = new UnivariateDistribution(input.DataFrame.Clone(), request.Distribution); + var analysis = new UnivariateAnalysis(distribution); + ApplyProbabilityOrdinates(request.ProbabilityOrdinates, analysis.ProbabilityOrdinates); + BayesianOptionsMapper.Apply(analysis.BayesianAnalysis, request.BayesianOptions, _options.MaxIterations); + // Priors are applied last: the model is fully configured, so nothing after this point + // triggers the default-parameter rebuild that would wipe them. + PriorMapper.ApplyParameterPriors(distribution, request.ParameterPriors); + PriorMapper.ApplyQuantilePriors(distribution, request.QuantilePriors, request.UseSingleQuantile); + + var resource = new AnalysisResource + { + Name = string.IsNullOrWhiteSpace(request.Name) + ? $"{MetadataMapper.ToDisplayName(request.Distribution)} analysis of {input.Name}" + : request.Name, + Description = request.Description, + Kind = AnalysisKind.Univariate, + Univariate = analysis, + InputDataId = input.Id + }; + return _store.AddAnalysis(resource); + } + + /// + public AnalysisResource CreateBulletin17C(CreateBulletin17CAnalysisRequest request) + { + ArgumentNullException.ThrowIfNull(request); + var input = _store.GetInputData(request.InputDataId) + ?? throw new ResourceNotFoundException("input data", request.InputDataId); + + if (!Bulletin17CDistribution.IsSupportedDistributionType(request.Distribution)) + { + throw new ArgumentException( + $"Distribution '{request.Distribution}' is not supported by the Bulletin 17C analysis. " + + "Supported: logPearsonTypeIII, pearsonTypeIII, logNormal, normal, gammaDistribution, exponential."); + } + + var distribution = new Bulletin17CDistribution(input.DataFrame.Clone(), request.Distribution); + var analysis = new Bulletin17CAnalysis(distribution); + if (request.UncertaintyMethod.HasValue) + { + analysis.UncertaintyMethod = request.UncertaintyMethod.Value; + } + ApplyProbabilityOrdinates(request.ProbabilityOrdinates, analysis.ProbabilityOrdinates); + PriorMapper.ApplyPenalties(distribution, request.ParameterPenalties, request.QuantilePenalties); + + // The Expected Moments Algorithm has no measurement-error likelihood, so uncertain + // observations are silently skipped by the model. Never silent here: record a warning + // that survives on the resource for create/get/validate responses. + var warnings = new List(); + if (input.DataFrame.UncertainSeries.Count > 0) + { + warnings.Add( + $"The input data contains {input.DataFrame.UncertainSeries.Count} uncertain observation(s). " + + "The Bulletin 17C Expected Moments Algorithm does not use uncertain data, so these observations " + + "are ignored by this analysis. Use the univariate analysis (POST api/analyses/univariate) for " + + "full measurement-error propagation."); + } + + var resource = new AnalysisResource + { + Name = string.IsNullOrWhiteSpace(request.Name) ? $"Bulletin 17C analysis of {input.Name}" : request.Name, + Description = request.Description, + Kind = AnalysisKind.Bulletin17C, + Bulletin17C = analysis, + InputDataId = input.Id, + CreationWarnings = warnings + }; + return _store.AddAnalysis(resource); + } + + /// + public AnalysisResource CreateRatingCurve(CreateRatingCurveAnalysisRequest request) + { + ArgumentNullException.ThrowIfNull(request); + var stage = _store.GetTimeSeries(request.StageTimeSeriesId) + ?? throw new ResourceNotFoundException("stage time series", request.StageTimeSeriesId); + var discharge = _store.GetTimeSeries(request.DischargeTimeSeriesId) + ?? throw new ResourceNotFoundException("discharge time series", request.DischargeTimeSeriesId); + + if (request.MinStage.HasValue != request.MaxStage.HasValue) + { + throw new ArgumentException("minStage and maxStage must be supplied together to customize the stage grid."); + } + if (request.MinStage.HasValue && request.MinStage.Value >= request.MaxStage!.Value) + { + throw new ArgumentException("minStage must be less than maxStage."); + } + + var ratingCurve = new RatingCurve(CloneSeries(stage.TimeSeries), CloneSeries(discharge.TimeSeries), request.NumberOfSegments); + var analysis = new RatingCurveAnalysis(ratingCurve); + + if (request.MinStage.HasValue) + { + analysis.UseDefaultStageBins = false; + analysis.MinStage = request.MinStage.Value; + analysis.MaxStage = request.MaxStage!.Value; + } + if (request.StageBins.HasValue) + { + analysis.StageBins = request.StageBins.Value; + } + BayesianOptionsMapper.Apply(analysis.BayesianAnalysis, request.BayesianOptions, _options.MaxIterations); + // Priors are applied last so nothing after this point can rebuild the parameter list. + PriorMapper.ApplyParameterPriors(ratingCurve, request.ParameterPriors); + + var resource = new AnalysisResource + { + Name = string.IsNullOrWhiteSpace(request.Name) ? $"Rating curve: {stage.Name} / {discharge.Name}" : request.Name, + Description = request.Description, + Kind = AnalysisKind.RatingCurve, + RatingCurve = analysis, + StageTimeSeriesId = stage.Id, + DischargeTimeSeriesId = discharge.Id + }; + return _store.AddAnalysis(resource); + } + + /// + public IReadOnlyList List(AnalysisKind? kind = null) + { + var analyses = _store.ListAnalyses(); + return kind == null ? analyses : analyses.Where(a => a.Kind == kind.Value).ToList(); + } + + /// + public AnalysisResource Get(Guid id, AnalysisKind? expectedKind = null) + { + var resource = _store.GetAnalysis(id); + if (resource == null || (expectedKind.HasValue && resource.Kind != expectedKind.Value)) + { + throw new ResourceNotFoundException(DescribeKind(expectedKind), id); + } + return resource; + } + + /// + public ValidationResponse Validate(Guid id, AnalysisKind? expectedKind = null) + { + var resource = Get(id, expectedKind); + var (isValid, messages) = AnalysisRunHelper.Validate(resource); + return new ValidationResponse + { + IsValid = isValid, + Errors = isValid ? new List() : messages, + Warnings = resource.CreationWarnings.ToList() + }; + } + + /// + /// Kind-specific state synchronization immediately before a run. A coincident frequency + /// analysis pulls the marginal posterior chains from its LIVE upstream resources here (the + /// UI does the same before each run): a chain is available only when the marginal is a + /// plain univariate analysis that has been estimated; otherwise uncertainty comes from the + /// copula chain alone. + /// + /// The analysis resource about to run. + private static void PrepareForRun(AnalysisResource resource) + { + if (resource.Kind != AnalysisKind.CoincidentFrequency) + { + return; + } + var analysis = resource.CoincidentFrequency!; + var bivariate = resource.BivariateResource; + analysis.MarginalXChain = GetMarginalChain(bivariate?.MarginalXResource); + analysis.MarginalYChain = GetMarginalChain(bivariate?.MarginalYResource); + } + + /// + /// Returns a marginal resource's posterior chain when it is an estimated plain univariate + /// analysis; otherwise null. + /// + /// The marginal analysis resource, or null. + /// The MCMC results, or null. + private static Numerics.Sampling.MCMC.MCMCResults? GetMarginalChain(AnalysisResource? marginal) + { + if (marginal is { Kind: AnalysisKind.Univariate } && marginal.Analysis.IsEstimated) + { + return marginal.Univariate!.BayesianAnalysis.Results; + } + return null; + } + + /// + public async Task RunFrequencyAsync(Guid id, AnalysisKind? expectedKind, CancellationToken cancellationToken = default) + { + var resource = Get(id, expectedKind); + if (!IsFrequencyKind(resource.Kind)) + { + throw new ResourceNotFoundException(DescribeKind(expectedKind), id); + } + await RunCoreAsync(resource, cancellationToken); + return ResultsMapper.ToFrequencyResults(resource); + } + + /// + public async Task RunRatingCurveAsync(Guid id, CancellationToken cancellationToken = default) + { + var resource = Get(id, AnalysisKind.RatingCurve); + await RunCoreAsync(resource, cancellationToken); + return ResultsMapper.ToRatingCurveResults(resource); + } + + /// + public FrequencyResultsResponse GetFrequencyResults(Guid id, AnalysisKind? expectedKind = null) + { + var resource = Get(id, expectedKind); + if (!IsFrequencyKind(resource.Kind) || !resource.Analysis.IsEstimated) + { + throw new ResourceNotFoundException( + $"The analysis '{id}' has no frequency results. Run it first (POST .../{id}/run)."); + } + return ResultsMapper.ToFrequencyResults(resource); + } + + /// + public RatingCurveResultsResponse GetRatingCurveResults(Guid id) + { + var resource = Get(id, AnalysisKind.RatingCurve); + if (!resource.Analysis.IsEstimated) + { + throw new ResourceNotFoundException( + $"The analysis '{id}' has no results. Run it first (POST .../{id}/run)."); + } + return ResultsMapper.ToRatingCurveResults(resource); + } + + /// + public async Task RunDistributionFittingAsync(Guid id, CancellationToken cancellationToken = default) + { + var resource = Get(id, AnalysisKind.DistributionFitting); + await RunCoreAsync(resource, cancellationToken); + return DistributionFittingResultsMapper.ToResults(resource); + } + + /// + public DistributionFittingResultsResponse GetDistributionFittingResults(Guid id) + { + var resource = Get(id, AnalysisKind.DistributionFitting); + if (!resource.Analysis.IsEstimated) + { + throw new ResourceNotFoundException( + $"The analysis '{id}' has no results. Run it first (POST .../{id}/run)."); + } + return DistributionFittingResultsMapper.ToResults(resource); + } + + /// + public async Task RunBivariateAsync(Guid id, CancellationToken cancellationToken = default) + { + var resource = Get(id, AnalysisKind.Bivariate); + await RunCoreAsync(resource, cancellationToken); + return BivariateResultsMapper.ToBivariateResults(resource); + } + + /// + public BivariateResultsResponse GetBivariateResults(Guid id) + { + var resource = Get(id, AnalysisKind.Bivariate); + if (!resource.Analysis.IsEstimated) + { + throw new ResourceNotFoundException( + $"The analysis '{id}' has no results. Run it first (POST .../{id}/run)."); + } + return BivariateResultsMapper.ToBivariateResults(resource); + } + + /// + public async Task RunCoincidentFrequencyAsync(Guid id, CancellationToken cancellationToken = default) + { + var resource = Get(id, AnalysisKind.CoincidentFrequency); + await RunCoreAsync(resource, cancellationToken); + return BivariateResultsMapper.ToCoincidentFrequencyResults(resource); + } + + /// + public CoincidentFrequencyResultsResponse GetCoincidentFrequencyResults(Guid id) + { + var resource = Get(id, AnalysisKind.CoincidentFrequency); + if (!resource.Analysis.IsEstimated) + { + throw new ResourceNotFoundException( + $"The analysis '{id}' has no results. Run it first (POST .../{id}/run)."); + } + return BivariateResultsMapper.ToCoincidentFrequencyResults(resource); + } + + /// + public async Task RunTimeSeriesAsync(Guid id, CancellationToken cancellationToken = default) + { + var resource = Get(id, AnalysisKind.TimeSeries); + await RunCoreAsync(resource, cancellationToken); + return TimeSeriesResultsMapper.ToResults(resource); + } + + /// + public TimeSeriesResultsResponse GetTimeSeriesResults(Guid id) + { + var resource = Get(id, AnalysisKind.TimeSeries); + if (!resource.Analysis.IsEstimated) + { + throw new ResourceNotFoundException( + $"The analysis '{id}' has no results. Run it first (POST .../{id}/run)."); + } + return TimeSeriesResultsMapper.ToResults(resource); + } + + /// + public void Delete(Guid id, AnalysisKind? expectedKind = null) + { + var resource = Get(id, expectedKind); + if (!resource.RunLock.Wait(0)) + { + throw new ResourceConflictException( + $"The analysis '{id}' is currently running and cannot be deleted. Cancel the run (disconnect) or wait for it to finish."); + } + try + { + _store.DeleteAnalysis(id); + } + finally + { + resource.RunLock.Release(); + } + } + + /// + /// Shared run orchestration: per-analysis single-entry lock (409 on conflict), global run + /// throttle, pre-run model validation (400), state bookkeeping, and outcome normalization + /// via . + /// + /// The analysis resource to run. + /// Client cancellation. + /// A task completing when the run has succeeded. + /// Thrown when the analysis is already running. + /// Thrown when the configuration fails validation. + private async Task RunCoreAsync(AnalysisResource resource, CancellationToken cancellationToken) + { + if (!resource.RunLock.Wait(0)) + { + throw new ResourceConflictException( + $"The analysis '{resource.Id}' is already running. Wait for it to finish before starting another run."); + } + List? componentLocks = null; + try + { + // Composites read their components' live posteriors during the run; excluding + // component runs for the duration keeps that read consistent. Acquired before + // validation so a busy component reports 409 deterministically rather than racing + // the children-estimated validation check. + componentLocks = AcquireComponentLocks(resource); + + await _runThrottle.WaitAsync(cancellationToken); + try + { + var (isValid, messages) = AnalysisRunHelper.Validate(resource); + if (!isValid) + { + throw new RequestValidationException(messages, "The analysis configuration is invalid. See validationErrors."); + } + PrepareForRun(resource); + + resource.State = AnalysisRunState.Running; + resource.LastError = null; + var stopwatch = Stopwatch.StartNew(); + try + { + await AnalysisRunHelper.ExecuteAsync(resource, cancellationToken); + resource.State = AnalysisRunState.Succeeded; + } + catch (OperationCanceledException) + { + resource.State = AnalysisRunState.Cancelled; + resource.LastError = "The run was cancelled by the client."; + throw; + } + catch (Exception ex) + { + resource.State = AnalysisRunState.Failed; + resource.LastError = ex.Message; + throw; + } + finally + { + stopwatch.Stop(); + resource.LastRunUtc = DateTime.UtcNow; + resource.LastRunMs = stopwatch.ElapsedMilliseconds; + } + } + finally + { + _runThrottle.Release(); + } + } + finally + { + if (componentLocks != null) + { + foreach (var component in componentLocks) + { + component.RunLock.Release(); + } + } + resource.RunLock.Release(); + } + } + + /// + /// Try-acquires the run lock of every component resource the analysis consumes, so + /// component posteriors cannot change mid-run. Rolls back already-acquired locks and + /// reports a conflict when any component is busy. + /// + /// The analysis resource about to run. + /// The components whose locks were acquired (empty for component-free kinds). + /// Thrown when a component analysis is currently running. + private static List AcquireComponentLocks(AnalysisResource resource) + { + var acquired = new List(); + foreach (var component in GetComponentResources(resource)) + { + if (!component.RunLock.Wait(0)) + { + foreach (var held in acquired) + { + held.RunLock.Release(); + } + throw new ResourceConflictException( + $"The component analysis '{component.Id}' ('{component.Name}') is currently running. " + + "Wait for it to finish before running this analysis."); + } + acquired.Add(component); + } + return acquired; + } + + /// + /// Lists the LIVE component resources an analysis reads while it runs, de-duplicated by + /// id (a component listed twice must not conflict with itself). + /// + /// The analysis resource. + /// The distinct component resources; empty for kinds without components. + private static IReadOnlyList GetComponentResources(AnalysisResource resource) + { + if (resource.ComponentResources == null || resource.ComponentResources.Count == 0) + { + return Array.Empty(); + } + return resource.ComponentResources + .GroupBy(r => r.Id) + .Select(g => g.First()) + .ToList(); + } + + /// + /// Validates client-supplied exceedance-probability ordinates and replaces the analysis's + /// default ordinates in place (the property is read-only on some analyses). Values are + /// de-duplicated and sorted ascending because the model requires strictly increasing + /// ordinates; clients should not need to know that convention. No-op when the client did + /// not supply ordinates (model defaults apply). + /// + /// The client-supplied ordinates, or null. + /// The analysis's ordinate collection to replace. + /// Thrown when any ordinate is outside (0, 1). + private static void ApplyProbabilityOrdinates(List? ordinates, ProbabilityOrdinates target) + { + if (ordinates == null || ordinates.Count == 0) return; + if (ordinates.Any(p => p <= 0d || p >= 1d || double.IsNaN(p))) + { + throw new ArgumentException("Each probability ordinate must be strictly between 0 and 1 (annual exceedance probability)."); + } + target.Clear(); + target.AddRange(ordinates.Distinct().OrderBy(p => p)); + } + + /// + /// Copies a time series ordinate-by-ordinate so the analysis owns data independent of the + /// source resource. + /// + /// The series to copy. + /// An independent copy with the same interval and ordinates. + private static TimeSeries CloneSeries(TimeSeries source) + { + var clone = new TimeSeries(source.TimeInterval); + foreach (var ordinate in source) + { + clone.Add(new SeriesOrdinate(ordinate.Index, ordinate.Value)); + } + return clone; + } + + /// + /// Determines whether a kind produces frequency results (AEP-aligned curves with + /// [p, 2] confidence intervals served by the shared frequency run/results paths). + /// + /// The analysis kind. + /// True for the frequency-curve kinds. + private static bool IsFrequencyKind(AnalysisKind kind) + { + return kind is AnalysisKind.Univariate or AnalysisKind.Bulletin17C or AnalysisKind.Mixture + or AnalysisKind.PointProcess or AnalysisKind.CompetingRisks or AnalysisKind.Composite; + } + + /// + /// Builds the user-facing resource-type phrase for not-found messages. + /// + /// The expected kind, or null for any analysis. + /// The phrase (e.g., "univariate analysis"). + private static string DescribeKind(AnalysisKind? kind) + { + return kind switch + { + AnalysisKind.Univariate => "univariate analysis", + AnalysisKind.Bulletin17C => "Bulletin 17C analysis", + AnalysisKind.RatingCurve => "rating curve analysis", + AnalysisKind.Mixture => "mixture analysis", + AnalysisKind.PointProcess => "point process analysis", + AnalysisKind.CompetingRisks => "competing risks analysis", + AnalysisKind.Composite => "composite analysis", + AnalysisKind.DistributionFitting => "distribution fitting analysis", + AnalysisKind.Bivariate => "bivariate analysis", + AnalysisKind.CoincidentFrequency => "coincident frequency analysis", + AnalysisKind.TimeSeries => "time series analysis", + _ => "analysis" + }; + } + } +} diff --git a/src/RMC.BestFit.Api/Services/Exceptions/RequestValidationException.cs b/src/RMC.BestFit.Api/Services/Exceptions/RequestValidationException.cs new file mode 100644 index 0000000..1b805e1 --- /dev/null +++ b/src/RMC.BestFit.Api/Services/Exceptions/RequestValidationException.cs @@ -0,0 +1,27 @@ +namespace RMC.BestFit.Api.Services.Exceptions +{ + /// + /// Thrown when a request fails model-layer validation (e.g., DataFrame.Validate() or an + /// analysis Validate() reports errors). Carries the individual validation messages so + /// the controller layer can populate the response's validationErrors list alongside + /// HTTP 400. + /// + public class RequestValidationException : Exception + { + /// + /// Constructs the exception from a list of validation error messages. + /// + /// The individual validation error messages reported by the model layer. + /// An optional summary message; defaults to a generic validation-failed summary. + public RequestValidationException(IEnumerable errors, string? message = null) + : base(message ?? "Validation failed. See validationErrors for details.") + { + Errors = errors.ToList(); + } + + /// + /// The individual validation error messages reported by the model layer. + /// + public IReadOnlyList Errors { get; } + } +} diff --git a/src/RMC.BestFit.Api/Services/Exceptions/ResourceConflictException.cs b/src/RMC.BestFit.Api/Services/Exceptions/ResourceConflictException.cs new file mode 100644 index 0000000..80a29bd --- /dev/null +++ b/src/RMC.BestFit.Api/Services/Exceptions/ResourceConflictException.cs @@ -0,0 +1,16 @@ +namespace RMC.BestFit.Api.Services.Exceptions +{ + /// + /// Thrown when a request conflicts with the current state of the server — the resource store + /// is at capacity, or the target analysis is already running. Mapped to HTTP 409 by the + /// controller layer. + /// + public class ResourceConflictException : Exception + { + /// + /// Constructs the exception with a client-facing message describing the conflict and how to resolve it. + /// + /// A message describing the conflict (e.g., store at capacity, analysis already running). + public ResourceConflictException(string message) : base(message) { } + } +} diff --git a/src/RMC.BestFit.Api/Services/Exceptions/ResourceNotFoundException.cs b/src/RMC.BestFit.Api/Services/Exceptions/ResourceNotFoundException.cs new file mode 100644 index 0000000..6d5e0b3 --- /dev/null +++ b/src/RMC.BestFit.Api/Services/Exceptions/ResourceNotFoundException.cs @@ -0,0 +1,25 @@ +namespace RMC.BestFit.Api.Services.Exceptions +{ + /// + /// Thrown when a client references a resource id that does not exist in the resource store. + /// Mapped to HTTP 404 by the controller layer. + /// + public class ResourceNotFoundException : Exception + { + /// + /// Constructs the exception with a client-facing message. + /// + /// A message identifying the missing resource, including its id and expected type. + public ResourceNotFoundException(string message) : base(message) { } + + /// + /// Constructs the exception for a specific resource type and id. + /// + /// The user-facing resource type name (e.g., "time series"). + /// The id that was not found. + public ResourceNotFoundException(string resourceType, Guid id) + : base($"No {resourceType} resource exists with id '{id}'. It may have been deleted, or the id may belong to a different resource type.") + { + } + } +} diff --git a/src/RMC.BestFit.Api/Services/Exceptions/UsgsDataNotFoundException.cs b/src/RMC.BestFit.Api/Services/Exceptions/UsgsDataNotFoundException.cs new file mode 100644 index 0000000..dbe4875 --- /dev/null +++ b/src/RMC.BestFit.Api/Services/Exceptions/UsgsDataNotFoundException.cs @@ -0,0 +1,15 @@ +namespace RMC.BestFit.Api.Services.Exceptions +{ + /// + /// Thrown when a USGS download succeeds at the HTTP level but returns no data for the + /// requested site and series type. Mapped to HTTP 404 by the controller layer. + /// + public class UsgsDataNotFoundException : Exception + { + /// + /// Constructs the exception with a client-facing message. + /// + /// A message identifying the site number and series type that returned no data. + public UsgsDataNotFoundException(string message) : base(message) { } + } +} diff --git a/src/RMC.BestFit.Api/Services/Exceptions/UsgsUnavailableException.cs b/src/RMC.BestFit.Api/Services/Exceptions/UsgsUnavailableException.cs new file mode 100644 index 0000000..ab4655f --- /dev/null +++ b/src/RMC.BestFit.Api/Services/Exceptions/UsgsUnavailableException.cs @@ -0,0 +1,27 @@ +namespace RMC.BestFit.Api.Services.Exceptions +{ + /// + /// Thrown when the USGS water services cannot be reached or return an upstream error. + /// Mapped to HTTP 502 (upstream error) or 503 (no connectivity) by the controller layer. + /// + public class UsgsUnavailableException : Exception + { + /// + /// Constructs the exception with a client-facing message and the suggested HTTP status code. + /// + /// A message describing the upstream failure. + /// The HTTP status code the API should return: 502 for an upstream USGS error, 503 for no connectivity. + /// The underlying exception from the download layer, if any. + public UsgsUnavailableException(string message, int statusCode = 502, Exception? innerException = null) + : base(message, innerException) + { + StatusCode = statusCode; + } + + /// + /// The HTTP status code the API should return for this failure: 502 when the USGS service + /// responded with an error, 503 when there is no internet connectivity. + /// + public int StatusCode { get; } + } +} diff --git a/src/RMC.BestFit.Api/Services/IAnalysisService.cs b/src/RMC.BestFit.Api/Services/IAnalysisService.cs new file mode 100644 index 0000000..1d47c5b --- /dev/null +++ b/src/RMC.BestFit.Api/Services/IAnalysisService.cs @@ -0,0 +1,272 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Store; + +namespace RMC.BestFit.Api.Services +{ + /// + /// Creates, validates, runs, and deletes analysis resources (univariate, Bulletin 17C, and + /// rating curve). Shared by the REST controllers and the MCP tools; run methods are + /// synchronous (they return when the estimation completes). + /// + public interface IAnalysisService + { + /// + /// Creates a Bayesian MCMC univariate frequency analysis over a clone of the referenced + /// input data. + /// + /// The creation request. + /// The created resource. + /// Thrown when the input-data resource does not exist. + /// Thrown when the distribution or options are invalid. + AnalysisResource CreateUnivariate(CreateUnivariateAnalysisRequest request); + + /// + /// Creates a Bulletin 17C analysis over a clone of the referenced input data. + /// + /// The creation request. + /// The created resource. + /// Thrown when the input-data resource does not exist. + /// Thrown when the distribution is not supported by Bulletin 17C. + AnalysisResource CreateBulletin17C(CreateBulletin17CAnalysisRequest request); + + /// + /// Creates a rating curve analysis over clones of the referenced stage and discharge series. + /// + /// The creation request. + /// The created resource. + /// Thrown when either time-series resource does not exist. + /// Thrown when the stage-grid options are inconsistent. + AnalysisResource CreateRatingCurve(CreateRatingCurveAnalysisRequest request); + + /// + /// Creates a Bayesian MCMC mixture-distribution analysis over a clone of the referenced + /// input data. + /// + /// The creation request. + /// The created resource. + /// Thrown when the input-data resource does not exist. + /// Thrown when a component distribution or option is invalid. + AnalysisResource CreateMixture(CreateMixtureAnalysisRequest request); + + /// + /// Creates a Bayesian MCMC peaks-over-threshold point process analysis over a clone of + /// the referenced input data. + /// + /// The creation request. + /// The created resource. + /// Thrown when the input-data resource does not exist. + /// Thrown when a threshold, record-span, or option value is invalid. + AnalysisResource CreatePointProcess(CreatePointProcessAnalysisRequest request); + + /// + /// Creates a Bayesian MCMC competing risks analysis over a clone of the referenced input data. + /// + /// The creation request. + /// The created resource. + /// Thrown when the input-data resource does not exist. + /// Thrown when a component distribution or option is invalid. + AnalysisResource CreateCompetingRisks(CreateCompetingRisksAnalysisRequest request); + + /// + /// Creates a composite analysis over LIVE references to existing component analyses. + /// Components must be estimated by the time the composite runs, not at creation. + /// + /// The creation request. + /// The created resource. + /// Thrown when a component analysis does not exist. + /// Thrown when a component kind is not a valid composite component or mixture weights are invalid. + AnalysisResource CreateComposite(CreateCompositeAnalysisRequest request); + + /// + /// Creates a distribution-fitting analysis (parallel MLE with information-criterion + /// ranking) over a clone of the referenced input data. + /// + /// The creation request. + /// The created resource. + /// Thrown when the input-data resource does not exist. + /// Thrown when a candidate distribution is not supported. + AnalysisResource CreateDistributionFitting(CreateDistributionFittingAnalysisRequest request); + + /// + /// Runs a distribution-fitting analysis synchronously and returns its ranked fits. + /// + /// The resource id. + /// Client cancellation; aborts the fitting loop. + /// The ranked fitting results. + /// Thrown when no matching resource has the id. + /// Thrown when the analysis is already running. + /// Thrown when the configuration fails validation. + Task RunDistributionFittingAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// Returns the stored results of a previously run distribution-fitting analysis. + /// + /// The resource id. + /// The ranked fitting results. + /// Thrown when no matching resource has the id, or it has never been run. + DistributionFittingResultsResponse GetDistributionFittingResults(Guid id); + + /// + /// Creates a Bayesian MCMC bivariate copula analysis over LIVE references to two fitted + /// marginal analyses. Marginals must be estimated by the time this analysis runs. + /// + /// The creation request. + /// The created resource. + /// Thrown when a marginal analysis does not exist. + /// Thrown when a marginal kind, the pairing overlap, or an option is invalid. + AnalysisResource CreateBivariate(CreateBivariateAnalysisRequest request); + + /// + /// Creates a coincident frequency analysis over a LIVE reference to a bivariate analysis + /// plus a client-supplied response surface. The bivariate must be estimated by the time + /// this analysis runs. + /// + /// The creation request. + /// The created resource. + /// Thrown when the bivariate analysis does not exist. + /// Thrown when the response surface shape or an option is invalid. + AnalysisResource CreateCoincidentFrequency(CreateCoincidentFrequencyAnalysisRequest request); + + /// + /// Runs a bivariate copula analysis synchronously and returns its joint-exceedance results. + /// + /// The resource id. + /// Client cancellation; aborts the estimation. + /// The bivariate results. + /// Thrown when no matching resource has the id. + /// Thrown when the analysis or a marginal is already running. + /// Thrown when the configuration fails validation. + Task RunBivariateAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// Returns the stored results of a previously run bivariate analysis. + /// + /// The resource id. + /// The bivariate results. + /// Thrown when no matching resource has the id, or it has never been run. + BivariateResultsResponse GetBivariateResults(Guid id); + + /// + /// Runs a coincident frequency analysis synchronously and returns its response frequency curve. + /// + /// The resource id. + /// Client cancellation; aborts the aggregation. + /// The coincident frequency results. + /// Thrown when no matching resource has the id. + /// Thrown when the analysis, its bivariate, or a transitive marginal is already running. + /// Thrown when the configuration fails validation. + Task RunCoincidentFrequencyAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// Returns the stored results of a previously run coincident frequency analysis. + /// + /// The resource id. + /// The coincident frequency results. + /// Thrown when no matching resource has the id, or it has never been run. + CoincidentFrequencyResultsResponse GetCoincidentFrequencyResults(Guid id); + + /// + /// Creates a Bayesian MCMC time-series analysis (AR, MA, ARIMA, or ARIMAX per the + /// request's modelType) over clones of the referenced series and covariates. + /// + /// The creation request. + /// The created resource. + /// Thrown when the time-series or a covariate resource does not exist. + /// Thrown when a field does not apply to the model type or a value is out of range. + AnalysisResource CreateTimeSeries(CreateTimeSeriesAnalysisRequest request); + + /// + /// Runs a time-series analysis synchronously and returns its fitted-plus-forecast results. + /// + /// The resource id. + /// Client cancellation; aborts the estimation. + /// The time-series results. + /// Thrown when no matching resource has the id. + /// Thrown when the analysis is already running. + /// Thrown when the configuration fails validation. + Task RunTimeSeriesAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// Returns the stored results of a previously run time-series analysis. + /// + /// The resource id. + /// The time-series results. + /// Thrown when no matching resource has the id, or it has never been run. + TimeSeriesResultsResponse GetTimeSeriesResults(Guid id); + + /// + /// Lists analysis resources ordered by creation time, optionally filtered by kind. + /// + /// The kind to filter to, or null for all analyses. + /// The resources ordered by creation time. + IReadOnlyList List(AnalysisKind? kind = null); + + /// + /// Returns the analysis resource with the given id. + /// + /// The resource id. + /// When set, the resource must be of this kind or the lookup fails. + /// The resource. + /// Thrown when no matching resource has the id. + AnalysisResource Get(Guid id, AnalysisKind? expectedKind = null); + + /// + /// Runs model-layer validation for the analysis without running it. + /// + /// The resource id. + /// When set, the resource must be of this kind. + /// The validation response. + /// Thrown when no matching resource has the id. + ValidationResponse Validate(Guid id, AnalysisKind? expectedKind = null); + + /// + /// Runs a frequency-kind analysis (univariate, Bulletin 17C, mixture, point process, or + /// competing risks) synchronously and returns its frequency results. + /// + /// The resource id. + /// The expected kind, or null to accept any frequency kind. + /// Client cancellation; aborts the estimation. + /// The frequency results. + /// Thrown when no matching resource has the id. + /// Thrown when the analysis is already running. + /// Thrown when the configuration fails validation. + Task RunFrequencyAsync(Guid id, AnalysisKind? expectedKind, CancellationToken cancellationToken = default); + + /// + /// Runs a rating curve analysis synchronously and returns its results. + /// + /// The resource id. + /// Client cancellation; aborts the estimation. + /// The rating curve results. + /// Thrown when no matching resource has the id. + /// Thrown when the analysis is already running. + /// Thrown when the configuration fails validation. + Task RunRatingCurveAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// Returns the stored frequency results of a previously run frequency-kind analysis. + /// + /// The resource id. + /// The expected kind, or null to accept any frequency kind. + /// The frequency results. + /// Thrown when no matching resource has the id, or it has never been run. + FrequencyResultsResponse GetFrequencyResults(Guid id, AnalysisKind? expectedKind = null); + + /// + /// Returns the stored results of a previously run rating curve analysis. + /// + /// The resource id. + /// The rating curve results. + /// Thrown when no matching resource has the id, or it has never been run. + RatingCurveResultsResponse GetRatingCurveResults(Guid id); + + /// + /// Deletes the analysis resource with the given id. + /// + /// The resource id. + /// When set, the resource must be of this kind. + /// Thrown when no matching resource has the id. + /// Thrown when the analysis is currently running. + void Delete(Guid id, AnalysisKind? expectedKind = null); + } +} diff --git a/src/RMC.BestFit.Api/Services/IInputDataService.cs b/src/RMC.BestFit.Api/Services/IInputDataService.cs new file mode 100644 index 0000000..db139bb --- /dev/null +++ b/src/RMC.BestFit.Api/Services/IInputDataService.cs @@ -0,0 +1,78 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Store; + +namespace RMC.BestFit.Api.Services +{ + /// + /// Creates, lists, and deletes server-side input-data resources (model-layer data frames). + /// Shared by the REST controllers and the MCP tools. + /// + public interface IInputDataService + { + /// + /// Builds a data frame from client-supplied observations and stores it as a resource. + /// + /// The manual creation request. + /// The created resource. + /// Thrown when the data frame fails model-layer validation. + InputDataResource CreateManual(CreateManualInputDataRequest request); + + /// + /// Extracts block maxima (e.g., annual maxima by water year) from a linked time-series + /// resource and stores the result as a resource. + /// + /// The block-maxima creation request. + /// The created resource. + /// Thrown when the linked time series does not exist. + /// Thrown when extraction produces no observations or the data frame fails validation. + InputDataResource CreateBlockMax(CreateBlockMaxInputDataRequest request); + + /// + /// Extracts independent peaks-over-threshold events from a linked time-series resource and + /// stores the result as a resource. + /// + /// The peaks-over-threshold creation request. + /// The created resource. + /// Thrown when the linked time series does not exist. + /// Thrown when extraction produces no observations or the data frame fails validation. + InputDataResource CreatePeaksOverThreshold(CreatePotInputDataRequest request); + + /// + /// Downloads the USGS annual peak-flow file for a site and stores it as a resource. + /// + /// The USGS peak download request. + /// Cancellation token for the download. + /// The created resource. + /// Thrown when the series type is not peak discharge or peak stage. + Task CreateFromUsgsPeaksAsync(CreateUsgsPeaksInputDataRequest request, CancellationToken cancellationToken = default); + + /// + /// Lists all input-data resources ordered by creation time. + /// + /// The resources ordered by creation time. + IReadOnlyList List(); + + /// + /// Returns the input-data resource with the given id. + /// + /// The resource id. + /// The resource. + /// Thrown when no resource has the id. + InputDataResource Get(Guid id); + + /// + /// Computes the sample summary statistics of an input-data resource over all data. + /// + /// The resource id. + /// Summary statistics keyed by measure name. + /// Thrown when no resource has the id. + Dictionary GetSummaryStatistics(Guid id); + + /// + /// Deletes the input-data resource with the given id. + /// + /// The resource id. + /// Thrown when no resource has the id. + void Delete(Guid id); + } +} diff --git a/src/RMC.BestFit.Api/Services/ITimeSeriesService.cs b/src/RMC.BestFit.Api/Services/ITimeSeriesService.cs new file mode 100644 index 0000000..7fb580b --- /dev/null +++ b/src/RMC.BestFit.Api/Services/ITimeSeriesService.cs @@ -0,0 +1,48 @@ +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Store; + +namespace RMC.BestFit.Api.Services +{ + /// + /// Creates, lists, and deletes server-side time-series resources. Shared by the REST + /// controllers and the MCP tools. + /// + public interface ITimeSeriesService + { + /// + /// Downloads a series from the USGS water services and stores it as a resource. + /// + /// The download request (site number, series type, name). + /// Cancellation token for the download. + /// The created resource. + Task CreateFromUsgsAsync(CreateUsgsTimeSeriesRequest request, CancellationToken cancellationToken = default); + + /// + /// Builds a series from client-supplied points and stores it as a resource. + /// + /// The manual creation request (interval, points, name). + /// The created resource. + TimeSeriesResource CreateManual(CreateManualTimeSeriesRequest request); + + /// + /// Lists all time-series resources ordered by creation time. + /// + /// The resources ordered by creation time. + IReadOnlyList List(); + + /// + /// Returns the time-series resource with the given id. + /// + /// The resource id. + /// The resource. + /// Thrown when no resource has the id. + TimeSeriesResource Get(Guid id); + + /// + /// Deletes the time-series resource with the given id. + /// + /// The resource id. + /// Thrown when no resource has the id. + void Delete(Guid id); + } +} diff --git a/src/RMC.BestFit.Api/Services/IUsgsTimeSeriesService.cs b/src/RMC.BestFit.Api/Services/IUsgsTimeSeriesService.cs new file mode 100644 index 0000000..e6f150d --- /dev/null +++ b/src/RMC.BestFit.Api/Services/IUsgsTimeSeriesService.cs @@ -0,0 +1,27 @@ +using Numerics.Data; + +namespace RMC.BestFit.Api.Services +{ + /// + /// Abstraction over the USGS water-services download so the rest of the API can be tested + /// without network access. The production implementation adapts the static + /// class from the Numerics library. + /// + public interface IUsgsTimeSeriesService + { + /// + /// Downloads a time series from the USGS water services. + /// + /// The 8-digit USGS surface-water site number. + /// The series to download (daily, peak, instantaneous, or measured discharge/stage). + /// Token observed before and after the download (the underlying client does not support mid-flight cancellation). + /// The downloaded series and the raw response text for provenance. + /// Thrown when the site number or series type is invalid (mapped to HTTP 400). + /// Thrown when the site returns no data for the series type (mapped to HTTP 404). + /// Thrown when the USGS service errors or there is no connectivity (mapped to HTTP 502/503). + Task<(TimeSeries TimeSeries, string RawText)> DownloadAsync( + string siteNumber, + TimeSeriesDownload.TimeSeriesType seriesType, + CancellationToken cancellationToken = default); + } +} diff --git a/src/RMC.BestFit.Api/Services/IWorkflowService.cs b/src/RMC.BestFit.Api/Services/IWorkflowService.cs new file mode 100644 index 0000000..588e7cf --- /dev/null +++ b/src/RMC.BestFit.Api/Services/IWorkflowService.cs @@ -0,0 +1,45 @@ +using RMC.BestFit.Api.DTOs; + +namespace RMC.BestFit.Api.Services +{ + /// + /// Composes the resource services into one-shot USGS workflows (download → process → analyze + /// → results). Step failures are reported in-body (success=false, failedStep, partial ids) + /// rather than thrown, so clients keep the ids of everything already created. + /// + public interface IWorkflowService + { + /// + /// Downloads USGS annual peaks, builds input data, and runs a univariate frequency analysis. + /// + /// The workflow request. + /// Client cancellation. + /// The workflow response with resource ids and results. + Task RunUsgsPeakFrequencyAsync(UsgsPeakFrequencyWorkflowRequest request, CancellationToken cancellationToken = default); + + /// + /// Downloads USGS daily flow, extracts block maxima, and runs a univariate frequency analysis. + /// + /// The workflow request. + /// Client cancellation. + /// The workflow response with resource ids and results. + Task RunUsgsBlockMaxFrequencyAsync(UsgsBlockMaxFrequencyWorkflowRequest request, CancellationToken cancellationToken = default); + + /// + /// Downloads USGS annual peaks, builds input data, and runs a Bulletin 17C analysis. + /// + /// The workflow request. + /// Client cancellation. + /// The workflow response with resource ids and results. + Task RunUsgsBulletin17CAsync(UsgsBulletin17CWorkflowRequest request, CancellationToken cancellationToken = default); + + /// + /// Downloads USGS measured stage and discharge, and runs a rating curve analysis over the + /// date-aligned pairs. + /// + /// The workflow request. + /// Client cancellation. + /// The workflow response with resource ids and results. + Task RunUsgsRatingCurveAsync(UsgsRatingCurveWorkflowRequest request, CancellationToken cancellationToken = default); + } +} diff --git a/src/RMC.BestFit.Api/Services/InputDataService.cs b/src/RMC.BestFit.Api/Services/InputDataService.cs new file mode 100644 index 0000000..01d328e --- /dev/null +++ b/src/RMC.BestFit.Api/Services/InputDataService.cs @@ -0,0 +1,282 @@ +using Numerics.Data; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Mappers; +using RMC.BestFit.Api.Services.Exceptions; +using RMC.BestFit.Api.Store; +using RMC.BestFit.Models; + +namespace RMC.BestFit.Api.Services +{ + /// + /// Default implementation. Builds model-layer + /// instances using the same series-population sequence as the model's + /// own factory methods (suppress collection events, populate, raise a single reset) so lambda + /// and plotting positions are recomputed exactly as in the desktop application. + /// + public class InputDataService : IInputDataService + { + /// + /// The in-memory resource store. + /// + private readonly IResourceStore _store; + + /// + /// The USGS download adapter (mockable seam for tests). + /// + private readonly IUsgsTimeSeriesService _usgs; + + /// + /// Constructs the service. + /// + /// The in-memory resource store. + /// The USGS download adapter. + /// Thrown when either dependency is null. + public InputDataService(IResourceStore store, IUsgsTimeSeriesService usgs) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + _usgs = usgs ?? throw new ArgumentNullException(nameof(usgs)); + } + + /// + public InputDataResource CreateManual(CreateManualInputDataRequest request) + { + ArgumentNullException.ThrowIfNull(request); + if (request.ExactData == null || request.ExactData.Count == 0) + { + throw new ArgumentException("At least one exact observation is required.", nameof(request)); + } + + var dataFrame = new DataFrame(); + if (request.PlottingParameter.HasValue) dataFrame.PlottingParameter = request.PlottingParameter.Value; + if (request.LowOutlierThreshold.HasValue) dataFrame.LowOutlierThreshold = request.LowOutlierThreshold.Value; + + // Populate the exact series with a single reset so the data frame recomputes lambda and + // plotting positions once, matching the model's own factory methods. + dataFrame.ExactSeries.SuppressCollectionChanged = true; + foreach (var observation in request.ExactData) + { + int index = observation.Index + ?? observation.DateTime?.Year + ?? throw new ArgumentException("Each exact observation requires either 'index' (water year) or 'dateTime'."); + dataFrame.ExactSeries.Add(new ExactData(index, observation.Value, 0d, observation.IsLowOutlier)); + } + dataFrame.ExactSeries.SuppressCollectionChanged = false; + dataFrame.ExactSeries.RaiseCollectionChangedReset(); + + if (request.UncertainData is { Count: > 0 }) + { + dataFrame.UncertainSeries.SuppressCollectionChanged = true; + for (int i = 0; i < request.UncertainData.Count; i++) + { + var observation = request.UncertainData[i]; + int index = observation.Index + ?? observation.DateTime?.Year + ?? throw new ArgumentException($"uncertainData[{i}]: each uncertain observation requires either 'index' (water year) or 'dateTime'."); + var distribution = DistributionSpecMapper.ToDistribution(observation.Distribution, $"uncertainData[{i}].distribution"); + dataFrame.UncertainSeries.Add(new UncertainData(index, distribution)); + } + dataFrame.UncertainSeries.SuppressCollectionChanged = false; + dataFrame.UncertainSeries.RaiseCollectionChangedReset(); + } + + if (request.IntervalData is { Count: > 0 }) + { + dataFrame.IntervalSeries.SuppressCollectionChanged = true; + foreach (var interval in request.IntervalData) + { + double value = interval.Value ?? (interval.LowerBound + interval.UpperBound) / 2d; + dataFrame.IntervalSeries.Add(new IntervalData(interval.Index, interval.LowerBound, value, interval.UpperBound)); + } + dataFrame.IntervalSeries.SuppressCollectionChanged = false; + dataFrame.IntervalSeries.RaiseCollectionChangedReset(); + } + + if (request.ThresholdData is { Count: > 0 }) + { + dataFrame.ThresholdSeries.SuppressCollectionChanged = true; + foreach (var threshold in request.ThresholdData) + { + dataFrame.ThresholdSeries.Add(new ThresholdData(threshold.StartIndex, threshold.EndIndex, threshold.Value) + { + NumberAbove = threshold.NumberAbove + }); + } + dataFrame.ThresholdSeries.SuppressCollectionChanged = false; + dataFrame.ThresholdSeries.RaiseCollectionChangedReset(); + } + + // The reset handler recomputes lambda as events/span; an explicit client value (e.g., + // for manually entered peaks-over-threshold data) overrides it. + if (request.Lambda.HasValue) dataFrame.SetLambda(request.Lambda.Value); + + ThrowIfInvalid(dataFrame); + + var resource = new InputDataResource + { + Name = string.IsNullOrWhiteSpace(request.Name) ? "Manual input data" : request.Name, + Description = request.Description, + DataFrame = dataFrame, + Method = InputDataMethod.Manual + }; + return _store.AddInputData(resource); + } + + /// + public InputDataResource CreateBlockMax(CreateBlockMaxInputDataRequest request) + { + ArgumentNullException.ThrowIfNull(request); + var source = _store.GetTimeSeries(request.TimeSeriesId) + ?? throw new ResourceNotFoundException("time series", request.TimeSeriesId); + + var dataFrame = new DataFrame(); + dataFrame.CreateBlockSeries(source.TimeSeries, request.TimeBlock, request.BlockFunction, + request.SmoothingFunction, request.StartMonth, request.EndMonth, request.Period); + + if (dataFrame.ExactSeries.Count == 0) + { + throw new RequestValidationException( + new[] { $"Block-maxima extraction produced no observations from time series '{source.Name}'. The series may be too short to contain a complete {request.TimeBlock} block." }); + } + + ThrowIfInvalid(dataFrame); + + var resource = new InputDataResource + { + Name = string.IsNullOrWhiteSpace(request.Name) ? $"Block maxima of {source.Name}" : request.Name, + Description = request.Description, + DataFrame = dataFrame, + Method = InputDataMethod.BlockMaxima, + SourceTimeSeriesId = source.Id, + TimeBlock = request.TimeBlock, + BlockFunction = request.BlockFunction, + SmoothingFunction = request.SmoothingFunction, + StartMonth = request.StartMonth, + EndMonth = request.EndMonth, + Period = request.Period + }; + return _store.AddInputData(resource); + } + + /// + public InputDataResource CreatePeaksOverThreshold(CreatePotInputDataRequest request) + { + ArgumentNullException.ThrowIfNull(request); + var source = _store.GetTimeSeries(request.TimeSeriesId) + ?? throw new ResourceNotFoundException("time series", request.TimeSeriesId); + + var dataFrame = new DataFrame(); + dataFrame.CreatePeaksOverThresholdSeries(source.TimeSeries, request.Threshold, + request.MinStepsBetweenPeaks, request.SmoothingFunction, request.Period); + + // The model's reset handler recomputes lambda as events / span-of-peak-years; an + // explicit client value (e.g., events / full record length) overrides it. + if (request.Lambda.HasValue) dataFrame.SetLambda(request.Lambda.Value); + + if (dataFrame.ExactSeries.Count == 0) + { + throw new RequestValidationException( + new[] { $"Peaks-over-threshold extraction produced no events from time series '{source.Name}' at threshold {request.Threshold}. Lower the threshold." }); + } + + ThrowIfInvalid(dataFrame); + + var resource = new InputDataResource + { + Name = string.IsNullOrWhiteSpace(request.Name) ? $"POT of {source.Name}" : request.Name, + Description = request.Description, + DataFrame = dataFrame, + Method = InputDataMethod.PeaksOverThreshold, + SourceTimeSeriesId = source.Id, + Threshold = request.Threshold, + MinStepsBetweenPeaks = request.MinStepsBetweenPeaks, + SmoothingFunction = request.SmoothingFunction, + Period = request.Period + }; + return _store.AddInputData(resource); + } + + /// + public async Task CreateFromUsgsPeaksAsync(CreateUsgsPeaksInputDataRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + if (request.SeriesType != TimeSeriesDownload.TimeSeriesType.PeakDischarge && + request.SeriesType != TimeSeriesDownload.TimeSeriesType.PeakStage) + { + throw new ArgumentException( + "The series type must be peakDischarge or peakStage. For daily records, create a time series (POST api/timeseries/usgs) and extract block maxima (POST api/inputdata/block-max).", + nameof(request)); + } + + // Download through the adapter seam (rather than DataFrame.CreateFromUSGS, which calls + // the static downloader directly) so tests can fake the network; the series population + // below mirrors DataFrame.CreateFromUSGS exactly. + var (timeSeries, _) = await _usgs.DownloadAsync(request.SiteNumber, request.SeriesType, cancellationToken); + + var dataFrame = new DataFrame(); + dataFrame.ExactSeries.SuppressCollectionChanged = true; + for (int i = 0; i < timeSeries.Count; i++) + { + dataFrame.ExactSeries.Add(new ExactData(timeSeries[i].Index, timeSeries[i].Value)); + } + dataFrame.SetLambda(1); + dataFrame.ExactSeries.SuppressCollectionChanged = false; + dataFrame.ExactSeries.RaiseCollectionChangedReset(); + + ThrowIfInvalid(dataFrame); + + var resource = new InputDataResource + { + Name = string.IsNullOrWhiteSpace(request.Name) ? $"USGS {request.SiteNumber} peaks" : request.Name, + Description = request.Description, + DataFrame = dataFrame, + Method = request.SeriesType == TimeSeriesDownload.TimeSeriesType.PeakStage + ? InputDataMethod.UsgsPeakStage + : InputDataMethod.UsgsPeakDischarge, + UsgsSiteNumber = request.SiteNumber + }; + return _store.AddInputData(resource); + } + + /// + public IReadOnlyList List() + { + return _store.ListInputData(); + } + + /// + public InputDataResource Get(Guid id) + { + return _store.GetInputData(id) ?? throw new ResourceNotFoundException("input data", id); + } + + /// + public Dictionary GetSummaryStatistics(Guid id) + { + return Get(id).DataFrame.SummaryStatisticsAllData(); + } + + /// + public void Delete(Guid id) + { + if (!_store.DeleteInputData(id)) + { + throw new ResourceNotFoundException("input data", id); + } + } + + /// + /// Runs model-layer validation on a freshly built data frame and converts a failure into + /// the API's validation exception (HTTP 400 with the individual messages). + /// + /// The data frame to validate. + /// Thrown when validation reports errors. + private static void ThrowIfInvalid(DataFrame dataFrame) + { + var (isValid, messages) = dataFrame.Validate(); + if (!isValid) + { + throw new RequestValidationException(messages); + } + } + } +} diff --git a/src/RMC.BestFit.Api/Services/TimeSeriesService.cs b/src/RMC.BestFit.Api/Services/TimeSeriesService.cs new file mode 100644 index 0000000..67f2634 --- /dev/null +++ b/src/RMC.BestFit.Api/Services/TimeSeriesService.cs @@ -0,0 +1,101 @@ +using Numerics.Data; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Helpers; +using RMC.BestFit.Api.Services.Exceptions; +using RMC.BestFit.Api.Store; + +namespace RMC.BestFit.Api.Services +{ + /// + /// Default implementation backed by the in-memory resource + /// store and the USGS download adapter. + /// + public class TimeSeriesService : ITimeSeriesService + { + /// + /// The in-memory resource store. + /// + private readonly IResourceStore _store; + + /// + /// The USGS download adapter (mockable seam for tests). + /// + private readonly IUsgsTimeSeriesService _usgs; + + /// + /// Constructs the service. + /// + /// The in-memory resource store. + /// The USGS download adapter. + /// Thrown when either dependency is null. + public TimeSeriesService(IResourceStore store, IUsgsTimeSeriesService usgs) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + _usgs = usgs ?? throw new ArgumentNullException(nameof(usgs)); + } + + /// + public async Task CreateFromUsgsAsync(CreateUsgsTimeSeriesRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + var (timeSeries, _) = await _usgs.DownloadAsync(request.SiteNumber, request.SeriesType, cancellationToken); + + var resource = new TimeSeriesResource(timeSeries) + { + Name = string.IsNullOrWhiteSpace(request.Name) + ? $"USGS {request.SiteNumber} {EnumHelper.ToCamelCase(request.SeriesType.ToString())}" + : request.Name, + Description = request.Description, + Source = TimeSeriesSource.Usgs, + UsgsSiteNumber = request.SiteNumber, + UsgsSeriesType = request.SeriesType + }; + return _store.AddTimeSeries(resource); + } + + /// + public TimeSeriesResource CreateManual(CreateManualTimeSeriesRequest request) + { + ArgumentNullException.ThrowIfNull(request); + if (request.Points == null || request.Points.Count == 0) + { + throw new ArgumentException("At least one point is required to create a time series.", nameof(request)); + } + + var timeSeries = new TimeSeries(request.TimeInterval); + foreach (var point in request.Points.OrderBy(p => p.DateTime)) + { + timeSeries.Add(new SeriesOrdinate(point.DateTime, point.Value)); + } + + var resource = new TimeSeriesResource(timeSeries) + { + Name = string.IsNullOrWhiteSpace(request.Name) ? "Manual time series" : request.Name, + Description = request.Description, + Source = TimeSeriesSource.Manual + }; + return _store.AddTimeSeries(resource); + } + + /// + public IReadOnlyList List() + { + return _store.ListTimeSeries(); + } + + /// + public TimeSeriesResource Get(Guid id) + { + return _store.GetTimeSeries(id) ?? throw new ResourceNotFoundException("time series", id); + } + + /// + public void Delete(Guid id) + { + if (!_store.DeleteTimeSeries(id)) + { + throw new ResourceNotFoundException("time series", id); + } + } + } +} diff --git a/src/RMC.BestFit.Api/Services/UsgsTimeSeriesService.cs b/src/RMC.BestFit.Api/Services/UsgsTimeSeriesService.cs new file mode 100644 index 0000000..b48ef4b --- /dev/null +++ b/src/RMC.BestFit.Api/Services/UsgsTimeSeriesService.cs @@ -0,0 +1,89 @@ +using Numerics.Data; +using RMC.BestFit.Api.Services.Exceptions; + +namespace RMC.BestFit.Api.Services +{ + /// + /// Production adapting the static + /// download + /// and translating its failure modes into the API's typed exceptions. + /// + public class UsgsTimeSeriesService : IUsgsTimeSeriesService + { + /// + /// The underlying download call. Defaults to the static Numerics downloader; tests inject + /// a delegate to exercise the exception translation without network access. + /// + private readonly Func> _download; + + /// + /// Constructs the service over the static Numerics USGS downloader. + /// + public UsgsTimeSeriesService() + : this(static (siteNumber, seriesType) => TimeSeriesDownload.FromUSGS(siteNumber, seriesType)) + { + } + + /// + /// Constructs the service over a custom download delegate. Intended for tests, which + /// cannot fake the static Numerics downloader directly. + /// + /// The download call to adapt. + /// Thrown when is null. + public UsgsTimeSeriesService(Func> download) + { + _download = download ?? throw new ArgumentNullException(nameof(download)); + } + + /// + public async Task<(TimeSeries TimeSeries, string RawText)> DownloadAsync( + string siteNumber, + TimeSeriesDownload.TimeSeriesType seriesType, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + TimeSeries timeSeries; + string rawText; + try + { + (timeSeries, rawText) = await _download(siteNumber, seriesType); + } + catch (ArgumentException) + { + // Invalid site number format or unsupported series type — the request itself is + // wrong. Rethrow for the controller layer to map to HTTP 400. + throw; + } + catch (OperationCanceledException) + { + // Preserve cancellation semantics (mapped to HTTP 499 by the controller layer). + throw; + } + catch (InvalidOperationException ex) when (ex.Message.Contains("internet", StringComparison.OrdinalIgnoreCase)) + { + throw new UsgsUnavailableException( + "The USGS water services could not be reached: no internet connection.", statusCode: 503, innerException: ex); + } + catch (Exception ex) when (ex.Message.Contains("No data found", StringComparison.OrdinalIgnoreCase)) + { + throw new UsgsDataNotFoundException( + $"USGS site '{siteNumber}' returned no {seriesType} data. Verify the site number and that the site records this series type."); + } + catch (Exception ex) + { + throw new UsgsUnavailableException( + $"The USGS download for site '{siteNumber}' ({seriesType}) failed: {ex.Message}", statusCode: 502, innerException: ex); + } + + cancellationToken.ThrowIfCancellationRequested(); + + if (timeSeries == null || timeSeries.Count == 0) + { + throw new UsgsDataNotFoundException( + $"USGS site '{siteNumber}' returned no {seriesType} data. Verify the site number and that the site records this series type."); + } + + return (timeSeries, rawText); + } + } +} diff --git a/src/RMC.BestFit.Api/Services/WorkflowService.cs b/src/RMC.BestFit.Api/Services/WorkflowService.cs new file mode 100644 index 0000000..53330ba --- /dev/null +++ b/src/RMC.BestFit.Api/Services/WorkflowService.cs @@ -0,0 +1,314 @@ +using Numerics.Data; +using RMC.BestFit.Api.DTOs; +using RMC.BestFit.Api.Services.Exceptions; +using RMC.BestFit.Api.Store; + +namespace RMC.BestFit.Api.Services +{ + /// + /// Default implementation. Each workflow runs the standard + /// resource pipeline through the shared services; when a step throws, the exception is folded + /// into the response (success=false, failedStep, errorMessage, validation errors) while the + /// ids of already-created resources are preserved. Client cancellation is the only exception + /// allowed to propagate (the controller maps it to HTTP 499). + /// + public class WorkflowService : IWorkflowService + { + /// + /// The time-series service (USGS downloads). + /// + private readonly ITimeSeriesService _timeSeries; + + /// + /// The input-data service (peaks and block maxima). + /// + private readonly IInputDataService _inputData; + + /// + /// The analysis service (create + run). + /// + private readonly IAnalysisService _analyses; + + /// + /// Constructs the service. + /// + /// The time-series service. + /// The input-data service. + /// The analysis service. + /// Thrown when a dependency is null. + public WorkflowService(ITimeSeriesService timeSeries, IInputDataService inputData, IAnalysisService analyses) + { + _timeSeries = timeSeries ?? throw new ArgumentNullException(nameof(timeSeries)); + _inputData = inputData ?? throw new ArgumentNullException(nameof(inputData)); + _analyses = analyses ?? throw new ArgumentNullException(nameof(analyses)); + } + + /// + public async Task RunUsgsPeakFrequencyAsync(UsgsPeakFrequencyWorkflowRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + var response = new UsgsFrequencyWorkflowResponse(); + + // Step 1: download the annual peaks directly into an input-data resource. + if (!await TryStepAsync(response, "createInputData", async () => + { + var inputData = await _inputData.CreateFromUsgsPeaksAsync(new CreateUsgsPeaksInputDataRequest + { + SiteNumber = request.SiteNumber, + SeriesType = request.SeriesType, + Name = request.Name == null ? null : $"{request.Name} - input data" + }, cancellationToken); + response.InputDataId = inputData.Id; + })) return response; + + // Step 2: create the analysis over a clone of the input data. + if (!TryStep(response, "createAnalysis", () => + { + var analysis = _analyses.CreateUnivariate(new CreateUnivariateAnalysisRequest + { + InputDataId = response.InputDataId!.Value, + Distribution = request.Distribution, + ProbabilityOrdinates = request.ProbabilityOrdinates, + BayesianOptions = request.BayesianOptions, + Name = request.Name + }); + response.AnalysisId = analysis.Id; + })) return response; + + // Step 3: run and attach the results. + await TryStepAsync(response, "runAnalysis", async () => + { + response.Results = await _analyses.RunFrequencyAsync(response.AnalysisId!.Value, AnalysisKind.Univariate, cancellationToken); + }); + return response; + } + + /// + public async Task RunUsgsBlockMaxFrequencyAsync(UsgsBlockMaxFrequencyWorkflowRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + var response = new UsgsFrequencyWorkflowResponse(); + + // Step 1: download the daily record as a time-series resource. + if (!await TryStepAsync(response, "downloadTimeSeries", async () => + { + var timeSeries = await _timeSeries.CreateFromUsgsAsync(new CreateUsgsTimeSeriesRequest + { + SiteNumber = request.SiteNumber, + SeriesType = request.SeriesType, + Name = request.Name == null ? null : $"{request.Name} - daily series" + }, cancellationToken); + response.TimeSeriesId = timeSeries.Id; + })) return response; + + // Step 2: extract block maxima into an input-data resource. + if (!TryStep(response, "createInputData", () => + { + var inputData = _inputData.CreateBlockMax(new CreateBlockMaxInputDataRequest + { + TimeSeriesId = response.TimeSeriesId!.Value, + TimeBlock = request.TimeBlock, + BlockFunction = request.BlockFunction, + SmoothingFunction = request.SmoothingFunction, + StartMonth = request.StartMonth, + EndMonth = request.EndMonth, + Period = request.Period, + Name = request.Name == null ? null : $"{request.Name} - block maxima" + }); + response.InputDataId = inputData.Id; + })) return response; + + // Step 3: create the analysis. + if (!TryStep(response, "createAnalysis", () => + { + var analysis = _analyses.CreateUnivariate(new CreateUnivariateAnalysisRequest + { + InputDataId = response.InputDataId!.Value, + Distribution = request.Distribution, + ProbabilityOrdinates = request.ProbabilityOrdinates, + BayesianOptions = request.BayesianOptions, + Name = request.Name + }); + response.AnalysisId = analysis.Id; + })) return response; + + // Step 4: run and attach the results. + await TryStepAsync(response, "runAnalysis", async () => + { + response.Results = await _analyses.RunFrequencyAsync(response.AnalysisId!.Value, AnalysisKind.Univariate, cancellationToken); + }); + return response; + } + + /// + public async Task RunUsgsBulletin17CAsync(UsgsBulletin17CWorkflowRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + var response = new UsgsFrequencyWorkflowResponse(); + + // Step 1: download the annual peaks directly into an input-data resource. + if (!await TryStepAsync(response, "createInputData", async () => + { + var inputData = await _inputData.CreateFromUsgsPeaksAsync(new CreateUsgsPeaksInputDataRequest + { + SiteNumber = request.SiteNumber, + SeriesType = request.SeriesType, + Name = request.Name == null ? null : $"{request.Name} - input data" + }, cancellationToken); + response.InputDataId = inputData.Id; + })) return response; + + // Step 2: create the Bulletin 17C analysis. + if (!TryStep(response, "createAnalysis", () => + { + var analysis = _analyses.CreateBulletin17C(new CreateBulletin17CAnalysisRequest + { + InputDataId = response.InputDataId!.Value, + Distribution = request.Distribution, + UncertaintyMethod = request.UncertaintyMethod, + ProbabilityOrdinates = request.ProbabilityOrdinates, + Name = request.Name + }); + response.AnalysisId = analysis.Id; + })) return response; + + // Step 3: run and attach the results. + await TryStepAsync(response, "runAnalysis", async () => + { + response.Results = await _analyses.RunFrequencyAsync(response.AnalysisId!.Value, AnalysisKind.Bulletin17C, cancellationToken); + }); + return response; + } + + /// + public async Task RunUsgsRatingCurveAsync(UsgsRatingCurveWorkflowRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + var response = new UsgsRatingCurveWorkflowResponse(); + + // Step 1: download the discrete stage measurements. + if (!await TryStepAsync(response, "downloadStage", async () => + { + var stage = await _timeSeries.CreateFromUsgsAsync(new CreateUsgsTimeSeriesRequest + { + SiteNumber = request.SiteNumber, + SeriesType = TimeSeriesDownload.TimeSeriesType.MeasuredStage, + Name = request.Name == null ? null : $"{request.Name} - measured stage" + }, cancellationToken); + response.StageTimeSeriesId = stage.Id; + })) return response; + + // Step 2: download the discrete discharge measurements. + if (!await TryStepAsync(response, "downloadDischarge", async () => + { + var discharge = await _timeSeries.CreateFromUsgsAsync(new CreateUsgsTimeSeriesRequest + { + SiteNumber = request.SiteNumber, + SeriesType = TimeSeriesDownload.TimeSeriesType.MeasuredDischarge, + Name = request.Name == null ? null : $"{request.Name} - measured discharge" + }, cancellationToken); + response.DischargeTimeSeriesId = discharge.Id; + })) return response; + + // Step 3: create the rating curve analysis over the aligned pairs. + if (!TryStep(response, "createAnalysis", () => + { + var analysis = _analyses.CreateRatingCurve(new CreateRatingCurveAnalysisRequest + { + StageTimeSeriesId = response.StageTimeSeriesId!.Value, + DischargeTimeSeriesId = response.DischargeTimeSeriesId!.Value, + NumberOfSegments = request.NumberOfSegments, + MinStage = request.MinStage, + MaxStage = request.MaxStage, + StageBins = request.StageBins, + BayesianOptions = request.BayesianOptions, + Name = request.Name + }); + response.AnalysisId = analysis.Id; + })) return response; + + // Step 4: run and attach the results. + await TryStepAsync(response, "runAnalysis", async () => + { + response.Results = await _analyses.RunRatingCurveAsync(response.AnalysisId!.Value, cancellationToken); + }); + return response; + } + + /// + /// Runs one asynchronous workflow step, folding any failure into the response and + /// reporting whether the workflow should continue. Cancellation propagates. + /// + /// The workflow response accumulating ids and failure state. + /// The step name recorded on failure. + /// The step body. + /// True to continue with the next step; false when the step failed. + private static async Task TryStepAsync(ResponseBase response, string stepName, Func step) + { + try + { + await step(); + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + RecordFailure(response, stepName, ex); + return false; + } + } + + /// + /// Runs one synchronous workflow step, folding any failure into the response and + /// reporting whether the workflow should continue. + /// + /// The workflow response accumulating ids and failure state. + /// The step name recorded on failure. + /// The step body. + /// True to continue with the next step; false when the step failed. + private static bool TryStep(ResponseBase response, string stepName, Action step) + { + try + { + step(); + return true; + } + catch (Exception ex) + { + RecordFailure(response, stepName, ex); + return false; + } + } + + /// + /// Records a step failure on the response: success flag, failed step, error message, and + /// validation errors when the failure carries them. + /// + /// The workflow response. + /// The failed step name. + /// The failure. + private static void RecordFailure(ResponseBase response, string stepName, Exception exception) + { + response.Success = false; + response.ErrorMessage = $"Workflow step '{stepName}' failed: {exception.Message}"; + if (exception is RequestValidationException validation) + { + response.ValidationErrors = validation.Errors.ToList(); + } + switch (response) + { + case UsgsFrequencyWorkflowResponse frequency: + frequency.FailedStep = stepName; + break; + case UsgsRatingCurveWorkflowResponse rating: + rating.FailedStep = stepName; + break; + default: + break; + } + } + } +} diff --git a/src/RMC.BestFit.Api/Store/AnalysisKind.cs b/src/RMC.BestFit.Api/Store/AnalysisKind.cs new file mode 100644 index 0000000..3878ddb --- /dev/null +++ b/src/RMC.BestFit.Api/Store/AnalysisKind.cs @@ -0,0 +1,68 @@ +namespace RMC.BestFit.Api.Store +{ + /// + /// Identifies which model-layer analysis type an analysis resource wraps. + /// + public enum AnalysisKind + { + /// + /// Bayesian MCMC univariate frequency analysis (). + /// + Univariate, + + /// + /// Bulletin 17C flood frequency analysis (). + /// + Bulletin17C, + + /// + /// Bayesian stage-discharge rating curve analysis (). + /// + RatingCurve, + + /// + /// Bayesian MCMC mixture-distribution frequency analysis (). + /// + Mixture, + + /// + /// Bayesian MCMC peaks-over-threshold point process analysis (). + /// + PointProcess, + + /// + /// Bayesian MCMC competing risks frequency analysis (). + /// + CompetingRisks, + + /// + /// Composite analysis combining already-fitted component analyses + /// (). + /// + Composite, + + /// + /// Parallel maximum-likelihood fit of many distributions with information-criterion + /// ranking (). + /// + DistributionFitting, + + /// + /// Bayesian MCMC copula analysis over two fitted marginal analyses + /// (). + /// + Bivariate, + + /// + /// Coincident frequency analysis integrating a user-supplied response surface over a + /// fitted bivariate analysis (). + /// + CoincidentFrequency, + + /// + /// Bayesian MCMC time-series analysis (AR, MA, ARIMA, or ARIMAX — discriminated by + /// ). + /// + TimeSeries + } +} diff --git a/src/RMC.BestFit.Api/Store/AnalysisResource.cs b/src/RMC.BestFit.Api/Store/AnalysisResource.cs new file mode 100644 index 0000000..f98b24c --- /dev/null +++ b/src/RMC.BestFit.Api/Store/AnalysisResource.cs @@ -0,0 +1,244 @@ +using RMC.BestFit.Analyses; + +namespace RMC.BestFit.Api.Store +{ + /// + /// A server-side analysis resource wrapping exactly one model-layer analysis together with + /// provenance ids and run state. + /// + /// + /// The wrapped analysis owns clones of its input data, so deleting the upstream time-series or + /// input-data resources never invalidates an existing analysis. Run state fields are the only + /// mutable part of any resource; they are guarded by so concurrent run + /// requests for the same analysis are rejected rather than interleaved. + /// + public class AnalysisResource : ResourceBase + { + /// + /// Which model-layer analysis type this resource wraps. Exactly one typed analysis + /// property matching the kind is non-null. + /// + public required AnalysisKind Kind { get; init; } + + /// + /// The wrapped Bayesian MCMC univariate frequency analysis, when is Univariate. + /// + public UnivariateAnalysis? Univariate { get; init; } + + /// + /// The wrapped Bulletin 17C analysis, when is Bulletin17C. + /// + public Bulletin17CAnalysis? Bulletin17C { get; init; } + + /// + /// The wrapped rating curve analysis, when is RatingCurve. + /// + public RatingCurveAnalysis? RatingCurve { get; init; } + + /// + /// The wrapped mixture-distribution analysis, when is Mixture. + /// + public MixtureAnalysis? Mixture { get; init; } + + /// + /// The wrapped peaks-over-threshold point process analysis, when is PointProcess. + /// + public PointProcessAnalysis? PointProcess { get; init; } + + /// + /// The wrapped competing risks analysis, when is CompetingRisks. + /// + public CompetingRiskAnalysis? CompetingRisks { get; init; } + + /// + /// The wrapped composite analysis, when is Composite. + /// + public CompositeAnalysis? Composite { get; init; } + + /// + /// The wrapped distribution-fitting analysis, when is DistributionFitting. + /// + public FittingAnalysis? DistributionFitting { get; init; } + + /// + /// The wrapped bivariate copula analysis, when is Bivariate. + /// + public BivariateAnalysis? Bivariate { get; init; } + + /// + /// The wrapped coincident frequency analysis, when is CoincidentFrequency. + /// + public CoincidentFrequencyAnalysis? CoincidentFrequency { get; init; } + + /// + /// Which time-series model family a TimeSeries-kind resource wraps; null for other kinds. + /// Exactly one of , , , or + /// matches it. + /// + public TimeSeriesModelType? TimeSeriesModel { get; init; } + + /// + /// The wrapped autoregressive analysis, when is Ar. + /// + public ARAnalysis? Ar { get; init; } + + /// + /// The wrapped moving-average analysis, when is Ma. + /// + public MAAnalysis? Ma { get; init; } + + /// + /// The wrapped ARIMA analysis, when is Arima. + /// + public ARIMAAnalysis? Arima { get; init; } + + /// + /// The wrapped ARIMAX analysis, when is Arimax. + /// + public ARIMAXAnalysis? Arimax { get; init; } + + /// + /// The id of the time-series resource a TimeSeries-kind analysis was created from. + /// Provenance only. + /// + public Guid? TimeSeriesId { get; init; } + + /// + /// The ids of the covariate time-series resources of an ARIMAX analysis, in covariate + /// order. Provenance only — the covariate data is cloned at creation. + /// + public List? CovariateTimeSeriesIds { get; init; } + + /// + /// LIVE references to the component analysis resources this analysis consumes + /// (composite components; a bivariate's two marginals; a coincident frequency analysis's + /// bivariate plus its transitive marginals). Held so the component objects and their run + /// locks stay reachable even if the components are later deleted from the store. + /// Component runs are excluded while this analysis runs (the run path try-acquires every + /// component's ), and re-running a component intentionally changes + /// this analysis's next run — the documented exception to the clone-at-create invariant. + /// + public IReadOnlyList? ComponentResources { get; init; } + + /// + /// The LIVE marginal-X analysis resource of a bivariate analysis, for chain access and + /// results metadata; null for other kinds. + /// + public AnalysisResource? MarginalXResource { get; init; } + + /// + /// The LIVE marginal-Y analysis resource of a bivariate analysis; null for other kinds. + /// + public AnalysisResource? MarginalYResource { get; init; } + + /// + /// The LIVE upstream bivariate analysis resource of a coincident frequency analysis; + /// null for other kinds. + /// + public AnalysisResource? BivariateResource { get; init; } + + /// + /// The id of the marginal-X analysis resource (bivariate analyses). Provenance only. + /// + public Guid? MarginalXAnalysisId { get; init; } + + /// + /// The id of the marginal-Y analysis resource (bivariate analyses). Provenance only. + /// + public Guid? MarginalYAnalysisId { get; init; } + + /// + /// The id of the upstream bivariate analysis resource (coincident frequency analyses). + /// Provenance only. + /// + public Guid? BivariateAnalysisId { get; init; } + + /// + /// The ids of the component analysis resources, aligned with the composite's component + /// order. Provenance only — a referenced resource may since have been deleted. + /// + public List? ComponentAnalysisIds { get; init; } + + /// + /// The id of the input-data resource the analysis was created from (univariate and + /// Bulletin 17C analyses). Provenance only — the referenced resource may since have been deleted. + /// + public Guid? InputDataId { get; init; } + + /// + /// The id of the stage time-series resource the analysis was created from (rating curve + /// analyses). Provenance only. + /// + public Guid? StageTimeSeriesId { get; init; } + + /// + /// The id of the discharge time-series resource the analysis was created from (rating curve + /// analyses). Provenance only. + /// + public Guid? DischargeTimeSeriesId { get; init; } + + /// + /// Non-fatal warnings recorded when the resource was created (e.g., a Bulletin 17C + /// analysis over input data containing uncertain observations, which its Expected Moments + /// Algorithm does not use). Surfaced on the summary and validate responses. + /// + public IReadOnlyList CreationWarnings { get; init; } = Array.Empty(); + + /// + /// The lifecycle state of the most recent (or in-flight) run. + /// + public AnalysisRunState State { get; set; } = AnalysisRunState.Created; + + /// + /// The UTC timestamp at which the most recent run finished, or null when never run. + /// + public DateTime? LastRunUtc { get; set; } + + /// + /// The error message from the most recent failed run, or null. + /// + public string? LastError { get; set; } + + /// + /// The wall-clock duration of the most recent run in milliseconds, or null when never run. + /// + public long? LastRunMs { get; set; } + + /// + /// Single-entry lock ensuring only one run executes per analysis at a time. Run requests + /// that cannot acquire it immediately are rejected with HTTP 409. + /// + public SemaphoreSlim RunLock { get; } = new SemaphoreSlim(1, 1); + + /// + /// The wrapped analysis viewed through the shared surface + /// (RunAsync, CancelAnalysis, IsEstimated), regardless of kind. + /// + /// + /// Thrown when the typed analysis field matching is null, which indicates + /// a construction bug in the service layer. + /// + public AnalysisBase Analysis => Kind switch + { + AnalysisKind.Univariate => Univariate ?? throw new InvalidOperationException("Analysis resource of kind Univariate has no univariate analysis attached."), + AnalysisKind.Bulletin17C => Bulletin17C ?? throw new InvalidOperationException("Analysis resource of kind Bulletin17C has no Bulletin 17C analysis attached."), + AnalysisKind.RatingCurve => RatingCurve ?? throw new InvalidOperationException("Analysis resource of kind RatingCurve has no rating curve analysis attached."), + AnalysisKind.Mixture => Mixture ?? throw new InvalidOperationException("Analysis resource of kind Mixture has no mixture analysis attached."), + AnalysisKind.PointProcess => PointProcess ?? throw new InvalidOperationException("Analysis resource of kind PointProcess has no point process analysis attached."), + AnalysisKind.CompetingRisks => CompetingRisks ?? throw new InvalidOperationException("Analysis resource of kind CompetingRisks has no competing risks analysis attached."), + AnalysisKind.Composite => Composite ?? throw new InvalidOperationException("Analysis resource of kind Composite has no composite analysis attached."), + AnalysisKind.DistributionFitting => DistributionFitting ?? throw new InvalidOperationException("Analysis resource of kind DistributionFitting has no fitting analysis attached."), + AnalysisKind.Bivariate => Bivariate ?? throw new InvalidOperationException("Analysis resource of kind Bivariate has no bivariate analysis attached."), + AnalysisKind.CoincidentFrequency => CoincidentFrequency ?? throw new InvalidOperationException("Analysis resource of kind CoincidentFrequency has no coincident frequency analysis attached."), + AnalysisKind.TimeSeries => TimeSeriesModel switch + { + TimeSeriesModelType.Ar => Ar ?? throw new InvalidOperationException("Analysis resource of kind TimeSeries (ar) has no AR analysis attached."), + TimeSeriesModelType.Ma => Ma ?? throw new InvalidOperationException("Analysis resource of kind TimeSeries (ma) has no MA analysis attached."), + TimeSeriesModelType.Arima => Arima ?? throw new InvalidOperationException("Analysis resource of kind TimeSeries (arima) has no ARIMA analysis attached."), + TimeSeriesModelType.Arimax => Arimax ?? throw new InvalidOperationException("Analysis resource of kind TimeSeries (arimax) has no ARIMAX analysis attached."), + _ => throw new InvalidOperationException("Analysis resource of kind TimeSeries has no model discriminator.") + }, + _ => throw new InvalidOperationException($"Unknown analysis kind '{Kind}'.") + }; + } +} diff --git a/src/RMC.BestFit.Api/Store/AnalysisRunState.cs b/src/RMC.BestFit.Api/Store/AnalysisRunState.cs new file mode 100644 index 0000000..576c864 --- /dev/null +++ b/src/RMC.BestFit.Api/Store/AnalysisRunState.cs @@ -0,0 +1,33 @@ +namespace RMC.BestFit.Api.Store +{ + /// + /// The lifecycle state of an analysis resource's most recent (or in-flight) run. + /// + public enum AnalysisRunState + { + /// + /// The analysis has been created but never run. + /// + Created, + + /// + /// A run is currently executing. + /// + Running, + + /// + /// The most recent run completed successfully and results are available. + /// + Succeeded, + + /// + /// The most recent run failed; see the resource's last error for details. + /// + Failed, + + /// + /// The most recent run was cancelled by the client. The analysis can be rerun. + /// + Cancelled + } +} diff --git a/src/RMC.BestFit.Api/Store/IResourceStore.cs b/src/RMC.BestFit.Api/Store/IResourceStore.cs new file mode 100644 index 0000000..e52ed59 --- /dev/null +++ b/src/RMC.BestFit.Api/Store/IResourceStore.cs @@ -0,0 +1,98 @@ +namespace RMC.BestFit.Api.Store +{ + /// + /// Thread-safe in-memory store for the API's server-side resources. All state created through + /// the REST endpoints or MCP tools lives here, keyed by GUID, for the lifetime of the host process. + /// + public interface IResourceStore + { + /// + /// The total number of resources currently held across all three collections. + /// + int TotalCount { get; } + + /// + /// Adds a time-series resource to the store. + /// + /// The resource to add. + /// The added resource, for fluent use. + /// Thrown when the store is at capacity. + TimeSeriesResource AddTimeSeries(TimeSeriesResource resource); + + /// + /// Returns the time-series resource with the given id, or null when it does not exist. + /// + /// The resource id. + /// The resource, or null. + TimeSeriesResource? GetTimeSeries(Guid id); + + /// + /// Lists all time-series resources ordered by creation time. + /// + /// The resources ordered by . + IReadOnlyList ListTimeSeries(); + + /// + /// Deletes the time-series resource with the given id. + /// + /// The resource id. + /// True when the resource existed and was removed; false when it did not exist. + bool DeleteTimeSeries(Guid id); + + /// + /// Adds an input-data resource to the store. + /// + /// The resource to add. + /// The added resource, for fluent use. + /// Thrown when the store is at capacity. + InputDataResource AddInputData(InputDataResource resource); + + /// + /// Returns the input-data resource with the given id, or null when it does not exist. + /// + /// The resource id. + /// The resource, or null. + InputDataResource? GetInputData(Guid id); + + /// + /// Lists all input-data resources ordered by creation time. + /// + /// The resources ordered by . + IReadOnlyList ListInputData(); + + /// + /// Deletes the input-data resource with the given id. + /// + /// The resource id. + /// True when the resource existed and was removed; false when it did not exist. + bool DeleteInputData(Guid id); + + /// + /// Adds an analysis resource to the store. + /// + /// The resource to add. + /// The added resource, for fluent use. + /// Thrown when the store is at capacity. + AnalysisResource AddAnalysis(AnalysisResource resource); + + /// + /// Returns the analysis resource with the given id, or null when it does not exist. + /// + /// The resource id. + /// The resource, or null. + AnalysisResource? GetAnalysis(Guid id); + + /// + /// Lists all analysis resources ordered by creation time. + /// + /// The resources ordered by . + IReadOnlyList ListAnalyses(); + + /// + /// Deletes the analysis resource with the given id. + /// + /// The resource id. + /// True when the resource existed and was removed; false when it did not exist. + bool DeleteAnalysis(Guid id); + } +} diff --git a/src/RMC.BestFit.Api/Store/InMemoryResourceStore.cs b/src/RMC.BestFit.Api/Store/InMemoryResourceStore.cs new file mode 100644 index 0000000..5b52b8c --- /dev/null +++ b/src/RMC.BestFit.Api/Store/InMemoryResourceStore.cs @@ -0,0 +1,152 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Options; +using RMC.BestFit.Api.Configuration; +using RMC.BestFit.Api.Services.Exceptions; + +namespace RMC.BestFit.Api.Store +{ + /// + /// Default implementation backed by concurrent dictionaries. + /// Registered as a DI singleton so resource ids remain valid across requests and across the + /// REST/MCP boundary for the lifetime of the host process. + /// + /// + /// The store enforces a soft total-capacity cap () and + /// rejects further creations with (HTTP 409). There is + /// deliberately no eviction: silently deleting a resource an agent still holds an id for would + /// be worse than asking the client to delete resources explicitly. + /// + public class InMemoryResourceStore : IResourceStore + { + /// + /// Time-series resources keyed by id. + /// + private readonly ConcurrentDictionary _timeSeries = new(); + + /// + /// Input-data resources keyed by id. + /// + private readonly ConcurrentDictionary _inputData = new(); + + /// + /// Analysis resources keyed by id. + /// + private readonly ConcurrentDictionary _analyses = new(); + + /// + /// The configured API limits (capacity cap). + /// + private readonly ApiOptions _options; + + /// + /// Constructs the store with the configured API limits. + /// + /// The API options carrying . + /// Thrown when is null. + public InMemoryResourceStore(IOptions options) + { + if (options is null) throw new ArgumentNullException(nameof(options)); + _options = options.Value; + } + + /// + public int TotalCount => _timeSeries.Count + _inputData.Count + _analyses.Count; + + /// + public TimeSeriesResource AddTimeSeries(TimeSeriesResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + EnsureCapacity(); + _timeSeries[resource.Id] = resource; + return resource; + } + + /// + public TimeSeriesResource? GetTimeSeries(Guid id) + { + return _timeSeries.TryGetValue(id, out var resource) ? resource : null; + } + + /// + public IReadOnlyList ListTimeSeries() + { + return _timeSeries.Values.OrderBy(r => r.CreatedUtc).ToList(); + } + + /// + public bool DeleteTimeSeries(Guid id) + { + return _timeSeries.TryRemove(id, out _); + } + + /// + public InputDataResource AddInputData(InputDataResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + EnsureCapacity(); + _inputData[resource.Id] = resource; + return resource; + } + + /// + public InputDataResource? GetInputData(Guid id) + { + return _inputData.TryGetValue(id, out var resource) ? resource : null; + } + + /// + public IReadOnlyList ListInputData() + { + return _inputData.Values.OrderBy(r => r.CreatedUtc).ToList(); + } + + /// + public bool DeleteInputData(Guid id) + { + return _inputData.TryRemove(id, out _); + } + + /// + public AnalysisResource AddAnalysis(AnalysisResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + EnsureCapacity(); + _analyses[resource.Id] = resource; + return resource; + } + + /// + public AnalysisResource? GetAnalysis(Guid id) + { + return _analyses.TryGetValue(id, out var resource) ? resource : null; + } + + /// + public IReadOnlyList ListAnalyses() + { + return _analyses.Values.OrderBy(r => r.CreatedUtc).ToList(); + } + + /// + public bool DeleteAnalysis(Guid id) + { + return _analyses.TryRemove(id, out _); + } + + /// + /// Rejects a creation when the store already holds the configured maximum number of + /// resources. The check-then-add is not atomic; under concurrent creations the cap may be + /// exceeded by a handful of entries, which is acceptable for a soft memory-protection limit. + /// + /// Thrown when the store is at capacity. + private void EnsureCapacity() + { + if (TotalCount >= _options.MaxResources) + { + throw new ResourceConflictException( + $"The resource store is at its configured capacity ({_options.MaxResources}). " + + "Delete resources you no longer need (DELETE api/timeseries/{id}, api/inputdata/{id}, api/analyses/.../{id}) and retry."); + } + } + } +} diff --git a/src/RMC.BestFit.Api/Store/InputDataMethod.cs b/src/RMC.BestFit.Api/Store/InputDataMethod.cs new file mode 100644 index 0000000..f7d4de2 --- /dev/null +++ b/src/RMC.BestFit.Api/Store/InputDataMethod.cs @@ -0,0 +1,33 @@ +namespace RMC.BestFit.Api.Store +{ + /// + /// Identifies how an input-data resource's exact series was created. + /// + public enum InputDataMethod + { + /// + /// Observations were supplied directly by the client. + /// + Manual, + + /// + /// Annual (or other time-block) maxima were extracted from a linked time-series resource. + /// + BlockMaxima, + + /// + /// Independent peaks exceeding a threshold were extracted from a linked time-series resource. + /// + PeaksOverThreshold, + + /// + /// Annual peak discharge values were downloaded directly from the USGS peak-flow file. + /// + UsgsPeakDischarge, + + /// + /// Annual peak stage values were downloaded directly from the USGS peak-flow file. + /// + UsgsPeakStage + } +} diff --git a/src/RMC.BestFit.Api/Store/InputDataResource.cs b/src/RMC.BestFit.Api/Store/InputDataResource.cs new file mode 100644 index 0000000..5941215 --- /dev/null +++ b/src/RMC.BestFit.Api/Store/InputDataResource.cs @@ -0,0 +1,79 @@ +using Numerics.Data; +using RMC.BestFit.Models; + +namespace RMC.BestFit.Api.Store +{ + /// + /// A server-side input-data resource wrapping a model-layer + /// together with provenance describing how the exact series was produced (manual entry, + /// block maxima or peaks-over-threshold from a linked time series, or a direct USGS peak download). + /// + public class InputDataResource : ResourceBase + { + /// + /// The model-layer data frame holding the exact / interval / threshold series and the + /// plotting-position and record-length settings. Treated as immutable after creation; + /// analyses clone it when they are created. + /// + public required DataFrame DataFrame { get; init; } + + /// + /// How the exact series was created. + /// + public required InputDataMethod Method { get; init; } + + /// + /// The id of the time-series resource the exact series was derived from, when + /// is block maxima or peaks-over-threshold. The referenced resource + /// may since have been deleted; the id is provenance only. + /// + public Guid? SourceTimeSeriesId { get; init; } + + /// + /// The USGS site number the peak data was downloaded from, when is a + /// direct USGS peak download. + /// + public string? UsgsSiteNumber { get; init; } + + /// + /// The time-block window used for block-maxima extraction (e.g., water year), when applicable. + /// + public TimeBlockWindow? TimeBlock { get; init; } + + /// + /// The block function (maximum, minimum, ...) used for block-maxima extraction, when applicable. + /// + public BlockFunctionType? BlockFunction { get; init; } + + /// + /// The smoothing function applied to the source series before extraction, when applicable. + /// + public SmoothingFunctionType? SmoothingFunction { get; init; } + + /// + /// The starting month of the custom (or water) year window, when applicable. + /// + public int? StartMonth { get; init; } + + /// + /// The ending month of the custom year window, when applicable. + /// + public int? EndMonth { get; init; } + + /// + /// The smoothing period (in time steps) applied to the source series, when applicable. + /// + public int? Period { get; init; } + + /// + /// The threshold value used for peaks-over-threshold extraction, when applicable. + /// + public double? Threshold { get; init; } + + /// + /// The minimum number of time steps required between independent peaks for + /// peaks-over-threshold extraction, when applicable. + /// + public int? MinStepsBetweenPeaks { get; init; } + } +} diff --git a/src/RMC.BestFit.Api/Store/ResourceBase.cs b/src/RMC.BestFit.Api/Store/ResourceBase.cs new file mode 100644 index 0000000..0538796 --- /dev/null +++ b/src/RMC.BestFit.Api/Store/ResourceBase.cs @@ -0,0 +1,33 @@ +namespace RMC.BestFit.Api.Store +{ + /// + /// Base class for all server-side resources held in the in-memory resource store. + /// + /// + /// Resources are immutable after creation (aside from analysis run state): changing a time + /// series or input data set means creating a new resource. This keeps downstream analyses, + /// which clone their inputs at creation, permanently consistent with the data they were fit to. + /// + public abstract class ResourceBase + { + /// + /// The unique identifier clients use to reference this resource in subsequent requests. + /// + public Guid Id { get; } = Guid.NewGuid(); + + /// + /// The client-supplied (or generated) display name of the resource. + /// + public required string Name { get; init; } + + /// + /// An optional client-supplied description of the resource. + /// + public string? Description { get; init; } + + /// + /// The UTC timestamp at which the resource was created. + /// + public DateTime CreatedUtc { get; } = DateTime.UtcNow; + } +} diff --git a/src/RMC.BestFit.Api/Store/TimeSeriesModelType.cs b/src/RMC.BestFit.Api/Store/TimeSeriesModelType.cs new file mode 100644 index 0000000..d4a33c7 --- /dev/null +++ b/src/RMC.BestFit.Api/Store/TimeSeriesModelType.cs @@ -0,0 +1,31 @@ +namespace RMC.BestFit.Api.Store +{ + /// + /// Identifies which time-series model family a TimeSeries-kind analysis resource wraps — + /// the discriminator of the single api/analyses/timeseries route. + /// + public enum TimeSeriesModelType + { + /// + /// Autoregressive model AR(p) (). + /// + Ar, + + /// + /// Moving average model MA(q) (). + /// + Ma, + + /// + /// Autoregressive integrated moving average model ARIMA(p,d,q) + /// (). + /// + Arima, + + /// + /// ARIMA with exogenous covariates, trend, and seasonality ARIMAX(p,d,q,b) + /// (). + /// + Arimax + } +} diff --git a/src/RMC.BestFit.Api/Store/TimeSeriesResource.cs b/src/RMC.BestFit.Api/Store/TimeSeriesResource.cs new file mode 100644 index 0000000..ea4a0ee --- /dev/null +++ b/src/RMC.BestFit.Api/Store/TimeSeriesResource.cs @@ -0,0 +1,74 @@ +using Numerics.Data; + +namespace RMC.BestFit.Api.Store +{ + /// + /// A server-side time-series resource wrapping a , + /// created either from a USGS download or from client-supplied points. + /// + /// + /// Summary statistics (point count, missing count, date range) are computed once at creation + /// because the underlying series is treated as immutable for the lifetime of the resource. + /// + public class TimeSeriesResource : ResourceBase + { + /// + /// Constructs the resource and computes the point-count / missing-count / date-range summary + /// from the supplied series. + /// + /// The underlying time series. Treated as immutable after construction. + /// Thrown when is null. + public TimeSeriesResource(TimeSeries timeSeries) + { + TimeSeries = timeSeries ?? throw new ArgumentNullException(nameof(timeSeries)); + PointCount = timeSeries.Count; + MissingCount = timeSeries.Count(o => double.IsNaN(o.Value)); + if (timeSeries.Count > 0) + { + StartDate = timeSeries[0].Index; + EndDate = timeSeries[timeSeries.Count - 1].Index; + } + } + + /// + /// The underlying time series. Treated as immutable; downstream consumers clone it before mutating. + /// + public TimeSeries TimeSeries { get; } + + /// + /// How the resource was created (USGS download or manual entry). + /// + public required TimeSeriesSource Source { get; init; } + + /// + /// The USGS site number the series was downloaded from, when is USGS. + /// + public string? UsgsSiteNumber { get; init; } + + /// + /// The USGS series type that was downloaded (daily/peak/measured discharge or stage), + /// when is USGS. + /// + public TimeSeriesDownload.TimeSeriesType? UsgsSeriesType { get; init; } + + /// + /// The number of ordinates in the series, computed at creation. + /// + public int PointCount { get; } + + /// + /// The number of ordinates whose value is NaN (missing observations), computed at creation. + /// + public int MissingCount { get; } + + /// + /// The date of the first ordinate, or null when the series is empty. + /// + public DateTime? StartDate { get; } + + /// + /// The date of the last ordinate, or null when the series is empty. + /// + public DateTime? EndDate { get; } + } +} diff --git a/src/RMC.BestFit.Api/Store/TimeSeriesSource.cs b/src/RMC.BestFit.Api/Store/TimeSeriesSource.cs new file mode 100644 index 0000000..d5ee876 --- /dev/null +++ b/src/RMC.BestFit.Api/Store/TimeSeriesSource.cs @@ -0,0 +1,18 @@ +namespace RMC.BestFit.Api.Store +{ + /// + /// Identifies how a time-series resource was created. + /// + public enum TimeSeriesSource + { + /// + /// The time series was supplied point-by-point by the client. + /// + Manual, + + /// + /// The time series was downloaded from the USGS water services. + /// + Usgs + } +} diff --git a/src/RMC.BestFit.Api/appsettings.Development.json b/src/RMC.BestFit.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/RMC.BestFit.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/RMC.BestFit.Api/appsettings.json b/src/RMC.BestFit.Api/appsettings.json new file mode 100644 index 0000000..4176079 --- /dev/null +++ b/src/RMC.BestFit.Api/appsettings.json @@ -0,0 +1,18 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "EnableSwagger": false, + "Api": { + "MaxResources": 500, + "MaxConcurrentRuns": 2, + "MaxIterations": 500000 + }, + "Cors": { + "AllowedOrigins": [ "https://localhost" ] + } +} diff --git a/src/RMC.BestFit.App.Tests/GUI/BivariateAnalysisControlCopulaTrackerTests.cs b/src/RMC.BestFit.App.Tests/GUI/BivariateAnalysisControlCopulaTrackerTests.cs new file mode 100644 index 0000000..d0fe4c3 --- /dev/null +++ b/src/RMC.BestFit.App.Tests/GUI/BivariateAnalysisControlCopulaTrackerTests.cs @@ -0,0 +1,138 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using RMC.BestFit.Models; +using RMC_BestFit; +using System; +using System.Collections.Generic; + +namespace RMC.BestFit.App.Tests.GUI +{ + /// + /// Tests the bivariate copula plot helpers that feed observed-point tracker text. + /// + /// + /// The helpers are static so the tests can verify tracker data without constructing + /// the full WPF control and application resource graph. + /// + [TestClass] + public class BivariateAnalysisControlCopulaTrackerTests + { + /// + /// Verifies raw-value observed copula points include only sorted shared indexes. + /// + [TestMethod] + public void CreateObservedCopulaPlotPoints_RawMode_UsesSharedIndexesAndRawValues() + { + var xData = new List + { + new ExactData(3, 30.0), + new ExactData(1, 10.0), + new ExactData(2, 20.0) + }; + var yData = new List + { + new ExactData(2, 200.0), + new ExactData(4, 400.0), + new ExactData(1, 100.0) + }; + + var points = BivariateAnalysisControl.CreateObservedCopulaPlotPoints(xData, yData, useProbabilityAxis: false); + + Assert.AreEqual(2, points.Count); + Assert.AreEqual(1, points[0].Index); + Assert.AreEqual(10.0, points[0].X, 1e-12); + Assert.AreEqual(100.0, points[0].Y, 1e-12); + Assert.AreEqual(2, points[1].Index); + Assert.AreEqual(20.0, points[1].X, 1e-12); + Assert.AreEqual(200.0, points[1].Y, 1e-12); + } + + /// + /// Verifies probability-axis observed copula points use plotting-position complements. + /// + [TestMethod] + public void CreateObservedCopulaPlotPoints_ProbabilityMode_UsesPlottingPositionComplements() + { + var xData = new List + { + new ExactData(10, 100.0, plottingPosition: 0.25), + new ExactData(20, 200.0, plottingPosition: 0.40) + }; + var yData = new List + { + new ExactData(10, 1000.0, plottingPosition: 0.10), + new ExactData(20, 2000.0, plottingPosition: 0.70) + }; + + var points = BivariateAnalysisControl.CreateObservedCopulaPlotPoints(xData, yData, useProbabilityAxis: true); + + Assert.AreEqual(2, points.Count); + Assert.AreEqual(10, points[0].Index); + Assert.AreEqual(0.75, points[0].X, 1e-12); + Assert.AreEqual(0.90, points[0].Y, 1e-12); + Assert.AreEqual(20, points[1].Index); + Assert.AreEqual(0.60, points[1].X, 1e-12); + Assert.AreEqual(0.30, points[1].Y, 1e-12); + } + + /// + /// Verifies low outliers are excluded before observed copula points are paired. + /// + [TestMethod] + public void CreateObservedCopulaPlotPoints_LowOutliers_AreExcludedBeforePairing() + { + var xData = new List + { + new ExactData(1, 10.0, isLowOutlier: true), + new ExactData(2, 20.0), + new ExactData(3, 30.0) + }; + var yData = new List + { + new ExactData(1, 100.0), + new ExactData(2, 200.0, isLowOutlier: true), + new ExactData(3, 300.0) + }; + + var points = BivariateAnalysisControl.CreateObservedCopulaPlotPoints(xData, yData, useProbabilityAxis: false); + + Assert.AreEqual(1, points.Count); + Assert.AreEqual(3, points[0].Index); + Assert.AreEqual(30.0, points[0].X, 1e-12); + Assert.AreEqual(300.0, points[0].Y, 1e-12); + } + + /// + /// Verifies observed exact-data trackers expose the OxyPlot item-property index token. + /// + [TestMethod] + public void CreateObservedCopulaTrackerFormat_IncludesIndexPlaceholder() + { + string rawTracker = BivariateAnalysisControl.CreateObservedCopulaTrackerFormat(useProbabilityAxis: false); + string probabilityTracker = BivariateAnalysisControl.CreateObservedCopulaTrackerFormat(useProbabilityAxis: true); + + StringAssert.Contains(rawTracker, "Index: {Index}"); + StringAssert.Contains(probabilityTracker, "Index: {Index}"); + StringAssert.Contains(rawTracker, "{1}: {2:"); + StringAssert.Contains(probabilityTracker, "{1}: {2:0.000000}"); + } + + /// + /// Verifies simulated scatter and contour tracker formats do not include input indexes. + /// + [TestMethod] + public void CopulaModelTrackerFormats_DoNotIncludeIndexPlaceholder() + { + string simulatedRaw = BivariateAnalysisControl.CreateCopulaScatterTrackerFormat(useProbabilityAxis: false); + string simulatedProbability = BivariateAnalysisControl.CreateCopulaScatterTrackerFormat(useProbabilityAxis: true); + string contourRaw = BivariateAnalysisControl.CreateContourTrackerFormat(useProbabilityAxis: false); + string contourProbability = BivariateAnalysisControl.CreateContourTrackerFormat(useProbabilityAxis: true); + + Assert.IsFalse(simulatedRaw.Contains("{Index}", StringComparison.Ordinal)); + Assert.IsFalse(simulatedProbability.Contains("{Index}", StringComparison.Ordinal)); + Assert.IsFalse(contourRaw.Contains("{Index}", StringComparison.Ordinal)); + Assert.IsFalse(contourProbability.Contains("{Index}", StringComparison.Ordinal)); + StringAssert.Contains(contourRaw, "{5}: {6:0.000000}"); + StringAssert.Contains(contourProbability, "{5}: {6:0.000000}"); + } + } +} diff --git a/src/RMC.BestFit.App.Tests/GUI/DataGridSelectableStyleAuditTests.cs b/src/RMC.BestFit.App.Tests/GUI/DataGridSelectableStyleAuditTests.cs new file mode 100644 index 0000000..5c14357 --- /dev/null +++ b/src/RMC.BestFit.App.Tests/GUI/DataGridSelectableStyleAuditTests.cs @@ -0,0 +1,296 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Xml.Linq; + +namespace RMC.BestFit.App.Tests.GUI +{ + /// + /// Source-level regression tests for App data grid cell style inheritance. + /// + [TestClass] + public class DataGridSelectableStyleAuditTests + { + private static readonly XNamespace XamlNamespace = "http://schemas.microsoft.com/winfx/2006/xaml"; + + private static readonly HashSet SelectableCellStyleBases = new HashSet(StringComparer.Ordinal) + { + "{StaticResource Left_CellStyle}", + "{StaticResource Center_CellStyle}", + "{StaticResource Right_CellStyle}", + "{StaticResource Center_ReadOnly_CellStyle}", + "{StaticResource Right_ReadOnly_CellStyle}", + "{StaticResource CopyPasteDataGridCellStyle}", + "{StaticResource ValidationDataGridCellStyle}" + }; + + /// + /// Verifies every App-owned DataGridCell style inherits a selectable framework cell style. + /// + [TestMethod] + public void AppDataGridCellStyles_InheritSelectableCellStyle() + { + var failures = new List(); + + foreach (string xamlFile in EnumerateAppXamlFiles()) + { + XDocument document = XDocument.Load(xamlFile); + foreach (XElement style in document.Descendants().Where(IsDataGridCellStyle)) + { + string basedOn = (string)style.Attribute("BasedOn") ?? string.Empty; + if (!SelectableCellStyleBases.Contains(basedOn)) + { + failures.Add(RelativePath(xamlFile) + ": DataGridCell style uses BasedOn='" + basedOn + "'."); + } + } + } + + Assert.AreEqual(0, failures.Count, string.Join(Environment.NewLine, failures)); + } + + /// + /// Verifies App XAML does not use the WPF implicit DataGridCell style as a base. + /// + [TestMethod] + public void AppXaml_DoesNotUseImplicitDataGridCellBaseStyle() + { + var failures = EnumerateAppXamlFiles() + .Where(file => File.ReadAllText(file).Contains("BasedOn=\"{StaticResource {x:Type DataGridCell}}\"", StringComparison.Ordinal)) + .Select(RelativePath) + .ToList(); + + Assert.AreEqual(0, failures.Count, string.Join(Environment.NewLine, failures)); + } + + /// + /// Verifies local CopyPasteDataGrid row styles preserve themed row selection behavior. + /// + [TestMethod] + public void CopyPasteDataGridRowStyles_InheritThemedRowStyle() + { + var failures = new List(); + + foreach (string xamlFile in EnumerateAppXamlFiles()) + { + XDocument document = XDocument.Load(xamlFile); + foreach (XElement rowStyle in document.Descendants().Where(element => element.Name.LocalName == "CopyPasteDataGrid.RowStyle")) + { + XElement style = rowStyle.Elements().FirstOrDefault(element => element.Name.LocalName == "Style"); + string basedOn = (string)style?.Attribute("BasedOn") ?? string.Empty; + if (!string.Equals(basedOn, "{StaticResource CopyPasteDataGridRowStyle}", StringComparison.Ordinal)) + { + failures.Add(RelativePath(xamlFile) + ": CopyPasteDataGrid.RowStyle uses BasedOn='" + basedOn + "'."); + } + } + } + + Assert.AreEqual(0, failures.Count, string.Join(Environment.NewLine, failures)); + } + + /// + /// Verifies local KDE summary grid cell styles inherit the centered selectable cell style. + /// + [TestMethod] + public void KdeCellStyles_InheritCenterCellStyle() + { + var failures = new List(); + int styleCount = 0; + + foreach (string xamlFile in EnumerateAppXamlFiles()) + { + XDocument document = XDocument.Load(xamlFile); + foreach (XElement style in document.Descendants().Where(IsKdeCellStyle)) + { + styleCount++; + string basedOn = (string)style.Attribute("BasedOn") ?? string.Empty; + if (!string.Equals(basedOn, "{StaticResource Center_CellStyle}", StringComparison.Ordinal)) + { + failures.Add(RelativePath(xamlFile) + ": KDECellStyle uses BasedOn='" + basedOn + "'."); + } + } + } + + Assert.AreEqual(3, styleCount); + Assert.AreEqual(0, failures.Count, string.Join(Environment.NewLine, failures)); + } + + /// + /// Verifies FittingAnalysis tooltip-heavy summary columns inherit the right-aligned selectable cell style. + /// + [TestMethod] + public void FittingAnalysisTooltipCellStyles_InheritSelectableCellStyles() + { + string xaml = File.ReadAllText(Path.Combine(FindRepoRoot(), "src", "RMC.BestFit.App", "GUI", "FittingAnalysis", "FittingAnalysisControl.xaml")); + string summaryGrid = ExtractXamlElement(xaml, ""); + string aicBicGrid = ExtractXamlElement(xaml, ""); + + Assert.AreEqual(15, CountOccurrences(summaryGrid, "BasedOn=\"{StaticResource Right_CellStyle}\"")); + Assert.AreEqual(1, CountOccurrences(aicBicGrid, "BasedOn=\"{StaticResource Left_CellStyle}\"")); + StringAssert.Contains(aicBicGrid, "BasedOn=\"{StaticResource CopyPasteDataGridRowStyle}\""); + } + + /// + /// Verifies the composite weight validation style keeps the themed right-aligned base style. + /// + [TestMethod] + public void CompositeWeightColumnStyle_InheritsRightCellStyle() + { + string source = File.ReadAllText(Path.Combine(FindRepoRoot(), "src", "RMC.BestFit.App", "GUI", "UnivariateAnalysis", "Composite", "CompositeAnalysisPropertiesControl.xaml.cs")); + + StringAssert.Contains(source, "new Style(typeof(DataGridCell), (Style)FindResource(\"Right_CellStyle\"))"); + Assert.IsFalse(source.Contains("var weightStyle = new Style();", StringComparison.Ordinal)); + Assert.IsFalse(source.Contains("WeightColumn.CellStyle.Setters", StringComparison.Ordinal)); + } + + /// + /// Verifies code-behind does not construct unbased DataGridCell styles. + /// + [TestMethod] + public void AppCodeBehind_DoesNotCreateUnbasedDataGridCellStyles() + { + var failures = new List(); + + foreach (string sourceFile in EnumerateAppSourceFiles()) + { + string source = File.ReadAllText(sourceFile); + if (source.Contains("new Style(typeof(DataGridCell))", StringComparison.Ordinal)) + { + failures.Add(RelativePath(sourceFile) + ": creates an unbased DataGridCell style."); + } + + if (source.Contains("new Style();", StringComparison.Ordinal) + && source.Contains("CellStyle", StringComparison.Ordinal)) + { + failures.Add(RelativePath(sourceFile) + ": creates a parameterless style in a file that assigns cell styles."); + } + } + + Assert.AreEqual(0, failures.Count, string.Join(Environment.NewLine, failures)); + } + + /// + /// Determines whether an element is a DataGridCell style declaration. + /// + /// The element to inspect. + /// true when the element is a DataGridCell style. + private static bool IsDataGridCellStyle(XElement element) + { + if (element.Name.LocalName != "Style") return false; + + string targetType = (string)element.Attribute("TargetType") ?? string.Empty; + return string.Equals(targetType, "DataGridCell", StringComparison.Ordinal) + || string.Equals(targetType, "{x:Type DataGridCell}", StringComparison.Ordinal); + } + + /// + /// Determines whether an element is the KDE cell style declaration. + /// + /// The element to inspect. + /// true when the element is the KDE cell style. + private static bool IsKdeCellStyle(XElement element) + { + return element.Name.LocalName == "Style" + && string.Equals((string)element.Attribute(XamlNamespace + "Key"), "KDECellStyle", StringComparison.Ordinal); + } + + /// + /// Finds App XAML files in the source tree. + /// + /// The App XAML file paths. + private static IEnumerable EnumerateAppXamlFiles() + { + string appRoot = Path.Combine(FindRepoRoot(), "src", "RMC.BestFit.App"); + return Directory.EnumerateFiles(appRoot, "*.xaml", SearchOption.AllDirectories) + .Where(path => !path.Contains(Path.DirectorySeparatorChar + "bin" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + .Where(path => !path.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Finds App C# source files in the source tree. + /// + /// The App source file paths. + private static IEnumerable EnumerateAppSourceFiles() + { + string appRoot = Path.Combine(FindRepoRoot(), "src", "RMC.BestFit.App"); + return Directory.EnumerateFiles(appRoot, "*.cs", SearchOption.AllDirectories) + .Where(path => !path.Contains(Path.DirectorySeparatorChar + "bin" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + .Where(path => !path.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Counts non-overlapping occurrences of a pattern. + /// + /// The source text to inspect. + /// The pattern to count. + /// The number of occurrences. + private static int CountOccurrences(string source, string pattern) + { + return source + .Split(new[] { pattern }, StringSplitOptions.None) + .Length - 1; + } + + /// + /// Extracts a XAML element from the source text. + /// + /// The XAML source text. + /// The opening token for the element. + /// The closing token for the element. + /// The element source including its opening and closing tags. + private static string ExtractXamlElement(string source, string startToken, string endToken) + { + int start = source.IndexOf(startToken, StringComparison.Ordinal); + Assert.IsTrue(start >= 0, startToken + " should exist."); + + int end = source.IndexOf(endToken, start, StringComparison.Ordinal); + Assert.IsTrue(end > start, endToken + " should exist."); + + return source.Substring(start, end - start + endToken.Length); + } + + /// + /// Converts an absolute path to a repository-relative path. + /// + /// The absolute path. + /// The repository-relative path. + private static string RelativePath(string path) + { + return Path.GetRelativePath(FindRepoRoot(), path); + } + + /// + /// Finds the repository root from the test output directory. + /// + /// The compiler-provided source path for this test file. + /// The absolute repository root. + private static string FindRepoRoot([CallerFilePath] string sourceFilePath = "") + { + string[] searchRoots = new[] + { + AppContext.BaseDirectory, + Directory.GetCurrentDirectory(), + Path.GetDirectoryName(sourceFilePath) + }; + + foreach (string searchRoot in searchRoots.Where(root => !string.IsNullOrEmpty(root))) + { + DirectoryInfo directory = new DirectoryInfo(searchRoot); + while (directory != null) + { + string candidate = Path.Combine(directory.FullName, "src", "RMC.BestFit.App"); + if (Directory.Exists(candidate)) + { + return directory.FullName; + } + + directory = directory.Parent; + } + } + + throw new DirectoryNotFoundException("Could not locate repository root from the test output directory."); + } + } +} diff --git a/src/RMC.BestFit.App.Tests/GUI/InputDataControlPlottingPositionRefreshTests.cs b/src/RMC.BestFit.App.Tests/GUI/InputDataControlPlottingPositionRefreshTests.cs new file mode 100644 index 0000000..885739e --- /dev/null +++ b/src/RMC.BestFit.App.Tests/GUI/InputDataControlPlottingPositionRefreshTests.cs @@ -0,0 +1,136 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Runtime.CompilerServices; + +namespace RMC.BestFit.App.Tests.GUI +{ + /// + /// Source-level regression tests for plotting-position refresh wiring in InputDataControl. + /// + /// + /// Constructing the full WPF control requires the application resource graph, so these tests + /// pin the control-code wiring that refreshes computed plotting-position grid columns. + /// + [TestClass] + public class InputDataControlPlottingPositionRefreshTests + { + /// + /// Verifies the element-level plotting-position event refreshes row wrappers. + /// + [TestMethod] + public void ElementPropertyChanged_RefreshesPlottingPositionColumns() + { + string source = ReadInputDataControlSource(); + string method = ExtractMethodSource(source, "ElementPropertyChanged"); + + StringAssert.Contains(method, "nameof(Data.PlottingPosition)"); + StringAssert.Contains(method, "RefreshPlottingPositionColumns();"); + } + + /// + /// Verifies plotting-position refresh stays limited to row-wrapper notifications. + /// + [TestMethod] + public void RefreshPlottingPositionColumns_NotifiesRowsWithoutRebindingOrUpdatingPlots() + { + string source = ReadInputDataControlSource(); + string method = ExtractMethodSource(source, "RefreshPlottingPositionColumns"); + + StringAssert.Contains(method, "ExactDataOrdinates.OfType()"); + StringAssert.Contains(method, "UncertainDataOrdinates.OfType()"); + StringAssert.Contains(method, "IntervalDataOrdinates.OfType()"); + StringAssert.Contains(method, "rowItem.RefreshPlottingPosition();"); + Assert.IsFalse(method.Contains("BindExactDataGrid", StringComparison.Ordinal)); + Assert.IsFalse(method.Contains("BindUncertainDataGrid", StringComparison.Ordinal)); + Assert.IsFalse(method.Contains("BindIntervalDataGrid", StringComparison.Ordinal)); + Assert.IsFalse(method.Contains("UpdateControl", StringComparison.Ordinal)); + Assert.IsFalse(method.Contains("ValidateTable", StringComparison.Ordinal)); + } + + /// + /// Reads the InputDataControl source file. + /// + /// The compiler-provided source path for this test file. + /// The source text for InputDataControl.xaml.cs. + private static string ReadInputDataControlSource([CallerFilePath] string sourceFilePath = "") + { + return File.ReadAllText(Path.Combine( + FindRepoRoot(sourceFilePath), + "src", + "RMC.BestFit.App", + "GUI", + "InputData", + "InputDataControl.xaml.cs")); + } + + /// + /// Extracts a private method body from a C# source file using brace counting. + /// + /// The C# source text. + /// The method name to extract. + /// The method source, including its signature and braces. + private static string ExtractMethodSource(string source, string methodName) + { + int start = source.IndexOf("private void " + methodName, StringComparison.Ordinal); + Assert.IsTrue(start >= 0, methodName + " should exist."); + + int openingBrace = source.IndexOf('{', start); + Assert.IsTrue(openingBrace > start, methodName + " should have an opening brace."); + + int depth = 0; + for (int i = openingBrace; i < source.Length; i++) + { + if (source[i] == '{') + { + depth++; + } + else if (source[i] == '}') + { + depth--; + if (depth == 0) + { + return source.Substring(start, i - start + 1); + } + } + } + + Assert.Fail(methodName + " should have a closing brace."); + return string.Empty; + } + + /// + /// Finds the repository root from the test output directory. + /// + /// The compiler-provided source path for this test file. + /// The absolute repository root. + /// Thrown when the source tree cannot be found. + private static string FindRepoRoot(string sourceFilePath) + { + string[] searchRoots = new[] + { + AppContext.BaseDirectory, + Directory.GetCurrentDirectory(), + Path.GetDirectoryName(sourceFilePath) + }; + + foreach (string searchRoot in searchRoots) + { + if (string.IsNullOrEmpty(searchRoot)) continue; + DirectoryInfo directory = new DirectoryInfo(searchRoot); + while (directory != null) + { + string candidate = Path.Combine(directory.FullName, "src", "RMC.BestFit.App"); + if (Directory.Exists(candidate)) + { + return directory.FullName; + } + + directory = directory.Parent; + } + } + + throw new DirectoryNotFoundException("Could not locate repository root from the test output directory."); + } + } +} diff --git a/src/RMC.BestFit.App.Tests/GUI/InputDataControlPotDiagnosticTests.cs b/src/RMC.BestFit.App.Tests/GUI/InputDataControlPotDiagnosticTests.cs new file mode 100644 index 0000000..90d39af --- /dev/null +++ b/src/RMC.BestFit.App.Tests/GUI/InputDataControlPotDiagnosticTests.cs @@ -0,0 +1,146 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Runtime.CompilerServices; + +namespace RMC.BestFit.App.Tests.GUI +{ + /// + /// Source-level regression tests for POT diagnostic lazy-load wiring in InputDataControl. + /// + /// + /// Constructing the full WPF control requires the application resource graph, so these tests + /// pin the control-code wiring that prevents blank lazy-loaded POT diagnostic plots. + /// + [TestClass] + public class InputDataControlPotDiagnosticTests + { + /// + /// Verifies radio-button changes compute POT diagnostics only when the diagnostics are dirty. + /// + [TestMethod] + public void ThresholdDiagnosticRadioButtonChecked_ComputesDirtyDiagnosticsWithWaitCursor() + { + string source = ReadInputDataControlSource(); + string method = ExtractMethodSource(source, "ThresholdDiagnosticRadioButton_Checked"); + + StringAssert.Contains(method, "_thresholdDiagnosticsDirty"); + StringAssert.Contains(method, "UpdateLazyContentWithWaitCursor(UpdateThresholdDiagnosticsPlots"); + StringAssert.Contains(method, "ShowSelectedDiagnosticPlot();"); + } + + /// + /// Verifies the selected diagnostic plot is invalidated after its host becomes visible. + /// + [TestMethod] + public void ShowSelectedDiagnosticPlot_InvalidatesSelectedPlotAfterVisibilitySwitch() + { + string source = ReadInputDataControlSource(); + string method = ExtractMethodSource(source, "ShowSelectedDiagnosticPlot"); + string invalidationMethod = ExtractMethodSource(source, "InvalidateSelectedDiagnosticPlot"); + + StringAssert.Contains(method, "ThresholdDiagnosticsToolbar.Plot"); + StringAssert.Contains(method, "InvalidateSelectedDiagnosticPlot();"); + StringAssert.Contains(invalidationMethod, "Dispatcher.BeginInvoke(DispatcherPriority.Loaded"); + StringAssert.Contains(invalidationMethod, "selectedPlot.InvalidatePlot(true)"); + } + + /// + /// Verifies wait-cursor ownership lives in the shared helper instead of the POT compute method. + /// + [TestMethod] + public void UpdateThresholdDiagnosticsPlots_DoesNotResetWaitCursorInternally() + { + string source = ReadInputDataControlSource(); + string method = ExtractMethodSource(source, "UpdateThresholdDiagnosticsPlots"); + + Assert.IsFalse(method.Contains("Mouse.OverrideCursor", StringComparison.Ordinal), + "POT diagnostics should not clear the shared wait cursor before the dispatcher reset can paint."); + } + + /// + /// Reads the InputDataControl source file. + /// + /// The compiler-provided source path for this test file. + /// The source text for InputDataControl.xaml.cs. + private static string ReadInputDataControlSource([CallerFilePath] string sourceFilePath = "") + { + return File.ReadAllText(Path.Combine( + FindRepoRoot(sourceFilePath), + "src", + "RMC.BestFit.App", + "GUI", + "InputData", + "InputDataControl.xaml.cs")); + } + + /// + /// Extracts a private method body from a C# source file using brace counting. + /// + /// The C# source text. + /// The method name to extract. + /// The method source, including its signature and braces. + private static string ExtractMethodSource(string source, string methodName) + { + int start = source.IndexOf("private void " + methodName, StringComparison.Ordinal); + Assert.IsTrue(start >= 0, methodName + " should exist."); + + int openingBrace = source.IndexOf('{', start); + Assert.IsTrue(openingBrace > start, methodName + " should have an opening brace."); + + int depth = 0; + for (int i = openingBrace; i < source.Length; i++) + { + if (source[i] == '{') + { + depth++; + } + else if (source[i] == '}') + { + depth--; + if (depth == 0) + { + return source.Substring(start, i - start + 1); + } + } + } + + Assert.Fail(methodName + " should have a closing brace."); + return string.Empty; + } + + /// + /// Finds the repository root from the test output directory. + /// + /// The compiler-provided source path for this test file. + /// The absolute repository root. + /// Thrown when the source tree cannot be found. + private static string FindRepoRoot(string sourceFilePath) + { + string[] searchRoots = new[] + { + AppContext.BaseDirectory, + Directory.GetCurrentDirectory(), + Path.GetDirectoryName(sourceFilePath) + }; + + foreach (string searchRoot in searchRoots) + { + if (string.IsNullOrEmpty(searchRoot)) continue; + DirectoryInfo directory = new DirectoryInfo(searchRoot); + while (directory != null) + { + string candidate = Path.Combine(directory.FullName, "src", "RMC.BestFit.App"); + if (Directory.Exists(candidate)) + { + return directory.FullName; + } + + directory = directory.Parent; + } + } + + throw new DirectoryNotFoundException("Could not locate repository root from the test output directory."); + } + } +} diff --git a/src/RMC.BestFit.App.Tests/GUI/InputDataControlReadOnlySelectionTests.cs b/src/RMC.BestFit.App.Tests/GUI/InputDataControlReadOnlySelectionTests.cs new file mode 100644 index 0000000..037ed4e --- /dev/null +++ b/src/RMC.BestFit.App.Tests/GUI/InputDataControlReadOnlySelectionTests.cs @@ -0,0 +1,209 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; + +namespace RMC.BestFit.App.Tests.GUI +{ + /// + /// Source-level regression tests for read-only input data grid selection behavior. + /// + /// + /// These tests pin the lightweight wiring that lets read-only exact data remain copyable + /// and keeps selected cells selected when focus leaves a grid. + /// + [TestClass] + public class InputDataControlReadOnlySelectionTests + { + /// + /// Verifies non-manual exact data stays read-only and cannot add/delete rows. + /// + [TestMethod] + public void BindExactDataGrid_NonManualMode_RemainsReadOnlyWithoutRowEdits() + { + string source = ReadInputDataControlSource(); + string method = ExtractMethodSource(source, "BindExactDataGrid"); + + StringAssert.Contains(method, "Element.ExactDataMethod == InputData.ExactDataEntryType.Manual"); + StringAssert.Contains(method, "ExactDataGrid.IsReadOnly = true;"); + StringAssert.Contains(method, "ExactDataGrid.CanUserAddInsertDeleteRows = false;"); + Assert.IsFalse(method.Contains("ExactDataGrid.IsEnabled = false", StringComparison.Ordinal)); + } + + /// + /// Verifies input data grids do not clear selection after focus leaves the grid. + /// + [TestMethod] + public void InputDataGrids_DoNotWireLostKeyboardFocusSelectionClear() + { + string xaml = ReadInputDataControlXaml(); + string source = ReadInputDataControlSource(); + + Assert.IsFalse(xaml.Contains("LostKeyboardFocus=\"InputDataGrid_LostKeyboardFocus\"", StringComparison.Ordinal)); + Assert.IsFalse(source.Contains("InputDataGrid_LostKeyboardFocus", StringComparison.Ordinal)); + StringAssert.Contains(xaml, "Name=\"ExactDataGrid\""); + StringAssert.Contains(xaml, "Name=\"UncertainDataGrid\""); + StringAssert.Contains(xaml, "Name=\"IntervalDataGrid\""); + StringAssert.Contains(xaml, "Name=\"ThresholdDataGrid\""); + } + + /// + /// Verifies the summary statistics grid keeps manual selectable cell styles. + /// + [TestMethod] + public void SummaryStatisticsDataGrid_UsesReadOnlyCopyPasteGridWithManualAlignmentCellStyles() + { + string xaml = ReadInputDataControlXaml(); + string grid = ExtractXamlElement(xaml, ""); + + StringAssert.Contains(grid, "Style=\"{DynamicResource CopyPasteDataGridStyle}\""); + StringAssert.Contains(grid, "SelectionMode=\"Extended\""); + StringAssert.Contains(grid, "SelectionUnit=\"Cell\""); + StringAssert.Contains(grid, "IsReadOnly=\"True\""); + StringAssert.Contains(grid, "CanUserDeleteRows=\"False\""); + StringAssert.Contains(grid, "CanUserAddRows=\"False\""); + StringAssert.Contains(grid, "CellStyle=\"{DynamicResource Left_CellStyle}\""); + Assert.AreEqual(2, CountOccurrences(grid, "CellStyle=\"{DynamicResource Right_CellStyle}\"")); + Assert.IsFalse(grid.Contains("LostKeyboardFocus", StringComparison.Ordinal)); + } + + /// + /// Verifies the hypothesis tests grid does not bypass selectable cell styles. + /// + [TestMethod] + public void HypothesisDataGrid_UsesSelectableCellStylesForAllColumns() + { + string xaml = ReadInputDataControlXaml(); + string grid = ExtractXamlElement(xaml, ""); + + StringAssert.Contains(grid, "Style=\"{DynamicResource CopyPasteDataGridStyle}\""); + StringAssert.Contains(grid, "SelectionMode=\"Extended\""); + StringAssert.Contains(grid, "SelectionUnit=\"Cell\""); + StringAssert.Contains(grid, "IsReadOnly=\"True\""); + Assert.AreEqual(2, CountOccurrences(grid, "BasedOn=\"{StaticResource Left_CellStyle}\"")); + Assert.AreEqual(2, CountOccurrences(grid, "CellStyle=\"{DynamicResource Center_CellStyle}\"")); + Assert.IsFalse(grid.Contains(" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/BivariateAnalysis/Bivariate/BivariateAnalysisPropertiesControl.xaml.cs b/src/RMC.BestFit.App/GUI/BivariateAnalysis/Bivariate/BivariateAnalysisPropertiesControl.xaml.cs new file mode 100644 index 0000000..40adaa6 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/BivariateAnalysis/Bivariate/BivariateAnalysisPropertiesControl.xaml.cs @@ -0,0 +1,682 @@ +using FrameworkInterfaces; +using GenericControls; +using RMC.BestFit.Models; +using RMC.BestFit.Estimation; +using RMC.BestFit.UI; +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Input; +using Numerics.Distributions; +using Numerics.Utilities; +using System.Collections.Generic; +using System.Windows.Controls.Primitives; +using System.Linq; +using Numerics.Distributions.Copulas; + +namespace RMC_BestFit +{ + /// + /// User control for configuring and managing properties of a bivariate analysis. + /// Provides controls for selecting marginal distributions, copula types, estimation methods, + /// Bayesian settings, and parameter priors. Also manages the execution of Bayesian estimation. + /// + public partial class BivariateAnalysisPropertiesControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public BivariateAnalysisPropertiesControl() + { + InitializeComponent(); + DataContext = this; + this.Unloaded += UserControl_Unloaded; + } + + /// + /// Stores the previous name of the element for validation purposes. + /// + private string _previousName; + + /// + /// The currently subscribed via + /// / . + /// Tracked so we can unsubscribe when the Element changes or the control unloads, + /// preventing stale lambdas from accumulating across open/close cycles. + /// + private IElementCollection _subscribedUnivariateCollection; + + /// + /// Dependency property for the property. + /// + public static DependencyProperty ElementProperty = DependencyProperty.Register(nameof(Element), typeof(BivariateAnalysis), typeof(BivariateAnalysisPropertiesControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the bivariate analysis element whose properties are being configured. + /// + public BivariateAnalysis Element + { + get { return (BivariateAnalysis)GetValue(ElementProperty); } + set { SetValue(ElementProperty, value); } + } + + /// + /// Callback invoked when the Element dependency property changes. + /// Subscribes to property change events and initializes the control state. + /// + /// The dependency object on which the property changed. + /// Event arguments containing old and new property values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as BivariateAnalysisPropertiesControl == null) return; + var thisControl = (BivariateAnalysisPropertiesControl)d; + + // Unsubscribe from old element + if (e.OldValue is BivariateAnalysis oldElement) + oldElement.PropertyChanged -= thisControl.Element_PropertyChanged; + + if (e.NewValue == null) return; + var newElement = e.NewValue as BivariateAnalysis; + if (newElement == null) return; + + newElement.PropertyChanged += thisControl.Element_PropertyChanged; + thisControl._previousName = newElement.Name; + thisControl.PropertyAttributes.GetClassAttributes(newElement); + thisControl.LoadUnivariateAnalyses(); + + + } + + /// + /// Dependency property for the existing names property. + /// + public static DependencyProperty ExistingNamesProperty = DependencyProperty.Register(nameof(ExistingNames), typeof(string[]), typeof(BivariateAnalysisPropertiesControl), new FrameworkPropertyMetadata(new string[] { })); + + /// + /// Gets or sets the array of existing element names used for validation to prevent duplicate names. + /// + public string[] ExistingNames + { + get { return (string[])GetValue(ExistingNamesProperty); } + private set { SetValue(ExistingNamesProperty, value); } + } + + /// + /// Gets the observable collection of univariate analyses available for selection as marginal distributions. + /// + public ObservableCollection UnivariateAnalysisList { get; private set; } = new ObservableCollection(); + + /// + /// Gets the observable collection of available copula types for bivariate modeling. + /// Includes Ali-Mikhail-Haq, Clayton, Frank, Gumbel, Joe, and Normal copulas. + /// + /// + /// The Student's t copula is intentionally omitted from the user-facing dropdown: + /// its 2-parameter likelihood (?, ?) is much more expensive to evaluate than the + /// closed-form 1-parameter copulas (each MCMC likelihood call requires inverse-CDF + /// evaluations on the univariate Student's t), and ? is only weakly identified at + /// typical hydrologic sample sizes. The copula itself (CopulaType.StudentT) + /// remains fully supported in the model library and is exercised by the verification + /// suite � it can be re-enabled here once either a better prior is plumbed through + /// or runtime costs are reduced. + /// + public ObservableCollection CopulaList { get; private set; } = new ObservableCollection() + { new CopulaItem("Ali-Mikhail-Haq", CopulaType.AliMikhailHaq), + new CopulaItem("Clayton", CopulaType.Clayton), + new CopulaItem("Frank", CopulaType.Frank), + new CopulaItem("Gumbel", CopulaType.Gumbel), + new CopulaItem("Joe", CopulaType.Joe), + new CopulaItem("Normal", CopulaType.Normal), + }; + + /// + /// Gets the observable collection of available copula estimation methods. + /// Includes Inference from Margins (IFM) and Pseudo-Likelihood methods. + /// + public ObservableCollection MethodList { get; private set; } = new ObservableCollection() + { + new CopulaEstimationItem("Inference from Margins", CopulaEstimationMethod.InferenceFromMargins, "Estimates the copula using fitted marginal distributions (Inference from Margins, IFM)."), + new CopulaEstimationItem("Pseudo-Likelihood", CopulaEstimationMethod.PseudoLikelihood, "Estimates the copula using empirical ranks from the marginal data (Pseudo-Likelihood).") + }; + + /// + /// Gets the observable collection of credible interval width options for Bayesian estimation. + /// Includes 90%, 95%, 98%, and 99% intervals. + /// + public ObservableCollection CredibleIntervalItems { get; private set; } = new ObservableCollection() + { + new CredibleIntervalItem("90%", 0.9), + new CredibleIntervalItem("95%", 0.95), + new CredibleIntervalItem("98%", 0.98), + new CredibleIntervalItem("99%", 0.99) + }; + + #region Property Attributes + + /// + /// Handles the PreviewMouseLeftButtonDown event for the Name control. + /// Displays property attributes for the element name. + /// + /// The source of the event. + /// The mouse button event data. + private void Name_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.Name), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the Description control. + /// Displays property attributes for the element description. + /// + /// The source of the event. + /// The mouse button event data. + private void Description_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.Description), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the CreationDate control. + /// Displays property attributes for the element creation date. + /// + /// The source of the event. + /// The mouse button event data. + private void CreationDate_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.CreationDate), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the LastModified control. + /// Displays property attributes for the element's last modified date. + /// + /// The source of the event. + /// The mouse button event data. + private void LastModified_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.LastModified), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the MarginalX combo box. + /// Displays property attributes for the X marginal distribution. + /// + /// The source of the event. + /// The mouse button event data. + private void MarginalXComboBox_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.MarginalX), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the MarginalY combo box. + /// Displays property attributes for the Y marginal distribution. + /// + /// The source of the event. + /// The mouse button event data. + private void MarginalYComboBox_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.MarginalY), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the Copula control. + /// Displays property attributes for the selected copula type. + /// + /// The source of the event. + /// The mouse button event data. + private void Copula_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.BivariateDistribution.Copula), Element.BivariateDistribution); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the EstimationMethod control. + /// Displays property attributes for the copula estimation method. + /// + /// The source of the event. + /// The mouse button event data. + private void EstimationMethod_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.BivariateDistribution.CopulaEstimationMethod), Element.BivariateDistribution); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the CredibleInterval control. + /// Displays property attributes for the credible interval width. + /// + /// The source of the event. + /// The mouse button event data. + private void CredibleInterval_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.BayesianAnalysis.CredibleIntervalWidth), Element.BayesianAnalysis); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the OutputLength control. + /// Displays property attributes for the MCMC output length. + /// + /// The source of the event. + /// The mouse button event data. + private void OutputLength_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.BayesianAnalysis.OutputLength), Element.BayesianAnalysis); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for tab items. + /// Displays class-level attributes for the element. + /// + /// The source of the event. + /// The mouse button event data. + private void TabItem_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetClassAttributes(Element); + } + + /// + /// Handles the ShowPropertyAttributes event from the ParameterPriorsControl. + /// Displays property attributes for the specified property and class object. + /// + /// The name of the property to display attributes for. + /// The object containing the property. + private void ParameterPriorsControl_ShowPropertyAttributes(string propertyName, object classObject) + { + PropertyAttributes.GetPropertyAttributes(propertyName, classObject); + } + + /// + /// Handles the ShowPropertyAttributes event from the BayesianOptionsControl. + /// Displays property attributes for the specified property and class object. + /// + /// The name of the property to display attributes for. + /// The object containing the property. + private void BayesianOptionsControl_ShowPropertyAttributes(string propertyName, object classObject) + { + PropertyAttributes.GetPropertyAttributes(propertyName, classObject); + } + + /// + /// Handles the ShowPropertyAttributes event from the BayesianOutputControl. + /// Displays property attributes for the specified property and class object. + /// + /// The name of the property to display attributes for. + /// The object containing the property. + private void BayesianOutputControl_ShowPropertyAttributes(string propertyName, object classObject) + { + PropertyAttributes.GetPropertyAttributes(propertyName, classObject); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the OrderedDataGridControl. + /// Displays default attributes for the X-Y ordinates data grid. + /// + /// The source of the event. + /// The mouse button event data. + private void OrderedDataGridControl_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.SetDefaultAttributes("X-Y Ordinates", "The X-Y values for computing joint exceedance probabilities."); + } + + #endregion + + /// + /// Handles the GotFocus event for the Name control. + /// Saves the previous name and updates the list of existing names for validation. + /// + /// The source of the event. + /// The event data. + private void Name_GotFocus(object sender, RoutedEventArgs e) + { + _previousName = Element.Name; + ExistingNames = Element.ParentCollection.GetElementNames(Element).ToArray(); + } + + /// + /// Handles the LostFocus event for the Name control. + /// Restores the previous name if validation fails. + /// + /// The source of the event. + /// The event data. + private void Name_LostFocus(object sender, RoutedEventArgs e) + { + if (ElementName.NameTextBox.IsValid) + return; + if (Element != null) + Element.Name = _previousName; + } + + /// + /// Handles the Unloaded event of the user control. Unsubscribes from element event handlers + /// to prevent memory leaks and stale event subscriptions. + /// + /// The source of the event. + /// The event data. + private void UserControl_Unloaded(object sender, RoutedEventArgs e) + { + UnsubscribeUnivariateAnalysisCollection(); + } + + /// + /// Handles property changes on the Element object. + /// + /// The source of the event. + /// The property changed event arguments. + private void Element_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + // Model object replaced (e.g., during undo) � push to sub-controls + if (e.PropertyName == nameof(Element.BivariateDistribution)) + { + ParameterPriorsControl.Model = Element.BivariateDistribution; + } + // BayesianAnalysis replaced � push to sub-controls + if (e.PropertyName == nameof(Element.BayesianAnalysis)) + { + BayesianOptionsControl.Analysis = Element.BayesianAnalysis; + BayesianOutputControl.Analysis = Element.BayesianAnalysis; + } + } + + /// + /// Loads all univariate analyses from the project into the + /// collection. Subscribes to element added/removed events via NAMED handlers (not anonymous + /// lambdas) so they can be unsubscribed deterministically � see Convention 8 in the project coding standards. + /// Calls first so repeated invocations + /// (re-loads after Element change, or after a transient Unload/Reload cycle) cannot + /// double-subscribe. + /// + private void LoadUnivariateAnalyses() + { + UnivariateAnalysisList.Clear(); + if (Element == null) return; + foreach (IElementCollection collection in Element.ParentCollection.ParentProject.ElementCollections) + { + if (collection.GetType() == typeof(UnivariateAnalysisCollection)) + { + UnsubscribeUnivariateAnalysisCollection(); + _subscribedUnivariateCollection = collection; + collection.ElementAdded += OnUnivariateAnalysisAdded; + collection.ElementRemoved += OnUnivariateAnalysisRemoved; + foreach (IElement element in collection) + { + if (element is IUnivariate uni && uni.GetMarginalModel() is not null) + { + UnivariateAnalysisList.Add(uni); + } + } + break; + } + } + } + + /// + /// Handles a new IUnivariate being added to the project. Accept any IUnivariate that + /// exposes a marginal model � CompositeAnalysis returns null from GetMarginalModel + /// because it does not own paired input data, so it is filtered out. + /// + private void OnUnivariateAnalysisAdded(IElement x) + { + if (x is IUnivariate uni && uni.GetMarginalModel() is not null && !UnivariateAnalysisList.Contains(uni)) + UnivariateAnalysisList.Add(uni); + } + + /// + /// Handles an IUnivariate being removed from the project. + /// + private void OnUnivariateAnalysisRemoved(IElement x) + { + if (x is IUnivariate uni) + UnivariateAnalysisList.Remove(uni); + } + + /// + /// Unsubscribes the named handlers from the tracked + /// and clears the tracker field. Called from and from + /// before re-subscription. + /// + private void UnsubscribeUnivariateAnalysisCollection() + { + if (_subscribedUnivariateCollection != null) + { + _subscribedUnivariateCollection.ElementAdded -= OnUnivariateAnalysisAdded; + _subscribedUnivariateCollection.ElementRemoved -= OnUnivariateAnalysisRemoved; + _subscribedUnivariateCollection = null; + } + } + + /// + /// Handles the SelectionChanged event for the MarginalX combo box. + /// Provides visual feedback if no valid marginal-X distribution is selected. + /// + /// The source of the event. + /// The selection changed event arguments. + private void MarginalXComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + // First-load NRE guard: WPF can fire SelectionChanged during DataContext + // resolution before Element is assigned (the WPF DataContext pattern). + if (Element == null) return; + if (Element.MarginalX == null || Element.MarginalX.Name == null) + { + ((Border)MarginalXComboBox.InnerContent).BorderThickness = new Thickness(1); + MarginalXComboBox.ToolTip = "Please select a valid marginal-X univariate analysis."; + } + else + { + ((Border)MarginalXComboBox.InnerContent).BorderThickness = new Thickness(0); + MarginalXComboBox.ToolTip = null; + } + } + + /// + /// Handles the SelectionChanged event for the MarginalY combo box. + /// Provides visual feedback if no valid marginal-Y distribution is selected. + /// + /// The source of the event. + /// The selection changed event arguments. + private void MarginalYComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + // First-load NRE guard (the WPF DataContext pattern). + if (Element == null) return; + if (Element.MarginalY == null || Element.MarginalY.Name == null) + { + ((Border)MarginalYComboBox.InnerContent).BorderThickness = new Thickness(1); + MarginalYComboBox.ToolTip = "Please select a valid marginal-Y univariate analysis."; + } + else + { + ((Border)MarginalYComboBox.InnerContent).BorderThickness = new Thickness(0); + MarginalYComboBox.ToolTip = null; + } + } + + /// + /// Handles the SelectionChanged event for the Copula combo box. + /// + /// The source of the event. + /// The selection changed event arguments. + private void CopulaComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + + } + + /// + /// Handles the Loaded event for the MarginalX combo box. + /// Sets up the sorted item source for displaying univariate analyses. + /// + /// The source of the event. + /// The event data. + private void MarginalXComboBox_Loaded(object sender, RoutedEventArgs e) + { + ComboBox cmbo = (ComboBox)sender; + CollectionViewSource csv = new CollectionViewSource() { Source = UnivariateAnalysisList, IsLiveSortingRequested = true }; + csv.SortDescriptions.Add(new SortDescription(nameof(IElement.Name), ListSortDirection.Ascending)); + var view = csv.View; + view.MoveCurrentToPosition(-1); + cmbo.ItemsSource = view; + } + + /// + /// Handles the Loaded event for the MarginalY combo box. + /// Sets up the sorted item source for displaying univariate analyses. + /// + /// The source of the event. + /// The event data. + private void MarginalYComboBox_Loaded(object sender, RoutedEventArgs e) + { + ComboBox cmbo = (ComboBox)sender; + CollectionViewSource csv = new CollectionViewSource() { Source = UnivariateAnalysisList, IsLiveSortingRequested = true }; + csv.SortDescriptions.Add(new SortDescription(nameof(IElement.Name), ListSortDirection.Ascending)); + var view = csv.View; + view.MoveCurrentToPosition(-1); + cmbo.ItemsSource = view; + } + + /// + /// Handles the Click event for the Estimate button. Initiates the Bayesian estimation process + /// for the bivariate analysis with progress reporting and UI state management. + /// + /// The source of the event. + /// The event data. + private async void EstimateButton_Click(object sender, RoutedEventArgs e) + { + // Check if the analysis is valid + if (Element.IsValid == false) + { + GenericControls.MessageBox.Show("Cannot perform the analysis because the inputs are invalid.", "Error!", MessageBoxButton.OK, MessageBoxImage.Error); + return; + } + + Mouse.OverrideCursor = Cursors.Wait; + + // Disable all open windows + FrameworkUI.ShellPublicVariables.SimulationInProgress = true; + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableMenuStrip(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableProjectExplorer(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableMessageWindow(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableOpenWindows(); + // Disable Properties + PropertiesExpander.IsEnabled = false; + ParameterPriorsExpander.IsEnabled = false; + Options_TabItem.IsEnabled = false; + Output_TabItem.IsEnabled = false; + + // Set up progress bar + AnalysisProgressDisplayHelper.ShowInitial(ProgressBar, ProgressTextBlock); + + // Show cancel button + EstimateButton.Visibility = Visibility.Hidden; + CancelButton.Visibility = Visibility.Visible; + + // Set up progress reporter + var progressReporter = new SafeProgressReporter(nameof(UnivariateAnalysis)); + progressReporter.ProgressReported += (SafeProgressReporter reporter, double progress, double progressDelta) => + { + AnalysisProgressDisplayHelper.PostProgress(Dispatcher, ProgressBar, ProgressTextBlock, progress); + }; + bool progressCleanupEnabled = false; + progressReporter.TaskEnded += () => + { + if (!progressCleanupEnabled) return; + // Close out progress bar + ProgressBar.Value = 100; + ProgressTextBlock.Text = "Simulation Complete"; + Mouse.OverrideCursor = null; + EstimateButton.IsEnabled = true; + ProgressBar.Visibility = Visibility.Hidden; + ProgressTextBlock.Visibility = Visibility.Hidden; + + // Close cancel button + EstimateButton.Visibility = Visibility.Visible; + CancelButton.Visibility = Visibility.Hidden; + + // Enable all windows + FrameworkUI.ShellPublicVariables.SimulationInProgress = false; + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableMenuStrip(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableProjectExplorer(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableMessageWindow(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableOpenWindows(); + // Enable Properties + PropertiesExpander.IsEnabled = true; + ParameterPriorsExpander.IsEnabled = true; + Options_TabItem.IsEnabled = true; + Output_TabItem.IsEnabled = true; + }; + + + // Perform Bayesian estimation + try + { + await WaitCursorHelper.RunWithVisibleWaitCursorAsync(Dispatcher, async () => + { + await Element.RunAsync(progressReporter); + }); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"BivariateAnalysisPropertiesControl.EstimateButton_Click: {ex}"); + GenericControls.MessageBox.Show("An unexpected error occurred. Please verify your analysis inputs and settings." + Environment.NewLine + "If the issue persists, contact the Risk Management Center.", "Error!", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + progressCleanupEnabled = true; + progressReporter.IndicateTaskEnded(); + } + + + } + + /// + /// Handles the Click event for the Cancel button. Cancels the currently running analysis. + /// + /// The source of the event. + /// The event data. + private void CancelButton_Click(object sender, RoutedEventArgs e) + { + Element.CancelAnalysis(); + } + + /// + /// Handles the KeyDown event for the control. Cancels the analysis if Escape key is pressed. + /// + /// The source of the event. + /// The key event data. + private void BivariateAnalysisProperties_KeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + Element.CancelAnalysis(); + } + + /// + /// Handles the ColumnsAutoGenerated event for the OrderedDataGridControl. + /// Applies user-defined string formatting to numeric columns. + /// + /// The source of the event. + /// The collection of auto-generated columns. + private void OrderedDataGridControl_ColumnsAutoGenerated(object sender, ObservableCollection e) + { + foreach (var column in e) + { + if (column is DataGridTextColumn textCol) + { + textCol.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + + // The Y ordinate is stored as the P1 property on DistributionRowItem (single-param + // Deterministic distribution). The auto-generated header defaults to the distribution's + // parameter display name ("Value" for Deterministic). Rewrite it to "Y" for the + // bivariate XY-ordinates grid. Matched by binding path so the fix is not fragile + // against changes in the Deterministic distribution's parameter name. + if (textCol.Binding is Binding binding && binding.Path?.Path == "P1") + { + textCol.Header = "Y"; + } + } + } + } + + + } +} diff --git a/src/RMC.BestFit.App/GUI/BivariateAnalysis/CoincidentFrequency/CoincidentFrequencyControl.xaml b/src/RMC.BestFit.App/GUI/BivariateAnalysis/CoincidentFrequency/CoincidentFrequencyControl.xaml new file mode 100644 index 0000000..b59fbd7 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/BivariateAnalysis/CoincidentFrequency/CoincidentFrequencyControl.xaml @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/BivariateAnalysis/CoincidentFrequency/CoincidentFrequencyControl.xaml.cs b/src/RMC.BestFit.App/GUI/BivariateAnalysis/CoincidentFrequency/CoincidentFrequencyControl.xaml.cs new file mode 100644 index 0000000..c730ea4 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/BivariateAnalysis/CoincidentFrequency/CoincidentFrequencyControl.xaml.cs @@ -0,0 +1,1228 @@ +using OxyPlot; +using OxyPlot.Wpf; +using OxyPlotControls; +using FrameworkUI; +using RMC.BestFit.Models; +using RMC.BestFit.Estimation; +using RMC.BestFit.UI; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Media3D; +using System.Windows.Threading; + +namespace RMC_BestFit +{ + /// + /// User control for displaying and interacting with coincident frequency analysis results. + /// Provides the stage-frequency plot (AEP vs Z with 5%/Mean/95%/Mode bands) and the + /// tabular AEP-by-Z output. + /// + public partial class CoincidentFrequencyControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public CoincidentFrequencyControl() + { + InitializeComponent(); + DataContext = this; + _colorHexCodes = GenericControls.GeneralMethods.RandomColorsLongList; + // Restrict the alternatives selector to only show other Coincident Frequency + // analyses — comparing a CFA against a plain BivariateAnalysis is not meaningful. + // Must be set BEFORE the AlternativeSelector.Element binding fires LoadAnalyses(). + AlternativeSelector.AlternativeFilterType = typeof(CoincidentFrequencyAnalysis); + // DataGrid Binding.StringFormat must be set before first render. + SetColumnStringFormats(); + } + + /// + /// Color palette used to assign distinct colors to alternative-analysis series in the + /// frequency plot. Mirrors the canonical pattern from . + /// + private string[] _colorHexCodes; + + #region Element DependencyProperty + ElementCallback + + /// + /// Dependency property for the property. + /// + public static DependencyProperty ElementProperty = DependencyProperty.Register( + nameof(Element), + typeof(CoincidentFrequencyAnalysis), + typeof(CoincidentFrequencyControl), + new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the coincident frequency analysis element associated with this control. + /// + public CoincidentFrequencyAnalysis Element + { + get { return (CoincidentFrequencyAnalysis)GetValue(ElementProperty); } + set { SetValue(ElementProperty, value); } + } + + /// + /// Callback invoked when the dependency property changes. + /// Manages event handler subscriptions for the old and new element instances. + /// + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as CoincidentFrequencyControl == null) return; + var thisControl = (CoincidentFrequencyControl)d; + + // Remove handlers from old element. + if (e.OldValue != null) + { + CoincidentFrequencyAnalysis oldElement = e.OldValue as CoincidentFrequencyAnalysis; + if (oldElement != null) + { + oldElement.PropertyChanged -= thisControl.Element_PropertyChanged; + thisControl.FrequencyPlotHost.Content = null; + thisControl.FrequencyPlotToolbar.Plot = null; + // PropertiesCalled is wired in XAML — no programmatic -= needed. + } + } + + if (e.NewValue == null) return; + var newElement = e.NewValue as CoincidentFrequencyAnalysis; + if (newElement == null) return; + + // Subscribe Element-scoped handler. + newElement.PropertyChanged += thisControl.Element_PropertyChanged; + + // Attach plot, wire toolbar, and bind axis titles inside a bridge-suspension block + // so visual-tree attachment does not record undo entries. + using (newElement.SuspendPlotBridges()) + { + thisControl.FrequencyPlotHost.Content = newElement.FrequencyPlot; + thisControl.FrequencyPlotToolbar.Plot = newElement.FrequencyPlot; + thisControl.BindAxisTitles(); + } + } + + #endregion + + #region Plot / events + + /// + /// Convenience accessor for the Element-owned frequency plot. + /// + private Plot FrequencyPlot => Element?.FrequencyPlot; + + /// + /// Gets a value indicating whether a plot area was clicked during the last mouse event. + /// + public bool PlotClicked { get; private set; } = false; + + /// + /// Occurs when plot properties are requested to be displayed or modified. + /// + public event PlotPropertiesCalledEventHandler PlotPropertiesCalled; + + /// + /// Delegate for the event. + /// + public delegate void PlotPropertiesCalledEventHandler(Plot plot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject); + + /// + /// Occurs when the user clicks within the preview control area. + /// + public event PreviewControlClickedEventHandler PreviewControlClicked; + + /// + /// Delegate for the event. + /// + public delegate void PreviewControlClickedEventHandler(bool plotClicked, bool toolbarClicked, Plot plot); + + /// + /// Forwards the toolbar's PropertiesCalled event to subscribers (MainProjectNode). + /// + private void PlotToolbar_PropertiesCalled(Plot targetPlot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject) + { + PlotPropertiesCalled?.Invoke(targetPlot, openProperties, propertyExpander, selectedObject); + } + + #endregion + + #region Loaded / Unloaded + + /// + /// Handles the Loaded event of the user control. Refreshes plot and tabular data on every + /// load. Element-scoped lifecycle (PropertyChanged, plot host, toolbar) is owned by + /// and survives unload/reload cycles. + /// + private void UserControl_Loaded(object sender, RoutedEventArgs e) + { + if (Element == null) return; + + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try + { + UpdateFrequencyPlot(); + BindFrequencyCurveDataGrid(); + SetFrequencyCurveTableColumnHeaders(); + // Initial population of the M×N response data grid. Without this call the + // grid sits empty until the user touches an X / Y ordinate (the only path + // that previously triggered RebuildBivariateGrid via PropertyChanged). + RebuildBivariateGrid(); + // Initial population of the Summary Statistics grid. Without this call the + // grid sits empty on reopen until the user changes PointEstimator (the only + // path that previously triggered the bind). + BindSummaryStatisticsDataGrid(); + } + finally + { + Element.IsUndoEnabled = wasUndoEnabled; + } + } + + /// + /// Handles the Unloaded event. Element-scoped lifecycle is owned by ElementCallback; + /// this control has no control-scoped subscriptions to tear down. + /// + private void UserControl_Unloaded(object sender, RoutedEventArgs e) + { + // No-op: Pattern B (canonical). + } + + /// + /// Forwards element property changes into UI refresh routines. + /// + private void Element_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke(new Action(() => Element_PropertyChanged(sender, e))); + return; + } + + if (e.PropertyName == nameof(Element.AnalysisResults) || + e.PropertyName == nameof(Element.IsEstimated) || + e.PropertyName == nameof(Element.ZOutputValues)) + { + UpdateFrequencyPlot(); + BindFrequencyCurveDataGrid(); + // Refresh summary statistics on every fit completion (the grid was already + // initialized in UserControl_Loaded; this keeps it in sync as the analysis + // is re-fit or undone). + BindSummaryStatisticsDataGrid(); + ResetWaitCursor(); + } + if (e.PropertyName == nameof(Element.InputData)) + { + using (Element?.SuspendPlotBridges()) + { + BindAxisTitles(); + } + UpdateFrequencyPlot(); + } + // BivariateResponse changes come from three sources: + // 1. X / Y ordinate add/remove (auto-resize) — needs grid rebuild for new dims. + // 2. Undo of a cell edit — needs grid rebuild so the displayed value reverts. + // 3. The user typing in a cell — does NOT need a rebuild (the grid already shows + // the new value, and rebuilding kills focus). + // Case 3 is excluded by the _suppressBivariateResponseRebuild flag set inside + // ResponseTable_ColumnChanged / BivariateDataGrid_DataPasted while the cell-edit + // assignment is in flight. + if (e.PropertyName == nameof(Element.BivariateResponse) && !_suppressBivariateResponseRebuild) + { + RebuildBivariateGrid(); + } + if (e.PropertyName == nameof(Element.BayesianAnalysis.CredibleIntervalWidth)) + { + SetFrequencyCurveTableColumnHeaders(); + UpdateFrequencyPlot(); + BindFrequencyCurveDataGrid(); + BindSummaryStatisticsDataGrid(); + } + if (e.PropertyName == nameof(Element.BayesianAnalysis.PointEstimator)) + { + UpdateFrequencyPlot(); + BindFrequencyCurveDataGrid(); + BindSummaryStatisticsDataGrid(); + } + // CoincidentFrequencyAnalysis exception: BayesianAnalysis.CredibleIntervalWidth and + // XValues/YValues call ClearResults (no per-realisation AEP cache). Only PointEstimator + // reprocesses via UpdatePointEstimateResultsAsync. + if (e.PropertyName == nameof(Element.BayesianAnalysis.PointEstimator) && Element.IsEstimated) + { + // Cursor wait while the model layer's ReprocessIfEstimated runs the recompute + // on TaskScheduler.Default. Reset is wired to the AnalysisResults notification above. + Mouse.OverrideCursor = Cursors.Wait; + } + } + + /// + /// Clears when it is currently set to . + /// Marshals onto the UI thread when called from a background-thread PropertyChanged + /// notification (the model layer's ReprocessIfEstimated path uses + /// TaskScheduler.Default). + /// + private void ResetWaitCursor() + { + // Reset at Background priority so the Render-priority cursor frame from the + // earlier `Mouse.OverrideCursor = Cursors.Wait` is guaranteed to flush before + // the reset runs. Without this, fast reprocesses (UpdatePointEstimateResultsAsync) + // can reset the cursor before the OS visually picks up the change — the user + // sees no wait cursor at all when the mouse is stationary. + Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + { + if (Mouse.OverrideCursor == Cursors.Wait) + Mouse.OverrideCursor = null; + })); + } + + /// + /// Binds the frequency plot Y-axis title to when an + /// overlay is selected, or clears the binding when it is null. + /// Called from and when + /// changes. Must be called inside a block. + /// + private void BindAxisTitles() + { + if (Element == null) return; + + var yAxis = FrequencyPlot?.Axes.FirstOrDefault(a => a.Key == "Yaxis"); + if (yAxis == null) return; + + if (Element.InputData != null) + PlotAxisTitleDefaults.BindTitleIfDefault(yAxis, Element.InputData, nameof(InputData.UnitLabel), Element.InputData.UnitLabel); + else + PlotAxisTitleDefaults.SetTitleIfDefault(yAxis, "Response (Z)"); + } + + #endregion + + #region Mouse / focus + + /// + /// Handles the LostFocus event for UserControl. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void UserControl_LostFocus(object sender, RoutedEventArgs e) + { + PlotClicked = false; + } + + /// + /// Handles the PreviewMouseDown event for UserControl. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void UserControl_PreviewMouseDown(object sender, MouseButtonEventArgs e) + { + Plot plot = GetCurrentPlot(); + OxyPlotToolbar toolbar = GetCurrentPlotToolbar(); + System.Windows.Media.HitTestResult plotHit = null; + System.Windows.Media.HitTestResult toolbarHit = null; + if (plot != null) plotHit = VisualTreeHelper.HitTest(plot, e.GetPosition(plot)); + if (toolbar != null) toolbarHit = VisualTreeHelper.HitTest(toolbar, e.GetPosition(toolbar)); + PreviewControlClicked?.Invoke(plotHit != null, toolbarHit != null, plot); + PlotClicked = plotHit != null; + } + + /// + /// Gets the currently visible plot. Returns the frequency plot only when the + /// Frequency Plot tab is selected; other tabs host no plot. + /// + public Plot GetCurrentPlot() + { + if (FrequencyPlotTabItem.IsSelected == true) return FrequencyPlot; + return null; + } + + /// + /// Gets the toolbar for the currently visible plot. Returns the frequency-plot toolbar + /// only when the Frequency Plot tab is selected; other tabs host no toolbar. + /// + public OxyPlotToolbar GetCurrentPlotToolbar() + { + if (FrequencyPlotTabItem.IsSelected == true) return FrequencyPlotToolbar; + return null; + } + + #endregion + + #region UpdateFrequencyPlot + + /// + /// Updates the stage-frequency plot. Uses the canonical lookup-or-create pattern so + /// user-edited series styling survives Save/Open: each series is resolved by Name from + /// the current plot.Series; if not found, a freshly default-styled instance is + /// created. Wrapped in Element.SuspendPlotBridges() to prevent series-clear and + /// ItemsSource assignments from polluting the undo stack. + /// + private void UpdateFrequencyPlot() + { + var plot = FrequencyPlot; + if (plot == null) return; + + // Resolve OUTSIDE the using block so user-customized styling is preserved. + // Canonical series styling matches CompositeAnalysisControl: a single AreaSeries + // for the credible-interval band (translucent blue fill, solid dark-blue border), + // a dashed-blue Posterior Predictive line, and a solid-black Posterior Mode line. + var credibleIntervals = plot.Series.OfType().FirstOrDefault(s => s.Name == "CredibleIntervals") + ?? new AreaSeries + { + Name = "CredibleIntervals", + Fill = Color.FromArgb(75, 104, 140, 175), + Color = Color.FromArgb(255, 53, 59, 122), + LineStyle = LineStyle.Solid, + BrokenLineThickness = 1, + StrokeThickness = 1, + Decimator = OxyPlot.Decimator.Decimate, + }; + var posteriorPredictive = plot.Series.OfType().FirstOrDefault(s => s.Name == "PosteriorPredictive") + ?? new LineSeries + { + Name = "PosteriorPredictive", + Title = "Posterior Predictive", + Color = Colors.Blue, + StrokeThickness = 2, + LineStyle = LineStyle.Dash, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0, + }; + var posteriorMode = plot.Series.OfType().FirstOrDefault(s => s.Name == "PosteriorMode") + ?? new LineSeries + { + Name = "PosteriorMode", + Title = "Posterior Mode", + Color = Colors.Black, + StrokeThickness = 2, + LineStyle = LineStyle.Solid, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0, + }; + // InputData overlay series — observed Z values plotted at their empirical AEPs. + // Mirrors CompositeAnalysisControl.UpdateFrequencyPlot so the frequency plot shows + // observed data when the user picks an InputData overlay (the bug fix the user asked + // for: "When input data changes in CFA properties it should update the frequency plot"). + var frequencyExactData = plot.Series.OfType().FirstOrDefault(s => s.Name == "ExactData") + ?? new ScatterPointSeries + { + Name = "ExactData", + Title = "Exact Data", + MarkerFill = Colors.Black, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle, + }; + var frequencyLowOutlierData = plot.Series.OfType().FirstOrDefault(s => s.Name == "LowOutlierData") + ?? new ScatterPointSeries + { + Name = "LowOutlierData", + Title = "Low Outlier Data", + MarkerStroke = Colors.Red, + MarkerStrokeThickness = 2, + MarkerSize = 3, + MarkerType = MarkerType.Cross, + }; + var frequencyUncertainData = plot.Series.OfType().FirstOrDefault(s => s.Name == "UncertainData") + ?? new ScatterErrorSeries + { + Name = "UncertainData", + Title = "Uncertain Data", + MarkerFill = Colors.Green, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Diamond, + }; + var frequencyIntervalData = plot.Series.OfType().FirstOrDefault(s => s.Name == "IntervalData") + ?? new ScatterErrorSeries + { + Name = "IntervalData", + Title = "Interval Data", + MarkerFill = Colors.Cyan, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle, + }; + using (Element?.SuspendPlotBridges()) + { + plot.Series.Clear(); + + if (Element != null && Element.AnalysisResults != null && Element.ZOutputValues != null) + { + var z = Element.ZOutputValues; + var ar = Element.AnalysisResults; + int n = z.Length; + + var ciPoints = new List(n); + var prdPoints = new List(n); + var mdPoints = new List(n); + + bool hasCI = ar.ConfidenceIntervals != null; + bool hasMean = ar.MeanCurve != null; + bool hasMode = ar.ModeCurve != null; + + for (int k = 0; k < n; k++) + { + if (hasCI) + { + double lower = ar.ConfidenceIntervals[k, 0]; + double upper = ar.ConfidenceIntervals[k, 1]; + // Point3D layout for the AreaSeries CI band — see DataField mapping + // below. X = z (response), Y = lower AEP, Z = upper AEP. + ciPoints.Add(new Point3D(z[k], lower, upper)); + } + if (hasMean) prdPoints.Add(new DataPoint(ar.MeanCurve[k], z[k])); + if (hasMode) mdPoints.Add(new DataPoint(ar.ModeCurve[k], z[k])); + } + + if (hasCI) + { + // Horizontal CI band at fixed Z varying along AEP (X axis). Mirrors the + // CompositeAnalysisControl AreaSeries band, but with X/Y swapped because + // CFA plots AEP on X and Z on Y (the inverse of Composite's orientation). + // primary edge: X=Point3D.Y=lower_AEP, Y=Point3D.X=z + // secondary edge: X=Point3D.Z=upper_AEP, Y=Point3D.X=z + credibleIntervals.ItemsSource = ciPoints; + credibleIntervals.DataFieldX = "Y"; + credibleIntervals.DataFieldY = "X"; + credibleIntervals.DataFieldX2 = "Z"; + credibleIntervals.DataFieldY2 = "X"; + credibleIntervals.Title = (Element.BayesianAnalysis.CredibleIntervalWidth * 100).ToString("F0") + "% Credible Intervals"; + credibleIntervals.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + plot.Series.Add(credibleIntervals); + } + if (hasMean) + { + posteriorPredictive.ItemsSource = prdPoints; + posteriorPredictive.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + plot.Series.Add(posteriorPredictive); + } + if (hasMode) + { + posteriorMode.Title = Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"; + posteriorMode.ItemsSource = mdPoints; + posteriorMode.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + plot.Series.Add(posteriorMode); + } + + // Re-add any alternative analyses that the user has checked. Each call + // routes to AlternativeSelector_AnalysisAdded which appends the alternative's + // CredibleIntervals, PosteriorPredictive, and PosteriorMode series. + if (Element.IsEstimated == true) + AlternativeSelector.AddAllChecked(); + } + + // InputData overlay — observed data points plotted at their empirical AEPs. + // Rendered whenever an InputData overlay is selected, regardless of estimation + // state, so the user can preview the dataset before running CFA. Mirrors the + // canonical pattern in CompositeAnalysisControl.UpdateFrequencyPlot. + if (Element?.InputData != null) + { + // Detect log-scale Y axis so we can filter out non-positive values that + // would crash the log axis. Mirrors Composite's handling. + bool logAxis = false; + foreach (var axis in plot.Axes) + { + if (axis.Key == "Yaxis" && axis is LogarithmicAxis) + { + logAxis = true; + break; + } + } + var df = Element.InputData.DataFrame; + + // Exact data (non-low-outlier observations). + frequencyExactData.ItemsSource = logAxis + ? df.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == false && x.Value > 1E-16) + : df.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == false); + frequencyExactData.Mapping = item => { var d = (ExactData)item; return new OxyPlot.Series.ScatterPoint(d.PlottingPosition, d.Value); }; + frequencyExactData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (df.ExactSeries.Count > 0) plot.Series.Add(frequencyExactData); + + // Low-outlier observations (rendered with a distinct marker). + frequencyLowOutlierData.ItemsSource = logAxis + ? df.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == true && x.Value > 1E-16) + : df.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == true); + frequencyLowOutlierData.Mapping = item => { var d = (ExactData)item; return new OxyPlot.Series.ScatterPoint(d.PlottingPosition, d.Value); }; + frequencyLowOutlierData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (df.NumberOfLowOutliers > 0) plot.Series.Add(frequencyLowOutlierData); + + // Uncertain observations (point with error bars on Y). + frequencyUncertainData.ItemsSource = logAxis + ? df.UncertainSeries + : df.UncertainSeries.Where(x => x.Value > 1E-16); + frequencyUncertainData.DataFieldX = nameof(UncertainData.PlottingPosition); + frequencyUncertainData.DataFieldY = nameof(UncertainData.Value); + frequencyUncertainData.DataFieldLowerErrorY = nameof(UncertainData.LowerValue); + frequencyUncertainData.DataFieldUpperErrorY = nameof(UncertainData.UpperValue); + frequencyUncertainData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (df.UncertainSeries.Count > 0) plot.Series.Add(frequencyUncertainData); + + // Interval observations. + frequencyIntervalData.ItemsSource = logAxis + ? df.IntervalSeries + : df.IntervalSeries.Where(x => x.Value > 1E-16); + frequencyIntervalData.DataFieldX = nameof(IntervalData.PlottingPosition); + frequencyIntervalData.DataFieldY = nameof(IntervalData.Value); + frequencyIntervalData.DataFieldLowerErrorY = nameof(IntervalData.LowerValue); + frequencyIntervalData.DataFieldUpperErrorY = nameof(IntervalData.UpperValue); + frequencyIntervalData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (df.IntervalSeries.Count > 0) plot.Series.Add(frequencyIntervalData); + } + + plot.InvalidatePlot(true); + } + Element?.RebuildSeriesAndAnnotationBridges(plot); + } + + #endregion + + #region Tabular results + + /// + /// Binds the frequency curve table to one row per Z output bin. + /// + private void BindFrequencyCurveDataGrid() + { + FrequencyCurveTable.ItemsSource = null; + + if (Element?.BayesianAnalysis == null) return; + + ModeColumn.Header = Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"; + + if (Element.AnalysisResults == null || Element.ZOutputValues == null) return; + + var z = Element.ZOutputValues; + var ar = Element.AnalysisResults; + int n = z.Length; + + var rows = new List(n); + for (int k = 0; k < n; k++) + { + double lower = ar.ConfidenceIntervals != null ? ar.ConfidenceIntervals[k, 0] : double.NaN; + double upper = ar.ConfidenceIntervals != null ? ar.ConfidenceIntervals[k, 1] : double.NaN; + double mean = ar.MeanCurve != null ? ar.MeanCurve[k] : double.NaN; + double mode = ar.ModeCurve != null ? ar.ModeCurve[k] : double.NaN; + rows.Add(new CoincidentFrequencyTablePoint(z[k], lower, upper, mean, mode)); + } + FrequencyCurveTable.ItemsSource = rows; + FrequencyCurveTable.Items.Refresh(); + } + + /// + /// Updates Lower/Upper column headers from the current credible-interval width + /// (e.g. "5.0% CI" / "95.0% CI" for CI=0.90). + /// + private void SetFrequencyCurveTableColumnHeaders() + { + if (Element?.BayesianAnalysis == null) return; + double alpha = (1.0 - Element.BayesianAnalysis.CredibleIntervalWidth) / 2.0; + UpperColumn.Header = ((1 - alpha) * 100).ToString("F1") + "% CI"; + LowerColumn.Header = (alpha * 100).ToString("F1") + "% CI"; + } + + /// + /// Sets the column StringFormats. Called from the constructor (must run before first + /// render) so binding values display in the user's preferred format. + /// + private void SetColumnStringFormats() + { + // ZColumn holds the response (Z) output value — not an AEP — so no special format + // is applied; the default binding format is sufficient for a linear response value. + // AEP columns use scientific notation to convey small probabilities clearly. + UpperColumn.Binding.StringFormat = "E4"; + LowerColumn.Binding.StringFormat = "E4"; + PredictiveColumn.Binding.StringFormat = "E4"; + ModeColumn.Binding.StringFormat = "E4"; + } + + /// + /// Binds the summary statistics datagrid. Mirrors the CompositeAnalysisControl pattern: + /// obtains an empirical distribution, then computes Min/Max/Mean/StdDev/Skewness/Kurtosis + /// from its central moments. + /// + private void BindSummaryStatisticsDataGrid() + { + var stats = new System.Collections.ObjectModel.ObservableCollection(); + if (Element?.BayesianAnalysis != null) + { + StatValueColumn.Header = Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"; + } + + if (Element?.IsEstimated == true) + { + var empDist = Element.GetPointEstimateDistribution(); + if (empDist != null && Element.ZOutputValues != null && Element.ZOutputValues.Length > 0) + { + try + { + var z = Element.ZOutputValues; + var moments = empDist.CentralMoments(1000); // [mean, stdDev, skew, kurt] + stats.Add(new SummaryStatistic("Minimum", z[0])); + stats.Add(new SummaryStatistic("Maximum", z[z.Length - 1])); + stats.Add(new SummaryStatistic("Mean", moments[0])); + stats.Add(new SummaryStatistic("Std Dev", moments[1])); + stats.Add(new SummaryStatistic("Skewness", moments[2])); + stats.Add(new SummaryStatistic("Kurtosis", moments[3])); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"BindSummaryStatisticsDataGrid: {ex.Message}"); + } + } + } + if (stats.Count == 0) + { + foreach (var name in new[] { "Minimum", "Maximum", "Mean", "Std Dev", "Skewness", "Kurtosis" }) + stats.Add(new SummaryStatistic(name, double.NaN)); + } + SummaryStatisticsTable.ItemsSource = stats; + } + + #endregion + + #region M×N Response Grid (Bivariate Response tab) + + private System.Data.DataTable _responseTable; + private bool _isResponsePasting; + + /// + /// True while the current cell edit is being pushed back to Element.BivariateResponse. + /// The setter fires PropertyChanged(nameof(BivariateResponse)) which would otherwise + /// re-enter and trigger , + /// rebuilding the DataTable mid-edit and killing the user's keyboard focus. The flag suppresses + /// the rebuild while a self-originated cell-edit assignment is in flight; it does NOT suppress + /// rebuilds from other sources (auto-resize from X/Y change, undo replay). + /// + private bool _suppressBivariateResponseRebuild; + + /// + /// Rebuilds the M×N response DataTable from the current Element.BivariateResponse. + /// X values become row headers (via LoadingRow); Y values become column headers (via + /// AutoGeneratingColumn, which strips a ">i" uniqueness suffix). + /// + private void RebuildBivariateGrid() + { + if (Element == null) return; + + BivariateDataGrid.ItemsSource = null; + if (_responseTable != null) _responseTable.ColumnChanged -= ResponseTable_ColumnChanged; + + _responseTable = new System.Data.DataTable(); + int rows = Element.XValues.Count; + int cols = Element.YValues.Count; + + for (int j = 0; j < cols; j++) + { + // ">i" suffix on column key to allow duplicate Y values without DataTable conflict + // (TotalRisk pattern). AutoGeneratingColumn handler strips the suffix for display. + string key = Element.YValues[j].ToString(System.Globalization.CultureInfo.InvariantCulture).Replace(".", "_") + ">" + (j + 1); + _responseTable.Columns.Add(key, typeof(double)); + } + for (int i = 0; i < rows; i++) + _responseTable.Rows.Add(_responseTable.NewRow()); + + var response = Element.BivariateResponse; + if (response != null) + { + int rR = response.GetLength(0); + int rC = response.GetLength(1); + for (int i = 0; i < Math.Min(rows, rR); i++) + for (int j = 0; j < Math.Min(cols, rC); j++) + _responseTable.Rows[i][j] = response[i, j]; + } + + _responseTable.ColumnChanged += ResponseTable_ColumnChanged; + BivariateDataGrid.ItemsSource = _responseTable.DefaultView; + ScheduleBivariateGridValidation(); + } + + /// + /// Handles the ColumnChanged event for ResponseTable. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void ResponseTable_ColumnChanged(object sender, System.Data.DataColumnChangeEventArgs e) + { + if (Element == null || _isResponsePasting) return; + if (Element.IsUndoEnabled == false) return; // skip during undo replay / Open + + int row = _responseTable.Rows.IndexOf(e.Row); + int col = e.Column.Ordinal; + if (row < 0 || col < 0) return; + if (Element.BivariateResponse == null) return; + if (row >= Element.BivariateResponse.GetLength(0) || col >= Element.BivariateResponse.GetLength(1)) return; + + double value = CoincidentFrequencyResponseGridValidator.ConvertCellValue(e.Row[col]) ?? double.NaN; + // Snapshot, mutate one cell, assign back through the property setter (records snapshot undo). + // Suppress the resulting BivariateResponse PropertyChanged from triggering a self-rebuild + // — the user's edit is already reflected in the DataGrid, and rebuilding kills focus. + var next = (double[,])Element.BivariateResponse.Clone(); + next[row, col] = value; + _suppressBivariateResponseRebuild = true; + try { Element.BivariateResponse = next; } + finally { _suppressBivariateResponseRebuild = false; } + ValidateBivariateGridCells(); + } + + /// + /// Handles the AutoGeneratingColumn event for BivariateResultsTable. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void BivariateResultsTable_AutoGeneratingColumn(object sender, DataGridAutoGeneratingColumnEventArgs e) + { + // Strip the ">i" suffix and restore "." in place of "_". + string raw = e.PropertyName ?? string.Empty; + int gt = raw.IndexOf('>'); + string header = gt >= 0 ? raw.Substring(0, gt) : raw; + header = header.Replace("_", "."); + e.Column = new DataGridTextColumn + { + Header = header, + Binding = new System.Windows.Data.Binding("[" + raw + "]") + { + StringFormat = "G", + UpdateSourceTrigger = System.Windows.Data.UpdateSourceTrigger.LostFocus, + }, + Width = new DataGridLength(1, DataGridLengthUnitType.Star), + MinWidth = 50, + }; + } + + /// + /// Handles the AutoGeneratedColumns event for BivariateResultsTable. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void BivariateResultsTable_AutoGeneratedColumns(object sender, EventArgs e) + { + // Reserved for post-generation styling (not currently needed). + } + + /// + /// Handles the LoadingRow event for BivariateResultsTable. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void BivariateResultsTable_LoadingRow(object sender, DataGridRowEventArgs e) + { + if (Element == null) return; + int idx = e.Row.GetIndex(); + if (idx >= 0 && idx < Element.XValues.Count) + e.Row.Header = Element.XValues[idx].ToString(System.Globalization.CultureInfo.InvariantCulture); + ScheduleBivariateGridRowValidation(idx); + } + + /// + /// Handles the PreviewPasteData event for BivariateDataGrid. + /// + /// The pasted clipboard cells. + /// Set to to cancel the operation. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void BivariateDataGrid_PreviewPasteData(string[][] clipboardData, ref bool cancelPaste) + { + _isResponsePasting = true; + Mouse.OverrideCursor = Cursors.Wait; + } + + /// + /// Handles the DataPasted event for BivariateDataGrid. + /// + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void BivariateDataGrid_DataPasted() + { + _isResponsePasting = false; + try + { + if (Element == null || _responseTable == null) return; + int rows = _responseTable.Rows.Count; + int cols = _responseTable.Columns.Count; + var next = new double[rows, cols]; + for (int i = 0; i < rows; i++) + for (int j = 0; j < cols; j++) + next[i, j] = CoincidentFrequencyResponseGridValidator.ConvertCellValue(_responseTable.Rows[i][j]) ?? double.NaN; + // Suppress the rebuild that would otherwise fire from the BivariateResponse + // PropertyChanged — the DataGrid already reflects the pasted values. + _suppressBivariateResponseRebuild = true; + try { Element.BivariateResponse = next; } + finally { _suppressBivariateResponseRebuild = false; } + ValidateBivariateGridCells(); + } + finally + { + Mouse.OverrideCursor = null; + } + } + + /// + /// Queues whole-grid response-surface validation after WPF has generated visible cells. + /// + /// + /// DataGrid cells are virtualized; immediate validation after assigning ItemsSource can + /// run before cell containers exist. Scheduling at Loaded priority lets visible cells + /// be painted without forcing scroll or realization side effects. + /// + private void ScheduleBivariateGridValidation() + { + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke(new Action(ScheduleBivariateGridValidation)); + return; + } + + Dispatcher.BeginInvoke(new Action(ValidateBivariateGridCells), System.Windows.Threading.DispatcherPriority.Loaded); + } + + /// + /// Queues validation for a row after WPF has generated its visible cell containers. + /// + /// The zero-based row index. + /// + /// This keeps validation styling correct when the grid virtualizes rows and creates + /// cells later as the user scrolls. + /// + private void ScheduleBivariateGridRowValidation(int rowIndex) + { + if (rowIndex < 0) return; + Dispatcher.BeginInvoke( + new Action(() => ValidateBivariateGridRow(rowIndex)), + System.Windows.Threading.DispatcherPriority.Loaded); + } + + /// + /// Validates and styles every currently realized response-grid cell. + /// + /// + /// Neighboring cells are revalidated together because one edit can make adjacent + /// values invalid under the strict-increase surface rule. + /// + private void ValidateBivariateGridCells() + { + if (_responseTable == null) return; + + var response = BuildResponseSurfaceFromTable(); + for (int i = 0; i < _responseTable.Rows.Count; i++) + for (int j = 0; j < _responseTable.Columns.Count; j++) + ApplyBivariateCellValidation(i, j, response); + } + + /// + /// Validates and styles every currently realized cell in a response-grid row. + /// + /// The zero-based row index. + /// + /// Row-level validation is used by so + /// virtualized rows receive the same validation styling as initially visible rows. + /// + private void ValidateBivariateGridRow(int rowIndex) + { + if (_responseTable == null || rowIndex < 0 || rowIndex >= _responseTable.Rows.Count) return; + + var response = BuildResponseSurfaceFromTable(); + for (int j = 0; j < _responseTable.Columns.Count; j++) + ApplyBivariateCellValidation(rowIndex, j, response); + } + + /// + /// Builds a nullable response surface from the editable DataTable. + /// + /// A nullable response surface where null represents a blank or non-convertible cell. + /// + /// Keeping blanks as null lets the validation layer distinguish missing + /// entries from true zero response values. + /// + private double?[,] BuildResponseSurfaceFromTable() + { + int rows = _responseTable?.Rows.Count ?? 0; + int cols = _responseTable?.Columns.Count ?? 0; + var response = new double?[rows, cols]; + + for (int i = 0; i < rows; i++) + for (int j = 0; j < cols; j++) + response[i, j] = CoincidentFrequencyResponseGridValidator.ConvertCellValue(_responseTable.Rows[i][j]); + + return response; + } + + /// + /// Applies validation styling to one realized response-grid cell. + /// + /// The zero-based row index. + /// The zero-based column index. + /// The nullable response surface used for validation. + /// + /// The method does nothing for virtualized cells that do not currently have a + /// visual container; those cells are validated when WPF later loads their row. + /// + private void ApplyBivariateCellValidation(int row, int column, double?[,] response) + { + var cell = GetDataGridCell(BivariateDataGrid, row, column); + if (cell == null) return; + + var validation = CoincidentFrequencyResponseGridValidator.ValidateCell(response, row, column); + SetCellValidation(cell, validation); + } + + /// + /// Applies or clears the invalid-cell visual state. + /// + /// The DataGrid cell to update. + /// The validation result for the cell. + /// + /// The visual treatment mirrors the TotalRisk bivariate-response grid while using + /// BestFit's response-surface validation rules. + /// + private static void SetCellValidation(DataGridCell cell, ResponseGridCellValidationResult validation) + { + if (!validation.IsValid) + { + cell.SetResourceReference(Control.BackgroundProperty, "DataGrid.Cell.Error.Background"); + cell.SetResourceReference(Control.BorderBrushProperty, "DataGrid.Cell.Error.Border"); + cell.BorderThickness = new Thickness(1.0); + cell.ToolTip = validation.ToolTip; + return; + } + + cell.ClearValue(Control.BorderThicknessProperty); + cell.ClearValue(Control.BorderBrushProperty); + cell.ClearValue(Control.BackgroundProperty); + cell.ClearValue(Control.PaddingProperty); + cell.ClearValue(FrameworkElement.MarginProperty); + cell.ClearValue(FrameworkElement.ToolTipProperty); + } + + /// + /// Gets a DataGrid cell visual when it is currently realized. + /// + /// The DataGrid that owns the cell. + /// The zero-based row index. + /// The zero-based column index. + /// The realized cell, or null when the row/cell is virtualized. + /// + /// This lookup intentionally does not scroll the grid into view, avoiding focus + /// movement or unexpected virtualization churn while the user is editing. + /// + private static DataGridCell GetDataGridCell(DataGrid dataGrid, int rowIndex, int columnIndex) + { + if (dataGrid == null || rowIndex < 0 || columnIndex < 0 || columnIndex >= dataGrid.Columns.Count) + return null; + + var row = dataGrid.ItemContainerGenerator.ContainerFromIndex(rowIndex) as DataGridRow; + if (row == null) return null; + + var presenter = FindVisualChild(row); + return presenter?.ItemContainerGenerator.ContainerFromIndex(columnIndex) as DataGridCell; + } + + /// + /// Finds the first visual child of the requested type. + /// + /// The child type to find. + /// The parent visual. + /// The first matching child, or null when none exists. + /// + /// DataGrid rows place cells inside a ; this + /// helper keeps that lookup local to the response-grid validation code. + /// + private static T FindVisualChild(DependencyObject parent) where T : DependencyObject + { + if (parent == null) return null; + + for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) + { + var child = VisualTreeHelper.GetChild(parent, i); + if (child is T match) return match; + + var descendant = FindVisualChild(child); + if (descendant != null) return descendant; + } + + return null; + } + + #endregion + + #region Frequency Plot tab — alternatives, header click + + /// + /// Handles the MouseLeftButtonUp event for TextBlock. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void TextBlock_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + // Header text click toggles the +/- ToggleButton, mirroring Composite. + ShowFilterToggleButton.IsChecked = !(ShowFilterToggleButton.IsChecked ?? false); + } + + /// + /// Handles the AnalysisAdded event from the AlternativeSelector. Adds the alternative + /// analysis's CredibleIntervals (AreaSeries), PosteriorPredictive, and PosteriorMode + /// series to the frequency plot. Mirrors + /// but with the X (AEP) and Y (Z response) axes swapped for the CFA orientation — + /// AreaSeries DataField mappings draw a horizontal CI band at fixed Z that varies + /// in AEP, rather than a vertical band at fixed AEP that varies in discharge. + /// + /// The selected alternative analysis. Guaranteed to be a + /// by the AlternativeFilterType set + /// on AlternativeSelector in the constructor. + private void AlternativeSelector_AnalysisAdded(AnalysisAlternativeItem analysisItem) + { + // Defensive: remove any stale instances of this item's series that might have been + // added on a previous AnalysisAdded firing (mirrors Composite). + FrequencyPlot.Series.Remove(analysisItem.CredibleIntervals); + FrequencyPlot.Series.Remove(analysisItem.PosteriorPredictive); + FrequencyPlot.Series.Remove(analysisItem.PosteriorMode); + + var alternative = analysisItem.Alternative as CoincidentFrequencyAnalysis; + if (alternative == null) return; + if (alternative.IsEstimated != true || alternative.AnalysisResults == null || alternative.ZOutputValues == null) return; + + // Pick a unique color: walk the palette, skip colors already used by another + // alternative's PosteriorMode series, and fall back to the last color if all + // are taken. Mirrors CompositeAnalysisControl's color-uniqueness logic. + var index = FrequencyPlot.Series.Count; + Color lineColor = (Color)ColorConverter.ConvertFromString(_colorHexCodes[index]); + Color fillColor = Color.FromArgb(100, lineColor.R, lineColor.G, lineColor.B); + + for (int i = 4; i < _colorHexCodes.Length; i++) + { + var templineColor = (Color)ColorConverter.ConvertFromString(_colorHexCodes[i]); + var tempfillColor = Color.FromArgb(100, templineColor.R, templineColor.G, templineColor.B); + bool colorExists = false; + for (int j = 0; j < FrequencyPlot.Series.Count; j++) + { + if (FrequencyPlot.Series[j].Name != null + && FrequencyPlot.Series[j].Name.Contains("Mode") + && FrequencyPlot.Series[j].Color == templineColor) + { + colorExists = true; + break; + } + } + if (colorExists == false || i == _colorHexCodes.Length - 1) + { + lineColor = templineColor; + fillColor = tempfillColor; + break; + } + } + + // Build the data: one Point3D per Z bin holding (z, lower_AEP, upper_AEP). + // The DataField mapping (below) draws an AreaSeries that, at each z, stretches + // horizontally from lower_AEP to upper_AEP along the X axis. + var z = alternative.ZOutputValues; + var ar = alternative.AnalysisResults; + int n = z.Length; + var ciPoints = new List(n); + var prdPoints = new List(n); + var mdPoints = new List(n); + for (int i = 0; i < n; i++) + { + double lo = ar.ConfidenceIntervals != null ? ar.ConfidenceIntervals[i, 0] : double.NaN; + double up = ar.ConfidenceIntervals != null ? ar.ConfidenceIntervals[i, 1] : double.NaN; + double prd = ar.MeanCurve != null ? ar.MeanCurve[i] : double.NaN; + double md = ar.ModeCurve != null ? ar.ModeCurve[i] : double.NaN; + ciPoints.Add(new Point3D(z[i], lo, up)); + prdPoints.Add(new DataPoint(prd, z[i])); + mdPoints.Add(new DataPoint(md, z[i])); + } + + // Series labels. + double ciWidth = alternative.BayesianAnalysis.CredibleIntervalWidth; + string ciLabel = "% Credible Intervals"; + string predLabel = " - Posterior Predictive"; + string pointLabel = " - " + (alternative.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"); + + // Credible Intervals — AreaSeries with X/Y swapped vs the Composite layout to + // produce a horizontal band at fixed z (Y) varying along AEP (X). + // primary curve: X=Point3D.Y=lo, Y=Point3D.X=z + // secondary curve: X=Point3D.Z=up, Y=Point3D.X=z + analysisItem.CredibleIntervals.Name = "CredibleIntervals_" + index; + analysisItem.CredibleIntervals.Title = alternative.Name + " - " + (ciWidth * 100).ToString("F0") + ciLabel; + analysisItem.CredibleIntervals.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + analysisItem.CredibleIntervals.ItemsSource = ciPoints; + analysisItem.CredibleIntervals.DataFieldX = "Y"; + analysisItem.CredibleIntervals.DataFieldY = "X"; + analysisItem.CredibleIntervals.DataFieldX2 = "Z"; + analysisItem.CredibleIntervals.DataFieldY2 = "X"; + analysisItem.CredibleIntervals.Decimator = OxyPlot.Decimator.Decimate; + analysisItem.CredibleIntervals.Fill = fillColor; + analysisItem.CredibleIntervals.Color = Colors.Transparent; + FrequencyPlot.Series.Add(analysisItem.CredibleIntervals); + + // Posterior Predictive — LineSeries with default DataPoint X/Y mapping (X=AEP, Y=z). + analysisItem.PosteriorPredictive.Name = "PosteriorPredictive_" + index; + analysisItem.PosteriorPredictive.Title = alternative.Name + predLabel; + analysisItem.PosteriorPredictive.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + analysisItem.PosteriorPredictive.ItemsSource = prdPoints; + analysisItem.PosteriorPredictive.Decimator = OxyPlot.Decimator.Decimate; + analysisItem.PosteriorPredictive.MinimumSegmentLength = 4.0; + analysisItem.PosteriorPredictive.Color = lineColor; + FrequencyPlot.Series.Add(analysisItem.PosteriorPredictive); + + // Point Estimator (Posterior Mean / Mode) — LineSeries with default DataPoint mapping. + analysisItem.PosteriorMode.Name = "PosteriorMode_" + index; + analysisItem.PosteriorMode.Title = alternative.Name + pointLabel; + analysisItem.PosteriorMode.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + analysisItem.PosteriorMode.ItemsSource = mdPoints; + analysisItem.PosteriorMode.Decimator = OxyPlot.Decimator.Decimate; + analysisItem.PosteriorMode.MinimumSegmentLength = 4.0; + analysisItem.PosteriorMode.Color = lineColor; + FrequencyPlot.Series.Add(analysisItem.PosteriorMode); + + FrequencyPlot.InvalidatePlot(true); + } + + /// + /// Handles the AnalysisRemoved event from the AlternativeSelector. Removes the + /// alternative's CredibleIntervals, PosteriorPredictive, and PosteriorMode series + /// from the frequency plot. Mirrors . + /// + /// The alternative analysis item being deselected. + private void AlternativeSelector_AnalysisRemoved(AnalysisAlternativeItem analysisItem) + { + FrequencyPlot.Series.Remove(analysisItem.CredibleIntervals); + FrequencyPlot.Series.Remove(analysisItem.PosteriorPredictive); + FrequencyPlot.Series.Remove(analysisItem.PosteriorMode); + FrequencyPlot.InvalidatePlot(true); + } + + #endregion + } +} diff --git a/src/RMC.BestFit.App/GUI/BivariateAnalysis/CoincidentFrequency/CoincidentFrequencyPropertiesControl.xaml b/src/RMC.BestFit.App/GUI/BivariateAnalysis/CoincidentFrequency/CoincidentFrequencyPropertiesControl.xaml new file mode 100644 index 0000000..c95649f --- /dev/null +++ b/src/RMC.BestFit.App/GUI/BivariateAnalysis/CoincidentFrequency/CoincidentFrequencyPropertiesControl.xaml @@ -0,0 +1,233 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/BivariateAnalysis/CoincidentFrequency/CoincidentFrequencyPropertiesControl.xaml.cs b/src/RMC.BestFit.App/GUI/BivariateAnalysis/CoincidentFrequency/CoincidentFrequencyPropertiesControl.xaml.cs new file mode 100644 index 0000000..a3a000c --- /dev/null +++ b/src/RMC.BestFit.App/GUI/BivariateAnalysis/CoincidentFrequency/CoincidentFrequencyPropertiesControl.xaml.cs @@ -0,0 +1,853 @@ +using FrameworkInterfaces; +using Numerics.Utilities; +using RMC.BestFit.Estimation; +using RMC.BestFit.UI; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Globalization; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; + +namespace RMC_BestFit +{ + /// + /// User control for displaying and editing the properties of a + /// : upstream link, X / Y ordinates, + /// the M×N response surface, output bin count, and credible-interval width. + /// + public partial class CoincidentFrequencyPropertiesControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public CoincidentFrequencyPropertiesControl() + { + InitializeComponent(); + // DataContext must be set AFTER InitializeComponent() to avoid NullReferenceExceptions + // from bindings that fire during XAML parsing when Element is still null. + DataContext = this; + } + + #region Element DependencyProperty + ElementCallback + + /// Stores the previous name for rollback when validation fails. + private string _previousName; + + /// + /// Tracks the currently subscribed via + /// / . + /// Used by to reliably unhook without + /// capturing a lambda that cannot be unhooked. + /// + private IElementCollection _subscribedInputDataCollection; + + /// The inner ComboBox used for optional input-data overlay selection. + private ComboBox _inputDataInnerComboBox; + + /// + /// Tracks the currently subscribed via + /// / . + /// + private IElementCollection _subscribedBivariateAnalysesCollection; + + /// + /// Dependency property for the property. + /// + public static DependencyProperty ElementProperty = DependencyProperty.Register( + nameof(Element), + typeof(CoincidentFrequencyAnalysis), + typeof(CoincidentFrequencyPropertiesControl), + new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the coincident frequency analysis element whose properties are displayed. + /// + public CoincidentFrequencyAnalysis Element + { + get { return (CoincidentFrequencyAnalysis)GetValue(ElementProperty); } + set { SetValue(ElementProperty, value); } + } + + /// + /// Callback invoked when the dependency property changes. + /// + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as CoincidentFrequencyPropertiesControl == null) return; + var thisControl = (CoincidentFrequencyPropertiesControl)d; + + // Unsubscribe from old element + if (e.OldValue is CoincidentFrequencyAnalysis oldElement) + { + oldElement.PropertyChanged -= thisControl.Element_PropertyChanged; + } + + // Unsubscribe collection trackers before re-subscribing for the new element. + thisControl.UnsubscribeInputDataCollection(); + thisControl.UnsubscribeBivariateAnalysesCollection(); + thisControl.ClearInputDataSelectionItems(); + + if (e.NewValue == null) return; + var newElement = e.NewValue as CoincidentFrequencyAnalysis; + if (newElement == null) return; + + newElement.PropertyChanged += thisControl.Element_PropertyChanged; + thisControl._previousName = newElement.Name; + thisControl.PropertyAttributes.GetClassAttributes(newElement); + thisControl.LoadInputData(); + thisControl.LoadBivariateAnalyses(); + // X / Y ordinate grids are owned by the two instances + // bound to Element.XValues / Element.YValues — they handle their own model↔UI + // sync via the OrdinatesProperty DependencyProperty callback. + // + // Reflect the initial validity of InputData / BivariateAnalysis in the combo + // borders. Without these calls, the XAML default red border stays visible + // after a saved element is reopened with a valid selection. + thisControl.UpdateInputDataValidationBorder(); + thisControl.UpdateAnalysisValidationBorder(); + thisControl.SelectCurrentInputDataItem(thisControl._inputDataInnerComboBox); + } + + /// + /// Forwards element property changes into UI sync routines. The M×N response grid + /// lives in the document control (CoincidentFrequencyControl), not here, so this + /// handler just marshals to the UI thread for any property-bound state on this side. + /// + private void Element_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke(new Action(() => Element_PropertyChanged(sender, e))); + return; + } + // The response grid is in the document control. X/Y wrapper rows are synced via + // Model{X,Y}Values_CollectionChanged below; only property-bound combo state lives here. + if (e.PropertyName == nameof(Element.InputData)) + { + UpdateInputDataValidationBorder(); + SelectCurrentInputDataItem(_inputDataInnerComboBox); + } + } + + #endregion + + #region Combo box option lists (static — referenced by XAML bindings) + + /// List of optional input-data overlay choices. + public ObservableCollection InputDataList { get; private set; } + = new ObservableCollection(); + + /// List of fitted bivariate analyses available as upstream input. + public ObservableCollection BivariateAnalysisList { get; private set; } + = new ObservableCollection(); + + /// + /// Credible interval width options exposed to the ComboBox. + /// Allocated once per class (not per instance) to avoid unnecessary allocations on + /// every XAML binding call. Matches the canonical pattern used by + /// InputDataPropertiesControl. + /// + private static readonly ObservableCollection _credibleIntervalItems + = new ObservableCollection + { + new CredibleIntervalItem("90%", 0.90), + new CredibleIntervalItem("95%", 0.95), + new CredibleIntervalItem("98%", 0.98), + new CredibleIntervalItem("99%", 0.99), + }; + + /// Gets the credible interval width options for the CI ComboBox. + public ObservableCollection CredibleIntervalItems => _credibleIntervalItems; + + private static readonly ObservableCollection _pointEstimatorItems + = new ObservableCollection() + { + new PointEstimatorItem("Posterior Mean", BayesianAnalysis.PointEstimateType.PosteriorMean, "Calculates the average of all posterior samples, providing a balanced summary of the parameter estimates."), + new PointEstimatorItem("Posterior Mode", BayesianAnalysis.PointEstimateType.PosteriorMode, "Selects the most likely parameter values for the posterior distribution (the Maximum A Posteriori estimate)."), + }; + + /// + /// Gets the observable collection of point estimator options for Bayesian analysis. + /// + public ObservableCollection PointEstimatorItems => _pointEstimatorItems; + + + #endregion + + #region Property attributes + + /// + /// Handles the PreviewMouseLeftButtonDown event for Name. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void Name_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element != null) PropertyAttributes.GetPropertyAttributes(nameof(Element.Name), Element); + } + /// + /// Handles the PreviewMouseLeftButtonDown event for Description. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void Description_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element != null) PropertyAttributes.GetPropertyAttributes(nameof(Element.Description), Element); + } + /// + /// Handles the PreviewMouseLeftButtonDown event for InputDataComboBox. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void InputDataComboBox_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element != null) PropertyAttributes.GetPropertyAttributes(nameof(Element.InputData), Element); + } + /// + /// Handles the PreviewMouseLeftButtonDown event for CreationDate. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void CreationDate_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element != null) PropertyAttributes.GetPropertyAttributes(nameof(Element.CreationDate), Element); + } + /// + /// Handles the PreviewMouseLeftButtonDown event for LastModified. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void LastModified_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element != null) PropertyAttributes.GetPropertyAttributes(nameof(Element.LastModified), Element); + } + /// + /// Handles the PreviewMouseLeftButtonDown event for AnalysisComboBox. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void AnalysisComboBox_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element != null) PropertyAttributes.GetPropertyAttributes(nameof(Element.BivariateAnalysis), Element); + } + /// + /// Handles the PreviewMouseLeftButtonDown event for XOrdinateDataGrid. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void XOrdinateDataGrid_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element != null) PropertyAttributes.GetPropertyAttributes(nameof(Element.XValues), Element); + } + /// + /// Handles the PreviewMouseLeftButtonDown event for YOrdinateDataGrid. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void YOrdinateDataGrid_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element != null) PropertyAttributes.GetPropertyAttributes(nameof(Element.YValues), Element); + } + /// + /// Handles the PreviewMouseLeftButtonDown event for the CredibleInterval control. Displays property attributes for the credible interval width. + /// + /// The source of the event. + /// The instance containing the event data. + private void CredibleInterval_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.BayesianAnalysis.CredibleIntervalWidth), Element.BayesianAnalysis); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the PointEstimator control. Displays property attributes for the point estimator type. + /// + /// The source of the event. + /// The instance containing the event data. + private void PointEstimator_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.BayesianAnalysis.PointEstimator), Element.BayesianAnalysis); + } + /// + /// Handles the PreviewMouseLeftButtonDown event for NumberOfBins. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void NumberOfBins_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element != null) PropertyAttributes.GetPropertyAttributes(nameof(Element.NumberOfBins), Element); + } + /// + /// Handles the PreviewMouseLeftButtonDown event for TabItem. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void TabItem_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element != null) PropertyAttributes.GetClassAttributes(Element); + } + + #endregion + + #region Name focus / rollback + + /// + /// Handles the GotFocus event for Name. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void Name_GotFocus(object sender, RoutedEventArgs e) + { + if (Element != null) _previousName = Element.Name; + } + + /// + /// Handles the LostFocus event for Name. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void Name_LostFocus(object sender, RoutedEventArgs e) + { + if (Element == null) return; + if (string.IsNullOrEmpty(Element.Name) || Element.Name == _previousName) return; + + // Validate uniqueness in parent collection. + foreach (IElement sibling in Element.ParentCollection) + { + if (!ReferenceEquals(sibling, Element) && sibling.Name == Element.Name) + { + GenericControls.MessageBox.Show( + $"An element named '{Element.Name}' already exists. Reverting.", + "Duplicate Name", + MessageBoxButton.OK, MessageBoxImage.Warning); + Element.Name = _previousName; + return; + } + } + _previousName = Element.Name; + } + + #endregion + + #region Loaded / Unloaded + + /// + /// Handles the Loaded event. Collection subscriptions are set up in + /// ; this method is a no-op for future use. + /// + private void UserControl_Loaded(object sender, RoutedEventArgs e) + { + // No-op: collection subscriptions are owned by ElementCallback. + } + + /// + /// Handles the Unloaded event. Unsubscribes the collection change handlers to prevent + /// memory leaks and clears input-data selection wrappers when this control is replaced. + /// + private void UserControl_Unloaded(object sender, RoutedEventArgs e) + { + UnsubscribeInputDataCollection(); + UnsubscribeBivariateAnalysesCollection(); + ClearInputDataSelectionItems(); + } + + #endregion + + #region Input data combo + + /// + /// Populates from the project's . + /// Subscribes / + /// via named methods so the subscription can be reliably unhooked by + /// . InputData is optional on CFA — it + /// provides an observed-data overlay on the Frequency Plot. + /// + private void LoadInputData() + { + UnsubscribeInputDataCollection(); + ClearInputDataSelectionItems(); + InputDataList.Add(InputDataSelectionItem.CreateNone()); + if (Element == null) return; + foreach (IElementCollection collection in Element.ParentCollection.ParentProject.ElementCollections) + { + if (collection.GetType() == typeof(InputDataCollection)) + { + _subscribedInputDataCollection = collection; + collection.ElementAdded += OnInputDataElementAdded; + collection.ElementRemoved += OnInputDataElementRemoved; + foreach (IElement element in collection) + if (element is InputData id) AddInputDataSelectionItem(id); + break; + } + } + } + + /// + /// Handles an element being added to the . + /// + private void OnInputDataElementAdded(IElement element) + { + if (element is InputData id) AddInputDataSelectionItem(id); + } + + /// + /// Handles an element being removed from the . + /// + private void OnInputDataElementRemoved(IElement element) + { + if (element is InputData id) RemoveInputDataSelectionItem(id); + } + + /// + /// Unsubscribes the named handlers from the tracked + /// and clears the tracker field. + /// + private void UnsubscribeInputDataCollection() + { + if (_subscribedInputDataCollection != null) + { + _subscribedInputDataCollection.ElementAdded -= OnInputDataElementAdded; + _subscribedInputDataCollection.ElementRemoved -= OnInputDataElementRemoved; + _subscribedInputDataCollection = null; + } + } + + /// + /// Adds a real input-data selection item if it is not already represented. + /// + /// The input data element to add. + /// + /// The no-overlay item is created only by ; this helper + /// is for project collection additions. + /// + private void AddInputDataSelectionItem(InputData inputData) + { + if (FindInputDataSelectionItem(inputData) != null) return; + InputDataList.Add(new InputDataSelectionItem(inputData)); + } + + /// + /// Removes and disposes a real input-data selection item. + /// + /// The input data element to remove. + /// + /// Disposing the wrapper releases the rename subscription held for ComboBox display. + /// + private void RemoveInputDataSelectionItem(InputData inputData) + { + var item = FindInputDataSelectionItem(inputData); + if (item == null) return; + InputDataList.Remove(item); + item.Dispose(); + } + + /// + /// Finds the selection item wrapping the supplied input data reference. + /// + /// The input data reference to find. + /// The matching selection item, or null when absent. + /// + /// Reference matching preserves identity semantics used by the analysis wrapper. + /// + private InputDataSelectionItem FindInputDataSelectionItem(InputData inputData) + { + foreach (InputDataSelectionItem item in InputDataList) + { + if (ReferenceEquals(item.Value, inputData)) + { + return item; + } + } + + return null; + } + + /// + /// Disposes all current input-data selection items and clears the list. + /// + /// + /// This is called before rebuilding the list and when the control unloads to avoid + /// retaining stale wrappers through input-data rename subscriptions. + /// + private void ClearInputDataSelectionItems() + { + foreach (InputDataSelectionItem item in InputDataList) + { + item.Dispose(); + } + + InputDataList.Clear(); + } + + /// + /// Handles loading of the optional input-data ComboBox. + /// + /// The ComboBox being loaded. + /// The routed event data. + /// + /// CFA keeps the project collection order for real input data and pins the no-overlay + /// item first by insertion order. + /// + private void InputDataComboBox_Loaded(object sender, RoutedEventArgs e) + { + if (sender is ComboBox cmbo) + { + _inputDataInnerComboBox = cmbo; + cmbo.ItemsSource = InputDataList; + SelectCurrentInputDataItem(cmbo); + } + } + + /// + /// Validates the InputData selection and toggles the red border and tooltip on the + /// surrounding content property control accordingly. Mirrors + /// . + /// + /// The ComboBox raising the event. + /// The selection-changed event data. + private void InputDataComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + UpdateInputDataValidationBorder(); + } + + /// + /// Toggles the red border + tooltip on based on + /// whether is non-null. Called + /// from the SelectionChanged handler AND from after + /// the new element binds, so the border reflects the initial state — not just + /// later user-driven changes. + /// + private void UpdateInputDataValidationBorder() + { + if (Element == null) return; + if (Element.InputData != null && Element.InputData.Name == null) + { + ((Border)InputDataComboBox.InnerContent).BorderThickness = new Thickness(1); + InputDataComboBox.ToolTip = "Please select a valid input data."; + } + else + { + ((Border)InputDataComboBox.InnerContent).BorderThickness = new Thickness(0); + InputDataComboBox.ToolTip = null; + } + } + + /// + /// Selects the ComboBox item that matches the element's current input-data reference. + /// + /// The ComboBox to synchronize. + /// + /// WPF can treat a null SelectedValue as no selection, so this method explicitly + /// selects the UI-only <None> item when the analysis overlay is null. + /// + private void SelectCurrentInputDataItem(ComboBox comboBox) + { + if (comboBox == null || Element == null) return; + foreach (object comboBoxItem in comboBox.Items) + { + if (comboBoxItem is InputDataSelectionItem item && ReferenceEquals(item.Value, Element.InputData)) + { + comboBox.SelectedItem = item; + return; + } + } + } + + #endregion + + #region Bivariate analysis combo + + /// + /// Populates from the project's + /// . Subscribes named handlers so the + /// subscription can be reliably unhooked by . + /// + private void LoadBivariateAnalyses() + { + BivariateAnalysisList.Clear(); + if (Element == null) return; + foreach (IElementCollection collection in Element.ParentCollection.ParentProject.ElementCollections) + { + if (collection.GetType() == typeof(BivariateAnalysisCollection)) + { + UnsubscribeBivariateAnalysesCollection(); + _subscribedBivariateAnalysesCollection = collection; + collection.ElementAdded += OnBivariateAnalysisElementAdded; + collection.ElementRemoved += OnBivariateAnalysisElementRemoved; + foreach (IElement element in collection) + { + if (element is BivariateAnalysis ba && !ReferenceEquals(ba, Element)) + BivariateAnalysisList.Add(ba); + } + break; + } + } + } + + /// + /// Handles an element being added to the . + /// + private void OnBivariateAnalysisElementAdded(IElement element) + { + if (element is BivariateAnalysis ba && !BivariateAnalysisList.Contains(ba)) + BivariateAnalysisList.Add(ba); + } + + /// + /// Handles an element being removed from the . + /// + private void OnBivariateAnalysisElementRemoved(IElement element) + { + if (element is BivariateAnalysis ba) BivariateAnalysisList.Remove(ba); + } + + /// + /// Unsubscribes the named handlers from the tracked + /// and clears the tracker field. + /// + private void UnsubscribeBivariateAnalysesCollection() + { + if (_subscribedBivariateAnalysesCollection != null) + { + _subscribedBivariateAnalysesCollection.ElementAdded -= OnBivariateAnalysisElementAdded; + _subscribedBivariateAnalysesCollection.ElementRemoved -= OnBivariateAnalysisElementRemoved; + _subscribedBivariateAnalysesCollection = null; + } + } + + /// + /// Handles the Loaded event for AnalysisComboBox. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void AnalysisComboBox_Loaded(object sender, RoutedEventArgs e) + { + if (Element == null) return; + if (sender is ComboBox cmbo) + cmbo.ItemsSource = BivariateAnalysisList; + } + + /// + /// Validates the Bivariate-Analysis selection and toggles the red border and tooltip + /// on the surrounding content property control. Mirrors the InputData + /// border-toggle logic. + /// + /// The ComboBox raising the event. + /// The selection-changed event data. + private void AnalysisComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + UpdateAnalysisValidationBorder(); + } + + /// + /// Toggles the red border + tooltip on based on + /// whether is non-null. + /// Called from the SelectionChanged handler AND from + /// after the new element binds. + /// + private void UpdateAnalysisValidationBorder() + { + if (Element == null) return; + if (Element.BivariateAnalysis == null || Element.BivariateAnalysis.Name == null) + { + ((Border)AnalysisComboBox.InnerContent).BorderThickness = new Thickness(1); + AnalysisComboBox.ToolTip = "Please select a valid bivariate analysis."; + } + else + { + ((Border)AnalysisComboBox.InnerContent).BorderThickness = new Thickness(0); + AnalysisComboBox.ToolTip = null; + } + } + + #endregion + + #region BayesianOutputControl + + /// + /// Handles the event by + /// asking the inline PropertyAttributes panel to display the description and + /// validation rules for the requested property. Mirrors the canonical handler in + /// — the delegate signature is + /// (string propertyName, object classObject), not the default WPF + /// RoutedEventHandler. + /// + /// Name of the property whose attributes should be shown. + /// The object that owns the property (typically the Bayesian analysis instance). + private void BayesianOutputControl_ShowPropertyAttributes(string propertyName, object classObject) + { + PropertyAttributes.GetPropertyAttributes(propertyName, classObject); + } + + #endregion + + #region Estimate + + /// + /// Handles the Click event for EstimateButton. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private async void EstimateButton_Click(object sender, RoutedEventArgs e) + { + if (Element == null) return; + if (!Element.IsValid) + { + GenericControls.MessageBox.Show( + "Cannot perform the analysis because the inputs are invalid.", + "Error!", MessageBoxButton.OK, MessageBoxImage.Error); + return; + } + + Mouse.OverrideCursor = Cursors.Wait; + + // Disable all open windows and main-window controls so the user cannot navigate + // away or kick off a second analysis on the same control while this one runs. + // Mirrors UnivariateAnalysisPropertiesControl.EstimateButton_Click. + FrameworkUI.ShellPublicVariables.SimulationInProgress = true; + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableMenuStrip(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableProjectExplorer(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableMessageWindow(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableOpenWindows(); + // Disable Properties + PropertiesExpander.IsEnabled = false; + XOrdinatesExpander.IsEnabled = false; + YOrdinatesExpander.IsEnabled = false; + Output_TabItem.IsEnabled = false; + + // Set up progress bar + AnalysisProgressDisplayHelper.ShowInitial(ProgressBar, ProgressTextBlock); + + // Show cancel button + EstimateButton.Visibility = Visibility.Hidden; + CancelButton.Visibility = Visibility.Visible; + + var progressReporter = new SafeProgressReporter(nameof(CoincidentFrequencyAnalysis)); + progressReporter.ProgressReported += (SafeProgressReporter reporter, double progress, double progressDelta) => + { + AnalysisProgressDisplayHelper.PostProgress(Dispatcher, ProgressBar, ProgressTextBlock, progress); + }; + bool progressCleanupEnabled = false; + progressReporter.TaskEnded += () => + { + if (!progressCleanupEnabled) return; + Dispatcher.BeginInvoke(new Action(() => + { + // Close out progress bar + ProgressBar.Value = 100; + ProgressTextBlock.Text = "Analysis Complete"; + Mouse.OverrideCursor = null; + ProgressBar.Visibility = Visibility.Hidden; + ProgressTextBlock.Visibility = Visibility.Hidden; + + // Restore Estimate button. + EstimateButton.Visibility = Visibility.Visible; + CancelButton.Visibility = Visibility.Hidden; + + // Re-enable the main window. + FrameworkUI.ShellPublicVariables.SimulationInProgress = false; + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableMenuStrip(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableProjectExplorer(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableMessageWindow(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableOpenWindows(); + // Enable Properties + PropertiesExpander.IsEnabled = true; + XOrdinatesExpander.IsEnabled = true; + YOrdinatesExpander.IsEnabled = true; + Output_TabItem.IsEnabled = true; + })); + }; + + try + { + await WaitCursorHelper.RunWithVisibleWaitCursorAsync(Dispatcher, async () => + { + await Element.RunAsync(progressReporter); + }); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"CoincidentFrequencyPropertiesControl.EstimateButton_Click: {ex}"); + GenericControls.MessageBox.Show( + "An unexpected error occurred. Please verify your analysis inputs and settings." + + Environment.NewLine + "If the issue persists, contact the Risk Management Center.", + "Error!", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + progressCleanupEnabled = true; + progressReporter.IndicateTaskEnded(); + } + } + + /// + /// Cancels the currently running analysis. UI cleanup is finalized after the awaited + /// analysis task returns in . + /// + private void CancelButton_Click(object sender, RoutedEventArgs e) + { + Element?.CancelAnalysis(); + } + + #endregion + + #region Keyboard + + /// + /// Routed key-down handler for the properties root. Cancels the running analysis when Escape is pressed. + /// + private void Properties_KeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + Element?.CancelAnalysis(); + } + + #endregion + } +} diff --git a/src/RMC.BestFit.App/GUI/FittingAnalysis/FittingAnalysisControl.xaml b/src/RMC.BestFit.App/GUI/FittingAnalysis/FittingAnalysisControl.xaml new file mode 100644 index 0000000..8c84d69 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/FittingAnalysis/FittingAnalysisControl.xaml @@ -0,0 +1,568 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/FittingAnalysis/FittingAnalysisControl.xaml.cs b/src/RMC.BestFit.App/GUI/FittingAnalysis/FittingAnalysisControl.xaml.cs new file mode 100644 index 0000000..443cb81 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/FittingAnalysis/FittingAnalysisControl.xaml.cs @@ -0,0 +1,1643 @@ +using FrameworkInterfaces; +using FrameworkUI; +using RMC.BestFit.Models; +using RMC.BestFit.UI; +using OxyPlot; +using OxyPlot.Wpf; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using System.Windows.Input; +using System.Windows.Media; +using Numerics; +using Numerics.Data.Statistics; +using OxyPlotControls; +using Numerics.Sampling; + +namespace RMC_BestFit +{ + /// + /// User control for displaying and managing distribution fitting analysis results. + /// Provides visualization of fitted distributions through multiple plot types (frequency, PDF, CDF, P-P, Q-Q) + /// and displays goodness-of-fit statistics in data grids. + /// + public partial class FittingAnalysisControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// Sets up color schemes and initializes the distribution column list for the data grid. + /// + public FittingAnalysisControl() + { + InitializeComponent(); + DataContext = this; + _colorHexCodes = GenericControls.GeneralMethods.RandomColorsLongList; + _columns.Add(ExpColumn); + _columns.Add(GammaColumn); + _columns.Add(GeneralizedExtremeValueColumn); + _columns.Add(GeneralizedLogisticColumn); + _columns.Add(GeneralizedNormalColumn); + _columns.Add(GeneralizedParetoColumn); + _columns.Add(GumbelColumn); + _columns.Add(KappaColumn); + _columns.Add(LnNormalColumn); + _columns.Add(LogisticColumn); + _columns.Add(LogNormalColumn); + _columns.Add(LogPearsonTypeIIIColumn); + _columns.Add(NormalColumn); + _columns.Add(PearsonTypeIIIColumn); + _columns.Add(WeibullColumn); + } + + #region Members + + /// + /// Dependency property for the Element property. + /// + public static DependencyProperty ElementProperty = DependencyProperty.Register(nameof(Element), typeof(FittingAnalysis), typeof(FittingAnalysisControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the fitting analysis element displayed by this control. + /// + public FittingAnalysis Element + { + get { return (FittingAnalysis)GetValue(ElementProperty); } + set { SetValue(ElementProperty, value); } + } + + /// + /// Callback method invoked when the Element property changes. + /// Manages event handler subscriptions and plot attachment for the old and new element instances. + /// + /// The dependency object where the property changed. + /// Event data containing the old and new property values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as FittingAnalysisControl == null) return; + var thisControl = (FittingAnalysisControl)d; + + // Remove handlers and detach plots from old element + if (e.OldValue is FittingAnalysis oldElement) + { + oldElement.PropertyChanged -= thisControl.ElementPropertyChanged; + + // Detach plots from hosts + thisControl.FrequencyPlotHost.Content = null; + thisControl.PDFPlotHost.Content = null; + thisControl.CDFPlotHost.Content = null; + thisControl.PPPlotHost.Content = null; + thisControl.QQPlotHost.Content = null; + + // Clear toolbar + thisControl.PlotToolbar.Plot = null; + thisControl.PlotToolbar.PropertiesCalled -= thisControl.PlotToolbar_PropertiesCalled; + } + + if (e.NewValue == null) return; + var newElement = e.NewValue as FittingAnalysis; + if (newElement == null) return; + + // Reset control-scoped caches so the next Loaded refresh rebuilds them + thisControl._seriesCreated = false; + thisControl._aicBicStylesCreated = false; + thisControl._lastFilterIndex = -1; + + // Add handlers + newElement.PropertyChanged += thisControl.ElementPropertyChanged; + + // Attach Element-owned plots to ContentControl hosts. + // Suspend plot bridges to prevent spurious undo entries from WPF property change notifications. + using (newElement.SuspendPlotBridges()) + { + thisControl.FrequencyPlotHost.Content = newElement.FrequencyPlot; + thisControl.PDFPlotHost.Content = newElement.PDFPlot; + thisControl.CDFPlotHost.Content = newElement.CDFPlot; + thisControl.PPPlotHost.Content = newElement.PPPlot; + thisControl.QQPlotHost.Content = newElement.QQPlot; + + // Wire toolbar to the initially visible plot (Frequency) + thisControl.PlotToolbar.Plot = newElement.FrequencyPlot; + thisControl.PlotToolbar.PropertiesCalled += thisControl.PlotToolbar_PropertiesCalled; + + // Bind axis titles to InputData.UnitLabel (must be inside suspension + // to prevent "Change Title" undo entries from axis PropertyChanged) + thisControl.BindAxisTitles(); + } + } + + /// + /// Indicates whether the frequency plot needs updating before display. + /// + private bool _frequencyPlotDirty = true; + + /// + /// Indicates whether the PDF plot needs updating before display. + /// + private bool _pdfPlotDirty = true; + + /// + /// Indicates whether the CDF plot needs updating before display. + /// + private bool _cdfPlotDirty = true; + + /// + /// Indicates whether the P-P plot needs updating before display. + /// + private bool _ppPlotDirty = true; + + /// + /// Indicates whether the Q-Q plot needs updating before display. + /// + private bool _qqPlotDirty = true; + + /// + /// Indicates whether LineSeries objects have been created for the current filter set. + /// + private bool _seriesCreated = false; + + /// + /// Indicates whether AIC/BIC/RMSE column header styles have been created. + /// + private bool _aicBicStylesCreated = false; + + /// + /// Tracks the last filter combo box index to avoid redundant FilteredDistributions recreation. + /// + private int _lastFilterIndex = -1; + + /// + /// Indicates whether all checkboxes are being checked or unchecked programmatically. + /// + private bool _checkingAll = false; + + /// + /// Indicates whether a single checkbox is being modified programmatically. + /// + private bool _singleCheck = false; + + /// + /// Gets a value indicating whether a plot area was clicked by the user. + /// + public bool PlotClicked { get; private set; } = false; + + /// + /// Occurs when the preview control (plot or toolbar) is clicked. + /// + public event PreviewControlClickedEventHandler PreviewControlClicked; + + /// + /// Represents the method that handles the PreviewControlClicked event. + /// + /// True if the plot area was clicked. + /// True if the toolbar area was clicked. + /// The plot that was clicked. + public delegate void PreviewControlClickedEventHandler(bool plotClicked, bool toolbarClicked, Plot plot); + + /// + /// Raised when plot properties are requested to be displayed or modified. + /// Re-raises the PropertiesCalled event from the toolbar so that MainProjectNode + /// only needs to subscribe to a single event on the control. + /// + public event PlotPropertiesCalledEventHandler PlotPropertiesCalled; + + /// + /// Delegate for the event. + /// + /// The plot whose properties are being accessed. + /// Indicates whether to open the properties panel. + /// The property expander to use for displaying properties. + /// The currently selected object in the plot. + public delegate void PlotPropertiesCalledEventHandler(Plot plot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject); + + /// + /// Gets the collection of fitted distributions filtered by the current selection criteria. + /// + public ObservableCollection FilteredDistributions { get; private set; } + + /// + /// Collection of fitted distribution statistics for display in the summary data grid. + /// + private ObservableCollection _fittedDistributionStatistics = new ObservableCollection(); + + /// + /// List of data grid columns for each distribution type. + /// + private List _columns = new List(); + + #region Plot Series + + /// + /// Array of color hex codes used for plotting different distribution series. + /// + private string[] _colorHexCodes; + + /// + /// List of line series for displaying distributions on the frequency plot. + /// + private List _frequencyDistributionSeriesList = new List(); + + /// + /// List of line series for displaying distributions on the PDF plot. + /// + private List _pdfDistributionSeriesList = new List(); + + /// + /// List of line series for displaying distributions on the CDF plot. + /// + private List _cdfDistributionSeriesList = new List(); + + /// + /// List of line series for displaying distributions on the P-P plot. + /// + private List _ppDistributionSeriesList = new List(); + + /// + /// List of line series for displaying distributions on the Q-Q plot. + /// + private List _qqDistributionSeriesList = new List(); + + #endregion + + #endregion + + /// + /// Handles the Loaded event of the user control. + /// Initializes the control by updating distribution handlers, filtered distributions, data grids, and plots. + /// + /// The source of the event. + /// Event data. + private void UserControl_Loaded(object sender, RoutedEventArgs e) + { + if (Element == null) return; + + // Data refresh on every load. Plot hosts, toolbars, and Element.PropertyChanged + // are wired once per Element in ElementCallback. + // UpdateFittedDistributionHandlers re-subscribes per-distribution PropertyChanged + // handlers (control-scoped); it is internally idempotent via an unsub-then-sub + // pattern so running it on every load is safe. + UpdateFittedDistributionHandlers(); + UpdateFilteredDistributions(); + UpdateAICBICDataGrid(); + UpdateSummaryStatisticsDataGrid(); + + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try + { + CreateLineSeries(); + MarkAllPlotsDirty(); + UpdateCurrentPlot(); + UpdateSeriesVisibility(); + } + finally + { + Element.IsUndoEnabled = wasUndoEnabled; + } + } + + /// + /// Handles the Unloaded event of the user control. + /// Unsubscribes per-distribution PropertyChanged handlers to prevent memory leaks. + /// Does NOT reset _isLoaded — the data hasn't changed, so no rebuild is needed on re-show. + /// Re-initialization is triggered by ElementCallback (new element) or ElementPropertyChanged (data change). + /// + /// The source of the event. + /// Event data. + private void UserControl_Unloaded(object sender, RoutedEventArgs e) + { + // Pattern B (canonical): Element-scoped lifecycle (PropertyChanged, plot + // hosts, toolbars) is owned by ElementCallback and survives Unload/Reload + // cycles. Unsub the CONTROL-scoped per-distribution handlers here — Loaded + // re-subscribes them via UpdateFittedDistributionHandlers() (idempotent). + if (Element == null) return; + + if (Element.FittedDistributions != null) + { + for (int i = 0; i < Element.FittedDistributions.Count; i++) + Element.FittedDistributions[i].PropertyChanged -= DistributionPropertyChanged; + } + } + + /// + /// Handles property changes on the Element object. + /// Refreshes the control when the IsEstimated or InputData properties change. + /// + /// The source of the event. + /// Event data containing the name of the property that changed. + private void ElementPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Element.IsEstimated)) + { + // Distributions — estimation results changed, series and filters need recreation + _seriesCreated = false; + _lastFilterIndex = -1; + UpdateFittedDistributionHandlers(); + UpdateFilteredDistributions(); + // Data grids + UpdateAICBICDataGrid(); + UpdateSummaryStatisticsDataGrid(); + // Plots — wrap in SuspendPlotBridges since these are data operations + UpdateAllPlotsAndSeries(); + } + if (e.PropertyName == nameof(Element.ProbabilityOrdinates)) + { + // Ordinates don't change the MLE fit itself, but the frequency plot + // (quantile curves per fitted distribution) and the summary-statistics + // table both iterate ProbabilityOrdinates to compute/display values. + // Refresh them in place; keep fitted parameters untouched. + if (Element?.IsEstimated == true) + { + UpdateSummaryStatisticsDataGrid(); + UpdateAllPlotsAndSeries(); + } + } + if (e.PropertyName == nameof(Element.InputData)) + { + // Suspend plot bridges while rebinding axis titles to prevent + // "Change Title" undo entries from axis PropertyChanged notifications. + using (Element.SuspendPlotBridges()) + { + BindAxisTitles(); + } + UpdateAllPlotsAndSeries(); + } + } + + /// + /// Handles the "Select All" checkbox checked event. + /// Shows results for all filtered distributions. + /// + /// The source of the event. + /// Event data. + private void CheckBox_Checked(object sender, RoutedEventArgs e) + { + if (_singleCheck == true) return; + if (Element == null || FilteredDistributions == null) return; + _checkingAll = true; + for (int i = 0; i < FilteredDistributions.Count; i++) + FilteredDistributions[i].ShowResults = true; + _checkingAll = false; + UpdateSeriesVisibility(); + + } + + /// + /// Handles the "Select All" checkbox unchecked event. + /// Hides results for all filtered distributions. + /// + /// The source of the event. + /// Event data. + private void CheckBox_Unchecked(object sender, RoutedEventArgs e) + { + if (_singleCheck == true) return; + if (Element == null || FilteredDistributions == null) return; + _checkingAll = true; + for (int i = 0; i < FilteredDistributions.Count; i++) + FilteredDistributions[i].ShowResults = false; + _checkingAll = false; + UpdateSeriesVisibility(); + } + + /// + /// Handles the distribution type combo box selection changed event. + /// Filters distributions by the selected number of parameters (2, 3, or 4 parameter distributions). + /// + /// The source of the event. + /// Event data. + private void TypeComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + // Distributions — filter changed, so series need recreation + _seriesCreated = false; + UpdateFilteredDistributions(); + // Data grids + UpdateAICBICDataGrid(); + // Plots + UpdateAllPlotsAndSeries(); + } + + /// + /// Updates the filtered distributions collection based on the selected filter criteria. + /// Filters by the number of distribution parameters (all, 2, 3, or 4 parameters). + /// Skips recreation if the filter index hasn't changed and FilteredDistributions is already populated. + /// + private void UpdateFilteredDistributions() + { + if (Element == null || Element.FittedDistributions == null) return; + + int currentFilter = TypeComboBox.SelectedIndex; + + // Skip recreation if filter hasn't changed and collection already exists + if (currentFilter == _lastFilterIndex && FilteredDistributions != null) + return; + + _lastFilterIndex = currentFilter; + + if (currentFilter == 0) + { + FilteredDistributions = new ObservableCollection(Element.FittedDistributions.ToList()); + } + else if (currentFilter == 1) + { + FilteredDistributions = new ObservableCollection(Element.FittedDistributions.Where(x => x.Distribution.NumberOfParameters == 2).ToList()); + } + else if (currentFilter == 2) + { + FilteredDistributions = new ObservableCollection(Element.FittedDistributions.Where(x => x.Distribution.NumberOfParameters == 3).ToList()); + } + else if (currentFilter == 3) + { + FilteredDistributions = new ObservableCollection(Element.FittedDistributions.Where(x => x.Distribution.NumberOfParameters == 4).ToList()); + } + else + { + FilteredDistributions = null; + } + } + + /// + /// Updates property change event handlers for all fitted distributions. + /// Ensures each distribution has the DistributionPropertyChanged handler attached. + /// + private void UpdateFittedDistributionHandlers() + { + if (Element == null || Element.FittedDistributions == null) return; + for (int i = 0; i < Element.FittedDistributions.Count; i++) + { + Element.FittedDistributions[i].PropertyChanged -= DistributionPropertyChanged; + Element.FittedDistributions[i].PropertyChanged += DistributionPropertyChanged; + } + } + + /// + /// Handles property changes on individual fitted distributions. + /// Updates the "Select All" checkbox state when individual distributions are shown or hidden. + /// + /// The fitted distribution that changed. + /// Event data containing the name of the property that changed. + private void DistributionPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(FittedDistribution.ShowResults)) + { + if (_checkingAll == true) return; + _singleCheck = true; + if (FilteredDistributions.Where(x => x.ShowResults == true).Count() == FilteredDistributions.Count()) + { + SelectAllCheckBox.IsChecked = true; + } + else + { + SelectAllCheckBox.IsChecked = false; + } + UpdateSeriesVisibility(); + _singleCheck = false; + } + } + + #region Plots + + /// + /// Gets the currently selected plot based on which radio button is checked. + /// Returns the Element-owned plot object. + /// + /// The currently active plot, or null if no plot is selected. + public Plot GetCurrentPlot() + { + if (FrequencyPlotRadioButton.IsChecked == true) return Element?.FrequencyPlot; + if (PDFPlotRadioButton.IsChecked == true) return Element?.PDFPlot; + if (CDFPlotRadioButton.IsChecked == true) return Element?.CDFPlot; + if (PPPlotRadioButton.IsChecked == true) return Element?.PPPlot; + if (QQPlotRadioButton.IsChecked == true) return Element?.QQPlot; + return null; + } + + /// + /// Gets the toolbar for the currently selected plot. FittingAnalysis uses a single shared toolbar. + /// + /// The plot toolbar. + public OxyPlotToolbar GetCurrentPlotToolbar() + { + return PlotToolbar; + } + + /// + /// Handles PropertiesCalled from the toolbar and re-raises it as the control-level PlotPropertiesCalled event. + /// + /// The plot whose properties are being accessed. + /// Indicates whether to open the properties panel. + /// The property expander to use. + /// The selected object in the plot. + private void PlotToolbar_PropertiesCalled(Plot targetPlot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject) + { + PlotPropertiesCalled?.Invoke(targetPlot, openProperties, propertyExpander, selectedObject); + } + + /// + /// Handles the PreviewMouseDown event of the user control. + /// Determines which plot area or toolbar was clicked and raises the PreviewControlClicked event. + /// + /// The source of the event. + /// Mouse button event data. + private void UserControl_PreviewMouseDown(object sender, MouseButtonEventArgs e) + { + Plot plot = GetCurrentPlot(); + OxyPlotToolbar toolbar = GetCurrentPlotToolbar(); + + if (plot == null || toolbar == null) + { + PlotClicked = false; + return; + } + + var plotHitResult = VisualTreeHelper.HitTest(plot, e.GetPosition(plot)); + var toolbarHitResult = VisualTreeHelper.HitTest(toolbar, e.GetPosition(toolbar)); + PreviewControlClicked?.Invoke(plotHitResult != null, toolbarHitResult != null, plot); + PlotClicked = plotHitResult != null; + } + + /// + /// Handles the LostFocus event of the user control. + /// Resets the PlotClicked property when the control loses focus. + /// + /// The source of the event. + /// Event data. + private void UserControl_LostFocus(object sender, RoutedEventArgs e) + { + PlotClicked = false; + } + + /// + /// Binds axis titles on Element-owned plots to the InputData.UnitLabel property. + /// Called after plot attachment and when InputData changes. + /// + private void BindAxisTitles() + { + if (Element == null) return; + + // Frequency plot: Y-axis title = UnitLabel + var freqYAxis = Element.FrequencyPlot?.Axes.FirstOrDefault(a => a.Key == "Yaxis"); + if (freqYAxis != null) + { + if (Element.InputData != null) + PlotAxisTitleDefaults.BindTitleIfDefault(freqYAxis, Element.InputData, nameof(InputData.UnitLabel), Element.InputData.UnitLabel); + else + PlotAxisTitleDefaults.SetTitleIfDefault(freqYAxis, string.Empty); + } + + // PDF plot: X-axis title = UnitLabel + var pdfXAxis = Element.PDFPlot?.Axes.FirstOrDefault(a => a.Key == "Xaxis"); + if (pdfXAxis != null) + { + if (Element.InputData != null) + PlotAxisTitleDefaults.BindTitleIfDefault(pdfXAxis, Element.InputData, nameof(InputData.UnitLabel), Element.InputData.UnitLabel); + else + PlotAxisTitleDefaults.SetTitleIfDefault(pdfXAxis, string.Empty); + } + + // CDF plot: X-axis title = UnitLabel + var cdfXAxis = Element.CDFPlot?.Axes.FirstOrDefault(a => a.Key == "Xaxis"); + if (cdfXAxis != null) + { + if (Element.InputData != null) + PlotAxisTitleDefaults.BindTitleIfDefault(cdfXAxis, Element.InputData, nameof(InputData.UnitLabel), Element.InputData.UnitLabel); + else + PlotAxisTitleDefaults.SetTitleIfDefault(cdfXAxis, string.Empty); + } + } + + /// + /// Updates all plots and series with current data, wrapping in SuspendPlotBridges + /// since these are data operations (not user visual edits). + /// Only the currently visible plot is updated immediately; others are deferred. + /// + private void UpdateAllPlotsAndSeries() + { + if (Element == null) return; + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try + { + CreateLineSeries(); + MarkAllPlotsDirty(); + UpdateCurrentPlot(); + UpdateSeriesVisibility(); + } + finally + { + Element.IsUndoEnabled = wasUndoEnabled; + } + } + + /// + /// Creates line series for all filtered distributions across all plot types. + /// Each distribution is assigned a unique color for consistent visualization across plots. + /// Series are only recreated when the filter set changes (tracked by _seriesCreated flag). + /// + private void CreateLineSeries() + { + // Skip recreation if series already exist for the current filter set + if (_seriesCreated && _frequencyDistributionSeriesList.Count == (FilteredDistributions?.Count ?? 0)) + return; + + _frequencyDistributionSeriesList.Clear(); + _pdfDistributionSeriesList.Clear(); + _cdfDistributionSeriesList.Clear(); + _ppDistributionSeriesList.Clear(); + _qqDistributionSeriesList.Clear(); + + if (Element == null || Element.IsValid == false || FilteredDistributions == null) return; + + + for (int i = 0; i < FilteredDistributions.Count; i++) + { + + Color seriesColor = (Color)ColorConverter.ConvertFromString(_colorHexCodes[i]); + + // Frequency Plot + _frequencyDistributionSeriesList.Add(new LineSeries() + { + Name = FilteredDistributions[i].Distribution.Type.ToString(), + Title = FilteredDistributions[i].Distribution.DisplayName, + TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}", + StrokeThickness = 1.5, + BrokenLineStyle = LineStyle.Solid, + Color = seriesColor, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0 + }); + + // PDF Plot + _pdfDistributionSeriesList.Add(new LineSeries() + { + Name = FilteredDistributions[i].Distribution.Type.ToString(), + Title = FilteredDistributions[i].Distribution.DisplayName, + TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:" + UserSettings.ValueStringFormat + "}" + Environment.NewLine + "{3}: {4:0.000000000}", + StrokeThickness = 1.5, + BrokenLineStyle = LineStyle.Solid, + Color = seriesColor, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0 + }); + + // CDF Plot + _cdfDistributionSeriesList.Add(new LineSeries() + { + Name = FilteredDistributions[i].Distribution.Type.ToString(), + Title = FilteredDistributions[i].Distribution.DisplayName, + TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:" + UserSettings.ValueStringFormat + "}" + Environment.NewLine + "{3}: {4:0.000000000}", + StrokeThickness = 1.5, + BrokenLineStyle = LineStyle.Solid, + Color = seriesColor, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0 + }); + + // PP Plot + _ppDistributionSeriesList.Add(new LineSeries() + { + Name = FilteredDistributions[i].Distribution.Type.ToString(), + Title = FilteredDistributions[i].Distribution.DisplayName, + TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:0.000000000}", + StrokeThickness = 1.5, + BrokenLineStyle = LineStyle.Solid, + Color = seriesColor, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0 + }); + + // QQ Plot + _qqDistributionSeriesList.Add(new LineSeries() + { + Name = FilteredDistributions[i].Distribution.Type.ToString(), + Title = FilteredDistributions[i].Distribution.DisplayName, + TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:" + UserSettings.ValueStringFormat + "}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}", + StrokeThickness = 1.5, + BrokenLineStyle = LineStyle.Solid, + Color = seriesColor, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0 + }); + } + + _seriesCreated = true; + } + + /// + /// Updates the visibility of distribution series on all plots based on the ShowResults property of each distribution. + /// Also manages the visibility and width of data grid columns. + /// + private void UpdateSeriesVisibility() + { + + if (Element == null || Element.IsValid == false || FilteredDistributions == null) return; + + + + for (int i = 0; i < FilteredDistributions.Count; i++) + { + if (FilteredDistributions[i].ShowResults == true) + { + _frequencyDistributionSeriesList[i].Visibility = Visibility.Visible; + _pdfDistributionSeriesList[i].Visibility = Visibility.Visible; + _cdfDistributionSeriesList[i].Visibility = Visibility.Visible; + _ppDistributionSeriesList[i].Visibility = Visibility.Visible; + _qqDistributionSeriesList[i].Visibility = Visibility.Visible; + } + else + { + _frequencyDistributionSeriesList[i].Visibility = Visibility.Hidden; + _pdfDistributionSeriesList[i].Visibility = Visibility.Hidden; + _cdfDistributionSeriesList[i].Visibility = Visibility.Hidden; + _ppDistributionSeriesList[i].Visibility = Visibility.Hidden; + _qqDistributionSeriesList[i].Visibility = Visibility.Hidden; + } + } + + double visCount = Math.Max(1, FilteredDistributions.Where(x => x.ShowResults == true).Count()); + + // Build a HashSet for O(1) Contains checks instead of O(n) on ObservableCollection + var filteredSet = new HashSet(FilteredDistributions); + + for (int i = 0; i < Element.FittedDistributions.Count; i++) + { + if (Element.FittedDistributions[i].ShowResults == true && filteredSet.Contains(Element.FittedDistributions[i])) + { + _columns[i].Visibility = Visibility.Visible; + _columns[i].Width = new DataGridLength((TabControl.ActualWidth - 120) / visCount); + } + else + { + _columns[i].Visibility = Visibility.Hidden; + } + } + // Column visibility/width changes are handled by WPF automatically — no Items.Refresh() needed + } + + /// + /// Marks all plot dirty flags so they will be refreshed on next view. + /// + private void MarkAllPlotsDirty() + { + _frequencyPlotDirty = true; + _pdfPlotDirty = true; + _cdfPlotDirty = true; + _ppPlotDirty = true; + _qqPlotDirty = true; + } + + /// + /// Updates only the currently visible plot. Other plots are marked dirty + /// and will be updated lazily when the user switches to them. + /// + private void UpdateCurrentPlot() + { + if (FrequencyPlotRadioButton.IsChecked == true) { UpdateDirtyPlotWithWaitCursor(UpdateFrequencyPlot, () => _frequencyPlotDirty = false); } + else if (PDFPlotRadioButton.IsChecked == true) { UpdateDirtyPlotWithWaitCursor(UpdatePDFPlot, () => _pdfPlotDirty = false); } + else if (CDFPlotRadioButton.IsChecked == true) { UpdateDirtyPlotWithWaitCursor(UpdateCDFPlot, () => _cdfPlotDirty = false); } + else if (PPPlotRadioButton.IsChecked == true) { UpdateDirtyPlotWithWaitCursor(UpdatePPPlot, () => _ppPlotDirty = false); } + else if (QQPlotRadioButton.IsChecked == true) { UpdateDirtyPlotWithWaitCursor(UpdateQQPlot, () => _qqPlotDirty = false); } + } + + /// + /// Runs a dirty plot update with visible wait-cursor feedback and clears its dirty flag afterward. + /// + /// The plot update action to run. + /// The action that clears the corresponding dirty flag. + /// + /// Fitted distribution plots can be expensive for large data sets and many selected + /// distributions, so dirty redraws use the same wait-cursor feedback as long analyses. + /// + private void UpdateDirtyPlotWithWaitCursor(Action updatePlotAction, Action markCleanAction) + { + WaitCursorHelper.RunWithVisibleWaitCursor(Dispatcher, () => + { + updatePlotAction(); + markCleanAction(); + }); + } + + #region Frequency Plot + + /// + /// Handles the Checked event of the frequency plot radio button. + /// Displays the frequency plot and hides all other plots. + /// + /// The source of the event. + /// Event data. + private void FrequencyPlotRadioButton_Checked(object sender, RoutedEventArgs e) + { + FrequencyPlotHost.Visibility = Visibility.Visible; + PDFPlotHost.Visibility = Visibility.Hidden; + CDFPlotHost.Visibility = Visibility.Hidden; + PPPlotHost.Visibility = Visibility.Hidden; + QQPlotHost.Visibility = Visibility.Hidden; + // + PlotToolbar.Plot = Element?.FrequencyPlot; + if (_frequencyPlotDirty) { UpdateDirtyPlotWithWaitCursor(UpdateFrequencyPlot, () => _frequencyPlotDirty = false); } + else Element?.FrequencyPlot?.InvalidatePlot(true); + } + + /// + /// Updates the frequency plot with distribution curves and data points. + /// The frequency plot displays exceedance probability (x-axis) versus values (y-axis). + /// + private void UpdateFrequencyPlot() + { + if (Element?.FrequencyPlot == null) return; + var plot = Element.FrequencyPlot; + + // Resolve named series via lookup-or-create so user-customized styling survives Save/Open. + var exactData = plot.Series.OfType().FirstOrDefault(s => s.Name == "ExactData") + ?? new ScatterPointSeries + { + Name = "ExactData", + Title = "Exact Data", + MarkerFill = Colors.Black, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle, + }; + var lowOutlierData = plot.Series.OfType().FirstOrDefault(s => s.Name == "LowOutlierData") + ?? new ScatterPointSeries + { + Name = "LowOutlierData", + Title = "Low Outlier Data", + MarkerStroke = Colors.Red, + MarkerStrokeThickness = 2, + MarkerSize = 3, + MarkerType = MarkerType.Cross, + }; + var uncertainData = plot.Series.OfType().FirstOrDefault(s => s.Name == "UncertainData") + ?? new ScatterErrorSeries + { + Name = "UncertainData", + Title = "Uncertain Data", + MarkerFill = Colors.Green, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Diamond, + }; + var intervalData = plot.Series.OfType().FirstOrDefault(s => s.Name == "IntervalData") + ?? new ScatterErrorSeries + { + Name = "IntervalData", + Title = "Interval Data", + MarkerFill = Colors.Cyan, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle, + }; + + using (Element.SuspendPlotBridges()) + { + plot.Series.Clear(); + + if (Element == null || Element.InputData == null || Element.IsValid == false || FilteredDistributions == null) + { + plot.InvalidatePlot(true); + } + else + { + // Add Curves + if (Element.IsEstimated == true) + { + for (int i = 0; i < FilteredDistributions.Count; i++) + { + if (FilteredDistributions[i].FitSucceeded == true) + { + // Load curve data + var data = new List(); + for (int j = 0; j < Element.ProbabilityOrdinates.Count; j++) + { + var x = Element.ProbabilityOrdinates[j]; + var y = FilteredDistributions[i].Distribution.InverseCDF(1 - x); + data.Add(new DataPoint(x, y)); + } + _frequencyDistributionSeriesList[i].ItemsSource = data; + if (FilteredDistributions[i].ShowResults == true) + { + _frequencyDistributionSeriesList[i].Visibility = Visibility.Visible; + } + else + { + _frequencyDistributionSeriesList[i].Visibility = Visibility.Hidden; + } + plot.Series.Add(_frequencyDistributionSeriesList[i]); + } + else + { + _frequencyDistributionSeriesList[i].ItemsSource = null; + _frequencyDistributionSeriesList[i].Visibility = Visibility.Hidden; + } + } + } + + // See if Y-axis is logarithmic + bool logAxis = false; + foreach (var axis in plot.Axes) + { + if (axis.Key == "Yaxis" && axis as LogarithmicAxis != null) + { + logAxis = true; + break; + } + } + + // Exact data + exactData.ItemsSource = logAxis == true ? Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == false && x.Value > 1E-16) : Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == false); + exactData.Mapping = item => { var d = (ExactData)item; return new OxyPlot.Series.ScatterPoint(d.PlottingPosition, d.Value); }; + exactData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.ExactSeries.Count > 0) plot.Series.Add(exactData); + + // low outliers data + lowOutlierData.ItemsSource = logAxis == true ? Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == true && x.Value > 1E-16) : Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == true); + lowOutlierData.Mapping = item => { var d = (ExactData)item; return new OxyPlot.Series.ScatterPoint(d.PlottingPosition, d.Value); }; + lowOutlierData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.NumberOfLowOutliers > 0) plot.Series.Add(lowOutlierData); + + // uncertain data + uncertainData.ItemsSource = logAxis == true ? Element.InputData.DataFrame.UncertainSeries : Element.InputData.DataFrame.UncertainSeries.Where(x => x.Value > 1E-16); + uncertainData.DataFieldX = nameof(UncertainData.PlottingPosition); + uncertainData.DataFieldY = nameof(UncertainData.Value); + uncertainData.DataFieldLowerErrorY = nameof(UncertainData.LowerValue); + uncertainData.DataFieldUpperErrorY = nameof(UncertainData.UpperValue); + uncertainData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.UncertainSeries.Count > 0) plot.Series.Add(uncertainData); + + // interval data + intervalData.ItemsSource = logAxis == true ? Element.InputData.DataFrame.IntervalSeries : Element.InputData.DataFrame.IntervalSeries.Where(x => x.Value > 1E-16); + intervalData.DataFieldX = nameof(IntervalData.PlottingPosition); + intervalData.DataFieldY = nameof(IntervalData.Value); + intervalData.DataFieldLowerErrorY = nameof(IntervalData.LowerValue); + intervalData.DataFieldUpperErrorY = nameof(IntervalData.UpperValue); + intervalData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.IntervalSeries.Count > 0) plot.Series.Add(intervalData); + + plot.InvalidatePlot(true); + } + } + Element.RebuildSeriesAndAnnotationBridges(plot); + } + + #endregion + + #region PDF Plot + + /// + /// Handles the Checked event of the PDF plot radio button. + /// Displays the PDF plot and hides all other plots. + /// + /// The source of the event. + /// Event data. + private void PDFPlotRadioButton_Checked(object sender, RoutedEventArgs e) + { + FrequencyPlotHost.Visibility = Visibility.Hidden; + PDFPlotHost.Visibility = Visibility.Visible; + CDFPlotHost.Visibility = Visibility.Hidden; + PPPlotHost.Visibility = Visibility.Hidden; + QQPlotHost.Visibility = Visibility.Hidden; + // + PlotToolbar.Plot = Element?.PDFPlot; + if (_pdfPlotDirty) { UpdateDirtyPlotWithWaitCursor(UpdatePDFPlot, () => _pdfPlotDirty = false); } + else Element?.PDFPlot?.InvalidatePlot(true); + } + + /// + /// Updates the PDF (Probability Density Function) plot with distribution curves and a histogram of the input data. + /// + private void UpdatePDFPlot() + { + if (Element?.PDFPlot == null) return; + var plot = Element.PDFPlot; + + var histogramSeries = plot.Series.OfType().FirstOrDefault(s => s.Name == "Histogram") + ?? new HistogramSeries + { + Name = "Histogram", + Title = "Input Data Histogram", + FillColor = Color.FromArgb(100, 176, 224, 230), + StrokeThickness = 1, + }; + + using (Element.SuspendPlotBridges()) + { + plot.Series.Clear(); + + if (Element != null && Element.InputData != null && Element.IsValid != false && FilteredDistributions != null) + { + // Create histogram + var xValues = Element.InputData.DataFrame.ExactSeries.ValuesToList(); + xValues.AddRange(Element.InputData.DataFrame.UncertainSeries.ValuesToList()); + xValues.AddRange(Element.InputData.DataFrame.IntervalSeries.ValuesToList()); + // Use Sturges' rule + int k = (int)(1 + 3.322 * Math.Log(xValues.Count)); + var histogram = new Histogram(xValues, k); + var histogramItems = new List(); + double sum = 0; + for (int i = 0; i < histogram.NumberOfBins; i++) + { + sum += histogram[i].Frequency * histogram.BinWidth; + } + for (int i = 0; i < histogram.NumberOfBins; i++) + { + histogramItems.Add(new OxyPlot.Series.HistogramItem(histogram[i].LowerBound, histogram[i].UpperBound, histogram[i].Frequency / sum * histogram.BinWidth)); + } + histogramSeries.ItemsSource = histogramItems; + histogramSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:" + UserSettings.ValueStringFormat + "}" + Environment.NewLine + "{3}: {4:0.000000000}"; + plot.Series.Add(histogramSeries); + + // Add Curves + if (Element.IsEstimated == true) + { + // Precompute x-value range once — same for all distributions + double minX = Math.Min(Math.Min(Element.InputData.DataFrame.ExactSeries.MinimumValue(), Element.InputData.DataFrame.UncertainSeries.MinimumValue()), Element.InputData.DataFrame.IntervalSeries.MinimumValue()); + double maxX = Math.Max(Math.Max(Element.InputData.DataFrame.ExactSeries.MaximumValue(), Element.InputData.DataFrame.UncertainSeries.MaximumValue()), Element.InputData.DataFrame.IntervalSeries.MaximumValue()); + double startX = minX - Math.Pow(10, (int)Math.Floor(Math.Log10(minX))); + double endX = maxX + Math.Pow(10, (int)Math.Floor(Math.Log10(maxX))); + var pdfXValues = Stratify.XValues(new StratificationOptions(startX, endX, 1000)); + + for (int i = 0; i < FilteredDistributions.Count; i++) + { + if (FilteredDistributions[i].FitSucceeded == true) + { + // Load curve data using precomputed x-values + var pdf = FilteredDistributions[i].Distribution.CreatePDFGraph(pdfXValues); + var data = new List(); + for (int j = 0; j < pdf.GetLength(0); j++) + { + var x = pdf[j, 0]; + var y = pdf[j, 1]; + data.Add(new DataPoint(x, y)); + } + _pdfDistributionSeriesList[i].ItemsSource = data; + if (FilteredDistributions[i].ShowResults == true) + { + _pdfDistributionSeriesList[i].Visibility = Visibility.Visible; + } + else + { + _pdfDistributionSeriesList[i].Visibility = Visibility.Hidden; + } + plot.Series.Add(_pdfDistributionSeriesList[i]); + } + else + { + _pdfDistributionSeriesList[i].ItemsSource = null; + _pdfDistributionSeriesList[i].Visibility = Visibility.Hidden; + } + } + } + } + + plot.InvalidatePlot(true); + } + Element.RebuildSeriesAndAnnotationBridges(plot); + } + + #endregion + + #region CDF Plot + + /// + /// Handles the Checked event of the CDF plot radio button. + /// Displays the CDF plot and hides all other plots. + /// + /// The source of the event. + /// Event data. + private void CDFPlotRadioButton_Checked(object sender, RoutedEventArgs e) + { + FrequencyPlotHost.Visibility = Visibility.Hidden; + PDFPlotHost.Visibility = Visibility.Hidden; + CDFPlotHost.Visibility = Visibility.Visible; + PPPlotHost.Visibility = Visibility.Hidden; + QQPlotHost.Visibility = Visibility.Hidden; + // + PlotToolbar.Plot = Element?.CDFPlot; + if (_cdfPlotDirty) { UpdateDirtyPlotWithWaitCursor(UpdateCDFPlot, () => _cdfPlotDirty = false); } + else Element?.CDFPlot?.InvalidatePlot(true); + } + + /// + /// Updates the CDF (Cumulative Distribution Function) plot with distribution curves and data points. + /// + private void UpdateCDFPlot() + { + if (Element?.CDFPlot == null) return; + var plot = Element.CDFPlot; + + var exactData = plot.Series.OfType().FirstOrDefault(s => s.Name == "ExactData") + ?? new ScatterPointSeries + { + Name = "ExactData", + Title = "Exact Data", + MarkerFill = Colors.Black, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle, + }; + var lowOutlierData = plot.Series.OfType().FirstOrDefault(s => s.Name == "LowOutlierData") + ?? new ScatterPointSeries + { + Name = "LowOutlierData", + Title = "Low Outlier Data", + MarkerStroke = Colors.Red, + MarkerStrokeThickness = 2, + MarkerSize = 3, + MarkerType = MarkerType.Cross, + }; + var uncertainData = plot.Series.OfType().FirstOrDefault(s => s.Name == "UncertainData") + ?? new ScatterErrorSeries + { + Name = "UncertainData", + Title = "Uncertain Data", + MarkerFill = Colors.Green, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Diamond, + }; + var intervalData = plot.Series.OfType().FirstOrDefault(s => s.Name == "IntervalData") + ?? new ScatterErrorSeries + { + Name = "IntervalData", + Title = "Interval Data", + MarkerFill = Colors.Cyan, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle, + }; + + using (Element.SuspendPlotBridges()) + { + plot.Series.Clear(); + + if (Element != null && Element.InputData != null && Element.IsValid != false && FilteredDistributions != null) + { + // Add Curves + if (Element.IsEstimated == true) + { + // Precompute x-value range once — same for all distributions + double minX = Math.Min(Math.Min(Element.InputData.DataFrame.ExactSeries.MinimumValue(), Element.InputData.DataFrame.UncertainSeries.MinimumValue()), Element.InputData.DataFrame.IntervalSeries.MinimumValue()); + double maxX = Math.Max(Math.Max(Element.InputData.DataFrame.ExactSeries.MaximumValue(), Element.InputData.DataFrame.UncertainSeries.MaximumValue()), Element.InputData.DataFrame.IntervalSeries.MaximumValue()); + double startX = minX - Math.Pow(10, (int)Math.Floor(Math.Log10(minX))); + double endX = maxX + Math.Pow(10, (int)Math.Floor(Math.Log10(maxX))); + var cdfXValues = Stratify.XValues(new StratificationOptions(startX, endX, 1000)); + + for (int i = 0; i < FilteredDistributions.Count; i++) + { + if (FilteredDistributions[i].FitSucceeded == true) + { + // Load curve data using precomputed x-values + var cdf = FilteredDistributions[i].Distribution.CreateCDFGraph(cdfXValues); + var data = new List(); + for (int j = 0; j < cdf.GetLength(0); j++) + { + var x = cdf[j, 0]; + var y = cdf[j, 1]; + data.Add(new DataPoint(x, y)); + } + _cdfDistributionSeriesList[i].ItemsSource = data; + if (FilteredDistributions[i].ShowResults == true) + { + _cdfDistributionSeriesList[i].Visibility = Visibility.Visible; + } + else + { + _cdfDistributionSeriesList[i].Visibility = Visibility.Hidden; + } + plot.Series.Add(_cdfDistributionSeriesList[i]); + } + else + { + _cdfDistributionSeriesList[i].ItemsSource = null; + _cdfDistributionSeriesList[i].Visibility = Visibility.Hidden; + } + } + } + + // Exact data + exactData.ItemsSource = Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == false); + exactData.Mapping = item => { var d = (ExactData)item; return new OxyPlot.Series.ScatterPoint(d.Value, d.PlottingPositionComplement); }; + exactData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:" + UserSettings.ValueStringFormat + "}" + Environment.NewLine + "{3}: {4:0.000000000}"; + if (Element.InputData.DataFrame.ExactSeries.Count > 0) plot.Series.Add(exactData); + + // low outliers data + lowOutlierData.ItemsSource = Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == true); + lowOutlierData.Mapping = item => { var d = (ExactData)item; return new OxyPlot.Series.ScatterPoint(d.Value, d.PlottingPositionComplement); }; + lowOutlierData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:" + UserSettings.ValueStringFormat + "}" + Environment.NewLine + "{3}: {4:0.000000000}"; + if (Element.InputData.DataFrame.NumberOfLowOutliers > 0) plot.Series.Add(lowOutlierData); + + // uncertain data + uncertainData.ItemsSource = Element.InputData.DataFrame.UncertainSeries; + uncertainData.DataFieldX = nameof(UncertainData.Value); + uncertainData.DataFieldY = nameof(UncertainData.PlottingPositionComplement); + uncertainData.DataFieldLowerErrorX = nameof(UncertainData.LowerValue); + uncertainData.DataFieldUpperErrorX = nameof(UncertainData.UpperValue); + uncertainData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:" + UserSettings.ValueStringFormat + "}" + Environment.NewLine + "{3}: {4:0.000000000}"; + if (Element.InputData.DataFrame.UncertainSeries.Count > 0) plot.Series.Add(uncertainData); + + // interval data + intervalData.ItemsSource = Element.InputData.DataFrame.IntervalSeries; + intervalData.DataFieldX = nameof(IntervalData.Value); + intervalData.DataFieldY = nameof(IntervalData.PlottingPositionComplement); + intervalData.DataFieldLowerErrorX = nameof(IntervalData.LowerValue); + intervalData.DataFieldUpperErrorX = nameof(IntervalData.UpperValue); + intervalData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:" + UserSettings.ValueStringFormat + "}" + Environment.NewLine + "{3}: {4:0.000000000}"; + if (Element.InputData.DataFrame.IntervalSeries.Count > 0) plot.Series.Add(intervalData); + } + + plot.InvalidatePlot(true); + } + Element.RebuildSeriesAndAnnotationBridges(plot); + } + + #endregion + + #region P-P Plot + + /// + /// Handles the Checked event of the P-P plot radio button. + /// Displays the P-P plot and hides all other plots. + /// + /// The source of the event. + /// Event data. + private void PPPlotRadioButton_Checked(object sender, RoutedEventArgs e) + { + FrequencyPlotHost.Visibility = Visibility.Hidden; + PDFPlotHost.Visibility = Visibility.Hidden; + CDFPlotHost.Visibility = Visibility.Hidden; + PPPlotHost.Visibility = Visibility.Visible; + QQPlotHost.Visibility = Visibility.Hidden; + // + PlotToolbar.Plot = Element?.PPPlot; + if (_ppPlotDirty) { UpdateDirtyPlotWithWaitCursor(UpdatePPPlot, () => _ppPlotDirty = false); } + else Element?.PPPlot?.InvalidatePlot(true); + } + + /// + /// Updates the P-P (Probability-Probability) plot with distribution curves. + /// Compares theoretical probabilities against empirical probabilities to assess goodness-of-fit. + /// + private void UpdatePPPlot() + { + if (Element?.PPPlot == null) return; + var plot = Element.PPPlot; + + var oneToOneLine = plot.Annotations.OfType().FirstOrDefault(a => a.Name == "OneToOneLine") + ?? new LineAnnotation + { + Name = "OneToOneLine", + Text = "1:1 Line", + Type = OxyPlot.Annotations.LineAnnotationType.LinearEquation, + Color = Colors.Black, + LineStyle = LineStyle.Dash, + StrokeThickness = 2, + TextLinePosition = 0.9, + Intercept = 0, + Slope = 1, + TextHorizontalAlignment = System.Windows.HorizontalAlignment.Right, + TextVerticalAlignment = System.Windows.VerticalAlignment.Top, + }; + + using (Element.SuspendPlotBridges()) + { + plot.Series.Clear(); + plot.Annotations.Clear(); + + if (Element != null && Element.InputData != null && Element.IsValid != false && FilteredDistributions != null) + { + // Load percentiles + var xValues = Element.InputData.DataFrame.ExactSeries.Select(x => x.Value).ToList(); + xValues.AddRange(Element.InputData.DataFrame.UncertainSeries.Select(x => x.Value).ToList()); + xValues.AddRange(Element.InputData.DataFrame.IntervalSeries.Select(x => x.Value).ToList()); + xValues.Sort(); + var pValues = Element.InputData.DataFrame.ExactSeries.Select(x => x.PlottingPositionComplement).ToList(); + pValues.AddRange(Element.InputData.DataFrame.UncertainSeries.Select(x => x.PlottingPositionComplement).ToList()); + pValues.AddRange(Element.InputData.DataFrame.IntervalSeries.Select(x => x.PlottingPositionComplement).ToList()); + pValues.Sort(); + + // Add Curves + if (Element.IsEstimated == true) + { + for (int i = 0; i < FilteredDistributions.Count; i++) + { + if (FilteredDistributions[i].FitSucceeded == true) + { + // Load curve data + var data = new List(); + for (int j = 0; j < xValues.Count; j++) + { + var x = FilteredDistributions[i].Distribution.CDF(xValues[j]); + var y = pValues[j]; + data.Add(new DataPoint(x, y)); + } + _ppDistributionSeriesList[i].ItemsSource = data; + if (FilteredDistributions[i].ShowResults == true) + { + _ppDistributionSeriesList[i].Visibility = Visibility.Visible; + } + else + { + _ppDistributionSeriesList[i].Visibility = Visibility.Hidden; + } + plot.Series.Add(_ppDistributionSeriesList[i]); + } + else + { + _ppDistributionSeriesList[i].ItemsSource = null; + _ppDistributionSeriesList[i].Visibility = Visibility.Hidden; + } + } + } + + plot.Annotations.Add(oneToOneLine); + } + + plot.InvalidatePlot(true); + } + Element.RebuildSeriesAndAnnotationBridges(plot); + } + + #endregion + + #region Q-Q Plot + + /// + /// Handles the Checked event of the Q-Q plot radio button. + /// Displays the Q-Q plot and hides all other plots. + /// + /// The source of the event. + /// Event data. + private void QQPlotRadioButton_Checked(object sender, RoutedEventArgs e) + { + FrequencyPlotHost.Visibility = Visibility.Hidden; + PDFPlotHost.Visibility = Visibility.Hidden; + CDFPlotHost.Visibility = Visibility.Hidden; + PPPlotHost.Visibility = Visibility.Hidden; + QQPlotHost.Visibility = Visibility.Visible; + // + PlotToolbar.Plot = Element?.QQPlot; + if (_qqPlotDirty) { UpdateDirtyPlotWithWaitCursor(UpdateQQPlot, () => _qqPlotDirty = false); } + else Element?.QQPlot?.InvalidatePlot(true); + } + + /// + /// Updates the Q-Q (Quantile-Quantile) plot with distribution curves. + /// Compares theoretical quantiles against empirical quantiles to assess goodness-of-fit. + /// + private void UpdateQQPlot() + { + if (Element?.QQPlot == null) return; + var plot = Element.QQPlot; + + var oneToOneLine = plot.Annotations.OfType().FirstOrDefault(a => a.Name == "OneToOneLine") + ?? new LineAnnotation + { + Name = "OneToOneLine", + Text = "1:1 Line", + Type = OxyPlot.Annotations.LineAnnotationType.LinearEquation, + Color = Colors.Black, + LineStyle = LineStyle.Dash, + StrokeThickness = 2, + TextLinePosition = 0.9, + Intercept = 0, + Slope = 1, + TextHorizontalAlignment = System.Windows.HorizontalAlignment.Right, + TextVerticalAlignment = System.Windows.VerticalAlignment.Top, + }; + + using (Element.SuspendPlotBridges()) + { + plot.Series.Clear(); + plot.Annotations.Clear(); + + if (Element != null && Element.InputData != null && Element.IsValid != false && FilteredDistributions != null) + { + // Load percentiles + var xValues = Element.InputData.DataFrame.ExactSeries.Select(x => x.Value).ToList(); + xValues.AddRange(Element.InputData.DataFrame.UncertainSeries.Select(x => x.Value).ToList()); + xValues.AddRange(Element.InputData.DataFrame.IntervalSeries.Select(x => x.Value).ToList()); + xValues.Sort(); + var pValues = Element.InputData.DataFrame.ExactSeries.Select(x => x.PlottingPositionComplement).ToList(); + pValues.AddRange(Element.InputData.DataFrame.UncertainSeries.Select(x => x.PlottingPositionComplement).ToList()); + pValues.AddRange(Element.InputData.DataFrame.IntervalSeries.Select(x => x.PlottingPositionComplement).ToList()); + pValues.Sort(); + + // Add Curves + if (Element.IsEstimated == true) + { + for (int i = 0; i < FilteredDistributions.Count; i++) + { + if (FilteredDistributions[i].FitSucceeded == true) + { + // Load curve data + var data = new List(); + for (int j = 0; j < xValues.Count; j++) + { + var x = xValues[j]; + var y = FilteredDistributions[i].Distribution.InverseCDF(pValues[j]); + data.Add(new DataPoint(x, y)); + } + _qqDistributionSeriesList[i].ItemsSource = data; + if (FilteredDistributions[i].ShowResults == true) + { + _qqDistributionSeriesList[i].Visibility = Visibility.Visible; + } + else + { + _qqDistributionSeriesList[i].Visibility = Visibility.Hidden; + } + plot.Series.Add(_qqDistributionSeriesList[i]); + } + else + { + _qqDistributionSeriesList[i].ItemsSource = null; + _qqDistributionSeriesList[i].Visibility = Visibility.Hidden; + } + } + } + + plot.Annotations.Add(oneToOneLine); + } + + plot.InvalidatePlot(true); + } + Element.RebuildSeriesAndAnnotationBridges(plot); + } + + #endregion + + #endregion + + #region Data Grids + + /// + /// Updates the AIC/BIC data grid with goodness-of-fit statistics for filtered distributions. + /// Column header tooltip styles are created only once on first call. + /// + private void UpdateAICBICDataGrid() + { + if (AICBICTable == null || FilteredDistributions == null) return; + AICBICTable.ItemsSource = null; + AICBICTable.ItemsSource = FilteredDistributions; + + // Create column header tooltip styles only once — they never change + if (!_aicBicStylesCreated) + { + // AIC Column Header + var aicStyle = new Style(typeof(DataGridColumnHeader), AICColumn.HeaderStyle); + aicStyle.Setters.Add(new Setter(ToolTipProperty, new TextBlock() { Text = "Akaike Information Criteria (AIC). The smaller the value the better the fit. Left or right click to sort.", FontWeight = System.Windows.FontWeights.Normal, TextAlignment = TextAlignment.Left, TextWrapping = TextWrapping.Wrap })); + AICColumn.HeaderStyle = aicStyle; + // + // BIC Column Header + var bicStyle = new Style(typeof(DataGridColumnHeader), BICColumn.HeaderStyle); + bicStyle.Setters.Add(new Setter(ToolTipProperty, new TextBlock() { Text = "Bayesian Information Criteria (BIC). The smaller the value the better the fit. Left or right click to sort.", FontWeight = System.Windows.FontWeights.Normal, TextAlignment = TextAlignment.Left, TextWrapping = TextWrapping.Wrap })); + BICColumn.HeaderStyle = bicStyle; + // + // RMSE Column Header + var rmseStyle = new Style(typeof(DataGridColumnHeader), RMSEColumn.HeaderStyle); + rmseStyle.Setters.Add(new Setter(ToolTipProperty, new TextBlock() { Text = "Root Mean Square Error (RMSE). The smaller the value the better the fit. Left or right click to sort.", FontWeight = System.Windows.FontWeights.Normal, TextAlignment = TextAlignment.Left, TextWrapping = TextWrapping.Wrap })); + RMSEColumn.HeaderStyle = rmseStyle; + + _aicBicStylesCreated = true; + } + + AICBICTable.Items.Refresh(); + } + + /// + /// Updates the summary statistics data grid with distribution parameters and statistical measures. + /// Displays location, scale, shape parameters, summary statistics, and probability quantiles for each distribution. + /// + /// + /// Always rebuilds the row list from scratch. Row names encode the probability ordinate + /// values, and cell bindings to Value[i] (an array indexer) don't fire + /// , so in-place mutation of + /// is invisible to the DataGrid. Forcing a + /// full rebind on every call — via ItemsSource = null then re-assigning — is the + /// only way to guarantee the table reflects the current ordinates and fitted parameters. + /// Call frequency is low (post-estimation and per user ordinate edit) so the rebuild cost + /// is negligible. + /// + private void UpdateSummaryStatisticsDataGrid() + { + if (SummaryStatisticsDataGrid == null) return; + + // Detach current binding so WPF releases its view on the old list. + SummaryStatisticsDataGrid.ItemsSource = null; + + // Build the row list from scratch: 10 fixed rows + one per ordinate (names encode the + // ordinate value, so they must be rebuilt on every ordinate change). + _fittedDistributionStatistics.Clear(); + _fittedDistributionStatistics.Add(new FittedDistributionStatistic("Location", new double[15], new string[15])); + _fittedDistributionStatistics.Add(new FittedDistributionStatistic("Scale", new double[15], new string[15])); + _fittedDistributionStatistics.Add(new FittedDistributionStatistic("Shape", new double[15], new string[15])); + _fittedDistributionStatistics.Add(new FittedDistributionStatistic("Shape2", new double[15], new string[15])); + _fittedDistributionStatistics.Add(new FittedDistributionStatistic("Minimum", new double[15], new string[15])); + _fittedDistributionStatistics.Add(new FittedDistributionStatistic("Maximum", new double[15], new string[15])); + _fittedDistributionStatistics.Add(new FittedDistributionStatistic("Mean", new double[15], new string[15])); + _fittedDistributionStatistics.Add(new FittedDistributionStatistic("Std Dev", new double[15], new string[15])); + _fittedDistributionStatistics.Add(new FittedDistributionStatistic("Skewness", new double[15], new string[15])); + _fittedDistributionStatistics.Add(new FittedDistributionStatistic("Kurtosis", new double[15], new string[15])); + if (Element?.ProbabilityOrdinates != null) + { + for (int i = 0; i < Element.ProbabilityOrdinates.Count; i++) + { + _fittedDistributionStatistics.Add(new FittedDistributionStatistic(Element.ProbabilityOrdinates[i].ToString(), new double[15], new string[15])); + } + } + + // Populate values before binding. `FittedDistributionStatistic.Value` is a plain array + // with no change notification, so once the DataGrid is bound it will NOT re-read in-place + // mutations — populate first, then bind. + // + // When ordinates are invalid (out of order, out of [0,1] range, empty), skip population + // so the table renders blank — mirrors UpdateFrequencyPlot's `Element.IsValid == false` + // guard at line 925, which clears the plot under the same condition. + if (Element?.IsEstimated == true && Element.IsValid == true) + { + for (int i = 0; i < Element.FittedDistributions.Count; i++) + { + // parameters + var parms = Element.FittedDistributions[i].Distribution.GetParameters; + for (int j = 0; j < 4; j++) + { + _fittedDistributionStatistics[j].Value[i] = j < parms.Length ? parms[j] : double.NaN; + } + var min = Element.FittedDistributions[i].Distribution.Minimum; + var max = Element.FittedDistributions[i].Distribution.Maximum; + // summary stats + _fittedDistributionStatistics[4].Value[i] = min < -1E12 ? double.NegativeInfinity : min; + _fittedDistributionStatistics[5].Value[i] = max > 1E12 ? double.PositiveInfinity : max; + _fittedDistributionStatistics[6].Value[i] = Element.FittedDistributions[i].Distribution.Mean; + _fittedDistributionStatistics[7].Value[i] = Element.FittedDistributions[i].Distribution.StandardDeviation; + _fittedDistributionStatistics[8].Value[i] = Element.FittedDistributions[i].Distribution.Skewness; + _fittedDistributionStatistics[9].Value[i] = Element.FittedDistributions[i].Distribution.Kurtosis; + // probabilities + for (int j = 0; j < Element.ProbabilityOrdinates.Count; j++) + { + _fittedDistributionStatistics[10 + j].Value[i] = Element.FittedDistributions[i].Distribution.InverseCDF(1 - Element.ProbabilityOrdinates[j]); + } + // tool tip + string toolTip = Element.FittedDistributions[i].Distribution.DisplayName; + var parmString = Element.FittedDistributions[i].Distribution.ParametersToString; + for (int j = 0; j < Element.FittedDistributions[i].Distribution.NumberOfParameters; j++) + { + double p = double.NaN; + double.TryParse(parmString[j, 1], NumberStyles.Any, CultureInfo.InvariantCulture, out p); + toolTip += Environment.NewLine + parmString[j, 0] + " = " + p.ToString("N4"); + } + for (int j = 0; j < _fittedDistributionStatistics.Count; j++) + { + _fittedDistributionStatistics[j].ToolTip[i] = toolTip; + } + } + } + + // Bind AFTER population so the first render shows current values. + SummaryStatisticsDataGrid.ItemsSource = _fittedDistributionStatistics; + } + + /// + /// Handles the LoadingRow event of the summary statistics data grid. + /// Applies custom borders to visually separate parameter rows from summary statistic rows. + /// + /// The source of the event. + /// Event data containing the row being loaded. + private void SummaryStatisticsDataGrid_LoadingRow(object sender, DataGridRowEventArgs e) + { + if (((FittedDistributionStatistic)e.Row.DataContext).Name == "Minimum") + { + e.Row.BorderThickness = new Thickness(0, 2, 0, 0); + e.Row.SetResourceReference(Border.BorderBrushProperty, "EnvironmentWindowText"); + } + else if (((FittedDistributionStatistic)e.Row.DataContext).Name == "Kurtosis") + { + e.Row.BorderThickness = new Thickness(0, 0, 0, 2); + e.Row.SetResourceReference(Border.BorderBrushProperty, "EnvironmentWindowText"); + } + else + { + e.Row.BorderThickness = new Thickness(0, 0, 0, 0); + } + } + + + + #endregion + + } +} diff --git a/src/RMC.BestFit.App/GUI/FittingAnalysis/FittingAnalysisPropertiesControl.xaml b/src/RMC.BestFit.App/GUI/FittingAnalysis/FittingAnalysisPropertiesControl.xaml new file mode 100644 index 0000000..b5e4663 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/FittingAnalysis/FittingAnalysisPropertiesControl.xaml @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/FittingAnalysis/FittingAnalysisPropertiesControl.xaml.cs b/src/RMC.BestFit.App/GUI/FittingAnalysis/FittingAnalysisPropertiesControl.xaml.cs new file mode 100644 index 0000000..a9300cd --- /dev/null +++ b/src/RMC.BestFit.App/GUI/FittingAnalysis/FittingAnalysisPropertiesControl.xaml.cs @@ -0,0 +1,425 @@ +using FrameworkInterfaces; +using FrameworkUI; +using GenericControls; +using RMC.BestFit.Models; +using RMC.BestFit.UI; +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Input; +using Numerics.Utilities; +using System.Threading.Tasks; + +namespace RMC_BestFit +{ + /// + /// User control for displaying and editing properties of a fitting analysis. + /// Provides interface for configuring analysis parameters, selecting input data, + /// and executing distribution fitting operations. + /// + public partial class FittingAnalysisPropertiesControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public FittingAnalysisPropertiesControl() + { + InitializeComponent(); + DataContext = this; + } + + /// + /// Stores the previous name value to enable validation rollback. + /// + private string _previousName; + + /// + /// Tracks the currently subscribed InputDataCollection to enable proper unsubscription. + /// + private IElementCollection _subscribedInputDataCollection; + + /// + /// Dependency property for the Element property. + /// + public static DependencyProperty ElementProperty = DependencyProperty.Register(nameof(Element), typeof(FittingAnalysis), typeof(FittingAnalysisPropertiesControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the fitting analysis element whose properties are displayed and edited in this control. + /// + public FittingAnalysis Element + { + get { return (FittingAnalysis)GetValue(ElementProperty); } + set { SetValue(ElementProperty, value); } + } + + /// + /// Callback method invoked when the Element property changes. + /// Updates property attributes and loads available input data. + /// + /// The dependency object where the property changed. + /// Event data containing the old and new property values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as FittingAnalysisPropertiesControl == null) return; + var thisControl = (FittingAnalysisPropertiesControl)d; + + // Clean up old element subscriptions + thisControl.UnsubscribeInputDataCollection(); + + if (e.NewValue == null) return; + var newElement = e.NewValue as FittingAnalysis; + if (newElement == null) return; + + // Initialize previous name for rollback safety + thisControl._previousName = newElement.Name; + + thisControl.PropertyAttributes.GetClassAttributes(newElement); + thisControl.LoadInputData(); + } + + /// + /// Dependency property for the existing names property. + /// + public static DependencyProperty ExistingNamesProperty = DependencyProperty.Register(nameof(ExistingNames), typeof(string[]), typeof(FittingAnalysisPropertiesControl), new FrameworkPropertyMetadata(new string[] { })); + + /// + /// Gets or sets the array of existing names for this element type. + /// Used for name validation to prevent duplicates. + /// + public string[] ExistingNames + { + get { return (string[])GetValue(ExistingNamesProperty); } + private set { SetValue(ExistingNamesProperty, value); } + } + + /// + /// Gets the observable collection of available input data elements that can be selected for the fitting analysis. + /// + public ObservableCollection InputDataList { get; private set; } = new ObservableCollection(); + + #region Property Attributes + + /// + /// Handles the PreviewMouseLeftButtonDown event on the Name property control. + /// Displays property attributes for the Name property. + /// + /// The source of the event. + /// Mouse button event data. + private void Name_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.Name), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the Description property control. + /// Displays property attributes for the Description property. + /// + /// The source of the event. + /// Mouse button event data. + private void Description_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.Description), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the CreationDate property control. + /// Displays property attributes for the CreationDate property. + /// + /// The source of the event. + /// Mouse button event data. + private void CreationDate_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.CreationDate), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the LastModified property control. + /// Displays property attributes for the LastModified property. + /// + /// The source of the event. + /// Mouse button event data. + private void LastModified_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.LastModified), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the InputData property control. + /// Displays property attributes for the InputData property. + /// + /// The source of the event. + /// Mouse button event data. + private void InputData_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.InputData), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the ProbabilityOrdinates control. + /// Displays default attributes describing the probability ordinates. + /// + /// The source of the event. + /// Mouse button event data. + private void ProbabilityOrdinatesControl_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.SetDefaultAttributes("Probability Ordinates", "Exceedance probabilities used to plot the probability distribution."); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the TabItem control. + /// Displays class-level attributes for the Element. + /// + /// The source of the event. + /// Mouse button event data. + private void TabItem_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetClassAttributes(Element); + } + + #endregion + + /// + /// Handles the GotFocus event on the Name control. + /// Stores the current name and retrieves existing names for validation. + /// + /// The source of the event. + /// Event data. + private void Name_GotFocus(object sender, RoutedEventArgs e) + { + if (Element == null) return; + _previousName = Element.Name; + ExistingNames = Element.ParentCollection.GetElementNames(Element).ToArray(); + } + + /// + /// Handles the LostFocus event on the Name control. + /// Restores the previous name if the new name is invalid. + /// + /// The source of the event. + /// Event data. + private void Name_LostFocus(object sender, RoutedEventArgs e) + { + if (ElementName.NameTextBox.IsValid) + return; + if (Element != null) + Element.Name = _previousName; + } + + /// + /// Loads the list of available input data elements from the project's InputDataCollection. + /// Subscribes to collection change events to keep the list synchronized. + /// + private void LoadInputData() + { + UnsubscribeInputDataCollection(); + InputDataList.Clear(); + foreach (IElementCollection collection in Element.ParentCollection.ParentProject.ElementCollections) + { + if (collection.GetType() == typeof(InputDataCollection)) + { + _subscribedInputDataCollection = collection; + collection.ElementAdded += OnInputDataElementAdded; + collection.ElementRemoved += OnInputDataElementRemoved; + foreach (IElement element in collection) + InputDataList.Add((InputData)element); + break; + } + } + } + + /// + /// Handles the ElementAdded event from the InputDataCollection. + /// + /// The element that was added. + private void OnInputDataElementAdded(IElement element) + { + InputDataList.Add((InputData)element); + } + + /// + /// Handles the ElementRemoved event from the InputDataCollection. + /// + /// The element that was removed. + private void OnInputDataElementRemoved(IElement element) + { + InputDataList.Remove((InputData)element); + } + + /// + /// Unsubscribes from the currently tracked InputDataCollection's events. + /// + private void UnsubscribeInputDataCollection() + { + if (_subscribedInputDataCollection != null) + { + _subscribedInputDataCollection.ElementAdded -= OnInputDataElementAdded; + _subscribedInputDataCollection.ElementRemoved -= OnInputDataElementRemoved; + _subscribedInputDataCollection = null; + } + } + + /// + /// Handles the SelectionChanged event on the InputData combo box. + /// Validates that a valid input data element has been selected and updates the UI accordingly. + /// + /// The source of the event. + /// Event data. + private void InputDataComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (Element == null) return; + if (Element.InputData == null || Element.InputData.Name == null) + { + ((Border)InputDataComboBox.InnerContent).BorderThickness = new Thickness(1); + InputDataComboBox.ToolTip = "Select valid input data."; + } + else + { + ((Border)InputDataComboBox.InnerContent).BorderThickness = new Thickness(0); + InputDataComboBox.ToolTip = null; + } + } + + /// + /// Handles the Click event of the Fit button. + /// Validates the analysis configuration, disables the UI to prevent concurrent modifications, + /// and executes the distribution fitting operation asynchronously. + /// Displays progress feedback using a progress bar and a cancel button. + /// + /// The source of the event. + /// Event data. + private async void FitButton_Click(object sender, RoutedEventArgs e) + { + // Check if the fitting analysis is valid + if (Element.IsValid == false) + { + GenericControls.MessageBox.Show("Cannot perform distribution fitting because the inputs are invalid.", "Error!", MessageBoxButton.OK, MessageBoxImage.Error); + return; + } + + Mouse.OverrideCursor = Cursors.Wait; + + // Disable all windows + ShellPublicVariables.SimulationInProgress = true; + ((MainWindow)Application.Current.MainWindow).DisableMenuStrip(); + ((MainWindow)Application.Current.MainWindow).DisableProjectExplorer(); + ((MainWindow)Application.Current.MainWindow).DisableMessageWindow(); + ((MainWindow)Application.Current.MainWindow).DisableOpenWindows(); + // Disable tab items to prevent property changes during analysis + General_TabItem.IsEnabled = false; + Options_TabItem.IsEnabled = false; + + // Set up progress bar + AnalysisProgressDisplayHelper.ShowInitial(ProgressBar, ProgressTextBlock); + + // Show cancel button + FitButton.Visibility = Visibility.Hidden; + CancelButton.Visibility = Visibility.Visible; + + // Set up progress reporter + var progressReporter = new SafeProgressReporter(nameof(FittingAnalysis)); + progressReporter.ProgressReported += (SafeProgressReporter reporter, double progress, double progressDelta) => + { + AnalysisProgressDisplayHelper.PostProgress(Dispatcher, ProgressBar, ProgressTextBlock, progress); + }; + bool progressCleanupEnabled = false; + progressReporter.TaskEnded += () => + { + if (!progressCleanupEnabled) return; + // Close out progress bar + ProgressBar.Value = 100; + ProgressTextBlock.Text = "Analysis Complete"; + Mouse.OverrideCursor = null; + FitButton.IsEnabled = true; + ProgressBar.Visibility = Visibility.Hidden; + ProgressTextBlock.Visibility = Visibility.Hidden; + + // Close cancel button + FitButton.Visibility = Visibility.Visible; + CancelButton.Visibility = Visibility.Hidden; + + // Re-enable all windows + ShellPublicVariables.SimulationInProgress = false; + ((MainWindow)Application.Current.MainWindow).EnableMenuStrip(); + ((MainWindow)Application.Current.MainWindow).EnableProjectExplorer(); + ((MainWindow)Application.Current.MainWindow).EnableMessageWindow(); + ((MainWindow)Application.Current.MainWindow).EnableOpenWindows(); + // Re-enable tab items + General_TabItem.IsEnabled = true; + Options_TabItem.IsEnabled = true; + }; + + // Perform Fitting Analysis. + try + { + await WaitCursorHelper.RunWithVisibleWaitCursorAsync(Dispatcher, async () => + { + await Element.RunAsync(progressReporter); + }); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"FittingAnalysisPropertiesControl.EstimateButton_Click: {ex}"); + GenericControls.MessageBox.Show("An unexpected error occurred. Please verify your analysis inputs and settings." + Environment.NewLine + "If the issue persists, contact the Risk Management Center.", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + progressCleanupEnabled = true; + progressReporter.IndicateTaskEnded(); + } + } + + /// + /// Handles the Click event of the Cancel button. + /// Cancels the currently running distribution fitting analysis. + /// + /// The source of the event. + /// Event data. + private void CancelButton_Click(object sender, RoutedEventArgs e) + { + Element.CancelAnalysis(); + } + + /// + /// Handles key down events for the control. Cancels the analysis if Escape is pressed. + /// + /// The source of the event. + /// The key event arguments. + private void FittingAnalysisProperties_KeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + Element.CancelAnalysis(); + } + + /// + /// Handles the Loaded event of the ComboBox control. + /// Sets up a sorted collection view for the InputDataList and binds it to the combo box. + /// + /// The source of the event. + /// Event data. + private void ComboBox_Loaded(object sender, RoutedEventArgs e) + { + if (Element == null) return; + ComboBox cmbo = (ComboBox)sender; + CollectionViewSource csv = new CollectionViewSource() { Source = InputDataList, IsLiveSortingRequested = true }; + csv.SortDescriptions.Add(new SortDescription(nameof(IElement.Name), ListSortDirection.Ascending)); + var view = csv.View; + view.MoveCurrentToPosition(-1); + cmbo.ItemsSource = view; + } + + /// + /// Handles the Unloaded event of the UserControl. + /// Cleans up event subscriptions to prevent memory leaks. + /// + /// The source of the event. + /// Event data. + private void UserControl_Unloaded(object sender, RoutedEventArgs e) + { + UnsubscribeInputDataCollection(); + } + } +} diff --git a/src/RMC.BestFit.App/GUI/InputData/InputDataControl.xaml b/src/RMC.BestFit.App/GUI/InputData/InputDataControl.xaml new file mode 100644 index 0000000..9ff444d --- /dev/null +++ b/src/RMC.BestFit.App/GUI/InputData/InputDataControl.xaml @@ -0,0 +1,547 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/InputData/InputDataControl.xaml.cs b/src/RMC.BestFit.App/GUI/InputData/InputDataControl.xaml.cs new file mode 100644 index 0000000..f53d888 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/InputData/InputDataControl.xaml.cs @@ -0,0 +1,3656 @@ +using FrameworkInterfaces; +using FrameworkUI; +using RMC.BestFit.Models; +using RMC.BestFit.UI; +using OxyPlot; +using OxyPlot.Wpf; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Navigation; +using System.Windows.Shapes; +using System.Windows.Threading; +using OxyPlotControls; +using GenericControls; +using NumericControls.Distributions.Univariate; +using Numerics; +using Numerics.Data; +using Numerics.Data.Statistics; +using Numerics.Distributions; +using System.Collections; +using System.Collections.Specialized; +using System.Globalization; +using System.Windows.Media.Media3D; +using Numerics.Sampling; + +namespace RMC_BestFit +{ + /// + /// User control for displaying and managing input data including exact, uncertain, interval, and threshold data. + /// Provides comprehensive visualization through multiple plot types and data grids for statistical analysis. + /// + public partial class InputDataControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// Sets up the row types for the various data grids. + /// + public InputDataControl() + { + InitializeComponent(); + DataContext = this; + + ExactDataGrid.RowType = typeof(ExactDataRowItem); + UncertainDataGrid.RowType = typeof(UncertainDataRowItem); + IntervalDataGrid.RowType = typeof(IntervalDataRowItem); + ThresholdDataGrid.RowType = typeof(ThresholdDataRowItem); + } + + #region Members + + /// + /// Dependency property for the Element property. + /// + public static DependencyProperty ElementProperty = DependencyProperty.Register(nameof(Element), typeof(InputData), typeof(InputDataControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the input data element being displayed and managed by this control. + /// + public InputData Element + { + get { return (InputData)GetValue(ElementProperty); } + set { SetValue(ElementProperty, value); } + } + + /// + /// Callback method invoked when the Element dependency property changes. + /// Handles event subscription and data grid binding for the new element. + /// + /// The dependency object whose property changed. + /// Event args containing the old and new values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as InputDataControl == null) return; + var thisControl = (InputDataControl)d; + + // Remove handlers from old element and detach plots + if (e.OldValue != null) + { + InputData oldElement = e.OldValue as InputData; + if (oldElement != null) + { + oldElement.PropertyChanged -= thisControl.ElementPropertyChanged; + thisControl.UnsubscribeSeriesCollectionChanged(oldElement); + } + // Detach plots from ContentControl hosts + thisControl.DetachPlots(); + // Null all toolbar plots and unsubscribe PropertiesCalled + thisControl.PlotToolbar.Plot = null; + thisControl.PlotToolbar.PropertiesCalled -= thisControl.PlotToolbar_PropertiesCalled; + thisControl.SeasonalityPlotToolbar.Plot = null; + thisControl.SeasonalityPlotToolbar.PropertiesCalled -= thisControl.PlotToolbar_PropertiesCalled; + thisControl.DensityPlotToolbar.Plot = null; + thisControl.DensityPlotToolbar.PropertiesCalled -= thisControl.PlotToolbar_PropertiesCalled; + thisControl.HistogramPlotToolbar.Plot = null; + thisControl.HistogramPlotToolbar.PropertiesCalled -= thisControl.PlotToolbar_PropertiesCalled; + thisControl.QQPlotToolbar.Plot = null; + thisControl.QQPlotToolbar.PropertiesCalled -= thisControl.PlotToolbar_PropertiesCalled; + thisControl.ACFPlotToolbar.Plot = null; + thisControl.ACFPlotToolbar.PropertiesCalled -= thisControl.PlotToolbar_PropertiesCalled; + thisControl.PACFPlotToolbar.Plot = null; + thisControl.PACFPlotToolbar.PropertiesCalled -= thisControl.PlotToolbar_PropertiesCalled; + thisControl.ThresholdDiagnosticsToolbar.Plot = null; + thisControl.ThresholdDiagnosticsToolbar.PropertiesCalled -= thisControl.PlotToolbar_PropertiesCalled; + } + + if (e.NewValue == null) return; + var newElement = e.NewValue as InputData; + if (newElement == null) return; + + // Reset _isLoaded so the next Loaded event re-initializes plots for the new element + thisControl._isLoaded = false; + + // Add handlers + newElement.PropertyChanged += thisControl.ElementPropertyChanged; + + // Bind data grids + thisControl.BindExactDataGrid(); + thisControl.BindUncertainDataGrid(); + thisControl.BindIntervalDataGrid(); + thisControl.BindThresholdDataGrid(); + + // Subscribe to model CollectionChanged events so undo/redo replay propagates to UI. + thisControl.SubscribeSeriesCollectionChanged(newElement); + + // Suspend plot bridges during visual tree attachment to prevent WPF + // DependencyProperty changes from recording spurious undo entries. + using (newElement.SuspendPlotBridges()) + { + // Attach plots to ContentControl hosts + thisControl.AttachPlots(); + + // Wire toolbars to plots + thisControl.PlotToolbar.Plot = thisControl.ChronologyPlotRadioButton.IsChecked == true + ? newElement.ChronologyPlot : newElement.FrequencyPlot; + thisControl.SeasonalityPlotToolbar.Plot = newElement.SeasonalityPlot; + thisControl.DensityPlotToolbar.Plot = newElement.DensityPlot; + thisControl.HistogramPlotToolbar.Plot = newElement.HistogramPlot; + thisControl.QQPlotToolbar.Plot = newElement.QQPlot; + thisControl.ACFPlotToolbar.Plot = newElement.ACFPlot; + thisControl.PACFPlotToolbar.Plot = newElement.PACFPlot; + // Threshold diagnostics toolbar binds to whichever plot is currently selected + if (thisControl.MRLPlotRadioButton.IsChecked == true) + thisControl.ThresholdDiagnosticsToolbar.Plot = newElement.MRLPlot; + else if (thisControl.ModifiedScalePlotRadioButton.IsChecked == true) + thisControl.ThresholdDiagnosticsToolbar.Plot = newElement.ModifiedScalePlot; + else + thisControl.ThresholdDiagnosticsToolbar.Plot = newElement.ShapePlot; + + // Subscribe toolbar PropertiesCalled ? control-level PlotPropertiesCalled + thisControl.PlotToolbar.PropertiesCalled += thisControl.PlotToolbar_PropertiesCalled; + thisControl.SeasonalityPlotToolbar.PropertiesCalled += thisControl.PlotToolbar_PropertiesCalled; + thisControl.DensityPlotToolbar.PropertiesCalled += thisControl.PlotToolbar_PropertiesCalled; + thisControl.HistogramPlotToolbar.PropertiesCalled += thisControl.PlotToolbar_PropertiesCalled; + thisControl.QQPlotToolbar.PropertiesCalled += thisControl.PlotToolbar_PropertiesCalled; + thisControl.ACFPlotToolbar.PropertiesCalled += thisControl.PlotToolbar_PropertiesCalled; + thisControl.PACFPlotToolbar.PropertiesCalled += thisControl.PlotToolbar_PropertiesCalled; + thisControl.ThresholdDiagnosticsToolbar.PropertiesCalled += thisControl.PlotToolbar_PropertiesCalled; + } + } + + /// + /// Attaches the Element's Plot objects to the ContentControl hosts in the visual tree. + /// + private void AttachPlots() + { + if (Element == null) return; + ChronologyPlotHost.Content = Element.ChronologyPlot; + FrequencyPlotHost.Content = Element.FrequencyPlot; + SeasonalityPlotHost.Content = Element.SeasonalityPlot; + DensityPlotHost.Content = Element.DensityPlot; + HistogramPlotHost.Content = Element.HistogramPlot; + QQPlotHost.Content = Element.QQPlot; + ACFPlotHost.Content = Element.ACFPlot; + PACFPlotHost.Content = Element.PACFPlot; + MRLPlotHost.Content = Element.MRLPlot; + ModifiedScalePlotHost.Content = Element.ModifiedScalePlot; + ShapePlotHost.Content = Element.ShapePlot; + } + + /// + /// Detaches Plot objects from the ContentControl hosts to allow reuse with other elements. + /// + private void DetachPlots() + { + ChronologyPlotHost.Content = null; + FrequencyPlotHost.Content = null; + SeasonalityPlotHost.Content = null; + DensityPlotHost.Content = null; + HistogramPlotHost.Content = null; + QQPlotHost.Content = null; + ACFPlotHost.Content = null; + PACFPlotHost.Content = null; + MRLPlotHost.Content = null; + ModifiedScalePlotHost.Content = null; + ShapePlotHost.Content = null; + } + + + /// + /// Indicates whether the control has been loaded. + /// + private bool _isLoaded = false; + + /// + /// Tracks whether the exact data grid is in read-only mode (non-Manual entry method). + /// Used to apply read-only cell styles to Index/DateTime and Value columns. + /// + private bool _isExactDataReadOnly; + + /// + /// Guard flag to suppress CollectionChanged handlers during Bind methods and bulk operations + /// (add/delete/paste). During these operations, the UI is directly managing RowItems, so + /// the CollectionChanged handler should not interfere. Matches the _suppressModelUpdate + /// pattern in ProbabilityOrdinatesControl. + /// + private bool _suppressUIUpdate; + + // Dirty flags for lazy plot/stats/tests updates � only the visible tab is updated immediately; + // hidden tabs are updated on demand when the user switches to them. + /// Dirty flag indicating the chronology plot needs to be redrawn. + private bool _chronologyPlotDirty; + /// Dirty flag indicating the frequency plot needs to be redrawn. + private bool _frequencyPlotDirty; + /// Dirty flag indicating the seasonality plot needs to be redrawn. + private bool _seasonalityPlotDirty; + /// Dirty flag indicating the density plot needs to be redrawn. + private bool _densityPlotDirty; + /// Dirty flag indicating the histogram plot needs to be redrawn. + private bool _histogramPlotDirty; + /// Dirty flag indicating the QQ plot needs to be redrawn. + private bool _qqPlotDirty; + /// Dirty flag indicating the ACF plot needs to be redrawn. + private bool _acfPlotDirty; + /// Dirty flag indicating the PACF plot needs to be redrawn. + private bool _pacfPlotDirty; + /// Dirty flag indicating the threshold diagnostics plots need to be redrawn. + private bool _thresholdDiagnosticsDirty; + + /// Cached mean residual life result for threshold diagnostic plots. + private MeanResidualLifeResult _mrlResult; + /// Cached parameter stability result for threshold diagnostic plots. + private ParameterStabilityResult _stabilityResult; + + /// Dirty flag indicating the summary statistics need to be recalculated. + private bool _summaryStatsDirty; + /// Dirty flag indicating the hypothesis tests need to be recalculated. + private bool _hypothesisTestsDirty; + + /// Cached border thickness for the top separator in summary statistics rows. + private static readonly Thickness _topBorderThickness = new Thickness(0, 2, 0, 0); + /// Cached border thickness for the bottom separator in summary statistics rows. + private static readonly Thickness _bottomBorderThickness = new Thickness(0, 0, 0, 2); + /// Cached zero-thickness border for normal summary statistics rows. + private static readonly Thickness _noBorderThickness = new Thickness(0); + /// Cached black brush for summary statistics row separators. + private static readonly SolidColorBrush _borderBrush = new SolidColorBrush(Colors.Black); + + /// + /// Gets a value indicating whether a plot was clicked. + /// + public bool PlotClicked { get; private set; } = false; + + /// + /// Event raised when the preview control or toolbar is clicked. + /// + public event PreviewControlClickedEventHandler PreviewControlClicked; + + /// + /// Delegate for the PreviewControlClicked event. + /// + /// Indicates whether the plot was clicked. + /// Indicates whether the toolbar was clicked. + /// The plot that was clicked. + public delegate void PreviewControlClickedEventHandler(bool plotClicked, bool toolbarClicked, Plot plot); + + /// + /// Raised when plot properties are requested to be displayed or modified. + /// Re-raises the PropertiesCalled event from the active toolbar so that MainProjectNode + /// only needs to subscribe to a single event on the control. + /// + public event PlotPropertiesCalledEventHandler PlotPropertiesCalled; + + /// + /// Delegate for the event. + /// + /// The plot whose properties are being accessed. + /// Indicates whether to open the properties panel. + /// The property expander to use for displaying properties. + /// The currently selected object in the plot. + public delegate void PlotPropertiesCalledEventHandler(Plot plot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject); + + /// + /// Gets the observable collection of exact data ordinates for grid display. + /// + public ObservableCollection ExactDataOrdinates { get; private set; } = new ObservableCollection(); + + /// + /// Gets the observable collection of uncertain data ordinates for grid display. + /// + public ObservableCollection UncertainDataOrdinates { get; private set; } = new ObservableCollection(); + + /// + /// Gets the observable collection of interval data ordinates for grid display. + /// + public ObservableCollection IntervalDataOrdinates { get; private set; } = new ObservableCollection(); + + /// + /// Gets the observable collection of threshold data ordinates for grid display. + /// + public ObservableCollection ThresholdDataOrdinates { get; private set; } = new ObservableCollection(); + + /// + /// List of summary statistics for the input data. + /// + private List _summaryStatistics = new List(); + + /// + /// List of hypothesis test results for the input data. + /// + private List _hypothesisTestResults = new List(); + + #region Plot Convenience Properties + + /// Gets the chronology plot from the element. + public Plot ChronologyPlot => Element?.ChronologyPlot; + /// Gets the frequency plot from the element. + public Plot FrequencyPlot => Element?.FrequencyPlot; + /// Gets the seasonality plot from the element. + public Plot SeasonalityPlot => Element?.SeasonalityPlot; + /// Gets the density plot from the element. + public Plot DensityPlot => Element?.DensityPlot; + /// Gets the histogram plot from the element. + public Plot HistogramPlot => Element?.HistogramPlot; + /// Gets the QQ plot from the element. + public Plot QQPlot => Element?.QQPlot; + /// Gets the ACF plot from the element. + public Plot ACFPlot => Element?.ACFPlot; + /// Gets the PACF plot from the element. + public Plot PACFPlot => Element?.PACFPlot; + /// Gets the MRL plot from the element. + public Plot MRLPlot => Element?.MRLPlot; + /// Gets the modified scale plot from the element. + public Plot ModifiedScalePlot => Element?.ModifiedScalePlot; + /// Gets the shape plot from the element. + public Plot ShapePlot => Element?.ShapePlot; + + #endregion + + #endregion + + // TODO: Plot change events and undo-redo logic removed pending OxyPlot library updates. + + /// + /// Handles the Loaded event of the user control. + /// Initializes plot settings and updates the control display on first load. + /// + /// The source of the event. + /// Event args containing routed event data. + private void UserControl_Loaded(object sender, RoutedEventArgs e) + { + if (Element == null) return; + + // Always mark dirty and update on load. Plots must re-render whenever the + // control enters the visual tree (first open, close/reopen, tab switch back). + // Disable undo during plot updates so that Series.Clear/Add don't record as undo entries. + MarkAllDirty(); + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try + { + UpdateControl(); + UpdateUSGSTextBox(); + } + finally + { + Element.IsUndoEnabled = wasUndoEnabled; + } + _isLoaded = true; + + // Re-subscribe to series CollectionChanged on every Loaded event. + // Unloaded unsubscribes to prevent leaks, so we must re-subscribe here + // because Loaded/Unloaded fire on tab switches but ElementCallback does not. + if (Element != null) + { + UnsubscribeSeriesCollectionChanged(Element); + SubscribeSeriesCollectionChanged(Element); + } + } + + /// + /// Handles the Unloaded event for the control. + /// Unsubscribes from collection changed events to prevent memory leaks. + /// + /// The source of the event. + /// The routed event args. + private void UserControl_Unloaded(object sender, RoutedEventArgs e) + { + if (Element == null) return; + UnsubscribeSeriesCollectionChanged(Element); + } + + /// + /// Handles property changed events from the Element. + /// Rebinds data grids and updates control when relevant properties change. + /// + /// The source of the event. + /// Event args containing the property name that changed. + private void ElementPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Element.ExactDataMethod) || + e.PropertyName == nameof(Element.IsProcessed) || + e.PropertyName == nameof(Element.DataFrame.PlottingParameter) || + e.PropertyName == nameof(Element.DataFrame.LowOutlierThreshold) || + e.PropertyName == "LowOutliers" || + e.PropertyName == "TimeSeries") + { + Mouse.OverrideCursor = Cursors.Wait; + try + { + MarkAllDirty(); + BindExactDataGrid(); + // Disable undo during plot updates so Series.Clear/Add don't record as undo entries + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try { UpdateControl(); } + finally { Element.IsUndoEnabled = wasUndoEnabled; } + UpdateUSGSTextBox(); + } + finally + { + Mouse.OverrideCursor = null; + } + } + else if (e.PropertyName == nameof(Element.Threshold)) + { + Element?.UpdateThresholdAnnotation(); + } + else if (e.PropertyName == nameof(Data.PlottingPosition)) + { + RefreshPlottingPositionColumns(); + } + } + + /// + /// Notifies grid row wrappers that plotting positions were recomputed on the underlying data. + /// + /// + /// updates model rows while collection + /// notifications are suppressed, then raises one aggregate plotting-position event. The + /// grids bind through row wrappers, so the wrappers must forward that computed-property + /// notification without rebinding the grids or triggering validation. + /// + private void RefreshPlottingPositionColumns() + { + foreach (ExactDataRowItem rowItem in ExactDataOrdinates.OfType()) + { + rowItem.RefreshPlottingPosition(); + } + + foreach (UncertainDataRowItem rowItem in UncertainDataOrdinates.OfType()) + { + rowItem.RefreshPlottingPosition(); + } + + foreach (IntervalDataRowItem rowItem in IntervalDataOrdinates.OfType()) + { + rowItem.RefreshPlottingPosition(); + } + } + + /// + /// Subscribes to CollectionChanged events on all four DataSeries in the specified element. + /// Called from ElementCallback (after binding grids) and UserControl_Loaded (re-subscribe on tab switch). + /// + /// + /// This replaces the old UndoManager.StateChanged approach. When undo/redo replays a collection + /// action, the bridge modifies the model series which fires CollectionChanged. The bridge's own + /// handler skips re-recording (IsExecutingAction check), but this UI handler still receives the + /// event and updates the RowItems accordingly � matching the ProbabilityOrdinatesControl pattern. + /// + /// The InputData element whose series to subscribe to. + private void SubscribeSeriesCollectionChanged(InputData element) + { + if (element?.DataFrame == null) return; + element.DataFrame.ExactSeries.CollectionChanged += ExactSeries_CollectionChanged; + element.DataFrame.UncertainSeries.CollectionChanged += UncertainSeries_CollectionChanged; + element.DataFrame.IntervalSeries.CollectionChanged += IntervalSeries_CollectionChanged; + element.DataFrame.ThresholdSeries.CollectionChanged += ThresholdSeries_CollectionChanged; + } + + /// + /// Unsubscribes from CollectionChanged events on all four DataSeries in the specified element. + /// Called from ElementCallback (before switching elements) and UserControl_Unloaded (prevent leaks). + /// + /// The InputData element whose series to unsubscribe from. + private void UnsubscribeSeriesCollectionChanged(InputData element) + { + if (element?.DataFrame == null) return; + element.DataFrame.ExactSeries.CollectionChanged -= ExactSeries_CollectionChanged; + element.DataFrame.UncertainSeries.CollectionChanged -= UncertainSeries_CollectionChanged; + element.DataFrame.IntervalSeries.CollectionChanged -= IntervalSeries_CollectionChanged; + element.DataFrame.ThresholdSeries.CollectionChanged -= ThresholdSeries_CollectionChanged; + } + + /// + /// Handles CollectionChanged from the ExactSeries model collection. + /// On Replace (cell edit or cell-edit undo): updates the RowItem's ordinate in-place via SetOrdinate, + /// preserving DataGrid cell focus. On Reset (undo of add/delete/paste): full rebind from model state. + /// + /// The ExactSeries that changed. + /// Event args describing the change. + private void ExactSeries_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + if (_suppressUIUpdate) return; + + if (e.Action == NotifyCollectionChangedAction.Replace) + { + // Suppress PropertyChanged handlers during SetOrdinate to prevent redundant + // ValidateTable calls (SetOrdinate fires 5� NotifyPropertyChanged). + _suppressUIUpdate = true; + try + { + for (int i = 0; i < e.NewItems.Count; i++) + { + var rowItem = (ExactDataRowItem)ExactDataOrdinates[e.NewStartingIndex + i]; + rowItem.SetOrdinate((ExactData)e.NewItems[i]); + } + } + finally + { + _suppressUIUpdate = false; + } + // Exact data affects everything: all plots, summary stats, and hypothesis tests + MarkAllDirty(); + // Disable undo during plot updates so Series.Clear/Add don't record as undo entries + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try { UpdateControl(); } + finally { Element.IsUndoEnabled = wasUndoEnabled; } + } + else if (e.Action == NotifyCollectionChangedAction.Reset) + { + // Full rebuild � undo of paste/add/delete + MarkAllDirty(); + BindExactDataGrid(); + // Disable undo during plot updates so Series.Clear/Add don't record as undo entries + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try { UpdateControl(); } + finally { Element.IsUndoEnabled = wasUndoEnabled; } + } + } + + /// + /// Handles CollectionChanged from the UncertainSeries model collection. + /// On Replace: updates the RowItem's ordinate in-place. On Reset: full rebind. + /// + /// The UncertainSeries that changed. + /// Event args describing the change. + private void UncertainSeries_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + if (_suppressUIUpdate) return; + + if (e.Action == NotifyCollectionChangedAction.Replace) + { + _suppressUIUpdate = true; + try + { + for (int i = 0; i < e.NewItems.Count; i++) + { + var rowItem = (UncertainDataRowItem)UncertainDataOrdinates[e.NewStartingIndex + i]; + rowItem.SetOrdinate((UncertainData)e.NewItems[i]); + } + } + finally + { + _suppressUIUpdate = false; + } + // Non-exact data only affects chronology, frequency, and summary stats + MarkDataFrameDirty(); + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try { UpdateControl(); } + finally { Element.IsUndoEnabled = wasUndoEnabled; } + } + else if (e.Action == NotifyCollectionChangedAction.Reset) + { + MarkDataFrameDirty(); + BindUncertainDataGrid(); + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try { UpdateControl(); } + finally { Element.IsUndoEnabled = wasUndoEnabled; } + } + } + + /// + /// Handles CollectionChanged from the IntervalSeries model collection. + /// On Replace: updates the RowItem's ordinate in-place. On Reset: full rebind. + /// + /// The IntervalSeries that changed. + /// Event args describing the change. + private void IntervalSeries_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + if (_suppressUIUpdate) return; + + if (e.Action == NotifyCollectionChangedAction.Replace) + { + _suppressUIUpdate = true; + try + { + for (int i = 0; i < e.NewItems.Count; i++) + { + var rowItem = (IntervalDataRowItem)IntervalDataOrdinates[e.NewStartingIndex + i]; + rowItem.SetOrdinate((IntervalData)e.NewItems[i]); + } + } + finally + { + _suppressUIUpdate = false; + } + // Non-exact data only affects chronology, frequency, and summary stats + MarkDataFrameDirty(); + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try { UpdateControl(); } + finally { Element.IsUndoEnabled = wasUndoEnabled; } + } + else if (e.Action == NotifyCollectionChangedAction.Reset) + { + MarkDataFrameDirty(); + BindIntervalDataGrid(); + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try { UpdateControl(); } + finally { Element.IsUndoEnabled = wasUndoEnabled; } + } + } + + /// + /// Handles CollectionChanged from the ThresholdSeries model collection. + /// On Replace: updates the RowItem's ordinate in-place. On Reset: full rebind. + /// + /// The ThresholdSeries that changed. + /// Event args describing the change. + private void ThresholdSeries_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + if (_suppressUIUpdate) return; + + if (e.Action == NotifyCollectionChangedAction.Replace) + { + _suppressUIUpdate = true; + try + { + for (int i = 0; i < e.NewItems.Count; i++) + { + var rowItem = (ThresholdDataRowItem)ThresholdDataOrdinates[e.NewStartingIndex + i]; + rowItem.SetOrdinate((ThresholdData)e.NewItems[i]); + } + } + finally + { + _suppressUIUpdate = false; + } + // Non-exact data only affects chronology, frequency, and summary stats + MarkDataFrameDirty(); + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try { UpdateControl(); } + finally { Element.IsUndoEnabled = wasUndoEnabled; } + } + else if (e.Action == NotifyCollectionChangedAction.Reset) + { + MarkDataFrameDirty(); + BindThresholdDataGrid(); + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try { UpdateControl(); } + finally { Element.IsUndoEnabled = wasUndoEnabled; } + } + } + + /// + /// Updates all plots, summary statistics, and hypothesis tests in the control. + /// Configures the index slider based on exact data series quartiles. + /// + private void UpdateControl() + { + // Show/hide threshold diagnostics tab based on POT method and time series availability + if (Element.ExactDataMethod == InputData.ExactDataEntryType.PeaksOverThresholdSeries + && Element.TimeSeriesElement?.TimeSeries != null) + { + ThresholdDiagnosticsTab.Visibility = Visibility.Visible; + } + else + { + ThresholdDiagnosticsTab.Visibility = Visibility.Collapsed; + } + + // Set the hypothesis test split index slider BEFORE updating visible content, + // so that if the Hypothesis Tests tab is already selected (e.g., restored layout), + // UpdateHypothesisTests() uses a valid slider value instead of the default 0. + if (Element != null && Element.DataFrame != null && Element.DataFrame.ExactSeries != null && Element.DataFrame.ExactSeries.Count > 0) + { + int min = Element.DataFrame.ExactSeries[(int)Math.Floor(Element.DataFrame.ExactSeries.Count * 0.25)].Index; + int max = Element.DataFrame.ExactSeries[(int)Math.Floor(Element.DataFrame.ExactSeries.Count * 0.75)].Index; + int value = Element.DataFrame.ExactSeries[(int)Math.Floor(Element.DataFrame.ExactSeries.Count / 2d)].Index; + if (max <= min || value <= min || value >= max) + { + max = min + 2; + value = min + 1; + } + IndexSlider.Minimum = min; + IndexSlider.Maximum = max; + IndexSlider.Value = value; + } + + // Update only the currently visible content; other tabs are updated lazily on switch + UpdateVisibleContent(); + } + + /// + /// Updates the USGS text box with raw USGS data if the entry method is USGS peak data. + /// Shows a fallback message for backwards compatibility with older project files. + /// + private void UpdateUSGSTextBox() + { + if (Element == null) return; + + if (Element.ExactDataMethod == InputData.ExactDataEntryType.USGSPeakDischarge || + Element.ExactDataMethod == InputData.ExactDataEntryType.USGSPeakStage) + { + USGSTextFileTab.Visibility = Visibility.Visible; + var textRange = new TextRange(USGSRawTextBox.Document.ContentStart, USGSRawTextBox.Document.ContentEnd); + string rawText = Element.DataFrame.USGSRawText; + textRange.Text = string.IsNullOrEmpty(rawText) + ? "The source text file is not available. Please try downloading the data again." + : rawText; + } + else + { + USGSTextFileTab.Visibility = Visibility.Collapsed; + var textRange = new TextRange(USGSRawTextBox.Document.ContentStart, USGSRawTextBox.Document.ContentEnd); + textRange.Text = string.Empty; + } + } + + /// + /// Handles property changed events from exact data row items. + /// Validates the data grid and updates the control when non-plotting position properties change. + /// + /// The source of the event. + /// Event args containing the property name that changed. + private void ExactDataRowItem_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (_suppressUIUpdate) return; + if (e.PropertyName != nameof(Data.PlottingPosition)) + { + if (Element.DataFrame.ExactSeries.SuppressCollectionChanged == false) + { + ExactDataGrid.ValidateTable(); + } + } + } + + /// + /// Handles property changed events from uncertain data row items. + /// Validates the data grid and updates the control when non-plotting position properties change. + /// + /// The source of the event. + /// Event args containing the property name that changed. + private void UncertainDataRowItem_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (_suppressUIUpdate) return; + if (e.PropertyName != nameof(Data.PlottingPosition)) + { + if (Element.DataFrame.UncertainSeries.SuppressCollectionChanged == false) + { + UncertainDataGrid.ValidateTable(); + } + } + } + + /// + /// Handles property changed events from interval data row items. + /// Validates the data grid and updates the control when non-plotting position properties change. + /// + /// The source of the event. + /// Event args containing the property name that changed. + private void IntervalDataRowItem_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (_suppressUIUpdate) return; + if (e.PropertyName != nameof(Data.PlottingPosition)) + { + if (Element.DataFrame.IntervalSeries.SuppressCollectionChanged == false) + { + IntervalDataGrid.ValidateTable(); + } + } + } + + /// + /// Handles property changed events from threshold data row items. + /// Validates the data grid and updates the control when non-plotting position properties change. + /// + /// The source of the event. + /// Event args containing the property name that changed. + private void ThresholdDataRowItem_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (_suppressUIUpdate) return; + if (e.PropertyName != nameof(Data.PlottingPosition)) + { + if (Element.DataFrame.ThresholdSeries.SuppressCollectionChanged == false) + { + ThresholdDataGrid.ValidateTable(); + } + } + } + + #region Lazy Update Helpers + + /// + /// Marks all plots, summary statistics, and hypothesis tests as dirty. + /// Used when exact data changes or element properties change (everything needs refresh). + /// + private void MarkAllDirty() + { + _chronologyPlotDirty = true; + _frequencyPlotDirty = true; + _seasonalityPlotDirty = true; + _densityPlotDirty = true; + _histogramPlotDirty = true; + _qqPlotDirty = true; + _acfPlotDirty = true; + _pacfPlotDirty = true; + _thresholdDiagnosticsDirty = true; + _summaryStatsDirty = true; + _hypothesisTestsDirty = true; + } + + /// + /// Marks only the items that depend on non-exact data series (Uncertain, Interval, Threshold) as dirty. + /// These series only affect the chronology plot, frequency plot, and summary statistics. + /// Seasonality, density, histogram, QQ, ACF, PACF, and hypothesis tests use exact data only. + /// + private void MarkDataFrameDirty() + { + _chronologyPlotDirty = true; + _frequencyPlotDirty = true; + _summaryStatsDirty = true; + } + + /// + /// Updates only the content of the currently visible tab. Plots/stats/tests on hidden tabs + /// remain dirty and will be updated lazily when the user switches to them via + /// . + /// + private void UpdateVisibleContent() + { + if (Element == null) return; + + // Update visible plot + if (DataFrameTab.IsSelected) + { + if (_chronologyPlotDirty) { UpdateChronologyPlot(); _chronologyPlotDirty = false; } + if (_frequencyPlotDirty) { UpdateFrequencyPlot(); _frequencyPlotDirty = false; } + } + else if (SeasonalityTab.IsSelected && _seasonalityPlotDirty) { UpdateLazyContentWithWaitCursor(UpdateSeasonalityPlot, () => _seasonalityPlotDirty = false); } + else if (DensityTab.IsSelected && _densityPlotDirty) { UpdateLazyContentWithWaitCursor(UpdateDensityPlot, () => _densityPlotDirty = false); } + else if (HistogramTab.IsSelected && _histogramPlotDirty) { UpdateHistogramPlot(); _histogramPlotDirty = false; } + else if (QQTab.IsSelected && _qqPlotDirty) { UpdateQQPlot(); _qqPlotDirty = false; } + else if (ACFTab.IsSelected && _acfPlotDirty) { UpdateLazyContentWithWaitCursor(UpdateACFPlot, () => _acfPlotDirty = false); } + else if (PACFTab.IsSelected && _pacfPlotDirty) { UpdateLazyContentWithWaitCursor(UpdatePACFPlot, () => _pacfPlotDirty = false); } + else if (ThresholdDiagnosticsTab.IsSelected && _thresholdDiagnosticsDirty) { UpdateLazyContentWithWaitCursor(UpdateThresholdDiagnosticsPlots, () => _thresholdDiagnosticsDirty = false); } + + // Update visible non-plot tabs + if (SummaryStatisticsTab.IsSelected && _summaryStatsDirty) { UpdateSummaryStats(); _summaryStatsDirty = false; } + if (HypothesisTestsTab.IsSelected && _hypothesisTestsDirty) { UpdateLazyContentWithWaitCursor(UpdateHypothesisTests, () => _hypothesisTestsDirty = false); } + } + + /// + /// Handles tab selection changes to lazily update dirty content when the user switches tabs. + /// + /// The TabControl that raised the event. + /// Event arguments containing the selection change details. + private void MainTabControl_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (e.Source != MainTabControl) return; + if (Element == null) return; + if (DataFrameTab.IsSelected) + { + if (_chronologyPlotDirty) { UpdateChronologyPlot(); _chronologyPlotDirty = false; } + if (_frequencyPlotDirty) { UpdateFrequencyPlot(); _frequencyPlotDirty = false; } + } + else if (SeasonalityTab.IsSelected && _seasonalityPlotDirty) { UpdateLazyContentWithWaitCursor(UpdateSeasonalityPlot, () => _seasonalityPlotDirty = false); } + else if (DensityTab.IsSelected && _densityPlotDirty) { UpdateLazyContentWithWaitCursor(UpdateDensityPlot, () => _densityPlotDirty = false); } + else if (HistogramTab.IsSelected && _histogramPlotDirty) { UpdateHistogramPlot(); _histogramPlotDirty = false; } + else if (QQTab.IsSelected && _qqPlotDirty) { UpdateQQPlot(); _qqPlotDirty = false; } + else if (ACFTab.IsSelected && _acfPlotDirty) { UpdateLazyContentWithWaitCursor(UpdateACFPlot, () => _acfPlotDirty = false); } + else if (PACFTab.IsSelected && _pacfPlotDirty) { UpdateLazyContentWithWaitCursor(UpdatePACFPlot, () => _pacfPlotDirty = false); } + else if (ThresholdDiagnosticsTab.IsSelected && _thresholdDiagnosticsDirty) { UpdateLazyContentWithWaitCursor(UpdateThresholdDiagnosticsPlots, () => _thresholdDiagnosticsDirty = false); } + else if (SummaryStatisticsTab.IsSelected && _summaryStatsDirty) { UpdateSummaryStats(); _summaryStatsDirty = false; } + else if (HypothesisTestsTab.IsSelected && _hypothesisTestsDirty) { UpdateLazyContentWithWaitCursor(UpdateHypothesisTests, () => _hypothesisTestsDirty = false); } + } + + /// + /// Runs a lazy content update with visible wait-cursor feedback and marks the content clean afterward. + /// + /// The lazy update action to run. + /// The action that clears the corresponding dirty flag. + /// + /// Dirty flags are cleared only after the update action completes successfully. + /// + private void UpdateLazyContentWithWaitCursor(Action updateAction, Action markCleanAction) + { + WaitCursorHelper.RunWithVisibleWaitCursor(Dispatcher, () => + { + updateAction(); + markCleanAction(); + }); + } + + #endregion + + #region Plots + + /// + /// Gets the currently selected plot based on which tab and radio button is active. + /// + /// The currently active plot, or null if no plot tab is selected. + public Plot GetCurrentPlot() + { + if (DataFrameTab.IsSelected == true) + { + if (ChronologyPlotRadioButton.IsChecked == true) return ChronologyPlot; + if (FrequencyPlotRadioButton.IsChecked == true) return FrequencyPlot; + return null; + } + if (SeasonalityTab.IsSelected == true) return SeasonalityPlot; + if (DensityTab.IsSelected == true) return DensityPlot; + if (HistogramTab.IsSelected == true) return HistogramPlot; + if (QQTab.IsSelected == true) return QQPlot; + if (ACFTab.IsSelected == true) return ACFPlot; + if (PACFTab.IsSelected == true) return PACFPlot; + if (ThresholdDiagnosticsTab.IsSelected == true) + { + if (MRLPlotRadioButton.IsChecked == true) return MRLPlot; + if (ModifiedScalePlotRadioButton.IsChecked == true) return ModifiedScalePlot; + if (ShapePlotRadioButton.IsChecked == true) return ShapePlot; + } + return null; + } + + /// + /// Gets the toolbar for the currently selected plot based on which tab is active. + /// + /// The toolbar for the currently active plot, or null if no plot tab is selected. + public OxyPlotToolbar GetCurrentPlotToolbar() + { + if (DataFrameTab.IsSelected == true) return PlotToolbar; + if (SeasonalityTab.IsSelected == true) return SeasonalityPlotToolbar; + if (DensityTab.IsSelected == true) return DensityPlotToolbar; + if (HistogramTab.IsSelected == true) return HistogramPlotToolbar; + if (QQTab.IsSelected == true) return QQPlotToolbar; + if (ACFTab.IsSelected == true) return ACFPlotToolbar; + if (PACFTab.IsSelected == true) return PACFPlotToolbar; + if (ThresholdDiagnosticsTab.IsSelected == true) return ThresholdDiagnosticsToolbar; + return null; + } + + /// + /// Handles PropertiesCalled from any toolbar and re-raises it as the control-level PlotPropertiesCalled event. + /// + /// The plot whose properties are being accessed. + /// Indicates whether to open the properties panel. + /// The property expander to use. + /// The selected object in the plot. + private void PlotToolbar_PropertiesCalled(Plot targetPlot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject) + { + PlotPropertiesCalled?.Invoke(targetPlot, openProperties, propertyExpander, selectedObject); + } + + /// + /// Handles the PreviewMouseDown event for the user control. + /// Determines which plot and toolbar were clicked and raises the appropriate event. + /// + /// The source of the event. + /// Event args containing mouse button data. + private void UserControl_PreviewMouseDown(object sender, MouseButtonEventArgs e) + { + Plot plot = GetCurrentPlot(); + OxyPlotToolbar toolbar = GetCurrentPlotToolbar(); + + if (plot == null || toolbar == null) + { + PlotClicked = false; + return; + } + + var plotHitResult = VisualTreeHelper.HitTest(plot, e.GetPosition(plot)); + var toolbarHitResult = VisualTreeHelper.HitTest(toolbar, e.GetPosition(toolbar)); + PreviewControlClicked?.Invoke(plotHitResult != null, toolbarHitResult != null, plot); + PlotClicked = plotHitResult != null; + } + + /// + /// Handles the LostFocus event for the user control. + /// Resets the PlotClicked flag when focus is lost. + /// + /// The source of the event. + /// Event args containing routed event data. + private void UserControl_LostFocus(object sender, RoutedEventArgs e) + { + PlotClicked = false; + } + + /// + /// Handles the Checked event for the chronology plot radio button. + /// Shows the chronology plot and hides the frequency plot. + /// + /// The source of the event. + /// Event args containing routed event data. + private void ChronologyPlotRadioButton_Checked(object sender, RoutedEventArgs e) + { + ChronologyPlotHost.Visibility = Visibility.Visible; + FrequencyPlotHost.Visibility = Visibility.Hidden; + PlotToolbar.Plot = ChronologyPlot; + ChronologyPlot?.InvalidatePlot(true); + } + + /// + /// Handles the Checked event for the frequency plot radio button. + /// Shows the frequency plot and hides the chronology plot. + /// + /// The source of the event. + /// Event args containing routed event data. + private void FrequencyPlotRadioButton_Checked(object sender, RoutedEventArgs e) + { + ChronologyPlotHost.Visibility = Visibility.Hidden; + FrequencyPlotHost.Visibility = Visibility.Visible; + PlotToolbar.Plot = FrequencyPlot; + FrequencyPlot?.InvalidatePlot(true); + } + + /// + /// Updates the chronology plot with current exact, low outlier, uncertain, interval, and threshold data. + /// Configures data series with appropriate styling and tracker formats. + /// + private void UpdateChronologyPlot() + { + if (Element == null) return; + var plot = Element.ChronologyPlot; + if (plot == null) return; + var valueStringFormat = UserSettings.ValueStringFormat; + + // Look up named series before Clear + var exactData = plot.Series.OfType().FirstOrDefault(s => s.Name == "ExactData") + ?? new ScatterPointSeries + { + Name = "ExactData", + Title = "Exact Data", + MarkerFill = Colors.Black, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 4, + MarkerType = MarkerType.Circle + }; + + var lowOutlierData = plot.Series.OfType().FirstOrDefault(s => s.Name == "LowOutlierData") + ?? new ScatterPointSeries + { + Name = "LowOutlierData", + Title = "Low Outlier Data", + MarkerStroke = Colors.Red, + MarkerStrokeThickness = 2, + MarkerSize = 4, + MarkerType = MarkerType.Cross + }; + + var uncertainData = plot.Series.OfType().FirstOrDefault(s => s.Name == "UncertainData") + ?? new ScatterErrorSeries + { + Name = "UncertainData", + Title = "Uncertain Data", + MarkerFill = Colors.Green, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 4, + MarkerType = MarkerType.Diamond + }; + + var intervalData = plot.Series.OfType().FirstOrDefault(s => s.Name == "IntervalData") + ?? new ScatterErrorSeries + { + Name = "IntervalData", + Title = "Interval Data", + MarkerFill = Colors.Cyan, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 4, + MarkerType = MarkerType.Circle + }; + + using (Element.SuspendPlotBridges()) + { + plot.Series.Clear(); + + // Exact data + exactData.ItemsSource = Element.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == false); + exactData.Mapping = item => { var d = (ExactData)item; return new OxyPlot.Series.ScatterPoint(d.Index, d.Value); }; + exactData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0}" + Environment.NewLine + "{3}: {4:" + valueStringFormat + "}"; + if (Element.DataFrame.ExactSeries.Count > 0) plot.Series.Add(exactData); + + // Low outlier data + lowOutlierData.ItemsSource = Element.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == true); + lowOutlierData.Mapping = item => { var d = (ExactData)item; return new OxyPlot.Series.ScatterPoint(d.Index, d.Value); }; + lowOutlierData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0}" + Environment.NewLine + "{3}: {4:" + valueStringFormat + "}"; + if (Element.DataFrame.NumberOfLowOutliers > 0) plot.Series.Add(lowOutlierData); + + // Uncertain data + uncertainData.ItemsSource = Element.DataFrame.UncertainSeries; + uncertainData.DataFieldX = nameof(UncertainData.Index); + uncertainData.DataFieldY = nameof(UncertainData.Value); + uncertainData.DataFieldLowerErrorY = nameof(UncertainData.LowerValue); + uncertainData.DataFieldUpperErrorY = nameof(UncertainData.UpperValue); + uncertainData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0}" + Environment.NewLine + "{3}: {4:" + valueStringFormat + "}"; + if (Element.DataFrame.UncertainSeries.Count > 0) plot.Series.Add(uncertainData); + + // Interval data + intervalData.ItemsSource = Element.DataFrame.IntervalSeries; + intervalData.DataFieldX = nameof(IntervalData.Index); + intervalData.DataFieldY = nameof(IntervalData.Value); + intervalData.DataFieldLowerErrorY = nameof(IntervalData.LowerValue); + intervalData.DataFieldUpperErrorY = nameof(IntervalData.UpperValue); + intervalData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0}" + Environment.NewLine + "{3}: {4:" + valueStringFormat + "}"; + if (Element.DataFrame.IntervalSeries.Count > 0) plot.Series.Add(intervalData); + + // Threshold data + UpdateThresholdPlotSeries(valueStringFormat); + + plot.InvalidatePlot(true); + } + Element.RebuildSeriesAndAnnotationBridges(plot); + } + + /// + /// Updates the threshold data series displayed on the chronology plot. + /// Removes existing threshold area series and adds updated ones from the DataFrame. + /// + /// Format string for data values in tracker tooltips. + private void UpdateThresholdPlotSeries(string valueStringFormat = "N4") + { + var plot = Element.ChronologyPlot; + if (plot == null) return; + + // Clear the threshold data series from plot + for (int i = plot.Series.Count - 1; i >= 0; i -= 1) + { + if (plot.Series[i].GetType() == typeof(AreaSeries)) + plot.Series.RemoveAt(i); + } + // Add back in the threshold data + for (int i = 0; i <= Element.DataFrame.ThresholdSeries.Count - 1; i++) + AddThresholdDataPoint((ThresholdData)Element.DataFrame.ThresholdSeries[i], i + 1, i == 0 ? true : false, valueStringFormat); + } + + /// + /// Adds a threshold data point as an area series to the chronology plot. + /// Creates a shaded region representing the threshold value over a specified index range. + /// + /// The threshold data to plot. + /// The series index for naming purposes. + /// Whether to display this series in the legend. + /// Format string for data values in tracker tooltips. + private void AddThresholdDataPoint(ThresholdData data, int index, bool showInLegend = false, string valueStringFormat = "N4") + { + var plot = Element.ChronologyPlot; + if (plot == null) return; + + // Get start and end indexes in case they are the same + double startIndex = data.StartIndex; + double endIndex = data.EndIndex; + if (endIndex == startIndex) + { + startIndex -= 0.5; + endIndex += 0.5; + } + // Create a new area series for the threshold and add to the chronology plot + AreaSeries areaSeries = new AreaSeries() { Name = "ThresholdData_" + index + "_" + data.StartIndex.ToString(CultureInfo.InvariantCulture).Replace("-", "_") + "_" + data.Index.ToString(CultureInfo.InvariantCulture).Replace("-", "_") }; + if (showInLegend == true) + { + areaSeries.Title = "Threshold Data"; + areaSeries.TrackerFormatString = "Threshold " + data.StartIndex.ToString() + "-" + data.EndIndex.ToString() + Environment.NewLine + "{1}: {2:0}" + Environment.NewLine + "{3}: {4:" + valueStringFormat + "}"; + areaSeries.RenderInLegend = true; + } + else + { + areaSeries.Title = "Threshold " + data.StartIndex.ToString() + "-" + data.EndIndex.ToString(); + areaSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0}" + Environment.NewLine + "{3}: {4:" + valueStringFormat + "}"; + areaSeries.RenderInLegend = false; + } + areaSeries.Fill = Color.FromArgb(100, 250, 128, 114); + areaSeries.Color = Color.FromArgb(255, 250, 128, 114); + areaSeries.LineStyle = LineStyle.Solid; + areaSeries.BrokenLineThickness = 1; + areaSeries.StrokeThickness = 1; + ((OxyPlot.Series.AreaSeries)areaSeries.InternalSeries).Points.Clear(); + ((OxyPlot.Series.AreaSeries)areaSeries.InternalSeries).Points2.Clear(); + ((OxyPlot.Series.AreaSeries)areaSeries.InternalSeries).Points.Add(new DataPoint(startIndex, 0)); + ((OxyPlot.Series.AreaSeries)areaSeries.InternalSeries).Points2.Add(new DataPoint(startIndex, data.Value)); + ((OxyPlot.Series.AreaSeries)areaSeries.InternalSeries).Points.Add(new DataPoint(endIndex, 0)); + ((OxyPlot.Series.AreaSeries)areaSeries.InternalSeries).Points2.Add(new DataPoint(endIndex, data.Value)); + plot.Series.Add(areaSeries); + } + + /// + /// Updates the frequency plot with current exact, low outlier, uncertain, and interval data. + /// Handles logarithmic Y-axis filtering and configures data series with plotting positions. + /// + private void UpdateFrequencyPlot() + { + if (Element == null) return; + var plot = Element.FrequencyPlot; + if (plot == null) return; + var valueStringFormat = UserSettings.ValueStringFormat; + + // Look up named series before Clear + var exactData = plot.Series.OfType().FirstOrDefault(s => s.Name == "ExactData") + ?? new ScatterPointSeries + { + Name = "ExactData", + Title = "Exact Data", + MarkerFill = Colors.Black, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle + }; + + var lowOutlierData = plot.Series.OfType().FirstOrDefault(s => s.Name == "LowOutlierData") + ?? new ScatterPointSeries + { + Name = "LowOutlierData", + Title = "Low Outlier Data", + MarkerStroke = Colors.Red, + MarkerStrokeThickness = 2, + MarkerSize = 3, + MarkerType = MarkerType.Cross + }; + + var uncertainData = plot.Series.OfType().FirstOrDefault(s => s.Name == "UncertainData") + ?? new ScatterErrorSeries + { + Name = "UncertainData", + Title = "Uncertain Data", + MarkerFill = Colors.Green, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Diamond + }; + + var intervalData = plot.Series.OfType().FirstOrDefault(s => s.Name == "IntervalData") + ?? new ScatterErrorSeries + { + Name = "IntervalData", + Title = "Interval Data", + MarkerFill = Colors.Cyan, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle + }; + + using (Element.SuspendPlotBridges()) + { + plot.Series.Clear(); + + // See if Y-axis is logarithmic + bool logAxis = false; + foreach (var axis in plot.Axes) + { + if (axis.Key == "Yaxis" && axis as LogarithmicAxis != null) + { + logAxis = true; + break; + } + } + + // Exact data + exactData.ItemsSource = logAxis == true ? Element.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == false && x.Value > 1E-16) : Element.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == false); + exactData.Mapping = item => { var d = (ExactData)item; return new OxyPlot.Series.ScatterPoint(d.PlottingPosition, d.Value); }; + exactData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + valueStringFormat + "}"; + if (Element.DataFrame.ExactSeries.Count > 0) plot.Series.Add(exactData); + + // Low outlier data + lowOutlierData.ItemsSource = logAxis == true ? Element.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == true && x.Value > 1E-16) : Element.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == true); + lowOutlierData.Mapping = item => { var d = (ExactData)item; return new OxyPlot.Series.ScatterPoint(d.PlottingPosition, d.Value); }; + lowOutlierData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + valueStringFormat + "}"; + if (Element.DataFrame.NumberOfLowOutliers > 0) plot.Series.Add(lowOutlierData); + + // Uncertain data + uncertainData.ItemsSource = logAxis == true ? Element.DataFrame.UncertainSeries : Element.DataFrame.UncertainSeries.Where(x => x.Value > 1E-16); + uncertainData.DataFieldX = nameof(UncertainData.PlottingPosition); + uncertainData.DataFieldY = nameof(UncertainData.Value); + uncertainData.DataFieldLowerErrorY = nameof(UncertainData.LowerValue); + uncertainData.DataFieldUpperErrorY = nameof(UncertainData.UpperValue); + uncertainData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + valueStringFormat + "}"; + if (Element.DataFrame.UncertainSeries.Count > 0) plot.Series.Add(uncertainData); + + // Interval data + intervalData.ItemsSource = logAxis == true ? Element.DataFrame.IntervalSeries : Element.DataFrame.IntervalSeries.Where(x => x.Value > 1E-16); + intervalData.DataFieldX = nameof(IntervalData.PlottingPosition); + intervalData.DataFieldY = nameof(IntervalData.Value); + intervalData.DataFieldLowerErrorY = nameof(IntervalData.LowerValue); + intervalData.DataFieldUpperErrorY = nameof(IntervalData.UpperValue); + intervalData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + valueStringFormat + "}"; + if (Element.DataFrame.IntervalSeries.Count > 0) plot.Series.Add(intervalData); + + plot.InvalidatePlot(true); + } + Element.RebuildSeriesAndAnnotationBridges(plot); + } + + /// + /// Updates the seasonality plot displaying monthly frequency distribution. + /// Creates a histogram showing the distribution of data across calendar months. + /// Requires at least 10 exact data points and valid DateTime values. + /// + private void UpdateSeasonalityPlot() + { + if (Element == null) return; + var plot = Element.SeasonalityPlot; + if (plot == null) { SeasonalityWarning.Visibility = Visibility.Visible; return; } + var valueStringFormat = UserSettings.ValueStringFormat; + + // Look up named series before Clear + var histogramSeries = plot.Series.OfType().FirstOrDefault(s => s.Name == "Histogram") + ?? new HistogramSeries + { + Name = "Histogram", + Title = "Seasonality Plot", + FillColor = ColorFromHex("#7529D372"), + StrokeColor = Color.FromArgb(255, 53, 59, 122), + StrokeThickness = 1 + }; + + using (Element.SuspendPlotBridges()) + { + plot.Series.Clear(); + + if (Element.DataFrame.ExactSeries.Count < 10) + { + plot.InvalidatePlot(true); + SeasonalityWarning.Visibility = Visibility.Visible; + return; + } + + var timeSeries = new TimeSeries(TimeInterval.Irregular); + for (int i = 0; i < Element.DataFrame.ExactSeries.Count; i++) + { + var ordinate = new SeriesOrdinate(((ExactData)Element.DataFrame.ExactSeries[i]).DateTime, Element.DataFrame.ExactSeries[i].Value); + if (ordinate.Index != default(DateTime)) + timeSeries.Add(ordinate); + } + if (timeSeries.Count == 0) + { + plot.InvalidatePlot(true); + SeasonalityWarning.Visibility = Visibility.Visible; + return; + } + + var frequency = timeSeries.MonthlyFrequency(); + double sum = frequency.Sum(); + var seasonPoints = new List(); + for (int i = 1; i <= 12; i++) + { + var lo = OxyPlot.Axes.DateTimeAxis.ToDouble(new DateTime(2020, i, 1)); + var hi = OxyPlot.Axes.DateTimeAxis.ToDouble(new DateTime(i < 12 ? 2020 : 2021, i < 12 ? i + 1 : 1, 1)); + seasonPoints.Add(new OxyPlot.Series.HistogramItem(lo, hi, frequency[i - 1] / sum * (hi - lo))); + } + + for (int i = 0; i < plot.Axes.Count; i++) + { + if (plot.Axes[i].Position == OxyPlot.Axes.AxisPosition.Bottom) + { + plot.Axes[i].Minimum = OxyPlot.Axes.DateTimeAxis.ToDouble(new DateTime(2020, 1, 1)); + plot.Axes[i].Maximum = OxyPlot.Axes.DateTimeAxis.ToDouble(new DateTime(2020, 12, 31)); + } + } + + histogramSeries.ItemsSource = seasonPoints; + histogramSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:MMM}" + Environment.NewLine + "{3}: {4:" + valueStringFormat + "}"; + plot.Series.Add(histogramSeries); + + plot.InvalidatePlot(true); + } + Element.RebuildSeriesAndAnnotationBridges(plot); + SeasonalityWarning.Visibility = Visibility.Collapsed; + } + + /// + /// Updates the density plot with a kernel density estimate of the combined data. + /// Creates a smooth probability density function from exact, uncertain, and interval data. + /// Requires at least 10 exact data points. + /// + private void UpdateDensityPlot() + { + if (Element == null) return; + var plot = Element.DensityPlot; + if (plot == null) { DensityWarning.Visibility = Visibility.Visible; return; } + var valueStringFormat = UserSettings.ValueStringFormat; + + // Look up named series before Clear + var densitySeries = plot.Series.OfType().FirstOrDefault(s => s.Name == "Density") + ?? new AreaSeries + { + Name = "Density", + Title = "Density Plot", + Fill = Color.FromArgb(125, 104, 140, 175), + Color = Color.FromArgb(255, 53, 59, 122), + LineStyle = LineStyle.Solid, + BrokenLineThickness = 1, + StrokeThickness = 1, + Decimator = OxyPlot.Decimator.Decimate + }; + + using (Element.SuspendPlotBridges()) + { + plot.Series.Clear(); + + if (Element.DataFrame.ExactSeries.Count < 10) + { + plot.InvalidatePlot(true); + DensityWarning.Visibility = Visibility.Visible; + return; + } + + var values = Element.DataFrame.ExactSeries.ValuesToList(); + values.AddRange(Element.DataFrame.UncertainSeries.ValuesToList()); + values.AddRange(Element.DataFrame.IntervalSeries.ValuesToList()); + + var density = new KernelDensity(values); + var xValues = Stratify.XValues(new StratificationOptions(Tools.Min(values), Tools.Max(values), 1000)); + var pdf = density.CreatePDFGraph(xValues); + var postPoints = new List(); + for (int i = 0; i < pdf.GetLength(0); i++) + postPoints.Add(new Point3D(pdf[i, 0], 0, pdf[i, 1])); + + densitySeries.ItemsSource = postPoints; + densitySeries.DataFieldX = nameof(Point3D.X); + densitySeries.DataFieldY = nameof(Point3D.Y); + densitySeries.DataFieldX2 = nameof(Point3D.X); + densitySeries.DataFieldY2 = nameof(Point3D.Z); + densitySeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.0000}" + Environment.NewLine + "{3}: {4:0.000000}"; + plot.Series.Add(densitySeries); + + plot.InvalidatePlot(true); + } + Element.RebuildSeriesAndAnnotationBridges(plot); + DensityWarning.Visibility = Visibility.Collapsed; + } + + /// + /// Updates the histogram plot with frequency distribution of the combined data. + /// Uses Sturges' rule to determine the number of bins for the histogram. + /// Requires at least 10 exact data points. + /// + private void UpdateHistogramPlot() + { + if (Element == null) return; + var plot = Element.HistogramPlot; + if (plot == null) { HistogramWarning.Visibility = Visibility.Visible; return; } + var valueStringFormat = UserSettings.ValueStringFormat; + + // Look up named series before Clear + var histogramSeries = plot.Series.OfType().FirstOrDefault(s => s.Name == "Histogram") + ?? new HistogramSeries + { + Name = "Histogram", + Title = "Histogram Plot", + FillColor = Color.FromArgb(125, 104, 140, 175), + StrokeColor = Color.FromArgb(255, 53, 59, 122), + StrokeThickness = 1 + }; + + using (Element.SuspendPlotBridges()) + { + plot.Series.Clear(); + + if (Element.DataFrame.ExactSeries.Count < 10) + { + plot.InvalidatePlot(true); + HistogramWarning.Visibility = Visibility.Visible; + return; + } + + var values = Element.DataFrame.ExactSeries.ValuesToList(); + values.AddRange(Element.DataFrame.UncertainSeries.ValuesToList()); + values.AddRange(Element.DataFrame.IntervalSeries.ValuesToList()); + + // Use Sturges' rule + int k = (int)(1 + 3.322 * Math.Log(values.Count)); + var histogram = new Histogram(values, k); + var histItems = new List(); + double sum = 0; + for (int i = 0; i < histogram.NumberOfBins; i++) + sum += histogram[i].Frequency * histogram.BinWidth; + for (int i = 0; i < histogram.NumberOfBins; i++) + histItems.Add(new OxyPlot.Series.HistogramItem(histogram[i].LowerBound, histogram[i].UpperBound, histogram[i].Frequency * histogram.BinWidth / sum)); + + histogramSeries.ItemsSource = histItems; + histogramSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:" + valueStringFormat + "}" + Environment.NewLine + "{3}: {4:0.000000000}"; + plot.Series.Add(histogramSeries); + + plot.InvalidatePlot(true); + } + Element.RebuildSeriesAndAnnotationBridges(plot); + HistogramWarning.Visibility = Visibility.Collapsed; + } + + /// + /// Handles the Checked event for the real space radio button. + /// Updates the QQ plot to display data in real space. + /// + /// The source of the event. + /// Event args containing routed event data. + private void RealSpaceRadioButton_Checked(object sender, RoutedEventArgs e) + { + UpdateQQPlot(); + } + + /// + /// Handles the Checked event for the log space radio button. + /// Updates the QQ plot to display data in logarithmic space. + /// + /// The source of the event. + /// Event args containing routed event data. + private void LogSpaceRadioButton_Checked(object sender, RoutedEventArgs e) + { + UpdateQQPlot(); + } + + /// + /// Updates the QQ (Quantile-Quantile) plot comparing data to standardized normal distribution. + /// Displays data in either real space or log10 space based on the radio button selection. + /// Includes a 1:1 reference line for comparison. + /// Requires at least 10 exact data points. + /// + private void UpdateQQPlot() + { + if (Element == null) return; + var plot = Element.QQPlot; + if (plot == null) { QQWarning.Visibility = Visibility.Visible; return; } + if (Element.DataFrame == null) { QQWarning.Visibility = Visibility.Visible; return; } + bool useLogSpace = LogSpaceRadioButton.IsChecked == true; + var valueStringFormat = UserSettings.ValueStringFormat; + + // Look up named series before Clear + var exactData = plot.Series.OfType().FirstOrDefault(s => s.Name == "ExactData") + ?? new ScatterPointSeries + { + Name = "ExactData", + Title = "Exact Data", + MarkerFill = Colors.Black, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle + }; + + var lowOutlierData = plot.Series.OfType().FirstOrDefault(s => s.Name == "LowOutlierData") + ?? new ScatterPointSeries + { + Name = "LowOutlierData", + Title = "Low Outlier Data", + MarkerStroke = Colors.Red, + MarkerStrokeThickness = 2, + MarkerSize = 3, + MarkerType = MarkerType.Cross + }; + + var uncertainData = plot.Series.OfType().FirstOrDefault(s => s.Name == "UncertainData") + ?? new ScatterErrorSeries + { + Name = "UncertainData", + Title = "Uncertain Data", + MarkerFill = Colors.Green, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Diamond + }; + + var intervalData = plot.Series.OfType().FirstOrDefault(s => s.Name == "IntervalData") + ?? new ScatterErrorSeries + { + Name = "IntervalData", + Title = "Interval Data", + MarkerFill = Colors.Cyan, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle + }; + + using (Element.SuspendPlotBridges()) + { + plot.Series.Clear(); + plot.Annotations.Clear(); + + if (Element.DataFrame.ExactSeries.Count < 10) + { + plot.InvalidatePlot(true); + QQWarning.Visibility = Visibility.Visible; + return; + } + + Element.DataFrame.SetStandardizedValues(); + + // Exact data + exactData.ItemsSource = Element.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == false); + exactData.DataFieldX = useLogSpace ? nameof(ExactData.StandardizedLog10Value) : nameof(ExactData.StandardizedValue); + exactData.DataFieldY = useLogSpace ? nameof(ExactData.Log10Value) : nameof(ExactData.Value); + exactData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000}" + Environment.NewLine + "{3}: {4:" + valueStringFormat + "}"; + if (Element.DataFrame.ExactSeries.Count > 0) plot.Series.Add(exactData); + + // Low outlier data + lowOutlierData.ItemsSource = Element.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == true); + lowOutlierData.DataFieldX = useLogSpace ? nameof(ExactData.StandardizedLog10Value) : nameof(ExactData.StandardizedValue); + lowOutlierData.DataFieldY = useLogSpace ? nameof(ExactData.Log10Value) : nameof(ExactData.Value); + lowOutlierData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000}" + Environment.NewLine + "{3}: {4:" + valueStringFormat + "}"; + if (Element.DataFrame.NumberOfLowOutliers > 0) plot.Series.Add(lowOutlierData); + + // Uncertain data + uncertainData.ItemsSource = Element.DataFrame.UncertainSeries; + uncertainData.DataFieldX = useLogSpace ? nameof(UncertainData.StandardizedLog10Value) : nameof(UncertainData.StandardizedValue); + uncertainData.DataFieldY = useLogSpace ? nameof(UncertainData.Log10Value) : nameof(UncertainData.Value); + uncertainData.DataFieldLowerErrorY = useLogSpace ? nameof(UncertainData.Log10LowerValue) : nameof(UncertainData.LowerValue); + uncertainData.DataFieldUpperErrorY = useLogSpace ? nameof(UncertainData.Log10UpperValue) : nameof(UncertainData.UpperValue); + uncertainData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000}" + Environment.NewLine + "{3}: {4:" + valueStringFormat + "}"; + if (Element.DataFrame.UncertainSeries.Count > 0) plot.Series.Add(uncertainData); + + // Interval data + intervalData.ItemsSource = Element.DataFrame.IntervalSeries; + intervalData.DataFieldX = useLogSpace ? nameof(IntervalData.StandardizedLog10Value) : nameof(IntervalData.StandardizedValue); + intervalData.DataFieldY = useLogSpace ? nameof(IntervalData.Log10Value) : nameof(IntervalData.Value); + intervalData.DataFieldLowerErrorY = useLogSpace ? nameof(IntervalData.Log10LowerValue) : nameof(IntervalData.LowerValue); + intervalData.DataFieldUpperErrorY = useLogSpace ? nameof(IntervalData.Log10UpperValue) : nameof(IntervalData.UpperValue); + intervalData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000}" + Environment.NewLine + "{3}: {4:" + valueStringFormat + "}"; + if (Element.DataFrame.IntervalSeries.Count > 0) plot.Series.Add(intervalData); + + var anno1 = new LineAnnotation() + { + Name = "OneToOneLine", + Text = "1:1 Line", + Type = OxyPlot.Annotations.LineAnnotationType.LinearEquation, + Color = Colors.Black, + LineStyle = LineStyle.Dash, + StrokeThickness = 2, + TextLinePosition = 0.9, + Intercept = 0, + Slope = 1, + TextHorizontalAlignment = System.Windows.HorizontalAlignment.Right, + TextVerticalAlignment = System.Windows.VerticalAlignment.Top + }; + plot.Annotations.Add(anno1); + + plot.InvalidatePlot(true); + } + Element.RebuildSeriesAndAnnotationBridges(plot); + QQWarning.Visibility = Visibility.Collapsed; + } + + /// + /// Updates the ACF (Autocorrelation Function) plot showing serial correlation at various lags. + /// Displays correlation values with confidence interval bounds. + /// Requires at least 10 exact data points. + /// + private void UpdateACFPlot() + { + if (Element == null) return; + var plot = Element.ACFPlot; + if (plot == null) { ACFWarning.Visibility = Visibility.Visible; return; } + + // Look up named series before Clear + var acfSeries = plot.Series.OfType().FirstOrDefault(s => s.Name == "Autocorrelation") + ?? new HistogramSeries + { + Name = "Autocorrelation", + Title = "Autocorrelation", + FillColor = Color.FromArgb(125, 104, 140, 175), + StrokeColor = Color.FromArgb(255, 53, 59, 122), + StrokeThickness = 1 + }; + + using (Element.SuspendPlotBridges()) + { + plot.Series.Clear(); + plot.Annotations.Clear(); + + if (Element.DataFrame.ExactSeries.Count < 10) + { + plot.InvalidatePlot(true); + ACFWarning.Visibility = Visibility.Visible; + return; + } + + var acfItems = new List(); + var acf = Element.DataFrame.ExactSeries.Autocorrelation(); + for (int i = 0; i < acf.GetLength(0); i++) + { + acfItems.Add(new OxyPlot.Series.HistogramItem(i, i + 1, acf[i, 1])); + } + + acfSeries.ItemsSource = acfItems; + acfSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0}" + Environment.NewLine + "{3}: {4:0.000000}"; + plot.Series.Add(acfSeries); + + var ci = Autocorrelation.CorrelationConfidenceInterval(Element.DataFrame.ExactSeries.Count); + var anno1 = new LineAnnotation() + { + Name = "LowerCI", + Text = "2.5% CI", + Type = OxyPlot.Annotations.LineAnnotationType.Horizontal, + Color = Colors.Black, + LineStyle = LineStyle.Dash, + StrokeThickness = 2, + TextLinePosition = 1, + TextHorizontalAlignment = System.Windows.HorizontalAlignment.Right, + TextVerticalAlignment = System.Windows.VerticalAlignment.Top, + Y = ci[0] + }; + + var anno2 = new LineAnnotation() + { + Name = "UpperCI", + Text = "97.5% CI", + Type = OxyPlot.Annotations.LineAnnotationType.Horizontal, + Color = Colors.Black, + LineStyle = LineStyle.Dash, + StrokeThickness = 2, + TextLinePosition = 1, + TextHorizontalAlignment = System.Windows.HorizontalAlignment.Right, + TextVerticalAlignment = System.Windows.VerticalAlignment.Bottom, + Y = ci[1] + }; + + plot.Annotations.Add(anno1); + plot.Annotations.Add(anno2); + + foreach (var axis in plot.Axes) + { + if (axis.Key == "Yaxis") + { + axis.Minimum = Math.Round((Math.Min(ci[0], Tools.Min(acf.GetColumn(1))) - 0.1) * 20) / 20; + } + } + + plot.InvalidatePlot(true); + } + Element.RebuildSeriesAndAnnotationBridges(plot); + ACFWarning.Visibility = Visibility.Collapsed; + } + + /// + /// Updates the PACF (Partial Autocorrelation Function) plot showing conditional correlation at various lags. + /// Displays partial correlation values with confidence interval bounds. + /// Requires at least 10 exact data points. + /// + private void UpdatePACFPlot() + { + if (Element == null) return; + var plot = Element.PACFPlot; + if (plot == null) { PACFWarning.Visibility = Visibility.Visible; return; } + + // Look up named series before Clear + var pacfSeries = plot.Series.OfType().FirstOrDefault(s => s.Name == "PartialAutocorrelation") + ?? new HistogramSeries + { + Name = "PartialAutocorrelation", + Title = "Partial Autocorrelation", + FillColor = Color.FromArgb(125, 104, 140, 175), + StrokeColor = Color.FromArgb(255, 53, 59, 122), + StrokeThickness = 1 + }; + + using (Element.SuspendPlotBridges()) + { + plot.Series.Clear(); + plot.Annotations.Clear(); + + if (Element.DataFrame.ExactSeries.Count < 10) + { + plot.InvalidatePlot(true); + PACFWarning.Visibility = Visibility.Visible; + return; + } + + var pacfItems = new List(); + var pacf = Element.DataFrame.ExactSeries.PartialAutocorrelation(); + for (int i = 0; i < pacf.GetLength(0); i++) + { + pacfItems.Add(new OxyPlot.Series.HistogramItem(i, i + 1, pacf[i, 1])); + } + + pacfSeries.ItemsSource = pacfItems; + pacfSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0}" + Environment.NewLine + "{3}: {4:0.000000}"; + plot.Series.Add(pacfSeries); + + var ci = Autocorrelation.CorrelationConfidenceInterval(Element.DataFrame.ExactSeries.Count); + var anno1 = new LineAnnotation() + { + Name = "LowerCI", + Text = "2.5% CI", + Type = OxyPlot.Annotations.LineAnnotationType.Horizontal, + Color = Colors.Black, + LineStyle = LineStyle.Dash, + StrokeThickness = 2, + TextLinePosition = 1, + TextHorizontalAlignment = System.Windows.HorizontalAlignment.Right, + TextVerticalAlignment = System.Windows.VerticalAlignment.Top, + Y = ci[0] + }; + + var anno2 = new LineAnnotation() + { + Name = "UpperCI", + Text = "97.5% CI", + Type = OxyPlot.Annotations.LineAnnotationType.Horizontal, + Color = Colors.Black, + LineStyle = LineStyle.Dash, + StrokeThickness = 2, + TextLinePosition = 1, + TextHorizontalAlignment = System.Windows.HorizontalAlignment.Right, + TextVerticalAlignment = System.Windows.VerticalAlignment.Bottom, + Y = ci[1] + }; + + plot.Annotations.Add(anno1); + plot.Annotations.Add(anno2); + + foreach (var axis in plot.Axes) + { + if (axis.Key == "Yaxis") + { + axis.Maximum = Math.Round((Math.Max(ci[1], Tools.Max(pacf.GetColumn(1))) + 0.1) * 20) / 20; + axis.Minimum = Math.Round((Math.Min(ci[0], Tools.Min(pacf.GetColumn(1))) - 0.1) * 20) / 20; + } + } + + plot.InvalidatePlot(true); + } + Element.RebuildSeriesAndAnnotationBridges(plot); + PACFWarning.Visibility = Visibility.Collapsed; + } + + /// + /// Computes and draws all three threshold diagnostic plots (MRL, Modified Scale, Shape). + /// Requires a time series with at least 20 observations. + /// + /// + /// Callers that invoke this method from a lazy UI path should wrap it in wait-cursor + /// feedback because the GPD MLE fitting can take noticeable time. + /// + private void UpdateThresholdDiagnosticsPlots() + { + if (ThresholdDiagnosticsTab.Visibility != Visibility.Visible) return; + if (Element == null) return; + + var ts = Element.TimeSeriesElement?.TimeSeries; + if (ts == null || ts.Count < 20) return; + + var values = ts.Select(s => s.Value).ToList(); + var sorted = values.OrderBy(v => v).ToArray(); + double uMin = sorted[(int)(sorted.Length * 0.5)]; + double uMax = sorted[sorted.Length - 1]; + + _mrlResult = ThresholdDiagnostics.ComputeMeanResidualLife(values, uMin, uMax); + _stabilityResult = ThresholdDiagnostics.ComputeParameterStability(values, uMin, uMax); + + DrawMRLPlot(UserSettings.ValueStringFormat); + DrawModifiedScalePlot(UserSettings.ValueStringFormat); + DrawShapePlot(UserSettings.ValueStringFormat); + + ShowSelectedDiagnosticPlot(); + } + + /// + /// Draws the Mean Residual Life plot with confidence interval band, mean excess line, + /// and current threshold annotation. + /// + /// Format string for data values in tracker tooltips. + private void DrawMRLPlot(string valueStringFormat = "N4") + { + var plot = Element.MRLPlot; + if (plot == null) return; + + // Look up named series before Clear + var ciSeries = plot.Series.OfType().FirstOrDefault(s => s.Name == "MRL_CI") + ?? new AreaSeries + { + Name = "MRL_CI", + Title = "95% CI", + Fill = Color.FromArgb(75, 104, 140, 175), + Color = Color.FromArgb(255, 53, 59, 122), + LineStyle = LineStyle.Solid, + StrokeThickness = 1, + Decimator = OxyPlot.Decimator.Decimate + }; + + var lineSeries = plot.Series.OfType().FirstOrDefault(s => s.Name == "MeanExcess") + ?? new LineSeries + { + Name = "MeanExcess", + Title = "Mean Excess", + Color = Color.FromArgb(255, 53, 59, 122), + StrokeThickness = 2, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0 + }; + + using (Element.SuspendPlotBridges()) + { + plot.Series.Clear(); + plot.Annotations.Clear(); + + if (_mrlResult == null || _mrlResult.Points.Count == 0) + { + plot.InvalidatePlot(true); + return; + } + + // CI band (add first so it renders behind the line) + ciSeries.ItemsSource = _mrlResult.Points + .Select(p => new Point3D(p.Threshold, p.LowerCI, p.UpperCI)).ToList(); + ciSeries.DataFieldX = "X"; + ciSeries.DataFieldY = "Y"; + ciSeries.DataFieldX2 = "X"; + ciSeries.DataFieldY2 = "Z"; + ciSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:" + valueStringFormat + "}"; + plot.Series.Add(ciSeries); + + // Mean excess line + lineSeries.ItemsSource = _mrlResult.Points + .Select(p => new DataPoint(p.Threshold, p.MeanExcess)).ToList(); + lineSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:" + valueStringFormat + "}" + Environment.NewLine + "{3}: {4:" + valueStringFormat + "}"; + plot.Series.Add(lineSeries); + + AddThresholdAnnotation(plot); + + plot.InvalidatePlot(true); + } + Element.RebuildSeriesAndAnnotationBridges(plot); + } + + /// + /// Draws the modified scale parameter stability plot with confidence interval band, + /// parameter line, and current threshold annotation. + /// + /// Format string for data values in tracker tooltips. + private void DrawModifiedScalePlot(string valueStringFormat = "N4") + { + var plot = Element.ModifiedScalePlot; + if (plot == null) return; + + // Look up named series before Clear + var ciSeries = plot.Series.OfType().FirstOrDefault(s => s.Name == "ModifiedScale_CI") + ?? new AreaSeries + { + Name = "ModifiedScale_CI", + Title = "95% CI", + Fill = Color.FromArgb(75, 104, 140, 175), + Color = Color.FromArgb(255, 53, 59, 122), + LineStyle = LineStyle.Solid, + StrokeThickness = 1, + Decimator = OxyPlot.Decimator.Decimate + }; + + var lineSeries = plot.Series.OfType().FirstOrDefault(s => s.Name == "ModifiedScale") + ?? new LineSeries + { + Name = "ModifiedScale", + Title = "Modified Scale", + Color = Color.FromArgb(255, 53, 59, 122), + StrokeThickness = 2, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0 + }; + + using (Element.SuspendPlotBridges()) + { + plot.Series.Clear(); + plot.Annotations.Clear(); + + if (_stabilityResult == null || _stabilityResult.Points.Count == 0) + { + plot.InvalidatePlot(true); + return; + } + + // CI band + ciSeries.ItemsSource = _stabilityResult.Points + .Select(p => new Point3D(p.Threshold, p.ModifiedScaleLowerCI, p.ModifiedScaleUpperCI)).ToList(); + ciSeries.DataFieldX = "X"; + ciSeries.DataFieldY = "Y"; + ciSeries.DataFieldX2 = "X"; + ciSeries.DataFieldY2 = "Z"; + ciSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:" + valueStringFormat + "}"; + plot.Series.Add(ciSeries); + + // Modified scale line + lineSeries.ItemsSource = _stabilityResult.Points + .Select(p => new DataPoint(p.Threshold, p.ModifiedScale)).ToList(); + lineSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:" + valueStringFormat + "}" + Environment.NewLine + "{3}: {4:" + valueStringFormat + "}"; + plot.Series.Add(lineSeries); + + AddThresholdAnnotation(plot); + + plot.InvalidatePlot(true); + } + Element.RebuildSeriesAndAnnotationBridges(plot); + } + + /// + /// Draws the shape parameter stability plot with confidence interval band, + /// parameter line, and current threshold annotation. + /// + /// Format string for data values in tracker tooltips. + private void DrawShapePlot(string valueStringFormat = "N4") + { + var plot = Element.ShapePlot; + if (plot == null) return; + + // Look up named series before Clear + var ciSeries = plot.Series.OfType().FirstOrDefault(s => s.Name == "Shape_CI") + ?? new AreaSeries + { + Name = "Shape_CI", + Title = "95% CI", + Fill = Color.FromArgb(75, 104, 140, 175), + Color = Color.FromArgb(255, 53, 59, 122), + LineStyle = LineStyle.Solid, + StrokeThickness = 1, + Decimator = OxyPlot.Decimator.Decimate + }; + + var lineSeries = plot.Series.OfType().FirstOrDefault(s => s.Name == "Shape") + ?? new LineSeries + { + Name = "Shape", + Title = "Shape", + Color = Color.FromArgb(255, 53, 59, 122), + StrokeThickness = 2, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0 + }; + + using (Element.SuspendPlotBridges()) + { + plot.Series.Clear(); + plot.Annotations.Clear(); + + if (_stabilityResult == null || _stabilityResult.Points.Count == 0) + { + plot.InvalidatePlot(true); + return; + } + + // CI band + ciSeries.ItemsSource = _stabilityResult.Points + .Select(p => new Point3D(p.Threshold, p.ShapeLowerCI, p.ShapeUpperCI)).ToList(); + ciSeries.DataFieldX = "X"; + ciSeries.DataFieldY = "Y"; + ciSeries.DataFieldX2 = "X"; + ciSeries.DataFieldY2 = "Z"; + ciSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:" + valueStringFormat + "}"; + plot.Series.Add(ciSeries); + + // Shape line + lineSeries.ItemsSource = _stabilityResult.Points + .Select(p => new DataPoint(p.Threshold, p.Shape)).ToList(); + lineSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:" + valueStringFormat + "}" + Environment.NewLine + "{3}: {4:0.000000}"; + plot.Series.Add(lineSeries); + + AddThresholdAnnotation(plot); + + plot.InvalidatePlot(true); + } + Element.RebuildSeriesAndAnnotationBridges(plot); + } + + /// + /// Adds a red dashed vertical line annotation at the current threshold value to the specified plot. + /// + /// The OxyPlot plot control to add the annotation to. + private void AddThresholdAnnotation(Plot plot) + { + if (Element.Threshold > 0) + { + plot.Annotations.Add(new LineAnnotation() + { + Type = OxyPlot.Annotations.LineAnnotationType.Vertical, + X = Element.Threshold, + Color = Colors.Red, + TextColor = Colors.Red, + LineStyle = LineStyle.Dash, + StrokeThickness = 1.5, + Text = "Threshold", + TextLinePosition = 0.95 + }); + } + } + + /// + /// Converts a hex color string to a . + /// Supports formats: #AARRGGBB, #RRGGBB. + /// + /// The hex color string. + /// The parsed color. + private static Color ColorFromHex(string hex) + { + return (Color)System.Windows.Media.ColorConverter.ConvertFromString(hex); + } + + /// + /// Handles the Checked event for the threshold diagnostic radio buttons. + /// Shows the selected plot and hides the others. + /// + /// The radio button that was checked. + /// Event args containing routed event data. + private void ThresholdDiagnosticRadioButton_Checked(object sender, RoutedEventArgs e) + { + if (Element != null && ThresholdDiagnosticsTab?.IsSelected == true && _thresholdDiagnosticsDirty) + { + UpdateLazyContentWithWaitCursor(UpdateThresholdDiagnosticsPlots, () => _thresholdDiagnosticsDirty = false); + } + + ShowSelectedDiagnosticPlot(); + } + + /// + /// Shows the diagnostic plot corresponding to the currently selected radio button + /// and collapses the other two. Also rebinds the toolbar to the visible plot. + /// + private void ShowSelectedDiagnosticPlot() + { + // Guard against calls during XAML initialization when controls are not yet created + if (MRLPlotHost == null || ModifiedScalePlotRadioButton == null || ShapePlotRadioButton == null) return; + + MRLPlotHost.Visibility = MRLPlotRadioButton.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; + ModifiedScalePlotHost.Visibility = ModifiedScalePlotRadioButton.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; + ShapePlotHost.Visibility = ShapePlotRadioButton.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; + + // Rebind toolbar to the currently visible plot + if (MRLPlotRadioButton.IsChecked == true) + ThresholdDiagnosticsToolbar.Plot = MRLPlot; + else if (ModifiedScalePlotRadioButton.IsChecked == true) + ThresholdDiagnosticsToolbar.Plot = ModifiedScalePlot; + else + ThresholdDiagnosticsToolbar.Plot = ShapePlot; + + InvalidateSelectedDiagnosticPlot(); + } + + /// + /// Gets the threshold diagnostic plot selected by the diagnostic radio buttons. + /// + /// The selected threshold diagnostic plot, or null when the controls are not ready. + private Plot GetSelectedDiagnosticPlot() + { + if (MRLPlotRadioButton?.IsChecked == true) return MRLPlot; + if (ModifiedScalePlotRadioButton?.IsChecked == true) return ModifiedScalePlot; + if (ShapePlotRadioButton?.IsChecked == true) return ShapePlot; + return null; + } + + /// + /// Invalidates the selected threshold diagnostic plot after its host becomes visible. + /// + /// + /// OxyPlot can skip rendering a plot that was invalidated while its host was collapsed. + /// Scheduling another invalidation after the visibility switch avoids a blank plot until + /// the user triggers an unrelated toolbar action. + /// + private void InvalidateSelectedDiagnosticPlot() + { + Plot selectedPlot = GetSelectedDiagnosticPlot(); + if (selectedPlot == null) return; + + Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(() => + { + selectedPlot.InvalidatePlot(true); + })); + } + + #endregion + + #region Exact Data Grid + + /// + /// Binds the exact data grid to the Element's exact data series. + /// Configures read-only or editable mode based on the data entry method. + /// + private void BindExactDataGrid() + { + _suppressUIUpdate = true; + Element.DataFrame.ExactSeries.SuppressCollectionChanged = true; + + // Clear ordinates + for (int i = ExactDataOrdinates.Count - 1; i >= 0; i--) + { + var rowItem = (ExactDataRowItem)ExactDataOrdinates[i]; + rowItem.PropertyChanged -= ExactDataRowItem_PropertyChanged; + ExactDataOrdinates.RemoveAt(i); + } + + // Clear existing bindings + ExactDataGridToolBar.DataGrid = null; + ExactDataGrid.ItemsSource = null; + + // Check if manual entry or not + bool showDateTime = Element.ExactDataMethod != InputData.ExactDataEntryType.Manual; + if (Element.ExactDataMethod == InputData.ExactDataEntryType.Manual) + { + ExactDataGrid.IsReadOnly = false; + ExactDataGrid.CanUserAddInsertDeleteRows = true; + _isExactDataReadOnly = false; + } + else + { + ExactDataGrid.IsReadOnly = true; + ExactDataGrid.CanUserAddInsertDeleteRows = false; + _isExactDataReadOnly = true; + } + + // Add dummy rows + ExactDataOrdinates.Add(new ExactDataRowItem(ExactDataOrdinates, new ExactData(), Element.DataFrame.ExactSeries, showDateTime)); + + // Add all data items + for (int i = 0; i < Element.DataFrame.ExactSeries.Count; i++) + { + var data = (ExactData)Element.DataFrame.ExactSeries[i]; + var rowItem = new ExactDataRowItem(ExactDataOrdinates, data, Element.DataFrame.ExactSeries, showDateTime); + rowItem.PropertyChanged += ExactDataRowItem_PropertyChanged; + ExactDataOrdinates.Add(rowItem); + } + + // Bind data grids + ExactDataGridToolBar.DataGrid = ExactDataGrid; + ExactDataGrid.ItemsSource = ExactDataOrdinates; + ExactDataGrid.Items.Refresh(); + + Element.DataFrame.ExactSeries.SuppressCollectionChanged = false; + _suppressUIUpdate = false; + } + + /// + /// Handles the AutoGeneratedColumns event for the exact data grid. + /// Removes the dummy row after columns have been generated. + /// + /// The source of the event. + /// Event args for the event. + private void ExactDataGrid_AutoGeneratedColumns(object sender, EventArgs e) + { + // Remove dummy row. + ExactDataOrdinates.RemoveAt(0); + } + + /// + /// Handles the AutoGeneratingColumn event for the exact data grid. + /// Configures column formatting, width, and styles based on property names. + /// + /// The source of the event. + /// Event args containing column generation details. + private void ExactDataGrid_AutoGeneratingColumn(object sender, DataGridAutoGeneratingColumnEventArgs e) + { + if (e.PropertyName == nameof(ExactDataRowItem.Index)) + { + e.Column.MinWidth = 10; + e.Column.MaxWidth = 150; + e.Column.Width = new DataGridLength(0.25, DataGridLengthUnitType.Star); + ((DataGridTextColumn)e.Column).Binding.StringFormat = "F0"; + ((DataGridTextColumn)e.Column).CellStyle = (Style)FindResource(_isExactDataReadOnly ? "Center_ReadOnly_CellStyle" : "Center_CellStyle"); + ((DataGridTextColumn)e.Column).HeaderStyle = (Style)FindResource("Center_ColumnHeaderStyle"); + } + if (e.PropertyName == nameof(ExactDataRowItem.DateTime)) + { + e.Column.MinWidth = 10; + e.Column.MaxWidth = 150; + e.Column.Width = new DataGridLength(0.25, DataGridLengthUnitType.Star); + ((DataGridTextColumn)e.Column).Binding.StringFormat = "g"; + ((DataGridTextColumn)e.Column).CellStyle = (Style)FindResource(_isExactDataReadOnly ? "Center_ReadOnly_CellStyle" : "Center_CellStyle"); + ((DataGridTextColumn)e.Column).HeaderStyle = (Style)FindResource("Center_ColumnHeaderStyle"); + } + else if (e.PropertyName == nameof(ExactDataRowItem.Value)) + { + e.Column.MinWidth = 10; + e.Column.MaxWidth = 150; + e.Column.Width = new DataGridLength(0.25, DataGridLengthUnitType.Star); + ((DataGridTextColumn)e.Column).Binding.StringFormat = "{0:" + UserSettings.ValueStringFormat + "}"; + ((Binding)((DataGridTextColumn)e.Column).Binding).Converter = new DoubleToNAConverter(); + ((DataGridTextColumn)e.Column).CellStyle = (Style)FindResource(_isExactDataReadOnly ? "Right_ReadOnly_CellStyle" : "Right_CellStyle"); + ((DataGridTextColumn)e.Column).HeaderStyle = (Style)FindResource("Center_ColumnHeaderStyle"); + } + else if (e.PropertyName == nameof(ExactDataRowItem.PlottingPosition)) + { + e.Column.MinWidth = 10; + e.Column.MaxWidth = 150; + e.Column.IsReadOnly = true; + e.Column.Width = new DataGridLength(0.25, DataGridLengthUnitType.Star); + ((DataGridTextColumn)e.Column).Binding.StringFormat = "F6"; + ((Binding)((DataGridTextColumn)e.Column).Binding).Converter = new DoubleToNAConverter(); + ((DataGridTextColumn)e.Column).CellStyle = (Style)FindResource("Right_ReadOnly_CellStyle"); + ((DataGridTextColumn)e.Column).HeaderStyle = (Style)FindResource("Center_ColumnHeaderStyle"); + } + else if (e.PropertyName == nameof(ExactDataRowItem.IsLowOutlier)) + { + e.Column.MinWidth = 10; + e.Column.MaxWidth = 150; + e.Column.Width = new DataGridLength(1, DataGridLengthUnitType.Star); + e.Column.IsReadOnly = true; + ((DataGridCheckBoxColumn)e.Column).CellStyle = (Style)FindResource("Disabled_CellStyle"); + ((DataGridCheckBoxColumn)e.Column).ElementStyle = (Style)FindResource(typeof(CheckBox)); + ((DataGridCheckBoxColumn)e.Column).HeaderStyle = (Style)FindResource("Center_ColumnHeaderStyle"); + } + } + + /// + /// Handles the PreviewAddRows event for the exact data grid. + /// Creates new exact data rows and adds them to the collection and ordinate list. + /// + /// + /// Suppresses CollectionChanged during the bulk insert to prevent the bridge from + /// recording individual Add actions. A single Reset fires afterward so that the bridge + /// records one undo action for the entire add operation. + /// + /// The index where new rows should be inserted. + /// The number of rows to add. + /// Reference to a boolean indicating whether to cancel the add operation. + private void ExactDataGrid_PreviewAddRows(int startRowIndex, int nRows, ref bool cancelAddRows) + { + cancelAddRows = true; + bool showDateTime = Element.ExactDataMethod != InputData.ExactDataEntryType.Manual; + + _suppressUIUpdate = true; + bool wasSuppressed = Element.DataFrame.ExactSeries.SuppressCollectionChanged; + Element.DataFrame.ExactSeries.SuppressCollectionChanged = true; + + for (int i = startRowIndex; i < startRowIndex + nRows; i++) + { + var data = new ExactData(); + Element.DataFrame.ExactSeries.Insert(i, data); + var rowItem = new ExactDataRowItem(ExactDataOrdinates, data, Element.DataFrame.ExactSeries, showDateTime); + rowItem.PropertyChanged += ExactDataRowItem_PropertyChanged; + ExactDataOrdinates.Insert(i, rowItem); + } + + Element.DataFrame.ExactSeries.SuppressCollectionChanged = wasSuppressed; + // Keep _suppressUIUpdate = true so CollectionChanged handler skips the Reset + if (!wasSuppressed) + { + Element.DataFrame.ExactSeries.RaiseCollectionChangedReset(); + } + _suppressUIUpdate = false; + if (!wasSuppressed && _isLoaded) + { + MarkAllDirty(); + ExactDataGrid.ValidateTable(); + ExactDataGrid.Items.Refresh(); + // Disable undo during plot updates so Series.Clear/Add don't record as undo entries + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try { UpdateControl(); } + finally { Element.IsUndoEnabled = wasUndoEnabled; } + } + } + + /// + /// Handles the PreviewDeleteRows event for the exact data grid. + /// Removes selected rows from both the data series and ordinate collection. + /// Handles sorted views by mapping display indices to underlying collection indices. + /// + /// + /// Suppresses CollectionChanged during bulk removal to prevent the bridge from + /// recording individual Remove actions. A single Reset fires afterward so that the bridge + /// records one undo action for the entire delete operation. + /// + /// List of row indices to delete. + /// Reference to a boolean indicating whether to cancel the delete operation. + private void ExactDataGrid_PreviewDeleteRows(List rowindices, ref bool cancel) + { + cancel = true; + rowindices.Sort(); + + // Get the underlying item collection from the ItemsSource + ICollectionView cv = CollectionViewSource.GetDefaultView(ExactDataGrid.ItemsSource); + IList itemList = null; + if (cv is CollectionView collectionView && collectionView.SourceCollection is IList list) + { + itemList = list; + } + else + { + itemList = ExactDataGrid.ItemsSource as IList; + } + + // Build a list of the items in the current sort order + List rowList = new List(); + IEnumerator sortedList = CollectionViewSource.GetDefaultView(itemList).GetEnumerator(); + for (int i = 0; i < itemList.Count; i++) + { + if (!sortedList.MoveNext()) + break; + rowList.Add(sortedList.Current); + } + + // For each unique row (in sorted order), determine its index in the underlying collection + List uniqueSortedRows = new List(); + foreach (int rowIndex in rowindices) + { + int index = itemList.IndexOf(rowList[rowIndex]); + uniqueSortedRows.Add(index); + } + // Sort the underlying indices + uniqueSortedRows.Sort(); + + // Suppress collection changed during bulk removal to prevent mid-loop + // CollectionChanged handler ? BindExactDataGrid ? ExactDataOrdinates rebuild ? index misalignment. + _suppressUIUpdate = true; + bool wasSuppressed = Element.DataFrame.ExactSeries.SuppressCollectionChanged; + Element.DataFrame.ExactSeries.SuppressCollectionChanged = true; + + // Remove the items starting from the highest index so that removals do not affect remaining indices + for (int i = uniqueSortedRows.Count - 1; i >= 0; i--) + { + Element.DataFrame.ExactSeries.RemoveAt(uniqueSortedRows[i]); + var rowItem = (ExactDataRowItem)ExactDataOrdinates[uniqueSortedRows[i]]; + rowItem.PropertyChanged -= ExactDataRowItem_PropertyChanged; + ExactDataOrdinates.RemoveAt(uniqueSortedRows[i]); + } + + Element.DataFrame.ExactSeries.SuppressCollectionChanged = wasSuppressed; + // Keep _suppressUIUpdate = true so CollectionChanged handler skips the Reset + if (!wasSuppressed) + { + Element.DataFrame.ExactSeries.RaiseCollectionChangedReset(); + } + _suppressUIUpdate = false; + if (!wasSuppressed && _isLoaded) + { + MarkAllDirty(); + ExactDataGrid.ValidateTable(); + ExactDataGrid.Items.Refresh(); + // Disable undo during plot updates so Series.Clear/Add don't record as undo entries + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try { UpdateControl(); } + finally { Element.IsUndoEnabled = wasUndoEnabled; } + } + } + + /// + /// Handles the PreviewPasteData event for the exact data grid. + /// Suppresses collection changed events during the paste operation so that + /// individual cell edits and row additions do not fire intermediate events. + /// + /// The clipboard data being pasted. + /// Reference to a boolean indicating whether to cancel the paste operation. + private void ExactDataGrid_PreviewPasteData(string[][] clipboardData, ref bool cancelPaste) + { + Mouse.OverrideCursor = Cursors.Wait; + _suppressUIUpdate = true; + Element.DataFrame.ExactSeries.SuppressCollectionChanged = true; + } + + /// + /// Handles the DataPasted event for the exact data grid. + /// Re-enables collection changed events and raises a single Reset so that the bridge + /// records one undo action for the entire paste. Validates and refreshes the control. + /// + private void ExactDataGrid_DataPasted() + { + try + { + // Keep _suppressUIUpdate = true so CollectionChanged handler skips the Reset + Element.DataFrame.ExactSeries.SuppressCollectionChanged = false; + Element.DataFrame.ExactSeries.RaiseCollectionChangedReset(); + _suppressUIUpdate = false; + + // Full resync needed after paste (grid modified cells + added rows) + BindExactDataGrid(); + MarkAllDirty(); + ExactDataGrid.ValidateTable(); + + // Disable undo during plot updates so Series.Clear/Add don't record as undo entries + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try { UpdateControl(); } + finally { Element.IsUndoEnabled = wasUndoEnabled; } + } + finally + { + _suppressUIUpdate = false; + Element.DataFrame.ExactSeries.SuppressCollectionChanged = false; + Mouse.OverrideCursor = null; + } + } + + /// + /// Handles the MouseDown event for the exact data grid. + /// Commits edits and clears selection when clicking in empty space below rows. + /// + /// The source of the event. + /// Event args containing mouse button data. + private void ExactDataGrid_MouseDown(object sender, MouseButtonEventArgs e) + { + // Exit if not clicked in empty space below bottom row. + if (e.OriginalSource.GetType() != typeof(ScrollViewer)) { return; } + // Commit edits + ExactDataGrid.CommitEdit(); + // Clear Selection + ExactDataGrid.UnselectAllCells(); + // Move focus to force validation redraw + ExactDataGrid.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next)); + } + + #endregion + + #region Uncertain Data Grid + + /// + /// Binds the uncertain data grid to the Element's uncertain data series. + /// Configures the grid for editable uncertain data with probability distributions. + /// + private void BindUncertainDataGrid() + { + _suppressUIUpdate = true; + Element.DataFrame.UncertainSeries.SuppressCollectionChanged = true; + + // Unsubscribe PropertyChanged from old RowItems to prevent memory leaks + for (int i = UncertainDataOrdinates.Count - 1; i >= 0; i--) + { + var rowItem = (UncertainDataRowItem)UncertainDataOrdinates[i]; + rowItem.PropertyChanged -= UncertainDataRowItem_PropertyChanged; + UncertainDataOrdinates.RemoveAt(i); + } + + // Clear existing bindings + UncertainDataGridToolBar.DataGrid = null; + UncertainDataGrid.ItemsSource = null; + + // Add all data items + for (int i = 0; i < Element.DataFrame.UncertainSeries.Count; i++) + { + var data = (UncertainData)Element.DataFrame.UncertainSeries[i]; + var rowItem = new UncertainDataRowItem(UncertainDataOrdinates, data, Element.DataFrame, Element.DataFrame.UncertainSeries); + rowItem.PropertyChanged += UncertainDataRowItem_PropertyChanged; + UncertainDataOrdinates.Add(rowItem); + } + + // Bind data grids + UncertainDataGridToolBar.DataGrid = UncertainDataGrid; + UncertainDataGrid.ItemsSource = UncertainDataOrdinates; + UncertainDataGrid.Items.Refresh(); + + Element.DataFrame.UncertainSeries.SuppressCollectionChanged = false; + _suppressUIUpdate = false; + } + + /// + /// Handles the AutoGeneratedColumns event for the uncertain data grid. + /// Placeholder for post-generation column configuration. + /// + /// The source of the event. + /// Event args for the event. + private void UncertainDataGrid_AutoGeneratedColumns(object sender, EventArgs e) + { + // Remove dummy row. + //UncertainDataOrdinates.RemoveAt(0); + } + + /// + /// Handles the PreviewAddRows event for the uncertain data grid. + /// Creates new uncertain data rows with default distributions. + /// + /// The index where new rows should be inserted. + /// The number of rows to add. + /// Reference to a boolean indicating whether to cancel the add operation. + private void UncertainDataGrid_PreviewAddRows(int startRowIndex, int nRows, ref bool cancelAddRows) + { + cancelAddRows = true; + + _suppressUIUpdate = true; + bool wasSuppressed = Element.DataFrame.UncertainSeries.SuppressCollectionChanged; + Element.DataFrame.UncertainSeries.SuppressCollectionChanged = true; + + for (int i = startRowIndex; i < startRowIndex + nRows; i++) + { + var data = new UncertainData(); + Element.DataFrame.UncertainSeries.Insert(i, data); + var rowItem = new UncertainDataRowItem(UncertainDataOrdinates, data, Element.DataFrame, Element.DataFrame.UncertainSeries); + rowItem.PropertyChanged += UncertainDataRowItem_PropertyChanged; + UncertainDataOrdinates.Insert(i, rowItem); + } + + Element.DataFrame.UncertainSeries.SuppressCollectionChanged = wasSuppressed; + // Keep _suppressUIUpdate = true so CollectionChanged handler skips the Reset + if (!wasSuppressed) + { + Element.DataFrame.UncertainSeries.RaiseCollectionChangedReset(); + } + _suppressUIUpdate = false; + if (!wasSuppressed && _isLoaded) + { + MarkDataFrameDirty(); + UncertainDataGrid.ValidateTable(); + UncertainDataGrid.Items.Refresh(); + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try { UpdateControl(); } + finally { Element.IsUndoEnabled = wasUndoEnabled; } + } + } + + /// + /// Handles the PreviewDeleteRows event for UncertainDataGrid. + /// + /// The row indices affected by the operation. + /// Set to to cancel the operation. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void UncertainDataGrid_PreviewDeleteRows(List rowindices, ref bool cancel) + { + cancel = true; + rowindices.Sort(); + + // Get the underlying item collection from the ItemsSource + ICollectionView cv = CollectionViewSource.GetDefaultView(UncertainDataGrid.ItemsSource); + IList itemList = null; + if (cv is CollectionView collectionView && collectionView.SourceCollection is IList list) + { + itemList = list; + } + else + { + itemList = UncertainDataGrid.ItemsSource as IList; + } + + // Build a list of the items in the current sort order + List rowList = new List(); + IEnumerator sortedList = CollectionViewSource.GetDefaultView(itemList).GetEnumerator(); + for (int i = 0; i < itemList.Count; i++) + { + if (!sortedList.MoveNext()) + break; + rowList.Add(sortedList.Current); + } + + // For each unique row (in sorted order), determine its index in the underlying collection + List uniqueSortedRows = new List(); + foreach (int rowIndex in rowindices) + { + int index = itemList.IndexOf(rowList[rowIndex]); + uniqueSortedRows.Add(index); + } + // Sort the underlying indices + uniqueSortedRows.Sort(); + + // Suppress collection changed during bulk removal to prevent mid-loop + // CollectionChanged handler ? BindUncertainDataGrid ? UncertainDataOrdinates rebuild ? index misalignment. + _suppressUIUpdate = true; + bool wasSuppressed = Element.DataFrame.UncertainSeries.SuppressCollectionChanged; + Element.DataFrame.UncertainSeries.SuppressCollectionChanged = true; + + // Remove the items starting from the highest index so that removals do not affect remaining indices + for (int i = uniqueSortedRows.Count - 1; i >= 0; i--) + { + Element.DataFrame.UncertainSeries.RemoveAt(uniqueSortedRows[i]); + var rowItem = (UncertainDataRowItem)UncertainDataOrdinates[uniqueSortedRows[i]]; + rowItem.PropertyChanged -= UncertainDataRowItem_PropertyChanged; + UncertainDataOrdinates.RemoveAt(uniqueSortedRows[i]); + } + + Element.DataFrame.UncertainSeries.SuppressCollectionChanged = wasSuppressed; + // Keep _suppressUIUpdate = true so CollectionChanged handler skips the Reset + if (!wasSuppressed) + { + Element.DataFrame.UncertainSeries.RaiseCollectionChangedReset(); + } + _suppressUIUpdate = false; + if (!wasSuppressed && _isLoaded) + { + MarkDataFrameDirty(); + UncertainDataGrid.ValidateTable(); + UncertainDataGrid.Items.Refresh(); + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try { UpdateControl(); } + finally { Element.IsUndoEnabled = wasUndoEnabled; } + } + } + + /// + /// Handles the PreviewPasteData event for UncertainDataGrid. + /// + /// The pasted clipboard cells. + /// Set to to cancel the operation. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void UncertainDataGrid_PreviewPasteData(string[][] clipboardData, ref bool cancelPaste) + { + Mouse.OverrideCursor = Cursors.Wait; + _suppressUIUpdate = true; + Element.DataFrame.UncertainSeries.SuppressCollectionChanged = true; + } + + /// + /// Handles the DataPasted event for UncertainDataGrid. + /// + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void UncertainDataGrid_DataPasted() + { + try + { + // Keep _suppressUIUpdate = true so CollectionChanged handler skips the Reset + Element.DataFrame.UncertainSeries.SuppressCollectionChanged = false; + Element.DataFrame.UncertainSeries.RaiseCollectionChangedReset(); + _suppressUIUpdate = false; + + // Full resync needed after paste + BindUncertainDataGrid(); + MarkDataFrameDirty(); + UncertainDataGrid.ValidateTable(); + + // Disable undo during plot updates so Series.Clear/Add don't record as undo entries + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try { UpdateControl(); } + finally { Element.IsUndoEnabled = wasUndoEnabled; } + } + finally + { + _suppressUIUpdate = false; + Element.DataFrame.UncertainSeries.SuppressCollectionChanged = false; + Mouse.OverrideCursor = null; + } + } + + /// + /// Handles the MouseDown event for UncertainDataGrid. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void UncertainDataGrid_MouseDown(object sender, MouseButtonEventArgs e) + { + // Exit if not clicked in empty space below bottom row. + if (e.OriginalSource.GetType() != typeof(ScrollViewer)) { return; } + // Commit edits + UncertainDataGrid.CommitEdit(); + // Clear Selection + UncertainDataGrid.UnselectAllCells(); + // Move focus to force validation redraw + UncertainDataGrid.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next)); + } + + /// + /// Handles the Opened event for the distribution selection popup. + /// Initializes the distribution selector with available distributions and current selection. + /// + /// The source of the event. + /// Event args for the event. + private void DistributionPopup_Opened(object sender, EventArgs e) + { + if (Element == null) return; + Popup p = (Popup)sender; + if (p.Child == null) return; + var b = (Border)p.Child; + DistributionSelectorControl dsc = (DistributionSelectorControl)b.Child; + UnivariateDistributionBase dist = null; + if (p.DataContext.GetType() == typeof(UncertainDataRowItem)) + { + dist = ((UncertainDataRowItem)p.DataContext).Distribution.Clone(); + } + + var PriorDistributionsList = new List() + { + new GammaDistribution(), + new GeneralizedBeta(), + new LnNormal(), + new LogNormal(), + new Normal(), + new Pert(), + new StudentT(), + new Triangular(), + new TruncatedNormal(), + new Uniform() + }; + + dsc.Distributions = PriorDistributionsList; + dsc.SelectedDistribution = dist; + } + + /// + /// Handles the Closed event for the distribution selection popup. + /// Updates the uncertain data row's distribution with the selected distribution if valid. + /// + /// The source of the event. + /// Event args for the event. + private void DistributionPopup_Closed(object sender, EventArgs e) + { + if (Element == null) return; + Popup p = (Popup)sender; + if (p.Child == null) return; + var b = (Border)p.Child; + DistributionSelectorControl dsc = (DistributionSelectorControl)b.Child; + if (p.DataContext.GetType() == typeof(UncertainDataRowItem) && dsc.SelectedDistribution.ParametersValid == true) + { + ((UncertainDataRowItem)p.DataContext).Distribution = dsc.SelectedDistribution.Clone(); + } + dsc.SelectedDistribution = null; + } + + #endregion + + #region Interval Data Grid + + /// + /// Binds the interval data grid to the Element's interval data series. + /// Configures the grid for editable interval data with lower and upper bounds. + /// + private void BindIntervalDataGrid() + { + _suppressUIUpdate = true; + Element.DataFrame.IntervalSeries.SuppressCollectionChanged = true; + + // Unsubscribe PropertyChanged from old RowItems to prevent memory leaks + for (int i = IntervalDataOrdinates.Count - 1; i >= 0; i--) + { + var rowItem = (IntervalDataRowItem)IntervalDataOrdinates[i]; + rowItem.PropertyChanged -= IntervalDataRowItem_PropertyChanged; + IntervalDataOrdinates.RemoveAt(i); + } + + // Clear existing bindings + IntervalDataGridToolBar.DataGrid = null; + IntervalDataGrid.ItemsSource = null; + + // Add dummy rows + IntervalDataOrdinates.Add(new IntervalDataRowItem(IntervalDataOrdinates, new IntervalData(), Element.DataFrame, Element.DataFrame.IntervalSeries)); + + // Add all data items + for (int i = 0; i < Element.DataFrame.IntervalSeries.Count; i++) + { + var data = (IntervalData)Element.DataFrame.IntervalSeries[i]; + var rowItem = new IntervalDataRowItem(IntervalDataOrdinates, data, Element.DataFrame, Element.DataFrame.IntervalSeries); + rowItem.PropertyChanged += IntervalDataRowItem_PropertyChanged; + IntervalDataOrdinates.Add(rowItem); + } + + // Bind data grids + IntervalDataGridToolBar.DataGrid = IntervalDataGrid; + IntervalDataGrid.ItemsSource = IntervalDataOrdinates; + IntervalDataGrid.Items.Refresh(); + + Element.DataFrame.IntervalSeries.SuppressCollectionChanged = false; + _suppressUIUpdate = false; + } + + /// + /// Handles the AutoGeneratedColumns event for IntervalDataGrid. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void IntervalDataGrid_AutoGeneratedColumns(object sender, EventArgs e) + { + // Remove dummy row. + IntervalDataOrdinates.RemoveAt(0); + } + + /// + /// Handles the AutoGeneratingColumn event for IntervalDataGrid. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void IntervalDataGrid_AutoGeneratingColumn(object sender, DataGridAutoGeneratingColumnEventArgs e) + { + if (e.PropertyName == nameof(IntervalDataRowItem.Index)) + { + e.Column.MinWidth = 10; + e.Column.MaxWidth = 150; + e.Column.Width = new DataGridLength(0.2, DataGridLengthUnitType.Star); + ((DataGridTextColumn)e.Column).Binding.StringFormat = "F0"; + ((DataGridTextColumn)e.Column).CellStyle = (Style)FindResource("Center_CellStyle"); + ((DataGridTextColumn)e.Column).HeaderStyle = (Style)FindResource("Center_ColumnHeaderStyle"); + } + else if (e.PropertyName == nameof(IntervalDataRowItem.LowerValue)) + { + e.Column.MinWidth = 10; + e.Column.MaxWidth = 150; + e.Column.Width = new DataGridLength(0.2, DataGridLengthUnitType.Star); + ((DataGridTextColumn)e.Column).Binding.StringFormat = "{0:" + UserSettings.ValueStringFormat + "}"; + ((Binding)((DataGridTextColumn)e.Column).Binding).Converter = new DoubleToNAConverter(); + ((DataGridTextColumn)e.Column).CellStyle = (Style)FindResource("Right_CellStyle"); + ((DataGridTextColumn)e.Column).HeaderStyle = (Style)FindResource("Center_ColumnHeaderStyle"); + } + else if (e.PropertyName == nameof(IntervalDataRowItem.Value)) + { + e.Column.MinWidth = 10; + e.Column.MaxWidth = 150; + e.Column.Width = new DataGridLength(0.2, DataGridLengthUnitType.Star); + ((DataGridTextColumn)e.Column).Binding.StringFormat = "{0:" + UserSettings.ValueStringFormat + "}"; + ((Binding)((DataGridTextColumn)e.Column).Binding).Converter = new DoubleToNAConverter(); + ((DataGridTextColumn)e.Column).CellStyle = (Style)FindResource("Right_CellStyle"); + ((DataGridTextColumn)e.Column).HeaderStyle = (Style)FindResource("Center_ColumnHeaderStyle"); + } + else if (e.PropertyName == nameof(IntervalDataRowItem.UpperValue)) + { + e.Column.MinWidth = 10; + e.Column.MaxWidth = 150; + e.Column.Width = new DataGridLength(0.2, DataGridLengthUnitType.Star); + ((DataGridTextColumn)e.Column).Binding.StringFormat = "{0:" + UserSettings.ValueStringFormat + "}"; + ((Binding)((DataGridTextColumn)e.Column).Binding).Converter = new DoubleToNAConverter(); + ((DataGridTextColumn)e.Column).CellStyle = (Style)FindResource("Right_CellStyle"); + ((DataGridTextColumn)e.Column).HeaderStyle = (Style)FindResource("Center_ColumnHeaderStyle"); + } + else if (e.PropertyName == nameof(IntervalDataRowItem.PlottingPosition)) + { + e.Column.MinWidth = 10; + e.Column.MaxWidth = 150; + e.Column.IsReadOnly = true; + e.Column.Width = new DataGridLength(0.2, DataGridLengthUnitType.Star); + ((DataGridTextColumn)e.Column).Binding.StringFormat = "F6"; + ((Binding)((DataGridTextColumn)e.Column).Binding).Converter = new DoubleToNAConverter(); + ((DataGridTextColumn)e.Column).CellStyle = (Style)FindResource("Right_ReadOnly_CellStyle"); + ((DataGridTextColumn)e.Column).HeaderStyle = (Style)FindResource("Center_ColumnHeaderStyle"); + } + + } + + /// + /// Handles the PreviewAddRows event for IntervalDataGrid. + /// + /// The first row index affected by the operation. + /// The number of rows affected by the operation. + /// Set to to cancel the operation. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void IntervalDataGrid_PreviewAddRows(int startRowIndex, int nRows, ref bool cancelAddRows) + { + cancelAddRows = true; + + _suppressUIUpdate = true; + bool wasSuppressed = Element.DataFrame.IntervalSeries.SuppressCollectionChanged; + Element.DataFrame.IntervalSeries.SuppressCollectionChanged = true; + + for (int i = startRowIndex; i < startRowIndex + nRows; i++) + { + var data = new IntervalData(); + Element.DataFrame.IntervalSeries.Insert(i, data); + var rowItem = new IntervalDataRowItem(IntervalDataOrdinates, data, Element.DataFrame, Element.DataFrame.IntervalSeries); + rowItem.PropertyChanged += IntervalDataRowItem_PropertyChanged; + IntervalDataOrdinates.Insert(i, rowItem); + } + + Element.DataFrame.IntervalSeries.SuppressCollectionChanged = wasSuppressed; + // Keep _suppressUIUpdate = true so CollectionChanged handler skips the Reset + if (!wasSuppressed) + { + Element.DataFrame.IntervalSeries.RaiseCollectionChangedReset(); + } + _suppressUIUpdate = false; + if (!wasSuppressed && _isLoaded) + { + MarkDataFrameDirty(); + IntervalDataGrid.ValidateTable(); + IntervalDataGrid.Items.Refresh(); + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try { UpdateControl(); } + finally { Element.IsUndoEnabled = wasUndoEnabled; } + } + } + + /// + /// Handles the PreviewDeleteRows event for IntervalDataGrid. + /// + /// The row indices affected by the operation. + /// Set to to cancel the operation. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void IntervalDataGrid_PreviewDeleteRows(List rowindices, ref bool cancel) + { + cancel = true; + rowindices.Sort(); + + // Get the underlying item collection from the ItemsSource + ICollectionView cv = CollectionViewSource.GetDefaultView(IntervalDataGrid.ItemsSource); + IList itemList = null; + if (cv is CollectionView collectionView && collectionView.SourceCollection is IList list) + { + itemList = list; + } + else + { + itemList = IntervalDataGrid.ItemsSource as IList; + } + + // Build a list of the items in the current sort order + List rowList = new List(); + IEnumerator sortedList = CollectionViewSource.GetDefaultView(itemList).GetEnumerator(); + for (int i = 0; i < itemList.Count; i++) + { + if (!sortedList.MoveNext()) + break; + rowList.Add(sortedList.Current); + } + + // For each unique row (in sorted order), determine its index in the underlying collection + List uniqueSortedRows = new List(); + foreach (int rowIndex in rowindices) + { + int index = itemList.IndexOf(rowList[rowIndex]); + uniqueSortedRows.Add(index); + } + // Sort the underlying indices + uniqueSortedRows.Sort(); + + // Suppress collection changed during bulk removal to prevent mid-loop + // CollectionChanged handler ? BindIntervalDataGrid ? IntervalDataOrdinates rebuild ? index misalignment. + _suppressUIUpdate = true; + bool wasSuppressed = Element.DataFrame.IntervalSeries.SuppressCollectionChanged; + Element.DataFrame.IntervalSeries.SuppressCollectionChanged = true; + + // Remove the items starting from the highest index so that removals do not affect remaining indices + for (int i = uniqueSortedRows.Count - 1; i >= 0; i--) + { + Element.DataFrame.IntervalSeries.RemoveAt(uniqueSortedRows[i]); + var rowItem = (IntervalDataRowItem)IntervalDataOrdinates[uniqueSortedRows[i]]; + rowItem.PropertyChanged -= IntervalDataRowItem_PropertyChanged; + IntervalDataOrdinates.RemoveAt(uniqueSortedRows[i]); + } + + Element.DataFrame.IntervalSeries.SuppressCollectionChanged = wasSuppressed; + // Keep _suppressUIUpdate = true so CollectionChanged handler skips the Reset + if (!wasSuppressed) + { + Element.DataFrame.IntervalSeries.RaiseCollectionChangedReset(); + } + _suppressUIUpdate = false; + if (!wasSuppressed && _isLoaded) + { + MarkDataFrameDirty(); + IntervalDataGrid.ValidateTable(); + IntervalDataGrid.Items.Refresh(); + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try { UpdateControl(); } + finally { Element.IsUndoEnabled = wasUndoEnabled; } + } + } + + /// + /// Handles the PreviewPasteData event for IntervalDataGrid. + /// + /// The pasted clipboard cells. + /// Set to to cancel the operation. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void IntervalDataGrid_PreviewPasteData(string[][] clipboardData, ref bool cancelPaste) + { + Mouse.OverrideCursor = Cursors.Wait; + _suppressUIUpdate = true; + Element.DataFrame.IntervalSeries.SuppressCollectionChanged = true; + } + + /// + /// Handles the DataPasted event for IntervalDataGrid. + /// + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void IntervalDataGrid_DataPasted() + { + try + { + // Keep _suppressUIUpdate = true so CollectionChanged handler skips the Reset + Element.DataFrame.IntervalSeries.SuppressCollectionChanged = false; + Element.DataFrame.IntervalSeries.RaiseCollectionChangedReset(); + _suppressUIUpdate = false; + + // Full resync needed after paste + BindIntervalDataGrid(); + MarkDataFrameDirty(); + IntervalDataGrid.ValidateTable(); + + // Disable undo during plot updates so Series.Clear/Add don't record as undo entries + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try { UpdateControl(); } + finally { Element.IsUndoEnabled = wasUndoEnabled; } + } + finally + { + _suppressUIUpdate = false; + Element.DataFrame.IntervalSeries.SuppressCollectionChanged = false; + Mouse.OverrideCursor = null; + } + } + + /// + /// Handles the MouseDown event for IntervalDataGrid. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void IntervalDataGrid_MouseDown(object sender, MouseButtonEventArgs e) + { + // Exit if not clicked in empty space below bottom row. + if (e.OriginalSource.GetType() != typeof(ScrollViewer)) { return; } + // Commit edits + IntervalDataGrid.CommitEdit(); + // Clear Selection + IntervalDataGrid.UnselectAllCells(); + // Move focus to force validation redraw + IntervalDataGrid.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next)); + } + + #endregion + + #region Threshold Data Grid + + /// + /// Binds the threshold data grid to the Element's threshold data series. + /// Configures the grid for editable threshold data with start/end indices and values. + /// + private void BindThresholdDataGrid() + { + _suppressUIUpdate = true; + Element.DataFrame.ThresholdSeries.SuppressCollectionChanged = true; + + // Unsubscribe PropertyChanged from old RowItems to prevent memory leaks + for (int i = ThresholdDataOrdinates.Count - 1; i >= 0; i--) + { + var rowItem = (ThresholdDataRowItem)ThresholdDataOrdinates[i]; + rowItem.PropertyChanged -= ThresholdDataRowItem_PropertyChanged; + ThresholdDataOrdinates.RemoveAt(i); + } + + // Clear existing bindings + ThresholdDataGridToolBar.DataGrid = null; + ThresholdDataGrid.ItemsSource = null; + + // Add dummy rows + ThresholdDataOrdinates.Add(new ThresholdDataRowItem(ThresholdDataOrdinates, new ThresholdData(), Element.DataFrame.ThresholdSeries)); + + // Add all data items + for (int i = 0; i < Element.DataFrame.ThresholdSeries.Count; i++) + { + var data = (ThresholdData)Element.DataFrame.ThresholdSeries[i]; + var rowItem = new ThresholdDataRowItem(ThresholdDataOrdinates, data, Element.DataFrame.ThresholdSeries); + rowItem.PropertyChanged += ThresholdDataRowItem_PropertyChanged; + ThresholdDataOrdinates.Add(rowItem); + } + + // Bind data grids + ThresholdDataGridToolBar.DataGrid = ThresholdDataGrid; + ThresholdDataGrid.ItemsSource = ThresholdDataOrdinates; + ThresholdDataGrid.Items.Refresh(); + + Element.DataFrame.ThresholdSeries.SuppressCollectionChanged = false; + _suppressUIUpdate = false; + } + + /// + /// Handles the AutoGeneratedColumns event for ThresholdDataGrid. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void ThresholdDataGrid_AutoGeneratedColumns(object sender, EventArgs e) + { + // Remove dummy row. + ThresholdDataOrdinates.RemoveAt(0); + } + + /// + /// Handles the AutoGeneratingColumn event for ThresholdDataGrid. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void ThresholdDataGrid_AutoGeneratingColumn(object sender, DataGridAutoGeneratingColumnEventArgs e) + { + if (e.PropertyName == nameof(ThresholdDataRowItem.StartIndex)) + { + e.Column.MinWidth = 10; + e.Column.MaxWidth = 200; + e.Column.Width = new DataGridLength(0.25, DataGridLengthUnitType.Star); + ((DataGridTextColumn)e.Column).Binding.StringFormat = "F0"; + ((DataGridTextColumn)e.Column).CellStyle = (Style)FindResource("Center_CellStyle"); + ((DataGridTextColumn)e.Column).HeaderStyle = (Style)FindResource("Center_ColumnHeaderStyle"); + } + else if (e.PropertyName == nameof(ThresholdDataRowItem.EndIndex)) + { + e.Column.MinWidth = 10; + e.Column.MaxWidth = 200; + e.Column.Width = new DataGridLength(0.25, DataGridLengthUnitType.Star); + ((DataGridTextColumn)e.Column).Binding.StringFormat = "F0"; + ((DataGridTextColumn)e.Column).CellStyle = (Style)FindResource("Center_CellStyle"); + ((DataGridTextColumn)e.Column).HeaderStyle = (Style)FindResource("Center_ColumnHeaderStyle"); + } + else if (e.PropertyName == nameof(ThresholdDataRowItem.Value)) + { + e.Column.MinWidth = 10; + e.Column.MaxWidth = 200; + e.Column.Width = new DataGridLength(0.25, DataGridLengthUnitType.Star); + ((DataGridTextColumn)e.Column).Binding.StringFormat = "{0:" + UserSettings.ValueStringFormat + "}"; + ((Binding)((DataGridTextColumn)e.Column).Binding).Converter = new DoubleToNAConverter(); + ((DataGridTextColumn)e.Column).CellStyle = (Style)FindResource("Right_CellStyle"); + ((DataGridTextColumn)e.Column).HeaderStyle = (Style)FindResource("Center_ColumnHeaderStyle"); + } + else if (e.PropertyName == nameof(ThresholdDataRowItem.NumberAbove)) + { + e.Column.MinWidth = 10; + e.Column.MaxWidth = 200; + e.Column.Width = new DataGridLength(0.25, DataGridLengthUnitType.Star); + ((DataGridTextColumn)e.Column).Binding.StringFormat = "F0"; + ((DataGridTextColumn)e.Column).CellStyle = (Style)FindResource("Right_CellStyle"); + ((DataGridTextColumn)e.Column).HeaderStyle = (Style)FindResource("Center_ColumnHeaderStyle"); + } + } + + /// + /// Handles the PreviewAddRows event for ThresholdDataGrid. + /// + /// The first row index affected by the operation. + /// The number of rows affected by the operation. + /// Set to to cancel the operation. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void ThresholdDataGrid_PreviewAddRows(int startRowIndex, int nRows, ref bool cancelAddRows) + { + cancelAddRows = true; + + _suppressUIUpdate = true; + bool wasSuppressed = Element.DataFrame.ThresholdSeries.SuppressCollectionChanged; + Element.DataFrame.ThresholdSeries.SuppressCollectionChanged = true; + + for (int i = startRowIndex; i < startRowIndex + nRows; i++) + { + var data = new ThresholdData(); + Element.DataFrame.ThresholdSeries.Insert(i, data); + var rowItem = new ThresholdDataRowItem(ThresholdDataOrdinates, data, Element.DataFrame.ThresholdSeries); + rowItem.PropertyChanged += ThresholdDataRowItem_PropertyChanged; + ThresholdDataOrdinates.Insert(i, rowItem); + } + + Element.DataFrame.ThresholdSeries.SuppressCollectionChanged = wasSuppressed; + // Keep _suppressUIUpdate = true so CollectionChanged handler skips the Reset + if (!wasSuppressed) + { + Element.DataFrame.ThresholdSeries.RaiseCollectionChangedReset(); + } + _suppressUIUpdate = false; + if (!wasSuppressed && _isLoaded) + { + MarkDataFrameDirty(); + ThresholdDataGrid.ValidateTable(); + ThresholdDataGrid.Items.Refresh(); + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try { UpdateControl(); } + finally { Element.IsUndoEnabled = wasUndoEnabled; } + } + } + + /// + /// Handles the PreviewDeleteRows event for ThresholdDataGrid. + /// + /// The row indices affected by the operation. + /// Set to to cancel the operation. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void ThresholdDataGrid_PreviewDeleteRows(List rowindices, ref bool cancel) + { + cancel = true; + rowindices.Sort(); + + // Get the underlying item collection from the ItemsSource + ICollectionView cv = CollectionViewSource.GetDefaultView(ThresholdDataGrid.ItemsSource); + IList itemList = null; + if (cv is CollectionView collectionView && collectionView.SourceCollection is IList list) + { + itemList = list; + } + else + { + itemList = ThresholdDataGrid.ItemsSource as IList; + } + + // Build a list of the items in the current sort order + List rowList = new List(); + IEnumerator sortedList = CollectionViewSource.GetDefaultView(itemList).GetEnumerator(); + for (int i = 0; i < itemList.Count; i++) + { + if (!sortedList.MoveNext()) + break; + rowList.Add(sortedList.Current); + } + + // For each unique row (in sorted order), determine its index in the underlying collection + List uniqueSortedRows = new List(); + foreach (int rowIndex in rowindices) + { + int index = itemList.IndexOf(rowList[rowIndex]); + uniqueSortedRows.Add(index); + } + // Sort the underlying indices + uniqueSortedRows.Sort(); + + // Suppress collection changed during bulk removal to prevent mid-loop + // CollectionChanged handler ? BindThresholdDataGrid ? ThresholdDataOrdinates rebuild ? index misalignment. + _suppressUIUpdate = true; + bool wasSuppressed = Element.DataFrame.ThresholdSeries.SuppressCollectionChanged; + Element.DataFrame.ThresholdSeries.SuppressCollectionChanged = true; + + // Remove the items starting from the highest index so that removals do not affect remaining indices + for (int i = uniqueSortedRows.Count - 1; i >= 0; i--) + { + Element.DataFrame.ThresholdSeries.RemoveAt(uniqueSortedRows[i]); + var rowItem = (ThresholdDataRowItem)ThresholdDataOrdinates[uniqueSortedRows[i]]; + rowItem.PropertyChanged -= ThresholdDataRowItem_PropertyChanged; + ThresholdDataOrdinates.RemoveAt(uniqueSortedRows[i]); + } + + Element.DataFrame.ThresholdSeries.SuppressCollectionChanged = wasSuppressed; + // Keep _suppressUIUpdate = true so CollectionChanged handler skips the Reset + if (!wasSuppressed) + { + Element.DataFrame.ThresholdSeries.RaiseCollectionChangedReset(); + } + _suppressUIUpdate = false; + if (!wasSuppressed && _isLoaded) + { + MarkDataFrameDirty(); + ThresholdDataGrid.ValidateTable(); + ThresholdDataGrid.Items.Refresh(); + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try { UpdateControl(); } + finally { Element.IsUndoEnabled = wasUndoEnabled; } + } + } + + /// + /// Handles the PreviewPasteData event for ThresholdDataGrid. + /// + /// The pasted clipboard cells. + /// Set to to cancel the operation. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void ThresholdDataGrid_PreviewPasteData(string[][] clipboardData, ref bool cancelPaste) + { + Mouse.OverrideCursor = Cursors.Wait; + _suppressUIUpdate = true; + Element.DataFrame.ThresholdSeries.SuppressCollectionChanged = true; + } + + /// + /// Handles the DataPasted event for ThresholdDataGrid. + /// + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void ThresholdDataGrid_DataPasted() + { + try + { + // Keep _suppressUIUpdate = true so CollectionChanged handler skips the Reset + Element.DataFrame.ThresholdSeries.SuppressCollectionChanged = false; + Element.DataFrame.ThresholdSeries.RaiseCollectionChangedReset(); + _suppressUIUpdate = false; + + // Full resync needed after paste + BindThresholdDataGrid(); + MarkDataFrameDirty(); + ThresholdDataGrid.ValidateTable(); + + // Disable undo during plot updates so Series.Clear/Add don't record as undo entries + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try { UpdateControl(); } + finally { Element.IsUndoEnabled = wasUndoEnabled; } + } + finally + { + _suppressUIUpdate = false; + Element.DataFrame.ThresholdSeries.SuppressCollectionChanged = false; + Mouse.OverrideCursor = null; + } + } + + /// + /// Handles the MouseDown event for ThresholdDataGrid. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void ThresholdDataGrid_MouseDown(object sender, MouseButtonEventArgs e) + { + // Exit if not clicked in empty space below bottom row. + if (e.OriginalSource.GetType() != typeof(ScrollViewer)) { return; } + // Commit edits + ThresholdDataGrid.CommitEdit(); + // Clear Selection + ThresholdDataGrid.UnselectAllCells(); + // Move focus to force validation redraw + ThresholdDataGrid.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next)); + } + + + #endregion + + #region Summary Statistics + + /// + /// Updates the summary statistics data grid with descriptive statistics. + /// Calculates statistics for both exact data only and all data (nonparametric). + /// + private void UpdateSummaryStats() + { + SummaryStatisticsDataGrid.ItemsSource = null; + _summaryStatistics.Clear(); + + if (Element == null || Element.DataFrame == null || Element.DataFrame.ExactSeries == null) return; + + var stats = Element.DataFrame.SummaryStatisticsExactDataOnly(); + var allStats = Element.DataFrame.SummaryStatisticsAllData(); + for (int i = 0; i < stats.Count; i++) + _summaryStatistics.Add(new SummaryStatistic(stats.ElementAt(i).Key, stats.ElementAt(i).Value, allStats.ElementAt(i).Value)); + + // Column Headers + var headerStyle = new Style(typeof(DataGridColumnHeader), ExactDataColumn.HeaderStyle); + headerStyle.Setters.Add(new Setter(ToolTipProperty, new TextBlock() { Text = "The summary statistics using only the exact data sample.", FontWeight = System.Windows.FontWeights.Normal, TextAlignment = TextAlignment.Left, TextWrapping = TextWrapping.Wrap })); + ExactDataColumn.HeaderStyle = headerStyle; + + headerStyle = new Style(typeof(DataGridColumnHeader), NonparametricColumn.HeaderStyle); + headerStyle.Setters.Add(new Setter(ToolTipProperty, new TextBlock() { Text = "The summary statistics for all data, including interval and threshold data. A nonparametric distribution is created from the plotting positions.", FontWeight = System.Windows.FontWeights.Normal, TextAlignment = TextAlignment.Left, TextWrapping = TextWrapping.Wrap })); + NonparametricColumn.HeaderStyle = headerStyle; + + SummaryStatisticsDataGrid.ItemsSource = _summaryStatistics; + SummaryStatisticsDataGrid.Items.Refresh(); + } + + /// + /// Handles the LoadingRow event for the summary statistics data grid. + /// Applies border styling to separate different sections of statistics. + /// + /// The source of the event. + /// Event args containing row loading data. + private void SummaryStatisticsDataGrid_LoadingRow(object sender, DataGridRowEventArgs e) + { + if (((SummaryStatistic)e.Row.DataContext).Name == "Minimum") + { + e.Row.BorderThickness = _topBorderThickness; + e.Row.BorderBrush = _borderBrush; + } + else if (((SummaryStatistic)e.Row.DataContext).Name == "Kurtosis (of log)") + { + e.Row.BorderThickness = _bottomBorderThickness; + e.Row.BorderBrush = _borderBrush; + } + else + e.Row.BorderThickness = _noBorderThickness; + } + + + + #endregion + + #region Hypothesis Tests + + /// + /// Handles the ValueChanged event for the index slider. + /// Updates hypothesis tests when the split point for two-sample tests changes. + /// + /// The new slider value. + private void IndexSlider_ValueChanged(double value) + { + if (_isLoaded == true) + UpdateHypothesisTests(); + } + + /// + /// Handles the Checked event for the use log values checkbox. + /// Updates hypothesis tests to use logarithmic transformed data. + /// + /// The source of the event. + /// Event args containing routed event data. + private void UseLogValues_Checked(object sender, RoutedEventArgs e) + { + if (_isLoaded == true) + UpdateHypothesisTests(); + } + + /// + /// Handles the Unchecked event for the use log values checkbox. + /// Updates hypothesis tests to use original (non-transformed) data. + /// + /// The source of the event. + /// Event args containing routed event data. + private void UseLogValues_Unchecked(object sender, RoutedEventArgs e) + { + if (_isLoaded == true) + UpdateHypothesisTests(); + } + + /// + /// Updates the hypothesis tests data grid with statistical test results. + /// Performs various tests including normality, autocorrelation, stationarity, and variance tests. + /// Provides p-values and inferences for each test. + /// + private void UpdateHypothesisTests() + { + + HypothesisDataGrid.ItemsSource = null; + _hypothesisTestResults.Clear(); + + if (Element == null || Element.DataFrame == null || Element.DataFrame.ExactSeries == null) return; + + var results = Element.DataFrame.SummaryHypothesisTest((int)IndexSlider.Value, UseLogValues.IsChecked == true ? true : false); + + for (int i = 0; i < results.Count; i++) + { + double pval = results.ElementAt(i).Value; + string pvalStrg = pval > 1E-4 + ? NumberFormatHelper.FormatDouble(pval, "N4") + : pval < 1E-15 + ? "< " + NumberFormatHelper.FormatDouble(1E-15, "E0") + : NumberFormatHelper.FormatDouble(pval, "E2"); + string sig = pval < 1E-3 ? " ***" : pval < 1E-2 ? " **" : pval < 0.05 ? " *" : pval < 0.1 ? " ." : " "; + _hypothesisTestResults.Add(new HypothesisTestResult(results.ElementAt(i).Key, pvalStrg, sig)); + + if (i == 0) + { + // Jarque-Bera test + if (results.ElementAt(i).Value < 0.1) + _hypothesisTestResults[i].Inference = "The data is not Normally distributed."; + else + _hypothesisTestResults[i].Inference = "The data is Normally distributed."; + } + if (i == 1) + { + // Ljung-Box test + if (results.ElementAt(i).Value < 0.1) + _hypothesisTestResults[i].Inference = "The autocorrelation of the data is not zero."; + else + _hypothesisTestResults[i].Inference = "The autocorrelation of the data is zero."; + } + if (i >=2 && i <= 5) + { + // Wald-Wolfowitz/Mann-Whitney/Mann-Kendall/Linear trend + if (results.ElementAt(i).Value < 0.1) + _hypothesisTestResults[i].Inference = "The data is not stationary."; + else + _hypothesisTestResults[i].Inference = "The data is stationary."; + + } + if (i == 6) + { + // Equal variance t test + if (results.ElementAt(i).Value < 0.1) + _hypothesisTestResults[i].Inference = "The two samples (assuming equal variance) do not have the same mean."; + else + _hypothesisTestResults[i].Inference = "The two samples (assuming equal variance) have the same mean."; + } + if (i == 7) + { + // Unequal variance t test + if (results.ElementAt(i).Value < 0.1) + _hypothesisTestResults[i].Inference = "The two samples (assuming unequal variance) do not have the same mean."; + else + _hypothesisTestResults[i].Inference = "The two samples (assuming unequal variance) have the same mean."; + } + if (i == 8) + { + // F test + if (results.ElementAt(i).Value < 0.1) + _hypothesisTestResults[i].Inference = "The two samples do not have the same variance."; + else + _hypothesisTestResults[i].Inference = "The two samples have the same variance."; + } + if (i == 9) + { + // unimodality test + if (results.ElementAt(i).Value < 0.1) + _hypothesisTestResults[i].Inference = "The data is multimodal."; + else + _hypothesisTestResults[i].Inference = "The data is unimodal."; + } + } + + HypothesisDataGrid.ItemsSource = _hypothesisTestResults; + HypothesisDataGrid.Items.Refresh(); + } + + + + + #endregion + + + } +} diff --git a/src/RMC.BestFit.App/GUI/InputData/InputDataPropertiesControl.xaml b/src/RMC.BestFit.App/GUI/InputData/InputDataPropertiesControl.xaml new file mode 100644 index 0000000..a4db68d --- /dev/null +++ b/src/RMC.BestFit.App/GUI/InputData/InputDataPropertiesControl.xaml @@ -0,0 +1,289 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/InputData/InputDataPropertiesControl.xaml.cs b/src/RMC.BestFit.App/GUI/InputData/InputDataPropertiesControl.xaml.cs new file mode 100644 index 0000000..be3a203 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/InputData/InputDataPropertiesControl.xaml.cs @@ -0,0 +1,762 @@ +using Numerics.Data; +using FrameworkInterfaces; +using RMC.BestFit.Models; +using RMC.BestFit.UI; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Navigation; +using System.Windows.Shapes; + +namespace RMC_BestFit +{ + /// + /// User control for managing and displaying input data properties and configuration settings. + /// Provides interface for data entry methods, time series processing, and statistical parameter configuration. + /// + public partial class InputDataPropertiesControl : UserControl + { + /// + /// Maximum time the properties panel waits for an external USGS download before returning control to the user. + /// + /// + /// This UI-level cap complements the Numerics request timeout so a blocked provider cannot + /// leave the input-data panel appearing to process indefinitely. + /// + private static readonly TimeSpan ExternalDownloadTimeout = TimeSpan.FromSeconds(30); + + /// + /// Initializes a new instance of the class. + /// + public InputDataPropertiesControl() + { + InitializeComponent(); + DataContext = this; + } + + /// + /// Stores the previous name value for validation and rollback purposes. + /// Initialized in to prevent null corruption if + /// Name_LostFocus fires before Name_GotFocus (e.g., programmatic focus). + /// + private string _previousName; + + /// + /// Tracks the currently subscribed to allow + /// proper unsubscription when the Element changes or the control unloads. + /// + private TimeSeriesCollection _subscribedTimeSeriesCollection; + + /// + /// Dependency property for the Element property. + /// + public static DependencyProperty ElementProperty = DependencyProperty.Register(nameof(Element), typeof(InputData), typeof(InputDataPropertiesControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the input data element being displayed and edited in this control. + /// + public InputData Element + { + get { return (InputData)GetValue(ElementProperty); } + set { SetValue(ElementProperty, value); } + } + + /// + /// Callback method invoked when the Element dependency property changes. + /// Updates the property attributes and loads time series data for the new element. + /// + /// The dependency object whose property changed. + /// Event args containing the old and new values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as InputDataPropertiesControl == null) return; + var thisControl = (InputDataPropertiesControl)d; + + if (e.NewValue == null) return; + var newElement = e.NewValue as InputData; + if (newElement == null) return; + + thisControl._previousName = newElement.Name; + thisControl.PropertyAttributes.GetClassAttributes(newElement); + thisControl.UnsubscribeTimeSeriesCollection(); + thisControl.LoadTimeSeriesData(); + } + + /// + /// Cached options for exact data entry methods. Static to avoid allocating a new list on every XAML binding call. + /// + private static readonly List _exactDataMethodOptions = new List + { + new ExactDataMethodItem("Manual Entry", InputData.ExactDataEntryType.Manual), + new ExactDataMethodItem("Block Series", InputData.ExactDataEntryType.BlockSeries), + new ExactDataMethodItem("Peaks-Over-Threshold Series", InputData.ExactDataEntryType.PeaksOverThresholdSeries), + new ExactDataMethodItem("USGS Peak Discharge", InputData.ExactDataEntryType.USGSPeakDischarge), + new ExactDataMethodItem("USGS Peak Stage", InputData.ExactDataEntryType.USGSPeakStage) + }; + + /// + /// Gets the available options for exact data entry methods. + /// + public List ExactDataMethodOptions => _exactDataMethodOptions; + + /// + /// Cached month options for calendar-based filtering. + /// + private static readonly List _monthOptions = new List + { + new MonthItem("January", 1), + new MonthItem("February", 2), + new MonthItem("March", 3), + new MonthItem("April", 4), + new MonthItem("May", 5), + new MonthItem("June", 6), + new MonthItem("July", 7), + new MonthItem("August", 8), + new MonthItem("September", 9), + new MonthItem("October", 10), + new MonthItem("November", 11), + new MonthItem("December", 12) + }; + + /// + /// Gets the available month options for calendar-based filtering. + /// + public List MonthOptions => _monthOptions; + + /// + /// Cached block function options for time series aggregation. + /// + private static readonly List _blockFunctionOptions = new List + { + new BlockFunctionItem("Maximum", BlockFunctionType.Maximum), + new BlockFunctionItem("Minimum", BlockFunctionType.Minimum), + new BlockFunctionItem("Average", BlockFunctionType.Average), + new BlockFunctionItem("Sum", BlockFunctionType.Sum) + }; + + /// + /// Gets the available block function options for time series aggregation. + /// + public List BlockFunctionOptions => _blockFunctionOptions; + + /// + /// Cached time block window options for defining analysis periods. + /// + private static readonly List _timeBlockOptions = new List + { + new TimeBlockItem("Calendar Year", TimeBlockWindow.CalendarYear), + new TimeBlockItem("Water Year", TimeBlockWindow.WaterYear), + new TimeBlockItem("Custom Year", TimeBlockWindow.CustomYear) + }; + + /// + /// Gets the available time block window options for defining analysis periods. + /// + public List TimeBlockOptions => _timeBlockOptions; + + /// + /// Cached smoothing function options for time series preprocessing. + /// + private static readonly List _smoothingFunctionOptions = new List + { + new SmoothingFunctionItem("None", SmoothingFunctionType.None), + new SmoothingFunctionItem("Moving Average", SmoothingFunctionType.MovingAverage), + new SmoothingFunctionItem("Moving Sum", SmoothingFunctionType.MovingSum), + new SmoothingFunctionItem("Difference", SmoothingFunctionType.Difference) + }; + + /// + /// Gets the available smoothing function options for time series preprocessing. + /// + public List SmoothingFunctionOptions => _smoothingFunctionOptions; + + /// + /// Cached plotting position parameter options with their associated alpha values. + /// + private static readonly List _parameterOptions = new List + { + new PlottingPositionItem("Weibull (α = 0.0)", 0.0, "The Weibull plotting position formula (α = 0.0). Recommended as the default value because it is unbiased for all distributions."), + new PlottingPositionItem("Median (α = 0.3175)", 0.3175, "The Median plotting position formula (α = 0.3175). Provides median exceedance probabilities for all distributions."), + new PlottingPositionItem("Blom (α = 0.375)", 0.375, "The Blom (1958) plotting position formula (α = 0.375). Recommended for Normal, Gamma, 2-parameter Log Normal, 3-parameter Log Normal, and Log Pearson Type III distributions."), + new PlottingPositionItem("Cunnane (α = 0.40)", 0.4, "The Cunnane (1978) plotting position formula (α = 0.40). Recommended for GEV and Log-Gumbel distributions, approximately quantile unbiased."), + new PlottingPositionItem("Gringorten (α = 0.44)", 0.44, "The Gringorten (1963) plotting position formula (α = 0.44). Recommended for Exponential, Gumbel and Weibull distributions."), + new PlottingPositionItem("Hazen (α = 0.50)", 0.5, "The Hazen plotting position formula (α = 0.50). Recommended when the parameters of the parent distribution are unknown.") + }; + + /// + /// Gets the available plotting position parameter options with their associated alpha values. + /// + public List ParameterOptions => _parameterOptions; + + /// + /// Dependency property for the existing names collection. + /// + public static DependencyProperty ExistingNamesProperty = DependencyProperty.Register(nameof(ExistingNames), typeof(string[]), typeof(InputDataPropertiesControl), new FrameworkPropertyMetadata(new string[] { })); + + /// + /// Gets or sets the collection of existing element names for validation purposes. + /// + public string[] ExistingNames + { + get { return (string[])GetValue(ExistingNamesProperty); } + private set { SetValue(ExistingNamesProperty, value); } + } + + /// + /// Gets the observable collection of time series elements available for selection. + /// + public ObservableCollection TimeSeriesElements { get; private set; } = new ObservableCollection(); + + /// + /// Handles the mouse button down event on the Name field to display property attributes. + /// + /// The source of the event. + /// Event args containing mouse button data. + private void Name_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.Name), Element); + } + + /// + /// Handles the mouse button down event on the Description field to display property attributes. + /// + /// The source of the event. + /// Event args containing mouse button data. + private void Description_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.Description), Element); + } + + /// + /// Handles the mouse button down event on the CreationDate field to display property attributes. + /// + /// The source of the event. + /// Event args containing mouse button data. + private void CreationDate_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.CreationDate), Element); + } + + /// + /// Handles the mouse button down event on the LastModified field to display property attributes. + /// + /// The source of the event. + /// Event args containing mouse button data. + private void LastModified_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.LastModified), Element); + } + + /// + /// Handles the mouse button down event on the UnitLabel field to display property attributes. + /// + /// The source of the event. + /// Event args containing mouse button data. + private void UnitLabel_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.UnitLabel), Element); + } + + /// + /// Handles the mouse button down event on the IndexLabel field to display property attributes. + /// + /// The source of the event. + /// Event args containing mouse button data. + private void IndexLabel_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.IndexLabel), Element); + } + + /// + /// Handles the mouse button down event on the ExactDataMethod field to display property attributes. + /// + /// The source of the event. + /// Event args containing mouse button data. + private void ExactDataMethod_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.ExactDataMethod), Element); + } + + /// + /// Handles the mouse button down event on the USGSSiteNumber field to display property attributes. + /// + /// The source of the event. + /// Event args containing mouse button data. + private void USGSSiteNumber_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.USGSSiteNumber), Element); + } + + /// + /// Handles the mouse button down event on the TimeSeriesElement field to display property attributes. + /// + /// The source of the event. + /// Event args containing mouse button data. + private void TimeSeriesElement_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.TimeSeriesElement), Element); + } + + /// + /// Handles the mouse button down event on the BlockFunction field to display property attributes. + /// + /// The source of the event. + /// Event args containing mouse button data. + private void BlockFunction_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.BlockFunction), Element); + } + + /// + /// Handles the mouse button down event on the TimeBlock field to display property attributes. + /// + /// The source of the event. + /// Event args containing mouse button data. + private void TimeBlock_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.TimeBlock), Element); + } + + /// + /// Handles the mouse button down event on the StartMonth field to display property attributes. + /// + /// The source of the event. + /// Event args containing mouse button data. + private void StartMonth_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.StartMonth), Element); + } + + /// + /// Handles the mouse button down event on the EndMonth field to display property attributes. + /// + /// The source of the event. + /// Event args containing mouse button data. + private void EndMonth_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.EndMonth), Element); + } + + /// + /// Handles the mouse button down event on the SmoothingFunction field to display property attributes. + /// + /// The source of the event. + /// Event args containing mouse button data. + private void SmoothingFunction_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.SmoothingFunction), Element); + } + + /// + /// Handles the mouse button down event on the Period field to display property attributes. + /// + /// The source of the event. + /// Event args containing mouse button data. + private void Period_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.Period), Element); + } + + /// + /// Handles the mouse button down event on the POTThresholdValue field to display property attributes. + /// + /// The source of the event. + /// Event args containing mouse button data. + private void POTThresholdValue_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.Threshold), Element); + } + + /// + /// Handles the mouse button down event on the MinStepsBetweenPeaks field to display property attributes. + /// + /// The source of the event. + /// Event args containing mouse button data. + private void MinStepsBetweenPeaks_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.MinStepsBetweenPeaks), Element); + } + + /// + /// Handles the mouse button down event on the PlottingParameter field to display property attributes. + /// + /// The source of the event. + /// Event args containing mouse button data. + private void PlottingParameter_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.DataFrame.PlottingParameter), Element.DataFrame); + } + + /// + /// Handles the mouse button down event on the MGBTButton field to display property attributes. + /// + /// The source of the event. + /// Event args containing mouse button data. + private void MGBTButton_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.UseMultipleGrubbsBeckTest), Element); + } + + /// + /// Handles the mouse button down event on the LOThresholdValue field to display property attributes. + /// + /// The source of the event. + /// Event args containing mouse button data. + private void LOThresholdValue_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.DataFrame.LowOutlierThreshold), Element.DataFrame); + } + + /// + /// Handles the GotFocus event on the Name field, storing the previous value and loading existing names for validation. + /// + /// The source of the event. + /// Event args containing routed event data. + private void Name_GotFocus(object sender, RoutedEventArgs e) + { + _previousName = Element.Name; + ExistingNames = Element.ParentCollection.GetElementNames(Element).ToArray(); + } + + /// + /// Handles the LostFocus event on the Name field, restoring the previous value if validation fails. + /// + /// The source of the event. + /// Event args containing routed event data. + private void Name_LostFocus(object sender, RoutedEventArgs e) + { + if (ElementName.NameTextBox.IsValid) return; + if (Element == null) return; + Element.Name = _previousName; + } + + /// + /// Loads available time series elements from the parent project's element collections. + /// Subscribes to element added and removed events for dynamic updates. + /// + private void LoadTimeSeriesData() + { + TimeSeriesElements.Clear(); + foreach (IElementCollection collection in Element.ParentCollection.ParentProject.ElementCollections) + { + if (collection is TimeSeriesCollection tsc) + { + _subscribedTimeSeriesCollection = tsc; + tsc.ElementAdded += OnTimeSeriesElementAdded; + tsc.ElementRemoved += OnTimeSeriesElementRemoved; + foreach (IElement element in collection) + TimeSeriesElements.Add((TimeSeriesElement)element); + break; + } + } + } + + /// + /// Unsubscribes from the currently tracked events. + /// Called before subscribing to a new collection and during unload to prevent memory leaks. + /// + private void UnsubscribeTimeSeriesCollection() + { + if (_subscribedTimeSeriesCollection != null) + { + _subscribedTimeSeriesCollection.ElementAdded -= OnTimeSeriesElementAdded; + _subscribedTimeSeriesCollection.ElementRemoved -= OnTimeSeriesElementRemoved; + _subscribedTimeSeriesCollection = null; + } + } + + /// + /// Handles the addition of a time series element to the subscribed collection. + /// + /// The element that was added. + private void OnTimeSeriesElementAdded(IElement element) => TimeSeriesElements.Add((TimeSeriesElement)element); + + /// + /// Handles the removal of a time series element from the subscribed collection. + /// + /// The element that was removed. + private void OnTimeSeriesElementRemoved(IElement element) => TimeSeriesElements.Remove((TimeSeriesElement)element); + + /// + /// Handles the Unloaded event for the control. Unsubscribes from time series collection + /// events to prevent memory leaks when the control is removed from the visual tree. + /// + /// The source of the event. + /// Event args containing routed event data. + private void UserControl_Unloaded(object sender, RoutedEventArgs e) + { + UnsubscribeTimeSeriesCollection(); + } + + /// + /// Handles the selection changed event for the exact data method combo box. + /// Updates UI element visibility based on the selected data entry method. + /// + /// The source of the event. + /// Event args containing selection changed data. + private void ExactDateMethodComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (Element == null) return; + if (Element.ExactDataMethod == InputData.ExactDataEntryType.Manual) + { + USGSSiteNumber.Visibility = Visibility.Collapsed; + TimeSeriesElement.Visibility = Visibility.Collapsed; + BlockFunction.Visibility = Visibility.Collapsed; + TimeBlock.Visibility = Visibility.Collapsed; + SmoothingFunction.Visibility = Visibility.Collapsed; + Period.Visibility = Visibility.Collapsed; + POTThresholdValue.Visibility = Visibility.Collapsed; + MinStepsBetweenPeaks.Visibility = Visibility.Collapsed; + ProcessDataButton.Visibility = Visibility.Collapsed; + ProcessDataButton.ToolTip = "Click to process the time series data."; + ProcessDataButtonText.Text = "Process"; + } + else if (Element.ExactDataMethod == InputData.ExactDataEntryType.BlockSeries) + { + USGSSiteNumber.Visibility = Visibility.Collapsed; + TimeSeriesElement.Visibility = Visibility.Visible; + BlockFunction.Visibility = Visibility.Visible; + TimeBlock.Visibility = Visibility.Visible; + POTThresholdValue.Visibility = Visibility.Collapsed; + MinStepsBetweenPeaks.Visibility = Visibility.Collapsed; + SmoothingFunction.Visibility = Visibility.Visible; + Period.Visibility = Element.SmoothingFunction == SmoothingFunctionType.None ? Visibility.Collapsed : Visibility.Visible; + ProcessDataButton.Visibility = Visibility.Visible; + ProcessDataButton.ToolTip = "Click to process the time series data."; + ProcessDataButtonText.Text = "Process"; + } + else if (Element.ExactDataMethod == InputData.ExactDataEntryType.PeaksOverThresholdSeries) + { + USGSSiteNumber.Visibility = Visibility.Collapsed; + TimeSeriesElement.Visibility = Visibility.Visible; + BlockFunction.Visibility = Visibility.Collapsed; + TimeBlock.Visibility = Visibility.Collapsed; + POTThresholdValue.Visibility = Visibility.Visible; + MinStepsBetweenPeaks.Visibility = Visibility.Visible; + SmoothingFunction.Visibility = Visibility.Visible; + Period.Visibility = Element.SmoothingFunction == SmoothingFunctionType.None ? Visibility.Collapsed : Visibility.Visible; + ProcessDataButton.Visibility = Visibility.Visible; + ProcessDataButton.ToolTip = "Click to process the time series data."; + ProcessDataButtonText.Text = "Process"; + } + else if (Element.ExactDataMethod == InputData.ExactDataEntryType.USGSPeakDischarge || + Element.ExactDataMethod == InputData.ExactDataEntryType.USGSPeakStage) + { + USGSSiteNumber.Visibility = Visibility.Visible; + TimeSeriesElement.Visibility = Visibility.Collapsed; + BlockFunction.Visibility = Visibility.Collapsed; + TimeBlock.Visibility = Visibility.Collapsed; + SmoothingFunction.Visibility = Visibility.Collapsed; + Period.Visibility = Visibility.Collapsed; + POTThresholdValue.Visibility = Visibility.Collapsed; + MinStepsBetweenPeaks.Visibility = Visibility.Collapsed; + ProcessDataButton.Visibility = Visibility.Visible; + ProcessDataButton.ToolTip = "Click to download the time series data."; + ProcessDataButtonText.Text = "Download"; + } + UpdateTimeBlockVisibility(); + + } + + /// + /// Handles the selection changed event for the time block combo box. + /// Updates month selection visibility based on the selected time block window. + /// + /// The source of the event. + /// Event args containing selection changed data. + private void TimeBlockComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (Element == null) return; + UpdateTimeBlockVisibility(); + } + + /// + /// Updates the visibility of StartMonth and EndMonth controls based on the current + /// exact data method and time block window settings. Called from both + /// and + /// to avoid duplicating the visibility logic. + /// + private void UpdateTimeBlockVisibility() + { + if (Element.ExactDataMethod == InputData.ExactDataEntryType.BlockSeries) + { + StartMonth.Visibility = Element.TimeBlock == TimeBlockWindow.CalendarYear ? Visibility.Collapsed : Visibility.Visible; + EndMonth.Visibility = Element.TimeBlock == TimeBlockWindow.CustomYear ? Visibility.Visible : Visibility.Collapsed; + } + else + { + StartMonth.Visibility = Visibility.Collapsed; + EndMonth.Visibility = Visibility.Collapsed; + } + } + + /// + /// Handles the selection changed event for the time series element combo box. + /// Updates border and tooltip based on whether a valid time series is selected. + /// + /// The source of the event. + /// Event args containing selection changed data. + private void TimeSeriesElementComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (Element == null) return; + if (Element.TimeSeriesElement == null || Element.TimeSeriesElement.Name == null) + { + ((Border)TimeSeriesElement.InnerContent).BorderThickness = new Thickness(1); + TimeSeriesElement.ToolTip = "Select a valid time series."; + } + else + { + ((Border)TimeSeriesElement.InnerContent).BorderThickness = new Thickness(0); + TimeSeriesElement.ToolTip = null; + } + } + + /// + /// Handles the selection changed event for the smoothing function combo box. + /// Shows or hides the period field based on whether a smoothing function is selected. + /// + /// The source of the event. + /// Event args containing selection changed data. + private void SmoothingFunction_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (Element == null) return; + if (Element.SmoothingFunction == SmoothingFunctionType.None) + { + Period.Visibility = Visibility.Collapsed; + } + else + { + Period.Visibility = Visibility.Visible; + } + + } + + /// + /// Handles the click event for the process data button. + /// Processes time series data based on the selected exact data entry method. + /// + /// The source of the event. + /// Event args containing routed event data. + private async void ProcessDataButton_Click(object sender, RoutedEventArgs e) + { + if (Element == null || Element.DataFrame == null) return; + if (Element.IsTimeSeriesInputValid() == false) + { + GenericControls.MessageBox.Show("Unable to process time series data due to invalid input.", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + return; + } + + Element.ClearTimeSeriesResults(); + Mouse.OverrideCursor = Cursors.Wait; + try + { + if (Element.ExactDataMethod == InputData.ExactDataEntryType.BlockSeries) + { + Element.CreateBlockSeries(); + } + else if (Element.ExactDataMethod == InputData.ExactDataEntryType.PeaksOverThresholdSeries) + { + Element.CreatePeaksOverThresholdSeries(); + } + else if (Element.ExactDataMethod == InputData.ExactDataEntryType.USGSPeakDischarge || + Element.ExactDataMethod == InputData.ExactDataEntryType.USGSPeakStage) + { + using (var cts = new CancellationTokenSource(ExternalDownloadTimeout)) + { + await Element.CreateFromUSGS(cts.Token); + } + } + } + catch (OperationCanceledException) + { + GenericControls.MessageBox.Show( + $"The download did not complete within {ExternalDownloadTimeout.TotalSeconds:0} seconds. " + + "The USGS service may be blocked or unavailable from this network.", + "Download timed out", + MessageBoxButton.OK, + MessageBoxImage.Error); + return; + } + catch(Exception ex) + { + GenericControls.MessageBox.Show("An error occurred while processing the time series. " + ex.Message, "Error", MessageBoxButton.OK, MessageBoxImage.Error); + return; + } + finally + { + Mouse.OverrideCursor = null; + } + + } + + /// + /// Handles the click event for the run test button. + /// Executes the Multiple Grubbs-Beck Test or threshold-based test to identify low outliers. + /// + /// The source of the event. + /// Event args containing routed event data. + private void RunTestButton_Click(object sender, RoutedEventArgs e) + { + if (Element == null || Element.DataFrame == null || Element.DataFrame.ExactSeries == null) + return; + + if (!Element.IsValid) + { + GenericControls.MessageBox.Show("Unable to perform the low outlier test because the input data is invalid.", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + return; + } + + Mouse.OverrideCursor = Cursors.Wait; + try + { + if (MGBTButton.IsSelected) + { + Element.DataFrame.SetLowOutliersFromMGBT(); + GenericControls.MessageBox.Show($"The Multiple Grubbs-Beck Test identified {Element.DataFrame.NumberOfLowOutliers} low outlier(s).", + "Low Outlier Test Complete", MessageBoxButton.OK, MessageBoxImage.Exclamation); + } + else + { + Element.DataFrame.SetLowOutliersFromThreshold(); + GenericControls.MessageBox.Show($"The user-defined threshold identified {Element.DataFrame.NumberOfLowOutliers} low outlier(s).", + "Low Outlier Test Complete", MessageBoxButton.OK, MessageBoxImage.Exclamation); + } + } + finally + { + Mouse.OverrideCursor = null; + } + + } + + /// + /// Handles the loaded event for the combo box. + /// Configures the item source with sorted time series elements. + /// + /// The source of the event. + /// Event args containing routed event data. + private void ComboBox_Loaded(object sender, RoutedEventArgs e) + { + if (Element == null) return; + ComboBox cmbo = (ComboBox)sender; + CollectionViewSource csv = new CollectionViewSource() { Source = TimeSeriesElements, IsLiveSortingRequested = true }; + csv.SortDescriptions.Add(new SortDescription(nameof(IElement.Name), ListSortDirection.Ascending)); + var view = csv.View; + view.MoveCurrentToPosition(-1); + cmbo.ItemsSource = view; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/MainProjectNode.cs b/src/RMC.BestFit.App/GUI/MainProjectNode.cs new file mode 100644 index 0000000..1e7c554 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/MainProjectNode.cs @@ -0,0 +1,1568 @@ +using OxyPlot.Wpf; +using OxyPlotControls; +using GenericControls; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Documents; +using System.Windows.Navigation; +using System.Threading.Tasks; +using RMC.BestFit.Models; +using RMC.BestFit.UI; +using FrameworkInterfaces; +using FrameworkUI; +using FrameworkUI.ProjectExplorer; +using System.Collections.ObjectModel; + +namespace RMC_BestFit +{ + /// + /// Main project node controller for the RMC-BestFit WPF application. Manages the project tree structure, + /// document controls, menu items, and user interface interactions for all analysis types including + /// time series, input data, distribution fitting, univariate, bivariate, rating curve, and time series analyses. + /// + /// + /// This class extends and serves as the central coordinator for: + /// + /// Project explorer context menu definitions + /// Main window project menu definitions + /// AvalonDock document and properties control management + /// OxyPlot properties panel coordination + /// Creation wizards for all analysis and data element types + /// + /// + public class MainProjectNode : FrameworkUIController + { + /// + /// Initializes a new instance of the class with the specified project. + /// Configures the plot properties control and registers event handlers for property panel interactions. + /// + /// The project instance to associate with this node controller. + public MainProjectNode(IProject project) : base(project) + { + _plotPropertiesControl = new OxyPlotPropertiesControl() { Margin = new Thickness(0, 0, 5, 5) }; + _plotPropertiesControl.ClosePropertiesCalled += (x) => { ClosePlotProperties_Click(x.Plot); }; + } + + /// + /// Indicates whether the plot properties control panel is currently open and visible to the user. + /// + private bool _plotPropertiesOpen; + + /// + /// Flag indicating that the plot properties control is in the process of closing. + /// Used to prevent recursive closure operations and ensure proper cleanup. + /// + private bool _plotPropertiesClosing = false; + + /// + /// The OxyPlot properties control instance used to display and edit plot configuration settings. + /// This control is shared across all plot types in the application. + /// + private OxyPlotPropertiesControl _plotPropertiesControl; + + /// Gets a value indicating that the project node does not support multi-select. + public override bool CanMultiSelect => false; + + #region Menu Items + + /// + /// Defines and configures context menu items for the project explorer tree view. Creates custom menu items + /// for each element collection type including time series, input data, and various analysis types. + /// + /// + /// This method iterates through all child nodes in the project tree and adds appropriate context menu items + /// based on the element collection type. Menu items include creation wizards for new elements and batch + /// run operations for analysis collections. The method disables the default "Create New" context item and + /// replaces it with custom, type-specific menu items. + /// + protected override void DefineProjectExplorerMenuItems() + { + for (int i = 0; i < ChildNodes.Count; i++) + { + ElementNodeCollection elementNodeCollection = null; + if (ChildNodes[i] as ElementNodeCollection != null) + elementNodeCollection = (ElementNodeCollection)ChildNodes[i]; + + if (elementNodeCollection == null) continue; + + // Time Series Data + if (elementNodeCollection.ElementCollection.GetType() == typeof(TimeSeriesCollection)) + { + MenuItem menuItem = new MenuItem() { Header = "New Time Series...", Icon = CreateThemedIcon("TimeSeriesDataIcon") }; + menuItem.Click += (object sender, RoutedEventArgs e) => CreateNewTimeSeriesElement_Click(elementNodeCollection.ElementCollection); + elementNodeCollection.CustomContextItems.Add(menuItem); + } + + // Input Data + if (elementNodeCollection.ElementCollection.GetType() == typeof(InputDataCollection)) + { + MenuItem menuItem = new MenuItem() { Header = "New Input Data...", Icon = CreateThemedIcon("InputDataIcon") }; + menuItem.Click += (object sender, RoutedEventArgs e) => CreateNewInputData_Click(elementNodeCollection.ElementCollection); + elementNodeCollection.CustomContextItems.Add(menuItem); + } + + // Distribution Fitting Analysis + if (elementNodeCollection.ElementCollection.GetType() == typeof(FittingAnalysisCollection)) + { + MenuItem menuItem = new MenuItem() { Header = "New Distribution Fitting Analysis...", Icon = CreateThemedIcon("FittingAnalysisIcon") }; + menuItem.Click += (object sender, RoutedEventArgs e) => CreateNewFittingAnalysis_Click(elementNodeCollection.ElementCollection); + elementNodeCollection.CustomContextItems.Add(menuItem); + } + + // Univariate Distribution Analysis + if (elementNodeCollection.ElementCollection.GetType() == typeof(UnivariateAnalysisCollection)) + { + MenuItem udaMI = new MenuItem() { Header = "New Univariate Distribution Analysis...", Icon = CreateThemedIcon("UnivariateAnalysisIcon") }; + udaMI.Click += (object sender, RoutedEventArgs e) => CreateNewUnivariateAnalysis_Click(elementNodeCollection.ElementCollection); + elementNodeCollection.CustomContextItems.Add(udaMI); + + MenuItem b17MI = new MenuItem() { Header = "New Bulletin 17C Analysis...", Icon = CreateThemedIcon("B17AnalysisIcon") }; + b17MI.Click += (object sender, RoutedEventArgs e) => CreateNewB17CAnalysis_Click(elementNodeCollection.ElementCollection); + elementNodeCollection.CustomContextItems.Add(b17MI); + + MenuItem ppaMI = new MenuItem() { Header = "New Point Process Analysis...", Icon = CreateThemedIcon("PointProcessAnalysisIcon") }; + ppaMI.Click += (object sender, RoutedEventArgs e) => CreateNewPointProcessAnalysis_Click(elementNodeCollection.ElementCollection); + elementNodeCollection.CustomContextItems.Add(ppaMI); + + MenuItem mdaMI = new MenuItem() { Header = "New Mixture Distribution Analysis...", Icon = CreateThemedIcon("MixtureAnalysisIcon") }; + mdaMI.Click += (object sender, RoutedEventArgs e) => CreateNewMixtureAnalysis_Click(elementNodeCollection.ElementCollection); + elementNodeCollection.CustomContextItems.Add(mdaMI); + + MenuItem cudaMI = new MenuItem() { Header = "New Composite Distribution Analysis...", Icon = CreateThemedIcon("CompositeAnalysisIcon") }; + cudaMI.Click += (object sender, RoutedEventArgs e) => CreateNewCompositeAnalysis_Click(elementNodeCollection.ElementCollection); + elementNodeCollection.CustomContextItems.Add(cudaMI); + + // Batch Run + MenuItem batchMI = new MenuItem() { Header = "Batch Run...", Icon = CreateThemedIcon("BatchRunIcon") }; + batchMI.Click += (object sender, RoutedEventArgs e) => + { + var batchWindow = new BatchRunWindow(); + // Create list of valid analyses. + // Composite analyses are included if they are batch-eligible (properly + // configured but component analyses not yet estimated) since the batch + // runner will estimate their dependencies first. + List list = new List(); + foreach (var analysis in elementNodeCollection.ElementCollection) + { + if (analysis.IsValid) + list.Add(analysis); + else if (analysis is CompositeAnalysis ca && ca.IsBatchEligible) + list.Add(analysis); + } + batchWindow.Analyses = new ObservableCollection(list); + batchWindow.Owner = Window.GetWindow(this); + batchWindow.Show(); + }; + elementNodeCollection.CustomContextItems.Add(batchMI); + + } + + // Bivariate Distribution Analysis + if (elementNodeCollection.ElementCollection.GetType() == typeof(BivariateAnalysisCollection)) + { + MenuItem menuItem = new MenuItem() { Header = "New Bivariate Distribution Analysis...", Icon = CreateThemedIcon("BivariateAnalysisIcon") }; + menuItem.Click += (object sender, RoutedEventArgs e) => CreateNewBivariateAnalysis_Click(elementNodeCollection.ElementCollection); + elementNodeCollection.CustomContextItems.Add(menuItem); + + MenuItem cfaMI = new MenuItem() { Header = "New Coincident Frequency Analysis...", Icon = CreateThemedIcon("CoincidentFrequencyAnalysisIcon") }; + cfaMI.Click += (object sender, RoutedEventArgs e) => CreateNewCoincidentFrequencyAnalysis_Click(elementNodeCollection.ElementCollection); + elementNodeCollection.CustomContextItems.Add(cfaMI); + + // Batch Run + MenuItem batchMI = new MenuItem() { Header = "Batch Run...", Icon = CreateThemedIcon("BatchRunIcon") }; + batchMI.Click += (object sender, RoutedEventArgs e) => + { + var batchWindow = new BatchRunWindow(); + // Create list of valid analyses. CoincidentFrequencyAnalysis is included + // when batch-eligible (configured but upstream BA not yet estimated) since + // the model-layer batch runner orders CFAs into Phase 3 the un-estimated + // upstream BivariateAnalysis in the same collection is fitted in Phase 1 + // first. Mirrors the univariate handler above (Composite + components in + // UnivariateAnalysisCollection); the iteration walks every sibling element + // in the collection so dependents are loaded automatically via the IsValid + // branch no separate dependent-discovery pass is needed. + List list = new List(); + foreach (var analysis in elementNodeCollection.ElementCollection) + { + if (analysis.IsValid == true) + list.Add(analysis); + else if (analysis is CoincidentFrequencyAnalysis cfa && cfa.IsBatchEligible) + list.Add(analysis); + } + batchWindow.Analyses = new ObservableCollection(list); + batchWindow.Owner = Window.GetWindow(this); + batchWindow.Show(); + }; + elementNodeCollection.CustomContextItems.Add(batchMI); + } + + // Rating Curve Analysis + if (elementNodeCollection.ElementCollection.GetType() == typeof(RatingCurveAnalysisCollection)) + { + MenuItem menuItem = new MenuItem() { Header = "New Rating Curve Analysis...", Icon = CreateThemedIcon("RatingCurveAnalysisIcon") }; + menuItem.Click += (object sender, RoutedEventArgs e) => CreateNewRatingCurveAnalysis_Click(elementNodeCollection.ElementCollection); + elementNodeCollection.CustomContextItems.Add(menuItem); + + // Batch Run + MenuItem batchMI = new MenuItem() { Header = "Batch Run...", Icon = CreateThemedIcon("BatchRunIcon") }; + batchMI.Click += (object sender, RoutedEventArgs e) => + { + var batchWindow = new BatchRunWindow(); + // Create list of valid analyses + List list = new List(); + foreach (var analysis in elementNodeCollection.ElementCollection) + { + if (analysis.IsValid == true) + list.Add(analysis); + } + batchWindow.Analyses = new ObservableCollection(list); + batchWindow.Owner = Window.GetWindow(this); + batchWindow.Show(); + }; + elementNodeCollection.CustomContextItems.Add(batchMI); + } + + // Time Series Analysis + if (elementNodeCollection.ElementCollection.GetType() == typeof(TimeSeriesAnalysisCollection)) + { + MenuItem menuItem = new MenuItem() { Header = "New Time Series Analysis...", Icon = CreateThemedIcon("TimeSeriesAnalysisIcon") }; + menuItem.Click += (object sender, RoutedEventArgs e) => CreateNewTimeSeriesAnalysis_Click(elementNodeCollection.ElementCollection); + elementNodeCollection.CustomContextItems.Add(menuItem); + + // Batch Run + MenuItem batchMI = new MenuItem() { Header = "Batch Run...", Icon = CreateThemedIcon("BatchRunIcon") }; + batchMI.Click += (object sender, RoutedEventArgs e) => + { + var batchWindow = new BatchRunWindow(); + // Create list of valid analyses + List list = new List(); + foreach (var analysis in elementNodeCollection.ElementCollection) + { + if (analysis.IsValid == true) + list.Add(analysis); + } + batchWindow.Analyses = new ObservableCollection(list); + batchWindow.Owner = Window.GetWindow(this); + batchWindow.Show(); + }; + elementNodeCollection.CustomContextItems.Add(batchMI); + } + + } + } + + /// + /// Defines and configures menu items for the main window's Project menu. Creates menu entries for + /// adding new data elements and analyses, organized by type and functionality. + /// + /// + /// This method builds the Project menu by iterating through all element collections in the project + /// and creating appropriate menu items for each type. For univariate analyses, multiple sub-menu + /// items are grouped under a single parent menu item. All menu items are added to the + /// _projectMenuItems collection for display in the main window. + /// + protected override void DefineProjectMenuItems() + { + for (int i = 0; i < Project.ElementCollections.Count; i++) + { + var collection = Project.ElementCollections[i]; + + // Time Series Data + if (collection.GetType() == typeof(TimeSeriesCollection)) + { + MenuItem menuItem = new MenuItem() { Header = "New Time Series...", Icon = CreateThemedIcon("TimeSeriesDataIcon") }; + menuItem.Click += (object sender, RoutedEventArgs e) => CreateNewTimeSeriesElement_Click(collection); + _projectMenuItems.Add(menuItem); + } + + // Input Data + if (collection.GetType() == typeof(InputDataCollection)) + { + MenuItem menuItem = new MenuItem() { Header = "New Input Data...", Icon = CreateThemedIcon("InputDataIcon") }; + menuItem.Click += (object sender, RoutedEventArgs e) => CreateNewInputData_Click(collection); + _projectMenuItems.Add(menuItem); + } + + // Distribution Fitting Analysis + if (collection.GetType() == typeof(FittingAnalysisCollection)) + { + MenuItem menuItem = new MenuItem() { Header = "New Distribution Fitting Analysis...", Icon = CreateThemedIcon("FittingAnalysisIcon") }; + menuItem.Click += (object sender, RoutedEventArgs e) => CreateNewFittingAnalysis_Click(collection); + _projectMenuItems.Add(menuItem); + } + + // Univariate Distribution Analysis + if (collection.GetType() == typeof(UnivariateAnalysisCollection)) + { + MenuItem udaMI = new MenuItem() { Header = "New Univariate Distribution Analysis...", Icon = CreateThemedIcon("UnivariateAnalysisIcon") }; + udaMI.Click += (object sender, RoutedEventArgs e) => CreateNewUnivariateAnalysis_Click(collection); + + MenuItem b17MI = new MenuItem() { Header = "New Bulletin 17C Analysis...", Icon = CreateThemedIcon("B17AnalysisIcon") }; + b17MI.Click += (object sender, RoutedEventArgs e) => CreateNewB17CAnalysis_Click(collection); + + MenuItem ppaMI = new MenuItem() { Header = "New Point Process Analysis...", Icon = CreateThemedIcon("PointProcessAnalysisIcon") }; + ppaMI.Click += (object sender, RoutedEventArgs e) => CreateNewPointProcessAnalysis_Click(collection); + + MenuItem mdaMI = new MenuItem() { Header = "New Mixture Distribution Analysis...", Icon = CreateThemedIcon("MixtureAnalysisIcon") }; + mdaMI.Click += (object sender, RoutedEventArgs e) => CreateNewMixtureAnalysis_Click(collection); + + MenuItem cudaMI = new MenuItem() { Header = "New Composite Distribution Analysis...", Icon = CreateThemedIcon("CompositeAnalysisIcon") }; + cudaMI.Click += (object sender, RoutedEventArgs e) => CreateNewCompositeAnalysis_Click(collection); + + MenuItem menuItem = new MenuItem() { Header = "Univariate Analyses...", Icon = CreateThemedIcon("UnivariateAnalysisIcon") }; + menuItem.Items.Add(udaMI); + menuItem.Items.Add(b17MI); + menuItem.Items.Add(ppaMI); + menuItem.Items.Add(mdaMI); + menuItem.Items.Add(cudaMI); + _projectMenuItems.Add(menuItem); + } + + // Bivariate Distribution Analysis + if (collection.GetType() == typeof(BivariateAnalysisCollection)) + { + MenuItem menuItem = new MenuItem() { Header = "New Bivariate Distribution Analysis...", Icon = CreateThemedIcon("BivariateAnalysisIcon") }; + menuItem.Click += (object sender, RoutedEventArgs e) => CreateNewBivariateAnalysis_Click(collection); + _projectMenuItems.Add(menuItem); + + MenuItem cfaMI = new MenuItem() { Header = "New Coincident Frequency Analysis...", Icon = CreateThemedIcon("CoincidentFrequencyAnalysisIcon") }; + cfaMI.Click += (object sender, RoutedEventArgs e) => CreateNewCoincidentFrequencyAnalysis_Click(collection); + _projectMenuItems.Add(cfaMI); + } + + // Rating Curve Analysis + if (collection.GetType() == typeof(RatingCurveAnalysisCollection)) + { + MenuItem menuItem = new MenuItem() { Header = "New Rating Curve Analysis...", Icon = CreateThemedIcon("RatingCurveAnalysisIcon") }; + menuItem.Click += (object sender, RoutedEventArgs e) => CreateNewRatingCurveAnalysis_Click(collection); + _projectMenuItems.Add(menuItem); + } + + // Time Series Analysis + if (collection.GetType() == typeof(TimeSeriesAnalysisCollection)) + { + MenuItem menuItem = new MenuItem() { Header = "New Time Series Analysis...", Icon = CreateThemedIcon("TimeSeriesAnalysisIcon") }; + menuItem.Click += (object sender, RoutedEventArgs e) => CreateNewTimeSeriesAnalysis_Click(collection); + _projectMenuItems.Add(menuItem); + } + + } + + } + + /// + /// Defines custom tool menu items for the main window's Tools menu. + /// Currently not implemented and returns immediately without adding any menu items. + /// + /// + /// This method is provided as an override point for future extensibility. Custom tools + /// and utilities can be added to the Tools menu by implementing this method. + /// + protected override void DefineToolsMenuItems() + { + return; + } + + /// + /// Defines and configures menu items for the main window's Help menu. Adds menu entries for + /// accessing online documentation, example projects, Terms and Conditions, and the About dialog. + /// + /// + /// This method creates five help menu items: + /// + /// User Guide - Opens the online RMC-BestFit user guide in the default browser + /// Technical Reference - Opens the main-branch technical reference on GitHub + /// Example Projects - Opens the main-branch example project collection on GitHub + /// Terms and Conditions for Use - Displays the software license and usage terms dialog + /// About RMC-BestFit - Shows version information and software credits + /// + /// A separator groups the three online resources above the application-specific items. + /// All menu items are configured with appropriate icons and event handlers. + /// + protected override void DefineHelpMenuItems() + { + _helpMenuItems.Add(CreateOnlineHelpMenuItem("User Guide", OnlineHelpLauncher.UserGuideUrl, showHelpIcon: true)); + _helpMenuItems.Add(CreateOnlineHelpMenuItem("Technical Reference", OnlineHelpLauncher.TechnicalReferenceUrl)); + _helpMenuItems.Add(CreateOnlineHelpMenuItem("Example Projects", OnlineHelpLauncher.ExampleProjectsUrl)); + _helpMenuItems.Add(CreateHelpMenuSeparator()); + + // Terms & Conditions for Use + var tcuMenuItem = new MenuItem() { Header = "Terms & Conditions for Use", Icon = TryFindResource("TCUIcon") }; + tcuMenuItem.Click += (s, e) => + { + var window = new TermsAndConditionsWindow + { + ShowButtons = false, + TermsDocument = TermsAndConditionsDocumentFactory.Create(CitationLink_RequestNavigate) + }; + EnableTermsDocumentLinks(window); + if (Application.Current?.MainWindow != null) + { + window.Owner = Application.Current.MainWindow; + window.WindowStartupLocation = WindowStartupLocation.CenterOwner; + } + window.ShowDialog(); + }; + _helpMenuItems.Add(tcuMenuItem); + + // About + var aboutMenuItem = new MenuItem() { Header = "About " + ApplicationAttributes.Title, Icon = TryFindResource("AboutIcon") }; + aboutMenuItem.Click += (s, e) => + { + var window = new AboutWindow(); + window.SoftwareImage = Application.Current.TryFindResource("BestFit") as ImageSource; + if (Application.Current?.MainWindow != null) + { + window.Owner = Application.Current.MainWindow; + window.WindowStartupLocation = WindowStartupLocation.CenterOwner; + } + window.ShowDialog(); + }; + _helpMenuItems.Add(aboutMenuItem); + + } + + /// + /// Creates a Help menu item that opens an online RMC-BestFit resource in the default browser. + /// + /// User-visible menu item header and resource name. + /// Absolute HTTPS URL of the resource. + /// Whether to display the Help icon next to the menu item. + /// A configured Help menu item. + /// + /// Thrown when or is empty. + /// + /// + /// The item is disabled while connectivity is checked to prevent duplicate launches. Expected + /// offline results and unexpected launch failures are reported with distinct user messages. + /// + private MenuItem CreateOnlineHelpMenuItem(string header, string url, bool showHelpIcon = false) + { + if (string.IsNullOrWhiteSpace(header)) + { + throw new ArgumentException("A Help menu header is required.", nameof(header)); + } + if (string.IsNullOrWhiteSpace(url)) + { + throw new ArgumentException("A Help resource URL is required.", nameof(url)); + } + + var menuItem = new MenuItem() { Header = header }; + if (showHelpIcon) + { + menuItem.Icon = CreateThemedIcon("HelpImage"); + } + + menuItem.Click += async (x, y) => + { + menuItem.IsEnabled = false; + try + { + await OpenOnlineResourceAsync(header, url); + } + finally + { + menuItem.IsEnabled = true; + } + }; + + return menuItem; + } + + /// + /// Opens an online RMC-BestFit resource and reports expected or unexpected failures to the user. + /// + /// User-visible resource name used in diagnostic and error messages. + /// Absolute HTTPS URL to open. + /// A task representing the asynchronous connectivity check and browser launch. + /// + /// Expected offline results receive a connection-specific message. Unexpected connectivity or browser + /// failures are logged and receive a resource-specific message without escaping an asynchronous UI handler. + /// + private static async Task OpenOnlineResourceAsync(string resourceName, string url) + { + try + { + OnlineHelpLaunchResult result = await OnlineHelpLauncher.TryOpenAsync(url); + if (result == OnlineHelpLaunchResult.NoInternet) + { + GenericControls.MessageBox.Show( + "No internet connection is available. Please check your connection and try again.", + "Connection Error", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine("Failed to open " + resourceName + ": " + ex); + GenericControls.MessageBox.Show( + "Something went wrong. Cannot open the RMC-BestFit " + resourceName + ".", + "Error", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + } + + /// + /// Opens the Zenodo citation record when its hyperlink is activated in the Terms and Conditions document. + /// + /// The citation hyperlink that raised the navigation request. + /// Navigation event data. + /// + /// The hyperlink is disabled during the connectivity check to prevent duplicate browser launches and is + /// always restored afterward. Navigation uses the canonical concept DOI rather than a version-specific URL. + /// + private async void CitationLink_RequestNavigate(object sender, RequestNavigateEventArgs e) + { + e.Handled = true; + Hyperlink hyperlink = sender as Hyperlink; + if (hyperlink != null) hyperlink.IsEnabled = false; + + try + { + await OpenOnlineResourceAsync("citation information", OnlineHelpLauncher.ZenodoConceptDoiUrl); + } + finally + { + if (hyperlink != null) hyperlink.IsEnabled = true; + } + } + + /// + /// Enables interaction with hyperlinks in the read-only Terms and Conditions document viewer. + /// + /// Terms and Conditions window whose document viewer should allow link interaction. + /// + /// Thrown when is null. + /// + /// + /// WPF keeps content elements such as non-interactive unless the hosting + /// property is enabled. The document remains read-only. + /// + private static void EnableTermsDocumentLinks(TermsAndConditionsWindow window) + { + if (window == null) throw new ArgumentNullException(nameof(window)); + + if (window.FindName("TCURichTextBox") is RichTextBox termsTextBox) + { + termsTextBox.IsDocumentEnabled = true; + } + else + { + System.Diagnostics.Debug.WriteLine("Could not enable Terms and Conditions document hyperlinks because TCURichTextBox was not found."); + } + } + + /// + /// Creates a non-interactive, theme-aware separator compatible with the framework's Help menu collection. + /// + /// A menu item whose control template renders a horizontal separator. + /// + /// The shared framework exposes Help entries as instances, so a native + /// cannot be added to the collection. This template preserves that contract + /// while using the framework's dynamic separator brush and icon-gutter alignment. + /// + private static MenuItem CreateHelpMenuSeparator() + { + var separatorLine = new FrameworkElementFactory(typeof(Border)); + separatorLine.SetValue(FrameworkElement.HeightProperty, 1d); + separatorLine.SetValue(FrameworkElement.MarginProperty, new Thickness(28d, 4d, 4d, 4d)); + separatorLine.SetValue(UIElement.SnapsToDevicePixelsProperty, true); + separatorLine.SetResourceReference(Border.BackgroundProperty, "MenuPopupDefaultSeparator"); + + return new MenuItem + { + Focusable = false, + IsEnabled = false, + IsHitTestVisible = false, + Template = new ControlTemplate(typeof(MenuItem)) + { + VisualTree = separatorLine + } + }; + } + + /// + /// Handles the creation of a new time series data element. Displays a naming dialog and adds the element + /// to the collection if a valid name is provided. + /// + /// The element collection to which the new time series element will be added. + /// + /// The method generates a default name based on the current collection count and prompts the user + /// to provide a unique name. If the user cancels or provides an empty name, no element is created. + /// + private void CreateNewTimeSeriesElement_Click(IElementCollection collection) + { + string newName = CreateNewNameDialog("New Time Series", $"Time Series_{collection.Count + 1}", collection.Select(x => x.Name.ToString()).ToList()); + if (newName != "") collection.Add(new TimeSeriesElement(newName, collection)); + } + + /// + /// Handles the creation of a new input data element. Displays a naming dialog and adds the element + /// to the collection if a valid name is provided. + /// + /// The element collection to which the new input data element will be added. + /// + /// The method generates a default name based on the current collection count and prompts the user + /// to provide a unique name. If the user cancels or provides an empty name, no element is created. + /// + private void CreateNewInputData_Click(IElementCollection collection) + { + string newName = CreateNewNameDialog("New Input Data", $"Input Data_{collection.Count + 1}", collection.Select(x => x.Name.ToString()).ToList()); + if (newName != "") collection.Add(new InputData(newName, collection)); + } + + /// + /// Handles the creation of a new distribution fitting analysis. Displays a naming dialog and adds the + /// analysis to the collection if a valid name is provided. + /// + /// The element collection to which the new fitting analysis will be added. + /// + /// Distribution fitting analyses are used to fit statistical distributions to empirical data and evaluate + /// goodness-of-fit. The method generates a default name and prompts for user confirmation. + /// + private void CreateNewFittingAnalysis_Click(IElementCollection collection) + { + string newName = CreateNewNameDialog("New Distribution Fitting Analysis", $"Fitting Analysis_{collection.Count + 1}", collection.Select(x => x.Name.ToString()).ToList()); + if (newName != "") collection.Add(new FittingAnalysis(newName, collection)); + } + + /// + /// Handles the creation of a new univariate distribution analysis. Displays a naming dialog and adds the + /// analysis to the collection if a valid name is provided. + /// + /// The element collection to which the new univariate analysis will be added. + /// + /// Univariate analyses use Bayesian methods to estimate single-variable probability distributions. + /// The method generates a default name based on the collection count and validates uniqueness. + /// + private void CreateNewUnivariateAnalysis_Click(IElementCollection collection) + { + string newName = CreateNewNameDialog("New Univariate Distribution Analysis", $"Univariate Analysis_{collection.Count + 1}", collection.Select(x => x.Name.ToString()).ToList()); + if (newName != "") collection.Add(new UnivariateAnalysis(newName, collection)); + } + + /// + /// Handles the creation of a new point process analysis. Displays a naming dialog and adds the + /// analysis to the collection if a valid name is provided. + /// + /// The element collection to which the new point process analysis will be added. + /// + /// Point process analyses model events occurring in time or space using statistical methods appropriate + /// for count data and arrival processes. A default name is generated based on the collection count. + /// + private void CreateNewPointProcessAnalysis_Click(IElementCollection collection) + { + string newName = CreateNewNameDialog("New Point Process Analysis", $"Point Process Analysis_{collection.Count + 1}", collection.Select(x => x.Name.ToString()).ToList()); + if (newName != "") collection.Add(new PointProcessAnalysis(newName, collection)); + } + + /// + /// Handles the creation of a new mixture distribution analysis. Displays a naming dialog and adds the + /// analysis to the collection if a valid name is provided. + /// + /// The element collection to which the new mixture analysis will be added. + /// + /// Mixture distribution analyses combine multiple probability distributions to model complex data patterns + /// with multiple modes or populations. The method prompts the user for a unique name. + /// + private void CreateNewMixtureAnalysis_Click(IElementCollection collection) + { + string newName = CreateNewNameDialog("New Mixture Distribution Analysis", $"Mixture Analysis_{collection.Count + 1}", collection.Select(x => x.Name.ToString()).ToList()); + if (newName != "") collection.Add(new MixtureAnalysis(newName, collection)); + } + + /// + /// Handles the creation of a new Bulletin 17C flood frequency analysis. Displays a naming dialog and adds the + /// analysis to the collection if a valid name is provided. + /// + /// The element collection to which the new B17C analysis will be added. + /// + /// Bulletin 17C analyses use the Generalized Method of Moments (GMM) for flood frequency estimation + /// following USGS/FEMA guidelines, with configurable uncertainty quantification methods. + /// + private void CreateNewB17CAnalysis_Click(IElementCollection collection) + { + string newName = CreateNewNameDialog("New Bulletin 17C Analysis", $"B17C Analysis_{collection.Count + 1}", collection.Select(x => x.Name.ToString()).ToList()); + if (newName != "") collection.Add(new B17CAnalysis(newName, collection)); + } + + /// + /// Handles the creation of a new composite distribution analysis. Displays a naming dialog and adds the + /// analysis to the collection if a valid name is provided. + /// + /// The element collection to which the new composite analysis will be added. + /// + /// Composite distribution analyses combine multiple independent distributions or analyses into a single + /// unified probability distribution, useful for integrating results from different data sources or methods. + /// + private void CreateNewCompositeAnalysis_Click(IElementCollection collection) + { + string newName = CreateNewNameDialog("New Composite Distribution Analysis", $"Composite Analysis_{collection.Count + 1}", collection.Select(x => x.Name.ToString()).ToList()); + if (newName != "") collection.Add(new CompositeAnalysis(newName, collection)); + } + + /// + /// Handles the creation of a new bivariate distribution analysis. Displays a naming dialog and adds the + /// analysis to the collection if a valid name is provided. + /// + /// The element collection to which the new bivariate analysis will be added. + /// + /// Bivariate analyses model the joint probability distribution of two correlated variables using + /// copula functions and marginal distributions. The method ensures name uniqueness across the collection. + /// + private void CreateNewBivariateAnalysis_Click(IElementCollection collection) + { + string newName = CreateNewNameDialog("New Bivariate Distribution Analysis", $"Bivariate Analysis_{collection.Count + 1}", collection.Select(x => x.Name.ToString()).ToList()); + if (newName != "") collection.Add(new BivariateAnalysis(newName, collection)); + } + + /// + /// Handles the creation of a new coincident frequency analysis. Displays a naming + /// dialog and adds the analysis to the parent BivariateAnalysisCollection. + /// + /// The element collection to add the new analysis to. + /// + /// A coincident frequency analysis computes joint exceedance probabilities for two + /// correlated random variables (e.g., river inflow and reservoir pool elevation) and + /// depends on an upstream element to supply the fitted + /// bivariate distribution. The method validates name uniqueness within the collection. + /// + private void CreateNewCoincidentFrequencyAnalysis_Click(IElementCollection collection) + { + string newName = CreateNewNameDialog("New Coincident Frequency Analysis", $"Coincident Frequency_{collection.Count + 1}", collection.Select(x => x.Name.ToString()).ToList()); + if (newName != "") collection.Add(new CoincidentFrequencyAnalysis(newName, collection)); + } + + /// + /// Handles the creation of a new rating curve analysis. Displays a naming dialog and adds the + /// analysis to the collection if a valid name is provided. + /// + /// The element collection to which the new rating curve analysis will be added. + /// + /// Rating curve analyses establish relationships between stage (water level) and discharge (flow rate) + /// using various regression and uncertainty quantification methods. A unique name is required. + /// + private void CreateNewRatingCurveAnalysis_Click(IElementCollection collection) + { + string newName = CreateNewNameDialog("New Rating Curve Analysis", $"Rating Curve Analysis_{collection.Count + 1}", collection.Select(x => x.Name.ToString()).ToList()); + if (newName != "") collection.Add(new RatingCurveAnalysis(newName, collection)); + } + + /// + /// Handles the creation of a new time series analysis. Displays a naming dialog and adds the + /// analysis to the collection if a valid name is provided. + /// + /// The element collection to which the new time series analysis will be added. + /// + /// Time series analyses model temporal data patterns including trends, seasonality, and autocorrelation + /// using various statistical and machine learning methods. The method validates name uniqueness. + /// + private void CreateNewTimeSeriesAnalysis_Click(IElementCollection collection) + { + string newName = CreateNewNameDialog("New Time Series Analysis", $"Time Series Analysis_{collection.Count + 1}", collection.Select(x => x.Name.ToString()).ToList()); + if (newName != "") collection.Add(new TimeSeriesAnalysis(newName, collection)); + } + + /// + /// Creates and displays a dialog for entering a new element name with validation to ensure uniqueness + /// and compliance with naming rules. Returns the entered name if accepted, or an empty string if cancelled. + /// + /// The title text to display in the dialog window header. + /// The default name to pre-populate in the dialog's text box. + /// The list of existing element names used for uniqueness validation. The new name must not match any existing names. + /// + /// The validated element name entered by the user if the dialog is accepted; otherwise, an empty string + /// if the dialog is cancelled or closed without confirmation. + /// + /// + /// The dialog enforces standard naming constraints including character limits and invalid character + /// restrictions. The maximum name length is 50 characters, and default invalid characters are excluded. + /// + private string CreateNewNameDialog(string title, string initialName, List existingElementNames) + { + var nameDialog = new GenericControls.NameDialog(50, "", false, existingElementNames.ToArray(), NameTextBox.GetDefaultInvalidCharacters()) + { + Icon = Application.Current.TryFindResource("AddImage") as ImageSource, + Title = title, + Owner = Window.GetWindow(this), + Text = initialName + }; + + if (nameDialog.ShowDialog() == true) + return nameDialog.Text; + else + return ""; + } + + /// + /// Creates an element whose is bound to the + /// specified resource key via , + /// so the icon updates automatically when the theme changes. + /// + /// The resource key for the icon (e.g., "InputDataIcon"). + /// An with a dynamic resource binding for its source. + private static Image CreateThemedIcon(string resourceKey) + { + var img = new Image(); + img.SetResourceReference(Image.SourceProperty, resourceKey); + return img; + } + + #endregion + + #region AvalonDock + + /// + /// Creates and returns the appropriate document control for displaying and editing the specified element. + /// Matches the element type to its corresponding control and configures event handlers for plot properties + /// and preview interactions. + /// + /// The project element to create a document control for (e.g., time series, input data, analysis). + /// + /// A instance configured for the specified element type, or null if the element type + /// is not recognized or does not have a corresponding document control. + /// + /// + /// This method supports all element types in the RMC-BestFit application including: + /// + /// Time Series elements + /// Input Data elements + /// Fitting Analysis elements + /// Univariate, Point Process, Mixture, B17C, and Composite analyses + /// Bivariate Analysis elements + /// Rating Curve Analysis elements + /// Time Series Analysis elements + /// + /// Each control is configured with appropriate event handlers for plot property editing and user interactions. + /// + public override Control GetDocumentControl(IElement element) + { + // Time Series + if (element.ParentCollection as TimeSeriesCollection != null) + { + if (element as TimeSeriesElement != null) + { + var cntrl = new TimeSeriesControl() { Element = (TimeSeriesElement)element }; + cntrl.PlotPropertiesCalled += PlotPropertiesCalled; + cntrl.PreviewControlClicked += DocumentControl_PreviewClicked; + return cntrl; + } + } + + // Input Data + if (element.ParentCollection as InputDataCollection != null) + { + if (element as InputData != null) + { + var cntrl = new InputDataControl() { Element = (InputData)element }; + cntrl.PlotPropertiesCalled += PlotPropertiesCalled; + cntrl.PreviewControlClicked += DocumentControl_PreviewClicked; + return cntrl; + } + } + + // Fitting Analysis + if (element.ParentCollection as FittingAnalysisCollection != null) + { + if (element as FittingAnalysis != null) + { + var cntrl = new FittingAnalysisControl() { Element = (FittingAnalysis)element }; + cntrl.PlotPropertiesCalled += PlotPropertiesCalled; + cntrl.PreviewControlClicked += DocumentControl_PreviewClicked; + return cntrl; + } + } + + // Univariate Analysis + if (element.ParentCollection as UnivariateAnalysisCollection != null) + { + // Univariate + if (element as UnivariateAnalysis != null) + { + var cntrl = new UnivariateAnalysisControl() { Element = (UnivariateAnalysis)element }; + cntrl.PlotPropertiesCalled += PlotPropertiesCalled; + cntrl.PreviewControlClicked += DocumentControl_PreviewClicked; + return cntrl; + } + // Bulletin 17C Analysis + if (element as B17CAnalysis != null) + { + var cntrl = new B17CAnalysisControl() { Element = (B17CAnalysis)element }; + cntrl.PlotPropertiesCalled += PlotPropertiesCalled; + cntrl.PreviewControlClicked += DocumentControl_PreviewClicked; + return cntrl; + } + // Point Process + if (element as PointProcessAnalysis != null) + { + var cntrl = new PointProcessAnalysisControl() { Element = (PointProcessAnalysis)element }; + cntrl.PlotPropertiesCalled += PlotPropertiesCalled; + cntrl.PreviewControlClicked += DocumentControl_PreviewClicked; + return cntrl; + } + // Mixture + if (element as MixtureAnalysis != null) + { + var cntrl = new MixtureAnalysisControl() { Element = (MixtureAnalysis)element }; + cntrl.PlotPropertiesCalled += PlotPropertiesCalled; + cntrl.PreviewControlClicked += DocumentControl_PreviewClicked; + return cntrl; + } + // Composite + if (element as CompositeAnalysis != null) + { + var cntrl = new CompositeAnalysisControl() { Element = (CompositeAnalysis)element }; + cntrl.PlotPropertiesCalled += PlotPropertiesCalled; + cntrl.PreviewControlClicked += DocumentControl_PreviewClicked; + return cntrl; + } + } + + // Bivariate Analysis + if (element.ParentCollection as BivariateAnalysisCollection != null) + { + if (element as BivariateAnalysis != null) + { + var cntrl = new BivariateAnalysisControl() { Element = (BivariateAnalysis)element }; + cntrl.PlotPropertiesCalled += PlotPropertiesCalled; + cntrl.PreviewControlClicked += DocumentControl_PreviewClicked; + return cntrl; + } + // Coincident Frequency Analysis + if (element as CoincidentFrequencyAnalysis != null) + { + var cntrl = new CoincidentFrequencyControl() { Element = (CoincidentFrequencyAnalysis)element }; + cntrl.PlotPropertiesCalled += PlotPropertiesCalled; + cntrl.PreviewControlClicked += DocumentControl_PreviewClicked; + return cntrl; + } + } + + // Rating Curve Analysis + if (element.ParentCollection as RatingCurveAnalysisCollection != null) + { + if (element as RatingCurveAnalysis != null) + { + var cntrl = new RatingCurveAnalysisControl() { Element = (RatingCurveAnalysis)element }; + cntrl.PlotPropertiesCalled += PlotPropertiesCalled; + cntrl.PreviewControlClicked += DocumentControl_PreviewClicked; + return cntrl; + } + } + + // Time Series Analysis + if (element.ParentCollection as TimeSeriesAnalysisCollection != null) + { + if (element as TimeSeriesAnalysis != null) + { + var cntrl = new TimeSeriesAnalysisControl() { Element = (TimeSeriesAnalysis)element }; + cntrl.PlotPropertiesCalled += PlotPropertiesCalled; + cntrl.PreviewControlClicked += DocumentControl_PreviewClicked; + return cntrl; + } + } + + return null; + } + + /// + /// Retrieves the project element associated with the specified control. Works with both document controls + /// and properties controls to return the underlying data element being displayed or edited. + /// + /// The control to retrieve the associated element from. + /// + /// The instance associated with the control, or null if the control type + /// is not recognized or does not have an associated element. + /// + /// + /// This method supports extraction of elements from all document controls and properties controls + /// used in the application. It performs type checking on the control to determine which element + /// property to access. + /// + public override IElement GetControlElement(UIElement control) + { + // Check if document control + // Time Series + if (control as TimeSeriesControl != null) + return ((TimeSeriesControl)control).Element; + // Input Data + if (control as InputDataControl != null) + return ((InputDataControl)control).Element; + // Fitting Analysis + if (control as FittingAnalysisControl != null) + return ((FittingAnalysisControl)control).Element; + // Univariate Analysis + if (control as UnivariateAnalysisControl != null) + return ((UnivariateAnalysisControl)control).Element; + // Bulletin 17C Analysis + if (control as B17CAnalysisControl != null) + return ((B17CAnalysisControl)control).Element; + // Point Process Analysis + if (control as PointProcessAnalysisControl != null) + return ((PointProcessAnalysisControl)control).Element; + // Mixture Analysis + if (control as MixtureAnalysisControl != null) + return ((MixtureAnalysisControl)control).Element; + // Composite Analysis + if (control as CompositeAnalysisControl != null) + return ((CompositeAnalysisControl)control).Element; + // Bivariate Analysis + if (control as BivariateAnalysisControl != null) + return ((BivariateAnalysisControl)control).Element; + // Coincident Frequency Analysis + if (control as CoincidentFrequencyControl != null) + return ((CoincidentFrequencyControl)control).Element; + // Rating Curve Analysis + if (control as RatingCurveAnalysisControl != null) + return ((RatingCurveAnalysisControl)control).Element; + // Time Series Analysis + if (control as TimeSeriesAnalysisControl != null) + return ((TimeSeriesAnalysisControl)control).Element; + + // Check if properties control + // Time Series + if (control as TimeSeriesPropertiesControl != null) + return ((TimeSeriesPropertiesControl)control).Element; + // Input Data + if (control as InputDataPropertiesControl != null) + return ((InputDataPropertiesControl)control).Element; + // Fitting Analysis + if (control as FittingAnalysisPropertiesControl != null) + return ((FittingAnalysisPropertiesControl)control).Element; + // Univariate Analysis + if (control as UnivariateAnalysisPropertiesControl != null) + return ((UnivariateAnalysisPropertiesControl)control).Element; + // Bulletin 17C Analysis + if (control as B17CAnalysisPropertiesControl != null) + return ((B17CAnalysisPropertiesControl)control).Element; + // Point Process Analysis + if (control as PointProcessAnalysisPropertiesControl != null) + return ((PointProcessAnalysisPropertiesControl)control).Element; + // Mixture Analysis + if (control as MixtureAnalysisPropertiesControl != null) + return ((MixtureAnalysisPropertiesControl)control).Element; + // Composite Analysis + if (control as CompositeAnalysisPropertiesControl != null) + return ((CompositeAnalysisPropertiesControl)control).Element; + // Bivariate Analysis + if (control as BivariateAnalysisPropertiesControl != null) + return ((BivariateAnalysisPropertiesControl)control).Element; + // Coincident Frequency Analysis + if (control as CoincidentFrequencyPropertiesControl != null) + return ((CoincidentFrequencyPropertiesControl)control).Element; + // Rating Curve Analysis + if (control as RatingCurveAnalysisPropertiesControl != null) + return ((RatingCurveAnalysisPropertiesControl)control).Element; + // Time Series Analysis + if (control as TimeSeriesAnalysisPropertiesControl != null) + return ((TimeSeriesAnalysisPropertiesControl)control).Element; + + return null; + } + + /// + /// Handles cleanup operations when a document control is closed. Saves plot settings to the element + /// and unregisters event handlers to prevent memory leaks. + /// + /// The document control that is being closed. + /// + /// This method performs essential cleanup operations for each control type: + /// + /// Serializes and saves all plot configurations back to the element + /// Unsubscribes from plot properties events + /// Unsubscribes from preview control clicked events + /// + /// Proper cleanup ensures that user customizations to plots are preserved and that + /// no memory leaks occur from orphaned event handlers. + /// + public override void DocumentClosed(UIElement documentControl) + { + // Time Series + if (documentControl as TimeSeriesControl != null) + { + var cntrl = (TimeSeriesControl)documentControl; + cntrl.PlotPropertiesCalled -= PlotPropertiesCalled; + cntrl.PreviewControlClicked -= DocumentControl_PreviewClicked; + } + + // Input Data + if (documentControl as InputDataControl != null) + { + var cntrl = (InputDataControl)documentControl; + cntrl.PlotPropertiesCalled -= PlotPropertiesCalled; + cntrl.PreviewControlClicked -= DocumentControl_PreviewClicked; + } + + // Fitting Analysis + if (documentControl as FittingAnalysisControl != null) + { + var cntrl = (FittingAnalysisControl)documentControl; + cntrl.PlotPropertiesCalled -= PlotPropertiesCalled; + cntrl.PreviewControlClicked -= DocumentControl_PreviewClicked; + } + + // Univariate Analysis + if (documentControl as UnivariateAnalysisControl != null) + { + var cntrl = (UnivariateAnalysisControl)documentControl; + cntrl.PlotPropertiesCalled -= PlotPropertiesCalled; + cntrl.PreviewControlClicked -= DocumentControl_PreviewClicked; + } + + // Bulletin 17C Analysis element owns plots, no serialization needed on close + if (documentControl as B17CAnalysisControl != null) + { + var cntrl = (B17CAnalysisControl)documentControl; + cntrl.PlotPropertiesCalled -= PlotPropertiesCalled; + cntrl.PreviewControlClicked -= DocumentControl_PreviewClicked; + } + + // Point Process Analysis plots are owned by Element, no serialization needed on close + if (documentControl as PointProcessAnalysisControl != null) + { + var cntrl = (PointProcessAnalysisControl)documentControl; + cntrl.PlotPropertiesCalled -= PlotPropertiesCalled; + cntrl.PreviewControlClicked -= DocumentControl_PreviewClicked; + } + + // Mixture Analysis plots are owned by Element, no serialization needed on close + if (documentControl as MixtureAnalysisControl != null) + { + var cntrl = (MixtureAnalysisControl)documentControl; + cntrl.PlotPropertiesCalled -= PlotPropertiesCalled; + cntrl.PreviewControlClicked -= DocumentControl_PreviewClicked; + } + + // Composite Analysis plots are owned by Element, no serialization needed on close + if (documentControl as CompositeAnalysisControl != null) + { + var cntrl = (CompositeAnalysisControl)documentControl; + cntrl.PlotPropertiesCalled -= PlotPropertiesCalled; + cntrl.PreviewControlClicked -= DocumentControl_PreviewClicked; + } + + // Bivariate Analysis plots are owned by Element, no serialization needed on close + if (documentControl as BivariateAnalysisControl != null) + { + var cntrl = (BivariateAnalysisControl)documentControl; + cntrl.PlotPropertiesCalled -= PlotPropertiesCalled; + cntrl.PreviewControlClicked -= DocumentControl_PreviewClicked; + } + + // Coincident Frequency Analysis plots are owned by Element, no serialization needed on close + if (documentControl as CoincidentFrequencyControl != null) + { + var cntrl = (CoincidentFrequencyControl)documentControl; + cntrl.PlotPropertiesCalled -= PlotPropertiesCalled; + cntrl.PreviewControlClicked -= DocumentControl_PreviewClicked; + } + + // Rating Curve Analysis plots are owned by Element, no serialization needed on close + if (documentControl as RatingCurveAnalysisControl != null) + { + var cntrl = (RatingCurveAnalysisControl)documentControl; + cntrl.PlotPropertiesCalled -= PlotPropertiesCalled; + cntrl.PreviewControlClicked -= DocumentControl_PreviewClicked; + } + + // Time Series Analysis plots are owned by Element, no serialization needed on close + if (documentControl as TimeSeriesAnalysisControl != null) + { + var cntrl = (TimeSeriesAnalysisControl)documentControl; + cntrl.PlotPropertiesCalled -= PlotPropertiesCalled; + cntrl.PreviewControlClicked -= DocumentControl_PreviewClicked; + cntrl.Element = null; + } + + return; + } + + /// + /// Handles cleanup operations when a properties control is closed. Clears element references + /// to prevent memory leaks and ensure proper garbage collection. + /// + /// The properties control that is being closed. + /// + /// This method sets the Element property of each properties control to null, breaking the + /// reference to the underlying data element and allowing proper cleanup. This is important + /// for memory management in long-running sessions with many document operations. + /// + public override void PropertiesClosed(UIElement documentControl) + { + // Time Series + if (documentControl as TimeSeriesPropertiesControl != null) + ((TimeSeriesPropertiesControl)documentControl).Element = null; + + // Input Data + if (documentControl as InputDataPropertiesControl != null) + ((InputDataPropertiesControl)documentControl).Element = null; + + // Fitting Analysis + if (documentControl as FittingAnalysisPropertiesControl != null) + ((FittingAnalysisPropertiesControl)documentControl).Element = null; + + // Univariate Analysis + if (documentControl as UnivariateAnalysisPropertiesControl != null) + ((UnivariateAnalysisPropertiesControl)documentControl).Element = null; + + // Bulletin 17C Analysis + if (documentControl as B17CAnalysisPropertiesControl != null) + ((B17CAnalysisPropertiesControl)documentControl).Element = null; + + // Point Process Analysis + if (documentControl as PointProcessAnalysisPropertiesControl != null) + ((PointProcessAnalysisPropertiesControl)documentControl).Element = null; + + // Mixture Analysis + if (documentControl as MixtureAnalysisPropertiesControl != null) + ((MixtureAnalysisPropertiesControl)documentControl).Element = null; + + // Composite Analysis + if (documentControl as CompositeAnalysisPropertiesControl != null) + ((CompositeAnalysisPropertiesControl)documentControl).Element = null; + + // Bivariate Analysis + if (documentControl as BivariateAnalysisPropertiesControl != null) + ((BivariateAnalysisPropertiesControl)documentControl).Element = null; + + // Coincident Frequency Analysis + if (documentControl as CoincidentFrequencyPropertiesControl != null) + ((CoincidentFrequencyPropertiesControl)documentControl).Element = null; + + // Rating Curve Analysis + if (documentControl as RatingCurveAnalysisPropertiesControl != null) + ((RatingCurveAnalysisPropertiesControl)documentControl).Element = null; + + // Time Series Analysis + if (documentControl as TimeSeriesAnalysisPropertiesControl != null) + ((TimeSeriesAnalysisPropertiesControl)documentControl).Element = null; + } + + /// + /// Retrieves a properties control for the specified element. This method is currently not implemented + /// as RMC-BestFit uses document-based properties controls rather than element-based ones. + /// + /// The element to create a properties control for. + /// Always returns null as this method is not implemented. + /// + /// Properties controls in RMC-BestFit are created based on the active document control rather than + /// directly from elements. See for the implemented approach. + /// + public override Control GetPropertiesControl(IElement element) + { + return null; + } + + /// + /// Creates and returns the appropriate properties control for the specified document control. + /// Handles special logic for plot properties controls to prevent unnecessary closure and recreation. + /// + /// The document control to create a properties panel for. + /// + /// A instance configured as the properties panel for the document control, + /// or null if the plot properties control is already open for the current plot or if the control + /// type is not recognized. + /// + /// + /// This method implements smart properties panel management: + /// + /// Detects if the plot properties control is already open for the current plot and avoids reopening + /// Creates element-specific properties controls for each analysis and data type + /// Handles the _plotPropertiesOpen and _plotPropertiesClosing flags to coordinate state + /// + /// The method checks all plot types within each control to determine if the properties panel + /// should remain open or be replaced with a new properties control. + /// + public override Control GetPropertiesControl(UIElement documentControl) + { + bool isOpen = _plotPropertiesOpen; + _plotPropertiesOpen = false; + // The plot properties check has to be done in each if statement below because we have to do an equality check on the plot. + // Time Series + if (documentControl as TimeSeriesControl != null) + { + if (isOpen == true && _plotPropertiesClosing == false) + { + var plot = ((TimeSeriesControl)documentControl).GetCurrentPlot(); + if (plot != null && _plotPropertiesControl.Plot.Equals(plot)) + { + _plotPropertiesOpen = true; + return null; + } + } + var element = ((TimeSeriesControl)documentControl).Element; + return new TimeSeriesPropertiesControl() { Element = element }; + } + + // Input Data + if (documentControl as InputDataControl != null) + { + if (isOpen == true && _plotPropertiesClosing == false) + { + var plot = ((InputDataControl)documentControl).GetCurrentPlot(); + if (plot != null && _plotPropertiesControl.Plot.Equals(plot)) + { + _plotPropertiesOpen = true; + return null; + } + } + var element = ((InputDataControl)documentControl).Element; + return new InputDataPropertiesControl() { Element = element }; + } + + // Fitting Analysis + if (documentControl as FittingAnalysisControl != null) + { + if (isOpen == true && _plotPropertiesClosing == false) + { + var plot = ((FittingAnalysisControl)documentControl).GetCurrentPlot(); + if (plot != null && _plotPropertiesControl.Plot.Equals(plot)) + { + _plotPropertiesOpen = true; + return null; + } + } + var element = ((FittingAnalysisControl)documentControl).Element; + return new FittingAnalysisPropertiesControl() { Element = element }; + } + + // Univariate Analysis + if (documentControl as UnivariateAnalysisControl != null) + { + if (isOpen == true && _plotPropertiesClosing == false) + { + var plot = ((UnivariateAnalysisControl)documentControl).GetCurrentPlot(); + if (plot != null && _plotPropertiesControl.Plot.Equals(plot)) + { + _plotPropertiesOpen = true; + return null; + } + } + var element = ((UnivariateAnalysisControl)documentControl).Element; + return new UnivariateAnalysisPropertiesControl() { Element = element }; + } + + // Bulletin 17C Analysis + if (documentControl as B17CAnalysisControl != null) + { + if (isOpen == true && _plotPropertiesClosing == false) + { + var plot = ((B17CAnalysisControl)documentControl).GetCurrentPlot(); + if (plot != null && _plotPropertiesControl.Plot.Equals(plot)) + { + _plotPropertiesOpen = true; + return null; + } + } + var element = ((B17CAnalysisControl)documentControl).Element; + return new B17CAnalysisPropertiesControl() { Element = element }; + } + + // Point Process Analysis + if (documentControl as PointProcessAnalysisControl != null) + { + if (isOpen == true && _plotPropertiesClosing == false) + { + var plot = ((PointProcessAnalysisControl)documentControl).GetCurrentPlot(); + if (plot != null && _plotPropertiesControl.Plot.Equals(plot)) + { + _plotPropertiesOpen = true; + return null; + } + } + var element = ((PointProcessAnalysisControl)documentControl).Element; + return new PointProcessAnalysisPropertiesControl() { Element = element }; + } + + // Mixture Analysis + if (documentControl as MixtureAnalysisControl != null) + { + if (isOpen == true && _plotPropertiesClosing == false) + { + var plot = ((MixtureAnalysisControl)documentControl).GetCurrentPlot(); + if (plot != null && _plotPropertiesControl.Plot.Equals(plot)) + { + _plotPropertiesOpen = true; + return null; + } + } + var element = ((MixtureAnalysisControl)documentControl).Element; + return new MixtureAnalysisPropertiesControl() { Element = element }; + } + + // Composite Analysis + if (documentControl as CompositeAnalysisControl != null) + { + if (isOpen == true && _plotPropertiesClosing == false) + { + var plot = ((CompositeAnalysisControl)documentControl).Element?.FrequencyPlot; + if (plot != null && _plotPropertiesControl.Plot != null && _plotPropertiesControl.Plot.Equals(plot)) + { + _plotPropertiesOpen = true; + return null; + } + } + var element = ((CompositeAnalysisControl)documentControl).Element; + return new CompositeAnalysisPropertiesControl() { Element = element }; + } + + // Bivariate Analysis + if (documentControl as BivariateAnalysisControl != null) + { + if (isOpen == true && _plotPropertiesClosing == false) + { + var plot = ((BivariateAnalysisControl)documentControl).GetCurrentPlot(); + if (plot != null && _plotPropertiesControl.Plot.Equals(plot)) + { + _plotPropertiesOpen = true; + return null; + } + } + var element = ((BivariateAnalysisControl)documentControl).Element; + return new BivariateAnalysisPropertiesControl() { Element = element }; + } + + // Coincident Frequency Analysis + if (documentControl as CoincidentFrequencyControl != null) + { + if (isOpen == true && _plotPropertiesClosing == false) + { + var plot = ((CoincidentFrequencyControl)documentControl).GetCurrentPlot(); + if (plot != null && _plotPropertiesControl.Plot.Equals(plot)) + { + _plotPropertiesOpen = true; + return null; + } + } + var element = ((CoincidentFrequencyControl)documentControl).Element; + return new CoincidentFrequencyPropertiesControl() { Element = element }; + } + + // Rating Curve Analysis + if (documentControl as RatingCurveAnalysisControl != null) + { + if (isOpen == true && _plotPropertiesClosing == false) + { + var plot = ((RatingCurveAnalysisControl)documentControl).GetCurrentPlot(); + if (plot != null && _plotPropertiesControl.Plot.Equals(plot)) + { + _plotPropertiesOpen = true; + return null; + } + } + var element = ((RatingCurveAnalysisControl)documentControl).Element; + return new RatingCurveAnalysisPropertiesControl() { Element = element }; + } + + // Time Series Analysis + if (documentControl as TimeSeriesAnalysisControl != null) + { + if (isOpen == true && _plotPropertiesClosing == false) + { + var plot = ((TimeSeriesAnalysisControl)documentControl).GetCurrentPlot(); + if (plot != null && _plotPropertiesControl.Plot.Equals(plot)) + { + _plotPropertiesOpen = true; + return null; + } + } + var element = ((TimeSeriesAnalysisControl)documentControl).Element; + return new TimeSeriesAnalysisPropertiesControl() { Element = element }; + } + + return null; + } + + #endregion + + #region OxyPlot + + /// + /// Handles requests to show or toggle the OxyPlot properties control panel. Manages the visibility state + /// of the properties panel and coordinates which plot's properties are currently being displayed. + /// + /// The OxyPlot plot instance that is requesting the properties panel. + /// Indicates whether to open the properties control if it is not already open. + /// The specific property expander section to open within the properties control. + /// The OxyPlot object (axis, series, annotation, etc.) that has been selected by the user for editing. + /// + /// This method implements toggle behavior: if the properties panel is already open for the requesting plot + /// and no specific object is selected, the panel will be closed. Otherwise, the panel is opened or updated + /// to show the requested plot's properties. The method also handles expanding specific property sections + /// when a plot element is selected. + /// + private void PlotPropertiesCalled(Plot plotRequestingProperties, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject) + { + // Defensive: if _plotPropertiesOpen was set true but _plotPropertiesControl.Plot + // was reset to null by a separate code path, dereferencing .Plot.Equals would NRE. + if (_plotPropertiesOpen && _plotPropertiesControl.Plot != null && _plotPropertiesControl.Plot.Equals(plotRequestingProperties) && selectedObject == null) + { + ClosePlotProperties_Click(_plotPropertiesControl.Plot); + _plotPropertiesOpen = false; + } + else if (openProperties == true) + { + _plotPropertiesControl.Plot = plotRequestingProperties; + RaiseSetPropertiesControl(_plotPropertiesControl); + _plotPropertiesOpen = true; + } + + if (_plotPropertiesOpen) + { + _plotPropertiesControl.ExpandProperty( + propertyExpander ?? OxyPlotPropertiesControl.PropertyEXP.General_PlotTitle, + selectedObject); + } + } + + /// + /// Closes the OxyPlot properties control panel if it is currently open for the specified plot. + /// Sets appropriate state flags to coordinate the closure operation. + /// + /// The OxyPlot plot instance that is requesting closure of the properties panel. + /// + /// This method verifies that the properties panel is open and that it is displaying properties for + /// the requesting plot before initiating closure. The _plotPropertiesClosing flag is set during the + /// closure operation to prevent recursive calls or interference with other state management operations. + /// If the properties panel is not open or is showing a different plot, the method returns without action. + /// + private void ClosePlotProperties_Click(Plot plotRequestingProperties) + { + if (_plotPropertiesControl.Plot == null || _plotPropertiesOpen == false) return; + if (_plotPropertiesControl.Plot.Equals(plotRequestingProperties) == false) return; + _plotPropertiesClosing = true; + RaiseClosePropertiesControl(_plotPropertiesControl); + _plotPropertiesOpen = false; + _plotPropertiesClosing = false; + } + + /// + /// Handles preview click events on document controls to determine if the plot properties panel should be closed. + /// Closes the properties panel if the user clicked outside the plot and toolbar areas. + /// + /// Indicates whether the plot area itself was clicked by the user. + /// Indicates whether the plot's toolbar was clicked by the user. + /// The plot instance associated with the clicked document control. + /// + /// This method implements user-friendly behavior where clicking on the document control's background + /// or other non-plot areas will automatically close the plot properties panel. This helps maintain + /// a clean interface and clear user intent. If either the plot or toolbar was clicked, the properties + /// panel remains open to allow continued editing. + /// + private void DocumentControl_PreviewClicked(bool plotClicked, bool toolbarClicked, Plot plot) + { + if (plotClicked == false && toolbarClicked == false) + ClosePlotProperties_Click(plot); + } + + #endregion + + } +} diff --git a/src/RMC.BestFit.App/GUI/RatingCurveAnalysis/RatingCurveAnalysisControl.xaml b/src/RMC.BestFit.App/GUI/RatingCurveAnalysis/RatingCurveAnalysisControl.xaml new file mode 100644 index 0000000..9d14286 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/RatingCurveAnalysis/RatingCurveAnalysisControl.xaml @@ -0,0 +1,234 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/RatingCurveAnalysis/RatingCurveAnalysisControl.xaml.cs b/src/RMC.BestFit.App/GUI/RatingCurveAnalysis/RatingCurveAnalysisControl.xaml.cs new file mode 100644 index 0000000..699b054 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/RatingCurveAnalysis/RatingCurveAnalysisControl.xaml.cs @@ -0,0 +1,1171 @@ +using Numerics.Sampling; +using OxyPlot.Wpf; +using OxyPlot; +using OxyPlotControls; +using FrameworkInterfaces; +using RMC.BestFit.Models; +using ModelAnalyses = RMC.BestFit.Analyses; +using RMC.BestFit.Estimation; +using RMC.BestFit.UI; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; +using System.Xml.Linq; +using FrameworkUI; +using System.Windows.Media.Media3D; +using Numerics.Distributions; +using Numerics.Data.Statistics; +using Numerics; +using System.Xml.Serialization; + +namespace RMC_BestFit +{ + /// + /// User control for rating curve analysis visualization and interaction. + /// Provides plotting capabilities for rating curves, residuals, histograms, and diagnostic plots, + /// along with data tables for analysis results and summary statistics. + /// + public partial class RatingCurveAnalysisControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// Sets up default color schemes for plot visualization. + /// + public RatingCurveAnalysisControl() + { + InitializeComponent(); + DataContext = this; + _colorHexCodes = GenericControls.GeneralMethods.RandomColorsLongList; + // Column StringFormat must be set before first render (Binding.StringFormat + // throws "Binding cannot be changed after it has been used" if re-assigned + // on a used binding). Constructor is the only safe place — once per control + // instance, regardless of Element or Load/Unload cycles. + SetColumnStringFormats(); + } + + /// + /// Dependency property for the property. + /// + public static DependencyProperty ElementProperty = DependencyProperty.Register(nameof(Element), typeof(RatingCurveAnalysis), typeof(RatingCurveAnalysisControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the rating curve analysis element to display and analyze. + /// + public RatingCurveAnalysis Element + { + get { return (RatingCurveAnalysis)GetValue(ElementProperty); } + set { SetValue(ElementProperty, value); } + } + + /// + /// Callback method invoked when the property changes. + /// Manages event handler subscriptions and unsubscriptions for the old and new elements. + /// + /// The dependency object whose property changed. + /// Event arguments containing the old and new property values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as RatingCurveAnalysisControl == null) return; + var thisControl = (RatingCurveAnalysisControl)d; + + // Remove handlers + if (e.OldValue != null) + { + RatingCurveAnalysis oldElement = e.OldValue as RatingCurveAnalysis; + if (oldElement != null) + { + oldElement.PropertyChanged -= thisControl.Element_PropertyChanged; + thisControl.RatingCurvePlotHost.Content = null; + thisControl.ResidualPlotHost.Content = null; + thisControl.ResidualHistogramPlotHost.Content = null; + thisControl.ResidualQQPlotHost.Content = null; + thisControl.PlotToolbar.Plot = null; + thisControl.ResidualsPlotToolbar.Plot = null; + thisControl.HistogramPlotToolbar.Plot = null; + thisControl.QQPlotToolbar.Plot = null; + // NOTE: PropertiesCalled is wired in XAML for every toolbar — no programmatic -= needed. + } + } + + if (e.NewValue == null) return; + var newElement = e.NewValue as RatingCurveAnalysis; + if (newElement == null) return; + + // Reset _isLoaded so the next Loaded event re-runs one-time setup steps + // for the new element (mirrors BivariateAnalysisControl:85). + thisControl._isLoaded = false; + + // Subscribe Element-scoped handler + newElement.PropertyChanged += thisControl.Element_PropertyChanged; + + // Attach Element-owned plots to hosts and wire toolbars. Suspend plot bridges + // so WPF visual-tree attachment doesn't record "Change Title" / axis-style + // undo entries. PropertiesCalled / PlotPropertiesCalled are wired in XAML on + // every toolbar and every Bayesian sub-control — no programmatic += needed. + using (newElement.SuspendPlotBridges()) + { + thisControl.RatingCurvePlotHost.Content = newElement.RatingCurvePlot; + thisControl.PlotToolbar.Plot = newElement.RatingCurvePlot; + thisControl.ResidualPlotHost.Content = newElement.ResidualPlot; + thisControl.ResidualsPlotToolbar.Plot = newElement.ResidualPlot; + thisControl.ResidualHistogramPlotHost.Content = newElement.ResidualHistogramPlot; + thisControl.HistogramPlotToolbar.Plot = newElement.ResidualHistogramPlot; + thisControl.ResidualQQPlotHost.Content = newElement.ResidualQQPlot; + thisControl.QQPlotToolbar.Plot = newElement.ResidualQQPlot; + + thisControl.HistogramControl.SetPlot(newElement.BayesianPlots.HistogramPlot); + thisControl.KernelDensityControl.SetPlot(newElement.BayesianPlots.KernelDensityPlot); + thisControl.AutocorrelationControl.SetPlot(newElement.BayesianPlots.AutocorrelationPlot); + thisControl.MarkovChainTraceControl.SetPlot(newElement.BayesianPlots.MarkovChainTracePlot); + thisControl.MeanLikelihoodControl.SetPlot(newElement.BayesianPlots.MeanLikelihoodPlot); + thisControl.BivariateHeatMapControl.SetPlot(newElement.BayesianPlots.BivariateHeatMapPlot); + thisControl.InfluenceDiagnosticsControl.SetPlot(newElement.BayesianPlots.InfluenceDiagnosticsPlot); + + thisControl.BindAxisTitles(); + } + } + + /// + /// Indicates whether the control has completed its initial load operations. + /// Set to false by ElementCallback when a new element is assigned so the next + /// Loaded event re-runs one-time setup steps. + /// + private bool _isLoaded = false; + + /// Convenience accessor for the Element-owned rating curve plot. + private Plot RatingCurvePlot => Element?.RatingCurvePlot; + /// Convenience accessor for the Element-owned residual plot. + private Plot ResidualPlot => Element?.ResidualPlot; + /// Convenience accessor for the Element-owned residual histogram plot. + private Plot ResidualHistogramPlot => Element?.ResidualHistogramPlot; + /// Convenience accessor for the Element-owned residual QQ plot. + private Plot ResidualQQPlot => Element?.ResidualQQPlot; + + /// + /// Gets a value indicating whether a plot area was clicked by the user. + /// + public bool PlotClicked { get; private set; } = false; + + /// + /// Raised when plot properties are requested to be displayed. + /// + public event PlotPropertiesCalledEventHandler PlotPropertiesCalled; + + /// + /// Delegate for the event. + /// + /// The plot whose properties are being accessed. + /// Indicates whether to open the properties panel. + /// The property expander control to display. + /// The selected object in the plot. + public delegate void PlotPropertiesCalledEventHandler(Plot plot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject); + + /// + /// Raised when the preview control area is clicked. + /// + public event PreviewControlClickedEventHandler PreviewControlClicked; + + /// + /// Delegate for the event. + /// + /// Indicates whether the plot area was clicked. + /// Indicates whether the toolbar area was clicked. + /// The plot that was clicked. + public delegate void PreviewControlClickedEventHandler(bool plotClicked, bool toolbarClicked, Plot plot); + + /// + /// Stores the calculated residuals from the rating curve analysis. + /// + private double[] _residuals = null; + + /// + /// Array of hexadecimal color codes used for plot series styling. + /// + private string[] _colorHexCodes; + + #region Plot Series + + /// + /// POCO bound to the rating-curve observed-scatter series. Public properties + /// (not ValueTuple fields) are what OxyPlot's tracker format string reflects + /// over when it resolves placeholders like {Date}. + /// + private sealed class StageDischargeObservation + { + public string Date { get; } + public double Stage { get; } + public double Discharge { get; } + public StageDischargeObservation(DateTime date, double stage, double discharge) + { + Date = date.ToString("yyyy-MM-dd"); + Stage = stage; + Discharge = discharge; + } + } + + #endregion + + /// + /// Handles the Loaded event of the user control. + /// Initializes plots, data grids, and visual settings when the control is first loaded. + /// + /// The event sender. + /// The event arguments. + private void UserControl_Loaded(object sender, RoutedEventArgs e) + { + if (Element == null) return; + + // Data refresh on every load. Plot hosts and Element.PropertyChanged are wired + // once per Element in ElementCallback; this method just refreshes tabular / + // bound content so the UI reflects whatever state the Element is currently in + // (fresh load, returning from a tab switch, etc.). + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try + { + UpdatePlots(); + BindRatingCurveDataGrid(); + + // One-time setup: column headers and summary grid layout do not need to + // re-run on every transient visual-tree cycle — only on the first load + // after a new Element is assigned (RC1). + if (!_isLoaded) + { + SetRatingCurveTableColumnHeaders(); + BindSummaryStatisticsDataGrid(); + } + } + finally + { + Element.IsUndoEnabled = wasUndoEnabled; + } + _isLoaded = true; + } + + /// + /// Handles the Unloaded event of the user control. Unsubscribes from element event handlers + /// to prevent memory leaks and stale event subscriptions. + /// + /// The source of the event. + /// The event data. + private void UserControl_Unloaded(object sender, RoutedEventArgs e) + { + // Reset _isLoaded so the next Loaded event re-runs one-time setup steps (RC2). + // Element-scoped lifecycle (PropertyChanged, plot hosts, toolbars) is owned by + // ElementCallback and survives unload/reload cycles — no additional teardown needed. + _isLoaded = false; + } + + /// + /// Handles property changes on the . + /// Updates plots and data displays when relevant properties change. + /// + /// The event sender. + /// The property changed event arguments. + private void Element_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + // Marshal to UI thread if called from a background thread. The model layer's + // ReprocessIfEstimated path uses TaskScheduler.Default and AnalysisBase.RaisePropertyChange + // does NOT marshal — so the AnalysisResults notification after a stage-bin reprocess + // can arrive here on a worker thread. + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke(new Action(() => Element_PropertyChanged(sender, e))); + return; + } + + if (e.PropertyName == nameof(Element.StageData) || e.PropertyName == nameof(Element.DischargeData)) + { + // Re-bind axis titles to the new source TimeSeriesElement. Wrap in + // SuspendPlotBridges so default-axis title updates don't + // record "Change Title" undo entries (matches Convention 12). + using (Element.SuspendPlotBridges()) + { + BindAxisTitles(); + } + } + if (e.PropertyName == nameof(Element.AnalysisResults)) + { + UpdatePlots(); + BindRatingCurveDataGrid(); + BindSummaryStatisticsDataGrid(); + ResetWaitCursor(); + } + if (e.PropertyName == nameof(Element.BayesianAnalysis.CredibleIntervalWidth)) + { + SetRatingCurveTableColumnHeaders(); + } + // MinStage/MaxStage/StageBins reprocess is now owned by RatingCurveAnalysis itself + // (Phase 3a). The setter dispatches CreateUncertaintyAnalysisResultsAsync as a + // fire-and-forget task and the AnalysisResults PropertyChanged branch above + // refreshes the plots and tables when the rebuild completes. + if ((e.PropertyName == nameof(Element.BayesianAnalysis.PointEstimator) + || e.PropertyName == nameof(Element.BayesianAnalysis.CredibleIntervalWidth) + || e.PropertyName == nameof(Element.MinStage) + || e.PropertyName == nameof(Element.MaxStage) + || e.PropertyName == nameof(Element.StageBins)) + && Element.IsEstimated) + { + // Cursor wait while the model layer's ReprocessIfEstimated runs the recompute + // on TaskScheduler.Default. Reset is wired to the AnalysisResults notification above. + Mouse.OverrideCursor = Cursors.Wait; + } + } + + /// + /// Clears when it is currently set to . + /// Marshals onto the UI thread when called from a background-thread PropertyChanged + /// notification (the model layer's ReprocessIfEstimated path uses + /// TaskScheduler.Default). + /// + private void ResetWaitCursor() + { + // Reset at Background priority so the Render-priority cursor frame from the + // earlier `Mouse.OverrideCursor = Cursors.Wait` is guaranteed to flush before + // the reset runs. Without this, fast reprocesses (UpdatePointEstimateResultsAsync) + // can reset the cursor before the OS visually picks up the change — the user + // sees no wait cursor at all when the mouse is stationary. + Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + { + if (Mouse.OverrideCursor == Cursors.Wait) + Mouse.OverrideCursor = null; + })); + } + + #region Plots + + /// + /// Handles the LostFocus event of the user control. + /// Resets the flag when focus is lost. + /// + /// The event sender. + /// The event arguments. + private void UserControl_LostFocus(object sender, RoutedEventArgs e) + { + PlotClicked = false; + } + + /// + /// Handles the PreviewMouseDown event of the user control. + /// Determines which plot or toolbar was clicked and raises appropriate events. + /// + /// The event sender. + /// The mouse button event arguments. + private void UserControl_PreviewMouseDown(object sender, MouseButtonEventArgs e) + { + Plot plot = GetCurrentPlot(); + OxyPlotToolbar toolbar = GetCurrentPlotToolbar(); + System.Windows.Media.HitTestResult plotHitResult = null; + System.Windows.Media.HitTestResult toolbarHitResult = null; + if (plot != null) + { + plotHitResult = VisualTreeHelper.HitTest(plot, e.GetPosition(plot)); + } + if (toolbar != null) + { + toolbarHitResult = VisualTreeHelper.HitTest(toolbar, e.GetPosition(toolbar)); + } + PreviewControlClicked?.Invoke(plotHitResult != null, toolbarHitResult != null, plot); + PlotClicked = plotHitResult != null; + } + + /// + /// Handles the PropertiesCalled event from the plot toolbar. + /// Forwards the event to subscribers of . + /// + /// The plot whose properties are being accessed. + /// Indicates whether to open the properties panel. + /// The property expander control to display. + /// The selected object in the plot. + private void PlotToolbar_PropertiesCalled(Plot targetPlot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject) + { + PlotPropertiesCalled?.Invoke(targetPlot, openProperties, propertyExpander, selectedObject); + } + + /// + /// Gets the currently selected plot based on the active tab item. + /// + /// The currently active object, or null if no plot tab is selected. + public Plot GetCurrentPlot() + { + if (PlotTabItem.IsSelected == true) + { + return RatingCurvePlot; + } + else if (ResidualsPlotTab.IsSelected == true) + { + return ResidualPlot; + } + else if (ResidualHistogramTab.IsSelected == true) + { + return ResidualHistogramPlot; + } + else if (ResidualQQTab.IsSelected == true) + { + return ResidualQQPlot; + } + else if (KernelDensityTabItem.IsSelected == true) + { + return KernelDensityControl.Plot; + } + else if (HistogramTabItem.IsSelected == true) + { + return HistogramControl.Plot; + } + else if (BivariateTabItem.IsSelected == true) + { + return BivariateHeatMapControl.Plot; + } + else if (MeanLikelihoodTabItem.IsSelected == true) + { + return MeanLikelihoodControl.Plot; + } + else if (AutocorrelationTabItem.IsSelected == true) + { + return AutocorrelationControl.Plot; + } + else if (MarkovChainTabItem.IsSelected == true) + { + return MarkovChainTraceControl.Plot; + } + else if (InfluenceDiagnosticsTabItem.IsSelected == true) + { + return InfluenceDiagnosticsControl.Plot; + } + return null; + } + + /// + /// Gets the toolbar for the currently selected plot based on the active tab item. + /// + /// The for the active plot, or null if no plot tab is selected. + public OxyPlotToolbar GetCurrentPlotToolbar() + { + if (PlotTabItem.IsSelected == true) + { + return PlotToolbar; + } + else if (ResidualsPlotTab.IsSelected == true) + { + return ResidualsPlotToolbar; + } + else if (ResidualHistogramTab.IsSelected == true) + { + return HistogramPlotToolbar; + } + else if (ResidualQQTab.IsSelected == true) + { + return QQPlotToolbar; + } + else if (KernelDensityTabItem.IsSelected == true) + { + return KernelDensityControl.PlotToolbar; + } + else if (HistogramTabItem.IsSelected == true) + { + return HistogramControl.PlotToolbar; + } + else if (BivariateTabItem.IsSelected == true) + { + return BivariateHeatMapControl.PlotToolbar; + } + else if (MeanLikelihoodTabItem.IsSelected == true) + { + return MeanLikelihoodControl.PlotToolbar; + } + else if (AutocorrelationTabItem.IsSelected == true) + { + return AutocorrelationControl.PlotToolbar; + } + else if (MarkovChainTabItem.IsSelected == true) + { + return MarkovChainTraceControl.PlotToolbar; + } + else if (InfluenceDiagnosticsTabItem.IsSelected == true) + { + return InfluenceDiagnosticsControl.PlotToolbar; + } + return null; + } + + /// + /// Binds the rating-curve plot axis titles to the upstream TimeSeriesElement unit labels + /// via reactive WPF bindings. Y-axis tracks 's + /// UnitLabel; X-axis tracks 's UnitLabel. + /// Called from (inside SuspendPlotBridges) and from + /// when the source references change. Matches + /// the canonical pattern in Convention 12. + /// + private void BindAxisTitles() + { + if (Element == null) return; + if (RatingCurvePlot == null) return; + + var yAxis = RatingCurvePlot.Axes.FirstOrDefault(a => a.Key == "Yaxis"); + if (yAxis != null) + { + if (Element.StageData != null) + PlotAxisTitleDefaults.BindTitleIfDefault(yAxis, Element.StageData, nameof(TimeSeriesElement.UnitLabel), Element.StageData.UnitLabel); + else + PlotAxisTitleDefaults.SetTitleIfDefault(yAxis, string.Empty); + } + + var xAxis = RatingCurvePlot.Axes.FirstOrDefault(a => a.Key == "Xaxis"); + if (xAxis != null) + { + if (Element.DischargeData != null) + PlotAxisTitleDefaults.BindTitleIfDefault(xAxis, Element.DischargeData, nameof(TimeSeriesElement.UnitLabel), Element.DischargeData.UnitLabel); + else + PlotAxisTitleDefaults.SetTitleIfDefault(xAxis, string.Empty); + } + } + + /// + /// Updates all diagnostic plots with the latest analysis results. + /// Refreshes the rating curve, residuals, histogram, and Q-Q plots. + /// + private void UpdatePlots() + { + UpdateRatingCurvePlot(); + GetResiduals(); + UpdateResidualsPlot(); + UpdateHistogramPlot(); + UpdateQQPlot(); + } + + /// + /// Updates the main rating curve plot with observed data and fitted curves. + /// Displays the stage-discharge data, credible intervals, posterior predictive, and point estimate curves. + /// + private void UpdateRatingCurvePlot() + { + if (RatingCurvePlot == null) return; + + // Lookup-or-create named series so user-customized styling survives Save/Open. + var stageDischargeData = RatingCurvePlot.Series.OfType().FirstOrDefault(s => s.Name == "StageDischargeData") + ?? new ScatterPointSeries + { + Name = "StageDischargeData", + Title = "Stage-Discharge Data", + MarkerFill = Colors.Red, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle, + }; + var credibleIntervals = RatingCurvePlot.Series.OfType().FirstOrDefault(s => s.Name == "CredibleIntervals") + ?? new AreaSeries + { + Name = "CredibleIntervals", + Fill = Color.FromArgb(75, 104, 140, 175), + Color = Color.FromArgb(255, 53, 59, 122), + LineStyle = LineStyle.Solid, + BrokenLineThickness = 1, + StrokeThickness = 1, + Decimator = OxyPlot.Decimator.Decimate, + }; + var posteriorPredictive = RatingCurvePlot.Series.OfType().FirstOrDefault(s => s.Name == "PosteriorPredictive") + ?? new LineSeries + { + Name = "PosteriorPredictive", + Title = "Posterior Predictive", + Color = Colors.Blue, + StrokeThickness = 1, + LineStyle = LineStyle.Dash, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0, + }; + var posteriorMode = RatingCurvePlot.Series.OfType().FirstOrDefault(s => s.Name == "PosteriorMode") + ?? new LineSeries + { + Name = "PosteriorMode", + Title = "Posterior Mode", + Color = Colors.Black, + StrokeThickness = 1, + LineStyle = LineStyle.Solid, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0, + }; + + using (Element?.SuspendPlotBridges()) + { + RatingCurvePlot.Series.Clear(); + + if (Element != null && Element.BayesianAnalysis != null && Element.IsValid != false) + { + // Load the stage-discharge data using the same date-inner-join the + // model uses for the fit — observations with a date in only one series + // are excluded so the plotted scatter exactly matches the points the + // likelihood iterated. The observation date rides along as a public + // property on the POCO so the tracker format can resolve {Date}. + var stageDischageData = Element.GetAlignedObservations() + .Select(o => new StageDischargeObservation(o.Date, o.Stage, o.Discharge)) + .ToList(); + + // Tracker format string used by every series on the rating curve plot. + string curveTracker = "{0}" + Environment.NewLine + + "{1}: {2:N0}" + Environment.NewLine + + "{3}: {4:N2}"; + string observedTracker = "{0}" + Environment.NewLine + + "Date: {Date}" + Environment.NewLine + + "{1}: {2:N0}" + Environment.NewLine + + "{3}: {4:N2}"; + + // Add Curves + if (Element.BayesianAnalysis.IsEstimated == true && Element.AnalysisResults != null + && Element.AnalysisResults.ModeCurve.Length == Element.StageBins) + { + var ciPoints = new List(); + var prdPoints = new List(); + var mdPoints = new List(); + for (int i = 0; i < Element.StageBins; i++) + { + var q = Element.AnalysisResults.ConfidenceIntervals[i, 0]; + var lo = Element.AnalysisResults.ConfidenceIntervals[i, 1]; + var up = Element.AnalysisResults.ConfidenceIntervals[i, 2]; + var prd = Element.AnalysisResults.MeanCurve[i]; + var md = Element.AnalysisResults.ModeCurve[i]; + + ciPoints.Add(new Point3D(q, lo, up)); + // Axes are swapped: X-axis = discharge (prd/md), Y-axis = stage (q) + prdPoints.Add(new OxyPlot.DataPoint(prd, q)); + mdPoints.Add(new OxyPlot.DataPoint(md, q)); + } + + // Credible Intervals — keep DataFieldX/Y/X2/Y2 (swapped axes + dual boundary) + credibleIntervals.ItemsSource = ciPoints; + credibleIntervals.DataFieldX = "Y"; + credibleIntervals.DataFieldY = "X"; + credibleIntervals.DataFieldX2 = "Z"; + credibleIntervals.DataFieldY2 = "X"; + credibleIntervals.Title = (Element.BayesianAnalysis.CredibleIntervalWidth * 100).ToString("F0") + "% Credible Intervals"; + credibleIntervals.TrackerFormatString = curveTracker; + RatingCurvePlot.Series.Add(credibleIntervals); + + // Predictive + posteriorPredictive.ItemsSource = prdPoints; + posteriorPredictive.TrackerFormatString = curveTracker; + RatingCurvePlot.Series.Add(posteriorPredictive); + + // Point Estimator + posteriorMode.Title = Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"; + posteriorMode.ItemsSource = mdPoints; + posteriorMode.TrackerFormatString = curveTracker; + RatingCurvePlot.Series.Add(posteriorMode); + } + + // Add observed data. + stageDischargeData.ItemsSource = stageDischageData; + stageDischargeData.Mapping = item => + { + var obs = (StageDischargeObservation)item; + return new OxyPlot.Series.ScatterPoint(obs.Discharge, obs.Stage); + }; + stageDischargeData.TrackerFormatString = observedTracker; + RatingCurvePlot.Series.Add(stageDischargeData); + } + + RatingCurvePlot.InvalidatePlot(true); + } + Element?.RebuildSeriesAndAnnotationBridges(RatingCurvePlot); + } + + /// + /// Handles the addition of an alternative analysis to the plot. + /// Adds the alternative's curves to the main rating curve plot with unique colors. + /// + /// The alternative analysis item to add to the plot. + private void AlternativeSelector_AnalysisAdded(AnalysisAlternativeItem analysisItem) + { + // Remove series + RatingCurvePlot.Series.Remove(analysisItem.CredibleIntervals); + RatingCurvePlot.Series.Remove(analysisItem.PosteriorPredictive); + RatingCurvePlot.Series.Remove(analysisItem.PosteriorMode); + + var alternative = (RatingCurveAnalysis)analysisItem.Alternative; + if (alternative.BayesianAnalysis.IsEstimated == true && alternative.AnalysisResults != null) + { + // Get unique color + var index = RatingCurvePlot.Series.Count; + Color lineColor = (Color)ColorConverter.ConvertFromString(_colorHexCodes[index]); + Color fillColor = Color.FromArgb(100, lineColor.R, lineColor.G, lineColor.B); + + for (int i = 4; i < _colorHexCodes.Length; i++) + { + var templineColor = (Color)ColorConverter.ConvertFromString(_colorHexCodes[i]); + var tempfillColor = Color.FromArgb(100, templineColor.R, templineColor.G, templineColor.B); + bool colorExists = false; + for (int j = 0; j < RatingCurvePlot.Series.Count; j++) + { + if (RatingCurvePlot.Series[j].Name.Contains("Mode") && RatingCurvePlot.Series[j].Color == templineColor) + { + colorExists = true; + break; + } + } + if (colorExists == false || i == _colorHexCodes.Length - 1) + { + lineColor = templineColor; + fillColor = tempfillColor; + break; + } + } + + var ciPoints = new List(); + var prdPoints = new List(); + var mdPoints = new List(); + string curveTracker = "{0}" + Environment.NewLine + + "{1}: {2:N0}" + Environment.NewLine + + "{3}: {4:N2}"; + for (int i = 0; i < alternative.StageBins; i++) + { + var q = alternative.AnalysisResults.ConfidenceIntervals[i, 0]; + var lo = alternative.AnalysisResults.ConfidenceIntervals[i, 1]; + var up = alternative.AnalysisResults.ConfidenceIntervals[i, 2]; + var prd = alternative.AnalysisResults.MeanCurve[i]; + var md = alternative.AnalysisResults.ModeCurve[i]; + + ciPoints.Add(new Point3D(q, lo, up)); + // Axes are swapped: X-axis = discharge (prd/md), Y-axis = stage (q) + prdPoints.Add(new OxyPlot.DataPoint(prd, q)); + mdPoints.Add(new OxyPlot.DataPoint(md, q)); + } + + // Credible Intervals — keep DataFieldX/Y/X2/Y2 (swapped axes + dual boundary) + analysisItem.CredibleIntervals.Name = "CredibleIntervals_" + index; + analysisItem.CredibleIntervals.Title = alternative.Name + " - " + (alternative.BayesianAnalysis.CredibleIntervalWidth * 100).ToString("F0") + "% Credible Intervals"; + analysisItem.CredibleIntervals.ItemsSource = ciPoints; + analysisItem.CredibleIntervals.DataFieldX = "Y"; + analysisItem.CredibleIntervals.DataFieldY = "X"; + analysisItem.CredibleIntervals.DataFieldX2 = "Z"; + analysisItem.CredibleIntervals.DataFieldY2 = "X"; + analysisItem.CredibleIntervals.Fill = fillColor; + analysisItem.CredibleIntervals.Color = Colors.Transparent; + analysisItem.CredibleIntervals.TrackerFormatString = curveTracker; + RatingCurvePlot.Series.Add(analysisItem.CredibleIntervals); + + // Predictive + analysisItem.PosteriorPredictive.Name = "PosteriorPredictive_" + index; + analysisItem.PosteriorPredictive.Title = alternative.Name + " - Posterior Predictive"; + analysisItem.PosteriorPredictive.ItemsSource = prdPoints; + analysisItem.PosteriorPredictive.Color = lineColor; + analysisItem.PosteriorPredictive.TrackerFormatString = curveTracker; + RatingCurvePlot.Series.Add(analysisItem.PosteriorPredictive); + + // Point Estimator + analysisItem.PosteriorMode.Name = "PosteriorMode_" + index; + analysisItem.PosteriorMode.Title = alternative.Name + " - " + (alternative.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"); + analysisItem.PosteriorMode.ItemsSource = mdPoints; + analysisItem.PosteriorMode.Color = lineColor; + analysisItem.PosteriorMode.TrackerFormatString = curveTracker; + RatingCurvePlot.Series.Add(analysisItem.PosteriorMode); + + RatingCurvePlot.InvalidatePlot(true); + + } + + } + + /// + /// Handles the removal of an alternative analysis from the plot. + /// Removes the alternative's curves from the main rating curve plot. + /// + /// The alternative analysis item to remove from the plot. + private void AlternativeSelector_AnalysisRemoved(AnalysisAlternativeItem analysisItem) + { + RatingCurvePlot.Series.Remove(analysisItem.CredibleIntervals); + RatingCurvePlot.Series.Remove(analysisItem.PosteriorPredictive); + RatingCurvePlot.Series.Remove(analysisItem.PosteriorMode); + RatingCurvePlot.InvalidatePlot(true); + } + + /// + /// Handles the MouseLeftButtonUp event on the filter toggle text block. + /// Toggles the visibility of the filter panel. + /// + /// The event sender. + /// The mouse button event arguments. + private void TextBlock_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + ShowFilterToggleButton.IsChecked = !ShowFilterToggleButton.IsChecked; + } + + /// + /// Calculates residuals from the rating curve model using the current parameter estimates. + /// + private void GetResiduals() + { + _residuals = null; + if (Element == null || Element.BayesianAnalysis == null || Element.IsValid == false || Element.BayesianAnalysis.IsEstimated == false) return; + _residuals = Element.RatingCurve.Residuals(Element.RatingCurve.Parameters.Select(x => x.Value).ToArray()); + } + + /// + /// Updates the residuals plot showing residuals versus fitted values. + /// Includes a zero reference line for visual assessment of model fit. + /// + private void UpdateResidualsPlot() + { + if (ResidualPlot == null) return; + + var residualsSeries = ResidualPlot.Series.OfType().FirstOrDefault(s => s.Name == "Residuals") + ?? new ScatterPointSeries + { + Name = "Residuals", + Title = "Residuals", + MarkerFill = Colors.Black, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle, + }; + var zeroLine = ResidualPlot.Annotations.OfType().FirstOrDefault(a => a.Name == "ZeroLine") + ?? new LineAnnotation + { + Name = "ZeroLine", + Text = "", + Type = OxyPlot.Annotations.LineAnnotationType.LinearEquation, + Color = Colors.Black, + LineStyle = LineStyle.Dash, + StrokeThickness = 2, + TextLinePosition = 0.9, + Intercept = 0, + Slope = 0, + TextHorizontalAlignment = System.Windows.HorizontalAlignment.Right, + TextVerticalAlignment = System.Windows.VerticalAlignment.Top, + }; + + using (Element?.SuspendPlotBridges()) + { + ResidualPlot.Series.Clear(); + for (int i = ResidualPlot.Annotations.Count - 1; i >= 0; i--) + { + if (ResidualPlot.Annotations[i].Name == "ZeroLine") + { + ResidualPlot.Annotations.RemoveAt(i); + break; + } + } + + if (Element != null && Element.BayesianAnalysis != null && Element.IsValid != false && Element.BayesianAnalysis.IsEstimated == true + && _residuals != null && _residuals.Length > 0) + { + var fittedValues = Element.RatingCurve.FittedValues(Element.RatingCurve.Parameters.Select(x => x.Value).ToArray()); + var points = new List(); + int pairCount = Math.Min(fittedValues.Length, _residuals.Length); + for (int i = 0; i < pairCount; i++) + { + double fitted = fittedValues[i]; + double residual = _residuals[i]; + if (double.IsNaN(fitted) || double.IsInfinity(fitted)) continue; + if (double.IsNaN(residual) || double.IsInfinity(residual)) continue; + points.Add(new DataPoint(fitted, residual)); + } + + residualsSeries.ItemsSource = points; + residualsSeries.Mapping = item => { var d = (DataPoint)item; return new OxyPlot.Series.ScatterPoint(d.X, d.Y); }; + residualsSeries.TrackerFormatString = "{0}" + Environment.NewLine + + "{1}: {2:N3}" + Environment.NewLine + + "{3}: {4:N4}"; + ResidualPlot.Series.Add(residualsSeries); + ResidualPlot.Annotations.Add(zeroLine); + } + + ResidualPlot.InvalidatePlot(true); + } + Element?.RebuildSeriesAndAnnotationBridges(ResidualPlot); + } + + /// + /// Updates the histogram plot showing the distribution of residuals. + /// Overlays a normal density curve for comparison with theoretical normal distribution. + /// + private void UpdateHistogramPlot() + { + if (ResidualHistogramPlot == null) return; + + var histogramSeries = ResidualHistogramPlot.Series.OfType().FirstOrDefault(s => s.Name == "Residuals") + ?? new HistogramSeries + { + Name = "Residuals", + Title = "Residuals", + FillColor = Color.FromArgb(125, 104, 140, 175), + StrokeColor = Color.FromArgb(255, 53, 59, 122), + StrokeThickness = 1, + }; + var normalDensitySeries = ResidualHistogramPlot.Series.OfType().FirstOrDefault(s => s.Name == "NormalDensity") + ?? new LineSeries + { + Name = "NormalDensity", + Title = "Normal Density", + Color = Colors.Black, + StrokeThickness = 1, + LineStyle = LineStyle.Solid, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0, + }; + + using (Element?.SuspendPlotBridges()) + { + ResidualHistogramPlot.Series.Clear(); + + if (Element != null && Element.BayesianAnalysis != null && Element.IsValid != false && Element.BayesianAnalysis.IsEstimated == true + && _residuals != null && _residuals.Length > 0) + { + var values = _residuals.Where(r => !double.IsNaN(r) && !double.IsInfinity(r)).ToArray(); + if (values.Length >= 2) + { + // Use Sturges' rule + int k = (int)(1 + 3.322 * Math.Log(values.Length)); + var histogram = new Histogram(values, k); + var histItems = new List(); + double sum = 0; + for (int i = 0; i < histogram.NumberOfBins; i++) + sum += histogram[i].Frequency * histogram.BinWidth; + for (int i = 0; i < histogram.NumberOfBins; i++) + histItems.Add(new OxyPlot.Series.HistogramItem(histogram[i].LowerBound, histogram[i].UpperBound, histogram[i].Frequency * histogram.BinWidth / sum)); + + histogramSeries.ItemsSource = histItems; + histogramSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:" + UserSettings.ValueStringFormat + "}" + Environment.NewLine + "{3}: {4:0.000000000}"; + ResidualHistogramPlot.Series.Add(histogramSeries); + + double scale = Element.RatingCurve.Parameters.Last().Value; + if (!double.IsNaN(scale) && !double.IsInfinity(scale) && scale > 0) + { + var normal = new Normal(0, scale); + var normalPdf = normal.CreatePDFGraph(); + var normalPdfPoints = new List(); + for (int i = 0; i < normalPdf.GetLength(0); i++) + normalPdfPoints.Add(new DataPoint(normalPdf[i, 0], normalPdf[i, 1])); + + normalDensitySeries.ItemsSource = normalPdfPoints; + normalDensitySeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.0000}" + Environment.NewLine + "{3}: {4:0.000000}"; + ResidualHistogramPlot.Series.Add(normalDensitySeries); + } + } + } + + ResidualHistogramPlot.InvalidatePlot(true); + } + Element?.RebuildSeriesAndAnnotationBridges(ResidualHistogramPlot); + } + + /// + /// Updates the quantile-quantile (Q-Q) plot for assessing normality of residuals. + /// Compares theoretical normal quantiles against observed residual quantiles with a 1:1 reference line. + /// + private void UpdateQQPlot() + { + if (ResidualQQPlot == null) return; + + var qqSeries = ResidualQQPlot.Series.OfType().FirstOrDefault(s => s.Name == "Residuals") + ?? new ScatterPointSeries + { + Name = "Residuals", + Title = "Residuals", + MarkerFill = Colors.Black, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle, + }; + var qqLine = ResidualQQPlot.Annotations.OfType().FirstOrDefault(a => a.Name == "OneToOneLine") + ?? new LineAnnotation + { + Name = "OneToOneLine", + Text = "1:1 Line", + Type = OxyPlot.Annotations.LineAnnotationType.LinearEquation, + Color = Colors.Black, + LineStyle = LineStyle.Dash, + StrokeThickness = 2, + TextLinePosition = 0.9, + Intercept = 0, + Slope = 1, + TextHorizontalAlignment = System.Windows.HorizontalAlignment.Right, + TextVerticalAlignment = System.Windows.VerticalAlignment.Top, + }; + + using (Element?.SuspendPlotBridges()) + { + ResidualQQPlot.Series.Clear(); + for (int i = ResidualQQPlot.Annotations.Count - 1; i >= 0; i--) + { + if (ResidualQQPlot.Annotations[i].Name == "OneToOneLine") + { + ResidualQQPlot.Annotations.RemoveAt(i); + break; + } + } + + if (Element != null && Element.BayesianAnalysis != null && Element.IsValid != false && Element.BayesianAnalysis.IsEstimated == true + && _residuals != null && _residuals.Length > 0) + { + var values = _residuals.Where(r => !double.IsNaN(r) && !double.IsInfinity(r)).ToArray(); + if (values.Length >= 2) + { + Array.Sort(values); + var muSigma = Statistics.MeanStandardDeviation(values); + double mu = muSigma.Item1; + double sigma = muSigma.Item2; + if (!double.IsNaN(mu) && !double.IsInfinity(mu) && + !double.IsNaN(sigma) && !double.IsInfinity(sigma) && sigma > 0) + { + var normal = new Normal(mu, sigma); + var pp = PlottingPositions.Weibull(values.Length); + var qq = new List(); + for (int i = 0; i < values.Length; i++) + { + qq.Add(new DataPoint(normal.InverseCDF(pp[i]), values[i])); + } + + qqSeries.ItemsSource = qq; + qqSeries.Mapping = item => { var d = (DataPoint)item; return new OxyPlot.Series.ScatterPoint(d.X, d.Y); }; + qqSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + ResidualQQPlot.Series.Add(qqSeries); + ResidualQQPlot.Annotations.Add(qqLine); + } + } + } + + ResidualQQPlot.InvalidatePlot(true); + } + Element?.RebuildSeriesAndAnnotationBridges(ResidualQQPlot); + } + + #endregion + + /// + /// Binds the rating curve data grid with analysis results. + /// Populates the table with stage values, credible intervals, and point estimates. + /// + private void BindRatingCurveDataGrid() + { + RatingCurveTable.ItemsSource = null; + + if (Element == null || Element.BayesianAnalysis == null || Element.BayesianAnalysis.IsEstimated != true + || Element.AnalysisResults == null + || Element.AnalysisResults.ModeCurve.Length != Element.StageBins) + return; + + ModeColumn.Header = Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"; + + var curvePoints = new List(); + for (int i = 0; i < Element.StageBins; i++) + { + var s = Element.AnalysisResults.ConfidenceIntervals[i, 0]; + var lo = Element.AnalysisResults.ConfidenceIntervals[i, 1]; + var up = Element.AnalysisResults.ConfidenceIntervals[i, 2]; + var prd = Element.AnalysisResults.MeanCurve[i]; + var md = Element.AnalysisResults.ModeCurve[i]; + curvePoints.Add(new FrequencyCurvePoint(s, 0, up, lo, prd, md)); + } + RatingCurveTable.ItemsSource = curvePoints; + RatingCurveTable.Items.Refresh(); + } + + /// + /// Sets the column headers for the rating curve table based on the credible interval width. + /// Updates the upper and lower confidence interval column headers with appropriate percentile values. + /// + private void SetRatingCurveTableColumnHeaders() + { + double alpha = (1 - Element.BayesianAnalysis.CredibleIntervalWidth) / 2; + UpperColumn.Header = ((1 - alpha) * 100).ToString("F1") + "% CI"; + LowerColumn.Header = (alpha * 100).ToString("F1") + "% CI"; + } + + /// + /// Sets the string format for numeric columns in the rating curve table. + /// Applies user-defined formatting to ensure consistent number display. + /// + private void SetColumnStringFormats() + { + // Update the column binding string format + XColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + UpperColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + LowerColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + PredictiveColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + ModeColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + } + + /// + /// Binds the summary statistics data grid with parameter estimates and model fit statistics. + /// Displays parameter values, AIC, BIC, DIC, and RMSE for the current analysis. + /// + private void BindSummaryStatisticsDataGrid() + { + SummaryStatisticsTable.ItemsSource = null; + + if (Element == null || Element.BayesianAnalysis == null) + return; + + StatValueColumn.Header = Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"; + + var summaryStats = new List(); + if (Element.BayesianAnalysis.IsEstimated == false || Element.AnalysisResults == null) + { + + for (int i = 0; i < Element.RatingCurve.Parameters.Count; i++) + { + summaryStats.Add(new SummaryStatistic(Element.RatingCurve.Parameters[i].DisplayName, double.NaN)); + } + summaryStats.Add(new SummaryStatistic("AIC", double.NaN)); + summaryStats.Add(new SummaryStatistic("BIC", double.NaN)); + summaryStats.Add(new SummaryStatistic("DIC", double.NaN)); + summaryStats.Add(new SummaryStatistic("WAIC", double.NaN)); + summaryStats.Add(new SummaryStatistic("LOO-CV", double.NaN)); + summaryStats.Add(new SummaryStatistic("RMSE", double.NaN)); + } + else + { + for (int i = 0; i < Element.RatingCurve.Parameters.Count; i++) + { + summaryStats.Add(new SummaryStatistic(Element.RatingCurve.Parameters[i].DisplayName, Element.RatingCurve.Parameters[i].Value)); + } + summaryStats.Add(new SummaryStatistic("AIC", Element.AnalysisResults.AIC)); + summaryStats.Add(new SummaryStatistic("BIC", Element.AnalysisResults.BIC)); + summaryStats.Add(new SummaryStatistic("DIC", Element.AnalysisResults.DIC)); + summaryStats.Add(new SummaryStatistic("WAIC", Element.BayesianAnalysis.WAIC)); + summaryStats.Add(new SummaryStatistic("LOO-CV", Element.BayesianAnalysis.LOOIC)); + summaryStats.Add(new SummaryStatistic("RMSE", Element.AnalysisResults.RMSE)); + } + + SummaryStatisticsTable.ItemsSource = summaryStats; + SummaryStatisticsTable.Items.Refresh(); + } + + /// + /// Handles the LoadingRow event for the summary statistics table. + /// Applies custom formatting to create visual separation between parameter groups. + /// + /// The event sender. + /// The data grid row event arguments. + private void SummaryStatisticsTable_LoadingRow(object sender, DataGridRowEventArgs e) + { + if (((SummaryStatistic)e.Row.DataContext).Name == "Minimum" || + ((SummaryStatistic)e.Row.DataContext).Name == "AIC") + { + e.Row.BorderThickness = new Thickness(0, 2, 0, 0); + e.Row.SetResourceReference(Border.BorderBrushProperty, "EnvironmentWindowText"); + } + else + { + e.Row.BorderThickness = new Thickness(0, 0, 0, 0); + } + } + + + + } +} diff --git a/src/RMC.BestFit.App/GUI/RatingCurveAnalysis/RatingCurveAnalysisPropertiesControl.xaml b/src/RMC.BestFit.App/GUI/RatingCurveAnalysis/RatingCurveAnalysisPropertiesControl.xaml new file mode 100644 index 0000000..299852b --- /dev/null +++ b/src/RMC.BestFit.App/GUI/RatingCurveAnalysis/RatingCurveAnalysisPropertiesControl.xaml @@ -0,0 +1,233 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/RatingCurveAnalysis/RatingCurveAnalysisPropertiesControl.xaml.cs b/src/RMC.BestFit.App/GUI/RatingCurveAnalysis/RatingCurveAnalysisPropertiesControl.xaml.cs new file mode 100644 index 0000000..039b26d --- /dev/null +++ b/src/RMC.BestFit.App/GUI/RatingCurveAnalysis/RatingCurveAnalysisPropertiesControl.xaml.cs @@ -0,0 +1,620 @@ +using Numerics.Distributions.Copulas; +using Numerics.Utilities; +using FrameworkInterfaces; +using RMC.BestFit.Models; +using RMC.BestFit.Estimation; +using RMC.BestFit.UI; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Navigation; +using System.Windows.Shapes; + +namespace RMC_BestFit +{ + /// + /// User control for displaying and editing rating curve analysis properties. + /// Provides interface for configuring analysis settings, input data, parameter priors, + /// Bayesian options, and initiating the estimation process. + /// + public partial class RatingCurveAnalysisPropertiesControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public RatingCurveAnalysisPropertiesControl() + { + InitializeComponent(); + DataContext = this; + this.Unloaded += UserControl_Unloaded; + } + + /// + /// Stores the previous name value for validation and rollback purposes. + /// + private string _previousName; + + /// The currently subscribed (named handlers). + private IElementCollection _subscribedTimeSeriesCollection; + + /// + /// Dependency property for the property. + /// + public static DependencyProperty ElementProperty = DependencyProperty.Register(nameof(Element), typeof(RatingCurveAnalysis), typeof(RatingCurveAnalysisPropertiesControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the rating curve analysis element whose properties are being displayed and edited. + /// + public RatingCurveAnalysis Element + { + get { return (RatingCurveAnalysis)GetValue(ElementProperty); } + set { SetValue(ElementProperty, value); } + } + + /// + /// Callback method invoked when the property changes. + /// Initializes the control with the new element's data and sets up event handlers. + /// + /// The dependency object whose property changed. + /// Event arguments containing the old and new property values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as RatingCurveAnalysisPropertiesControl == null) return; + var thisControl = (RatingCurveAnalysisPropertiesControl)d; + + // Unsubscribe from old element + if (e.OldValue is RatingCurveAnalysis oldElement) + oldElement.PropertyChanged -= thisControl.Element_PropertyChanged; + + if (e.NewValue == null) return; + var newElement = e.NewValue as RatingCurveAnalysis; + if (newElement == null) return; + + newElement.PropertyChanged += thisControl.Element_PropertyChanged; + thisControl._previousName = newElement.Name; + thisControl.PropertyAttributes.GetClassAttributes(newElement); + thisControl.LoadTimeSeries(); + + + } + + /// + /// Dependency property for the property. + /// + public static DependencyProperty ExistingNamesProperty = DependencyProperty.Register(nameof(ExistingNames), typeof(string[]), typeof(RatingCurveAnalysisPropertiesControl), new FrameworkPropertyMetadata(new string[] { })); + + /// + /// Gets the array of existing element names used for validation to prevent duplicate names. + /// + public string[] ExistingNames + { + get { return (string[])GetValue(ExistingNamesProperty); } + private set { SetValue(ExistingNamesProperty, value); } + } + + /// + /// Gets the observable collection of available time series elements for data binding. + /// Used to populate stage and discharge data selection dropdowns. + /// + public ObservableCollection TimeSeriesElements { get; private set; } = new ObservableCollection(); + + + #region Property Attributes + + /// + /// Handles the PreviewMouseLeftButtonDown event for the Name field. + /// Displays property attributes for the element's name. + /// + /// The event sender. + /// The mouse button event arguments. + private void Name_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.Name), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the Description field. + /// Displays property attributes for the element's description. + /// + /// The event sender. + /// The mouse button event arguments. + private void Description_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.Description), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the CreationDate field. + /// Displays property attributes for the element's creation date. + /// + /// The event sender. + /// The mouse button event arguments. + private void CreationDate_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.CreationDate), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the LastModified field. + /// Displays property attributes for the element's last modified date. + /// + /// The event sender. + /// The mouse button event arguments. + private void LastModified_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.LastModified), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the StageDataComboBox. + /// Displays property attributes for the stage data selection. + /// + /// The event sender. + /// The mouse button event arguments. + private void StageDataComboBox_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.StageData), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the DischargeDataComboBox. + /// Displays property attributes for the discharge data selection. + /// + /// The event sender. + /// The mouse button event arguments. + private void DischargeDataComboBox_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.DischargeData), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the NumberOfSegments field. + /// Displays property attributes for the rating curve's number of segments. + /// + /// The event sender. + /// The mouse button event arguments. + private void NumberOfSegments_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.RatingCurve.NumberOfSegments), Element.RatingCurve); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the UseJeffreysRule field. + /// Displays property attributes for the Jeffrey's rule for scale setting. + /// + /// The event sender. + /// The mouse button event arguments. + private void UseJeffreysRule_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.RatingCurve.UseJeffreysRuleForScale), Element.RatingCurve); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the CredibleInterval field. + /// Displays property attributes for the credible interval width setting. + /// + /// The event sender. + /// The mouse button event arguments. + private void CredibleInterval_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.BayesianAnalysis.CredibleIntervalWidth), Element.BayesianAnalysis); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the OutputLength field. + /// Displays property attributes for the MCMC output length setting. + /// + /// The event sender. + /// The mouse button event arguments. + private void OutputLength_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.BayesianAnalysis.OutputLength), Element.BayesianAnalysis); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the MinStage field. + /// Displays property attributes for the minimum stage value. + /// + /// The event sender. + /// The mouse button event arguments. + private void MinStage_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.MinStage), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the MaxStage field. + /// Displays property attributes for the maximum stage value. + /// + /// The event sender. + /// The mouse button event arguments. + private void MaxStage_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.MaxStage), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the StageBins field. + /// Displays property attributes for the number of stage bins. + /// + /// The event sender. + /// The mouse button event arguments. + private void StageBins_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.StageBins), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the UseDefaults field. + /// Displays property attributes for the use default stage bins setting. + /// + /// The event sender. + /// The mouse button event arguments. + private void UseDefaults_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.UseDefaultStageBins), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for tab items. + /// Displays class-level attributes for the element. + /// + /// The event sender. + /// The mouse button event arguments. + private void TabItem_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetClassAttributes(Element); + } + + /// + /// Handles the ShowPropertyAttributes event from the ParameterPriorsControl. + /// Displays property attributes for the specified property. + /// + /// The name of the property to display attributes for. + /// The object instance containing the property. + private void ParameterPriorsControl_ShowPropertyAttributes(string propertyName, object classObject) + { + PropertyAttributes.GetPropertyAttributes(propertyName, classObject); + } + + /// + /// Handles the ShowPropertyAttributes event from the BayesianOptionsControl. + /// Displays property attributes for the specified property. + /// + /// The name of the property to display attributes for. + /// The object instance containing the property. + private void BayesianOptionsControl_ShowPropertyAttributes(string propertyName, object classObject) + { + PropertyAttributes.GetPropertyAttributes(propertyName, classObject); + } + + /// + /// Handles the ShowPropertyAttributes event from the BayesianOutputControl. + /// Displays property attributes for the specified property. + /// + /// The name of the property to display attributes for. + /// The object instance containing the property. + private void BayesianOutputControl_ShowPropertyAttributes(string propertyName, object classObject) + { + PropertyAttributes.GetPropertyAttributes(propertyName, classObject); + } + + + #endregion + + /// + /// Handles the GotFocus event for the Name field. + /// Stores the current name and retrieves the list of existing names for validation. + /// + /// The event sender. + /// The event arguments. + private void Name_GotFocus(object sender, RoutedEventArgs e) + { + _previousName = Element.Name; + ExistingNames = Element.ParentCollection.GetElementNames(Element).ToArray(); + } + + /// + /// Handles the LostFocus event for the Name field. + /// Reverts to the previous name if the new name is invalid. + /// + /// The event sender. + /// The event arguments. + private void Name_LostFocus(object sender, RoutedEventArgs e) + { + if (ElementName.NameTextBox.IsValid) + return; + if (Element != null) + Element.Name = _previousName; + } + + /// + /// Handles the Unloaded event of the user control. Unsubscribes from element event handlers + /// to prevent memory leaks and stale event subscriptions. + /// + /// The source of the event. + /// The event data. + private void UserControl_Unloaded(object sender, RoutedEventArgs e) + { + UnsubscribeTimeSeriesCollection(); + } + + /// + /// Handles property changes on the . + /// Can be used to respond to changes in element properties. + /// + /// The event sender. + /// The property changed event arguments. + private void Element_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + // Model object replaced (e.g., during undo) — push to sub-controls + if (e.PropertyName == nameof(Element.RatingCurve)) + { + ParameterPriorsControl.Model = Element.RatingCurve; + } + // BayesianAnalysis replaced — push to sub-controls + if (e.PropertyName == nameof(Element.BayesianAnalysis)) + { + BayesianOptionsControl.Analysis = Element.BayesianAnalysis; + BayesianOutputControl.Analysis = Element.BayesianAnalysis; + } + } + + /// + /// Loads available time series elements from the project. + /// Populates the collection and sets up event handlers + /// to track additions and removals of time series elements. + /// + private void LoadTimeSeries() + { + TimeSeriesElements.Clear(); + if (Element == null) return; + foreach (IElementCollection collection in Element.ParentCollection.ParentProject.ElementCollections) + { + if (collection.GetType() == typeof(TimeSeriesCollection)) + { + UnsubscribeTimeSeriesCollection(); + _subscribedTimeSeriesCollection = collection; + collection.ElementAdded += OnTimeSeriesElementAdded; + collection.ElementRemoved += OnTimeSeriesElementRemoved; + foreach (IElement element in collection) + { + if (element is TimeSeriesElement ts) TimeSeriesElements.Add(ts); + } + break; + } + } + } + + /// Handles a new TimeSeriesElement being added to the project. + private void OnTimeSeriesElementAdded(IElement x) + { + if (x is TimeSeriesElement ts && !TimeSeriesElements.Contains(ts)) TimeSeriesElements.Add(ts); + } + + /// Handles a TimeSeriesElement being removed from the project. + private void OnTimeSeriesElementRemoved(IElement x) + { + if (x is TimeSeriesElement ts) TimeSeriesElements.Remove(ts); + } + + /// Unsubscribes named TimeSeries handlers and clears the tracker field. + private void UnsubscribeTimeSeriesCollection() + { + if (_subscribedTimeSeriesCollection != null) + { + _subscribedTimeSeriesCollection.ElementAdded -= OnTimeSeriesElementAdded; + _subscribedTimeSeriesCollection.ElementRemoved -= OnTimeSeriesElementRemoved; + _subscribedTimeSeriesCollection = null; + } + } + + /// + /// Handles the SelectionChanged event for the StageDataComboBox. + /// Updates the visual state and tooltip to indicate whether a valid selection has been made. + /// + /// The event sender. + /// The selection changed event arguments. + private void StageDataComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + // First-load NRE guard (the WPF DataContext pattern). + if (Element == null) return; + if (Element.StageData == null || Element.StageData.Name == null) + { + ((Border)StageDataComboBox.InnerContent).BorderThickness = new Thickness(1); + StageDataComboBox.ToolTip = "Please select a valid stage time series."; + } + else + { + ((Border)StageDataComboBox.InnerContent).BorderThickness = new Thickness(0); + StageDataComboBox.ToolTip = null; + } + } + + /// + /// Handles the SelectionChanged event for the DischargeDataComboBox. + /// Updates the visual state and tooltip to indicate whether a valid selection has been made. + /// + /// The event sender. + /// The selection changed event arguments. + private void DischargeDataComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + // First-load NRE guard (the WPF DataContext pattern). + if (Element == null) return; + if (Element.DischargeData == null || Element.DischargeData.Name == null) + { + ((Border)DischargeDataComboBox.InnerContent).BorderThickness = new Thickness(1); + DischargeDataComboBox.ToolTip = "Please select a valid discharge time series."; + } + else + { + ((Border)DischargeDataComboBox.InnerContent).BorderThickness = new Thickness(0); + DischargeDataComboBox.ToolTip = null; + } + } + + /// + /// Handles the Loaded event for the StageDataComboBox. + /// Sets up the sorted data binding for the combo box items. + /// + /// The event sender. + /// The event arguments. + private void StageDataComboBox_Loaded(object sender, RoutedEventArgs e) + { + ComboBox cmbo = (ComboBox)sender; + CollectionViewSource csv = new CollectionViewSource() { Source = TimeSeriesElements, IsLiveSortingRequested = true }; + csv.SortDescriptions.Add(new SortDescription(nameof(IElement.Name), ListSortDirection.Ascending)); + var view = csv.View; + view.MoveCurrentToPosition(-1); + cmbo.ItemsSource = view; + } + + /// + /// Handles the Loaded event for the DischargeDataComboBox. + /// Sets up the sorted data binding for the combo box items. + /// + /// The event sender. + /// The event arguments. + private void DischargeDataComboBox_Loaded(object sender, RoutedEventArgs e) + { + ComboBox cmbo = (ComboBox)sender; + CollectionViewSource csv = new CollectionViewSource() { Source = TimeSeriesElements, IsLiveSortingRequested = true }; + csv.SortDescriptions.Add(new SortDescription(nameof(IElement.Name), ListSortDirection.Ascending)); + var view = csv.View; + view.MoveCurrentToPosition(-1); + cmbo.ItemsSource = view; + } + + /// + /// Handles the Click event for the Estimate button. + /// Validates the analysis configuration and initiates the Bayesian estimation process. + /// Manages UI state during the estimation including progress reporting and window disabling. + /// + /// The event sender. + /// The event arguments. + private async void EstimateButton_Click(object sender, RoutedEventArgs e) + { + // Check if the analysis is valid + if (Element.IsValid == false) + { + GenericControls.MessageBox.Show("Cannot perform the analysis because the inputs are invalid.", "Error!", MessageBoxButton.OK, MessageBoxImage.Error); + return; + } + + Mouse.OverrideCursor = Cursors.Wait; + + // Disable all open windows + FrameworkUI.ShellPublicVariables.SimulationInProgress = true; + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableMenuStrip(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableProjectExplorer(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableMessageWindow(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableOpenWindows(); + // Disable Properties + PropertiesExpander.IsEnabled = false; + ParameterPriorsExpander.IsEnabled = false; + Options_TabItem.IsEnabled = false; + Output_TabItem.IsEnabled = false; + + // Set up progress bar + AnalysisProgressDisplayHelper.ShowInitial(ProgressBar, ProgressTextBlock); + + // Show cancel button + EstimateButton.Visibility = Visibility.Hidden; + CancelButton.Visibility = Visibility.Visible; + + // Set up progress reporter + var progressReporter = new SafeProgressReporter(nameof(UnivariateAnalysis)); + progressReporter.ProgressReported += (SafeProgressReporter reporter, double progress, double progressDelta) => + { + AnalysisProgressDisplayHelper.PostProgress(Dispatcher, ProgressBar, ProgressTextBlock, progress); + }; + bool progressCleanupEnabled = false; + progressReporter.TaskEnded += () => + { + if (!progressCleanupEnabled) return; + // Close out progress bar + ProgressBar.Value = 100; + ProgressTextBlock.Text = "Simulation Complete"; + Mouse.OverrideCursor = null; + EstimateButton.IsEnabled = true; + ProgressBar.Visibility = Visibility.Hidden; + ProgressTextBlock.Visibility = Visibility.Hidden; + + // Close cancel button + EstimateButton.Visibility = Visibility.Visible; + CancelButton.Visibility = Visibility.Hidden; + + // Enable all windows + FrameworkUI.ShellPublicVariables.SimulationInProgress = false; + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableMenuStrip(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableProjectExplorer(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableMessageWindow(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableOpenWindows(); + // Enable Properties + PropertiesExpander.IsEnabled = true; + ParameterPriorsExpander.IsEnabled = true; + Options_TabItem.IsEnabled = true; + Output_TabItem.IsEnabled = true; + }; + + + // Perform Bayesian estimation + try + { + await WaitCursorHelper.RunWithVisibleWaitCursorAsync(Dispatcher, async () => + { + await Element.RunAsync(progressReporter); + }); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"RatingCurveAnalysisPropertiesControl.EstimateButton_Click: {ex}"); + GenericControls.MessageBox.Show("An unexpected error occurred. Please verify your analysis inputs and settings." + Environment.NewLine + "If the issue persists, contact the Risk Management Center.", "Error!", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + progressCleanupEnabled = true; + progressReporter.IndicateTaskEnded(); + } + + + } + + /// + /// Handles the Click event for the Cancel button. + /// Cancels the currently running Bayesian analysis. + /// + /// The event sender. + /// The event arguments. + private void CancelButton_Click(object sender, RoutedEventArgs e) + { + Element.CancelAnalysis(); + } + + /// + /// Handles the KeyDown event for the properties panel. + /// Cancels the analysis when the Escape key is pressed. + /// + /// The event sender. + /// The key event arguments. + private void Properties_KeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + Element.CancelAnalysis(); + } + + + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/AnalysisProgressDisplayHelper.cs b/src/RMC.BestFit.App/GUI/Support/AnalysisProgressDisplayHelper.cs new file mode 100644 index 0000000..421e851 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/AnalysisProgressDisplayHelper.cs @@ -0,0 +1,107 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; + +namespace RMC_BestFit +{ + /// + /// Applies shared progress display behavior to standalone analysis property controls. + /// + internal static class AnalysisProgressDisplayHelper + { + /// + /// Progress value at which the UI switches to the processing-results state. + /// + internal const double ProcessingThreshold = 99.0; + + /// + /// Shows an initialized progress bar and text block. + /// + /// The progress bar to initialize. + /// The progress text block to initialize. + /// The text to show before measurable progress is available. + internal static void ShowInitial(ProgressBar progressBar, TextBlock progressTextBlock, string startingText = "Starting...") + { + progressBar.IsIndeterminate = true; + progressBar.Value = 0; + progressTextBlock.Text = startingText; + progressBar.Visibility = Visibility.Visible; + progressTextBlock.Visibility = Visibility.Visible; + } + + /// + /// Applies progress to the controls on the owning dispatcher. + /// + /// The dispatcher that owns the controls. + /// The progress bar to update. + /// The progress text block to update. + /// The progress percentage. + /// The text to show before measurable progress is available. + internal static void PostProgress( + Dispatcher dispatcher, + ProgressBar progressBar, + TextBlock progressTextBlock, + double progress, + string startingText = "Starting...") + { + if (dispatcher.CheckAccess()) + { + ApplyProgress(progressBar, progressTextBlock, progress, startingText); + return; + } + + _ = dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + { + ApplyProgress(progressBar, progressTextBlock, progress, startingText); + })); + } + + /// + /// Applies progress to the controls. + /// + /// The progress bar to update. + /// The progress text block to update. + /// The progress percentage. + /// The text to show before measurable progress is available. + internal static void ApplyProgress( + ProgressBar progressBar, + TextBlock progressTextBlock, + double progress, + string startingText = "Starting...") + { + progressBar.Value = Math.Max(0.0, Math.Min(100.0, progress)); + + if (progress <= 0.01) + { + progressTextBlock.Text = startingText; + progressBar.IsIndeterminate = true; + } + else if (progress >= ProcessingThreshold) + { + progressTextBlock.Text = "Processing Results..."; + progressBar.IsIndeterminate = true; + } + else + { + progressTextBlock.Text = progress.ToString("N0") + "% Complete"; + progressBar.IsIndeterminate = false; + } + } + + /// + /// Hides progress controls after the awaited analysis task has completed. + /// + /// The progress bar to hide. + /// The progress text block to hide. + /// The completion text to set before hiding. + internal static void HideCompleted(ProgressBar progressBar, TextBlock progressTextBlock, string completeText = "Analysis Complete") + { + progressBar.Value = 100; + progressBar.IsIndeterminate = false; + progressTextBlock.Text = completeText; + progressBar.Visibility = Visibility.Hidden; + progressTextBlock.Visibility = Visibility.Hidden; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/BatchProgressUpdateDispatcher.cs b/src/RMC.BestFit.App/GUI/Support/BatchProgressUpdateDispatcher.cs new file mode 100644 index 0000000..05e7d3b --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/BatchProgressUpdateDispatcher.cs @@ -0,0 +1,216 @@ +using RMC.BestFit.Analyses; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Threading; + +namespace RMC_BestFit +{ + /// + /// Coalesces batch progress updates before applying them to WPF row view models. + /// + /// + /// Parallel analyses can report progress from several worker threads at once. This helper + /// keeps only the latest progress value for each analysis and applies those values on the + /// dispatcher at a modest cadence so rendering and input are not starved by progress chatter. + /// + internal sealed class BatchProgressUpdateDispatcher : IDisposable + { + /// + /// Minimum time between queued progress flushes. + /// + private const int MinimumFlushIntervalMilliseconds = 50; + + /// + /// Dispatcher that owns the row view models. + /// + private readonly Dispatcher _dispatcher; + + /// + /// Analysis-to-row lookup updated by this dispatcher. + /// + private readonly Dictionary _analysisToViewModel; + + /// + /// Synchronizes pending progress and completion state. + /// + private readonly object _gate = new object(); + + /// + /// Latest progress value by analysis. + /// + private readonly Dictionary _pendingProgress = new Dictionary(); + + /// + /// Analyses whose terminal state has already been received. + /// + private readonly HashSet _completedAnalyses = new HashSet(); + + /// + /// Tracks elapsed time since the last progress flush. + /// + private readonly Stopwatch _flushStopwatch = Stopwatch.StartNew(); + + /// + /// Indicates whether a background flush has already been queued. + /// + private bool _flushQueued; + + /// + /// Indicates whether this dispatcher has been disposed. + /// + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The WPF dispatcher that owns the row view models. + /// The analysis-to-row lookup to update. + /// Thrown when an argument is null. + internal BatchProgressUpdateDispatcher( + Dispatcher dispatcher, + Dictionary analysisToViewModel) + { + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _analysisToViewModel = analysisToViewModel ?? throw new ArgumentNullException(nameof(analysisToViewModel)); + } + + /// + /// Marks an analysis as running on the dispatcher. + /// + /// The analysis that is starting. + internal void MarkStarting(IAnalysis analysis) + { + if (analysis == null) return; + + lock (_gate) + { + _completedAnalyses.Remove(analysis); + _pendingProgress.Remove(analysis); + } + + BeginInvoke(DispatcherPriority.Normal, () => + { + BatchRunCoordinator.MarkStarting(_analysisToViewModel, analysis); + }); + } + + /// + /// Applies a completed result and prevents later stale progress for the same analysis. + /// + /// The completed batch result. + internal void ApplyResult(BatchAnalysisResult result) + { + if (result == null) return; + + lock (_gate) + { + _completedAnalyses.Add(result.Analysis); + _pendingProgress.Remove(result.Analysis); + } + + BeginInvoke(DispatcherPriority.Normal, () => + { + BatchRunCoordinator.ApplyResult(_analysisToViewModel, result); + }); + } + + /// + /// Records the latest progress value for an analysis and queues a coalesced flush. + /// + /// The analysis whose progress changed. + /// The latest progress percentage. + internal void PostProgress(IAnalysis analysis, double progress) + { + if (analysis == null) return; + + int delayMilliseconds; + lock (_gate) + { + if (_disposed || _completedAnalyses.Contains(analysis)) return; + + _pendingProgress[analysis] = progress; + if (_flushQueued) return; + + _flushQueued = true; + long remaining = MinimumFlushIntervalMilliseconds - _flushStopwatch.ElapsedMilliseconds; + delayMilliseconds = (int)Math.Max(0, remaining); + } + + _ = Task.Delay(delayMilliseconds).ContinueWith(delayTask => + { + if (_dispatcher.HasShutdownStarted || _dispatcher.HasShutdownFinished) return; + _ = _dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(Flush)); + }, TaskScheduler.Default); + } + + /// + /// Flushes any pending progress updates on the dispatcher. + /// + /// A task that completes after the flush has run. + internal Task FlushAsync() + { + if (_dispatcher.CheckAccess()) + { + Flush(); + return Task.CompletedTask; + } + + return _dispatcher.InvokeAsync(Flush, DispatcherPriority.Background).Task; + } + + /// + /// Prevents future progress updates from being queued. + /// + public void Dispose() + { + lock (_gate) + { + _disposed = true; + _pendingProgress.Clear(); + _completedAnalyses.Clear(); + } + } + + /// + /// Queues an action on the dispatcher if the window is still alive. + /// + /// The dispatcher priority to use. + /// The action to invoke. + private void BeginInvoke(DispatcherPriority priority, Action action) + { + if (_dispatcher.HasShutdownStarted || _dispatcher.HasShutdownFinished) return; + _ = _dispatcher.BeginInvoke(priority, action); + } + + /// + /// Applies the latest pending progress values to the row view models. + /// + private void Flush() + { + KeyValuePair[] updates; + lock (_gate) + { + if (_disposed) + { + _flushQueued = false; + return; + } + + updates = _pendingProgress + .Where(pair => !_completedAnalyses.Contains(pair.Key)) + .ToArray(); + _pendingProgress.Clear(); + _flushQueued = false; + _flushStopwatch.Restart(); + } + + foreach (KeyValuePair update in updates) + { + BatchRunCoordinator.ApplyProgress(_analysisToViewModel, update.Key, update.Value); + } + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/BatchRunCoordinator.cs b/src/RMC.BestFit.App/GUI/Support/BatchRunCoordinator.cs new file mode 100644 index 0000000..b535645 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/BatchRunCoordinator.cs @@ -0,0 +1,230 @@ +using FrameworkInterfaces; +using RMC.BestFit.Analyses; +using RMC.BestFit.UI; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace RMC_BestFit +{ + /// + /// Provides testable batch-run view-model state transitions used by . + /// + /// + /// This class keeps orchestration logic that does not require WPF controls out of the + /// window code-behind so status mapping, selection, and summary behavior can be covered + /// by fast unit tests. + /// + internal static class BatchRunCoordinator + { + /// + /// Builds batch-run row view models for elements that expose a model-layer analysis. + /// + /// The candidate project elements. + /// A collection of view models for runnable analysis elements. + /// Thrown when is null. + internal static ObservableCollection BuildViewModels(IEnumerable elements) + { + if (elements == null) throw new ArgumentNullException(nameof(elements)); + + var viewModels = new ObservableCollection(); + foreach (IElement element in elements) + { + if (element is IAnalysisElement analysisElement && analysisElement.InnerAnalysis != null) + { + var viewModel = new BatchRunItemViewModel(element, analysisElement); + if (analysisElement.IsEstimated) + { + viewModel.Status = BatchRunStatus.Succeeded; + viewModel.Progress = 100; + viewModel.StatusMessage = "Already estimated"; + } + + viewModels.Add(viewModel); + } + } + + return viewModels; + } + + /// + /// Builds a lookup from model-layer analysis instances to their row view models. + /// + /// The view models to index. + /// A dictionary keyed by model-layer analysis. + /// Thrown when is null. + internal static Dictionary BuildAnalysisMap(IEnumerable viewModels) + { + if (viewModels == null) throw new ArgumentNullException(nameof(viewModels)); + + var map = new Dictionary(); + foreach (BatchRunItemViewModel viewModel in viewModels) + { + if (viewModel.Analysis.InnerAnalysis != null) + { + map[viewModel.Analysis.InnerAnalysis] = viewModel; + } + } + + return map; + } + + /// + /// Resets selected rows before a new batch execution begins. + /// + /// The selected view models. + /// Thrown when is null. + internal static void ResetForRun(IEnumerable viewModels) + { + if (viewModels == null) throw new ArgumentNullException(nameof(viewModels)); + + foreach (BatchRunItemViewModel viewModel in viewModels) + { + viewModel.Status = BatchRunStatus.Pending; + viewModel.Progress = 0; + viewModel.Duration = null; + viewModel.StatusMessage = "Waiting..."; + } + } + + /// + /// Marks the supplied analysis as running when its start event is received. + /// + /// The analysis-to-view-model map. + /// The analysis that started. + /// Thrown when is null. + internal static void MarkStarting(Dictionary map, IAnalysis analysis) + { + if (map == null) throw new ArgumentNullException(nameof(map)); + + if (analysis != null && map.TryGetValue(analysis, out BatchRunItemViewModel viewModel)) + { + viewModel.Status = BatchRunStatus.Running; + viewModel.StatusMessage = "Running..."; + viewModel.Progress = 0; + } + } + + /// + /// Applies an analysis completion result to the matching row view model. + /// + /// The analysis-to-view-model map. + /// The batch result to apply. + /// + /// Thrown when or is null. + /// + internal static void ApplyResult(Dictionary map, BatchAnalysisResult result) + { + if (map == null) throw new ArgumentNullException(nameof(map)); + if (result == null) throw new ArgumentNullException(nameof(result)); + + if (!map.TryGetValue(result.Analysis, out BatchRunItemViewModel viewModel)) return; + + viewModel.Duration = result.Duration; + if (result.Succeeded) + { + viewModel.Status = BatchRunStatus.Succeeded; + viewModel.Progress = 100; + viewModel.StatusMessage = "Complete"; + } + else if (result.WasCanceled) + { + viewModel.Status = BatchRunStatus.Canceled; + viewModel.StatusMessage = "Canceled"; + } + else + { + viewModel.Status = BatchRunStatus.Failed; + viewModel.StatusMessage = result.Error?.Message ?? "Failed"; + } + } + + /// + /// Applies a progress update to the matching row view model. + /// + /// The analysis-to-view-model map. + /// The analysis whose progress changed. + /// The progress percentage. + /// Thrown when is null. + internal static void ApplyProgress(Dictionary map, IAnalysis analysis, double progress) + { + if (map == null) throw new ArgumentNullException(nameof(map)); + + if (analysis != null && map.TryGetValue(analysis, out BatchRunItemViewModel viewModel)) + { + viewModel.Progress = progress; + } + } + + /// + /// Selects the model-layer analyses represented by the selected view models. + /// + /// The selected view models. + /// The runnable model-layer analyses, preserving view-model order. + /// Thrown when is null. + internal static List SelectAnalyses(IEnumerable viewModels) + { + if (viewModels == null) throw new ArgumentNullException(nameof(viewModels)); + + return viewModels + .Where(viewModel => viewModel.Analysis.InnerAnalysis != null) + .Select(viewModel => viewModel.Analysis.InnerAnalysis) + .ToList(); + } + + /// + /// Creates batch-analysis execution options from the UI parallel setting. + /// + /// Whether parallel execution is requested. + /// The processor count to use for parallel execution. + /// The configured batch-analysis options. + internal static BatchAnalysisOptions CreateOptions(bool runParallel, int processorCount) + { + return new BatchAnalysisOptions + { + MaxDegreeOfParallelism = runParallel ? CalculateParallelAnalysisSlots(processorCount) : 1, + ContinueOnError = true, + OrderByDependency = true + }; + } + + /// + /// Calculates the number of analyses to run concurrently from the processor count. + /// + /// The number of logical processors available. + /// A conservative parallel slot count that leaves headroom for WPF rendering. + internal static int CalculateParallelAnalysisSlots(int processorCount) + { + int availableProcessors = Math.Max(1, processorCount); + return Math.Max(1, Math.Min(4, availableProcessors / 2)); + } + + /// + /// Formats the completion summary displayed after a batch run. + /// + /// The completed batch results. + /// A concise status summary. + /// Thrown when is null. + internal static string FormatSummary(IReadOnlyCollection results) + { + if (results == null) throw new ArgumentNullException(nameof(results)); + + int total = results.Count; + int succeeded = results.Count(result => result.Succeeded); + int failed = results.Count(result => !result.Succeeded && !result.WasCanceled); + int canceled = results.Count(result => result.WasCanceled); + + var summary = new System.Text.StringBuilder(); + summary.Append($"Batch run finished: {total} {(total == 1 ? "Analysis" : "Analyses")}"); + summary.Append($" ({succeeded} Succeeded"); + if (failed > 0) + summary.Append($", {failed} Failed"); + if (canceled > 0) + summary.Append($", {canceled} Canceled"); + summary.Append(')'); + + return summary.ToString(); + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/BatchRunStatusIconKeys.cs b/src/RMC.BestFit.App/GUI/Support/BatchRunStatusIconKeys.cs new file mode 100644 index 0000000..3bf8ed7 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/BatchRunStatusIconKeys.cs @@ -0,0 +1,33 @@ +using System; + +namespace RMC_BestFit +{ + /// + /// Maps batch-run statuses to application resource keys. + /// + /// + /// Keeping the mapping separate from the WPF converter lets unit tests assert every + /// supported status without creating an . + /// + internal static class BatchRunStatusIconKeys + { + /// + /// Gets the icon resource key for a batch-run status. + /// + /// The status to map. + /// The application resource key for the status icon. + /// Thrown when is not supported. + internal static string ForStatus(BatchRunStatus status) + { + return status switch + { + BatchRunStatus.Pending => "StatusPendingIcon", + BatchRunStatus.Running => "StatusRunningIcon", + BatchRunStatus.Succeeded => "StatusSucceededIcon", + BatchRunStatus.Failed => "StatusFailedIcon", + BatchRunStatus.Canceled => "StatusCanceledIcon", + _ => throw new ArgumentOutOfRangeException(nameof(status), status, "Unsupported batch-run status.") + }; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/BatchRunStatusToIconConverter.cs b/src/RMC.BestFit.App/GUI/Support/BatchRunStatusToIconConverter.cs new file mode 100644 index 0000000..61175db --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/BatchRunStatusToIconConverter.cs @@ -0,0 +1,81 @@ +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace RMC_BestFit +{ + /// + /// Converts a value to its corresponding + /// DrawingImage icon from the application's IconDictionary.xaml. + /// + /// + /// + /// Authors: + /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil + /// + /// + /// This converter is used in the DataGrid to display + /// the appropriate status icon (pending clock, running spinner, green check, red X) + /// for each analysis row. The icons are defined as DrawingImage resources + /// in Resources/IconDictionary.xaml and are theme-neutral (they look correct + /// on both light and dark backgrounds). + /// + /// + /// Resource keys used: + /// + /// StatusPendingIcon — gray clock for + /// StatusRunningIcon — blue circular arrows for + /// StatusSucceededIcon — green circle with white check for + /// StatusFailedIcon — red circle with white X for + /// + /// + /// + public class BatchRunStatusToIconConverter : IValueConverter + { + /// + /// Converts a value to its corresponding icon resource. + /// + /// The value to convert. + /// The target type (not used). + /// An optional parameter (not used). + /// The culture information (not used). + /// + /// A DrawingImage resource from the application resources, or null + /// if the value is not a valid . + /// + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is BatchRunStatus status) + { + string resourceKey; + try + { + resourceKey = BatchRunStatusIconKeys.ForStatus(status); + } + catch (ArgumentOutOfRangeException) + { + return null; + } + + return Application.Current.FindResource(resourceKey); + } + + return null; + } + + /// + /// Not supported. This is a one-way converter. + /// + /// The value to convert back (not used). + /// The target type (not used). + /// An optional parameter (not used). + /// The culture information (not used). + /// Never returns; always throws . + /// Always thrown. + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotSupportedException("BatchRunStatusToIconConverter is a one-way converter."); + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/CoincidentFrequencyResponseGridValidator.cs b/src/RMC.BestFit.App/GUI/Support/CoincidentFrequencyResponseGridValidator.cs new file mode 100644 index 0000000..9055f1d --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/CoincidentFrequencyResponseGridValidator.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace RMC_BestFit +{ + /// + /// Validates editable coincident-frequency response-surface grid cells. + /// + /// + /// The coincident-frequency grid stores response values, not probabilities. A valid + /// cell must therefore be finite and consistent with the strictly increasing + /// response surface required by the model-layer inversion algorithm. + /// + internal static class CoincidentFrequencyResponseGridValidator + { + /// + /// Converts a DataGrid/DataTable cell payload into a nullable double. + /// + /// The raw cell payload. + /// The converted double, or null when the cell is blank or cannot be converted. + /// + /// Blank or non-convertible cells are kept distinct from numeric zero so the + /// visual validation layer does not silently turn missing data into a valid + /// response value. + /// + internal static double? ConvertCellValue(object value) + { + if (value == null || value == DBNull.Value) return null; + + try + { + return Convert.ToDouble(value, CultureInfo.InvariantCulture); + } + catch (FormatException) + { + return null; + } + catch (InvalidCastException) + { + return null; + } + catch (OverflowException) + { + return null; + } + } + + /// + /// Validates one cell in a response surface. + /// + /// The nullable response surface. A null entry means the cell is blank or non-numeric. + /// The zero-based row index. + /// The zero-based column index. + /// A validation result with a tooltip message for invalid cells. + /// + /// The method only compares against finite neighboring values. Neighboring blank + /// cells are marked invalid by their own validation result, while valid neighbors + /// still provide strict-increase constraints for the current cell. + /// + internal static ResponseGridCellValidationResult ValidateCell(double?[,] response, int row, int column) + { + if (response == null) + return ResponseGridCellValidationResult.Invalid("The response surface is not available."); + + int rows = response.GetLength(0); + int columns = response.GetLength(1); + if (row < 0 || row >= rows || column < 0 || column >= columns) + return ResponseGridCellValidationResult.Invalid("The response cell is outside the response surface."); + + double? maybeValue = response[row, column]; + if (!maybeValue.HasValue) + return ResponseGridCellValidationResult.Invalid("The response value is required."); + + double value = maybeValue.Value; + if (!IsFinite(value)) + return ResponseGridCellValidationResult.Invalid("The response value must be finite."); + + var messages = new List(); + if (row > 0 && TryGetFinite(response[row - 1, column], out double previousX) && !(value > previousX)) + messages.Add("greater than the previous X-row value"); + if (row < rows - 1 && TryGetFinite(response[row + 1, column], out double nextX) && !(value < nextX)) + messages.Add("less than the next X-row value"); + if (column > 0 && TryGetFinite(response[row, column - 1], out double previousY) && !(value > previousY)) + messages.Add("greater than the previous Y-column value"); + if (column < columns - 1 && TryGetFinite(response[row, column + 1], out double nextY) && !(value < nextY)) + messages.Add("less than the next Y-column value"); + + if (messages.Count == 0) + return ResponseGridCellValidationResult.Valid; + + return ResponseGridCellValidationResult.Invalid( + "The response surface must be strictly increasing; this value must be " + + string.Join(", ", messages) + "."); + } + + /// + /// Determines whether a value is finite. + /// + /// The value to inspect. + /// true when the value is neither NaN nor infinity; otherwise, false. + private static bool IsFinite(double value) + { + return !double.IsNaN(value) && !double.IsInfinity(value); + } + + /// + /// Tries to extract a finite value from a nullable cell. + /// + /// The nullable value. + /// The finite value when conversion succeeds. + /// true when the cell has a finite value; otherwise, false. + private static bool TryGetFinite(double? value, out double finiteValue) + { + finiteValue = value.GetValueOrDefault(); + return value.HasValue && IsFinite(finiteValue); + } + } + + /// + /// Represents the validation status of an editable response-surface grid cell. + /// + internal readonly struct ResponseGridCellValidationResult + { + /// + /// Initializes a new instance of the struct. + /// + /// Whether the cell is valid. + /// The tooltip message to show when the cell is invalid. + private ResponseGridCellValidationResult(bool isValid, string toolTip) + { + IsValid = isValid; + ToolTip = toolTip ?? string.Empty; + } + + /// + /// Gets a valid cell result. + /// + internal static ResponseGridCellValidationResult Valid => new ResponseGridCellValidationResult(true, string.Empty); + + /// + /// Gets a value indicating whether the cell is valid. + /// + internal bool IsValid { get; } + + /// + /// Gets the tooltip message to show when the cell is invalid. + /// + internal string ToolTip { get; } + + /// + /// Creates an invalid cell result. + /// + /// The tooltip message to show when the cell is invalid. + /// An invalid validation result. + internal static ResponseGridCellValidationResult Invalid(string toolTip) + { + return new ResponseGridCellValidationResult(false, toolTip); + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/CoincidentFrequencyTablePoint.cs b/src/RMC.BestFit.App/GUI/Support/CoincidentFrequencyTablePoint.cs new file mode 100644 index 0000000..965896d --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/CoincidentFrequencyTablePoint.cs @@ -0,0 +1,58 @@ +namespace RMC_BestFit +{ + /// + /// Represents a single row in the coincident frequency analysis tabular output. + /// + /// + /// + /// Different shape from : in coincident frequency the + /// independent variable is the response Z (the first column of the table) and the + /// dependent variables are the AEPs at that Z level. This mirrors the spreadsheet + /// convention used in the Waimea CFA workbook. + /// + /// + public class CoincidentFrequencyTablePoint + { + /// + /// Gets or sets the response (Z) value at which AEPs are evaluated. + /// + public double Z { get; set; } + + /// + /// Gets or sets the lower-bound credible-interval AEP at this Z value. + /// + public double Lower { get; set; } + + /// + /// Gets or sets the upper-bound credible-interval AEP at this Z value. + /// + public double Upper { get; set; } + + /// + /// Gets or sets the posterior-predictive (mean curve) AEP at this Z value. + /// + public double Predictive { get; set; } + + /// + /// Gets or sets the point-estimate (mode) AEP at this Z value. + /// + public double Mode { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The Z (response) value. + /// The lower-bound CI AEP. + /// The upper-bound CI AEP. + /// The posterior-predictive AEP. + /// The point-estimate (mode) AEP. + public CoincidentFrequencyTablePoint(double z, double lower, double upper, double predictive, double mode) + { + Z = z; + Lower = lower; + Upper = upper; + Predictive = predictive; + Mode = mode; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/AnalysisAlternativeItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/AnalysisAlternativeItem.cs new file mode 100644 index 0000000..dbdc881 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/AnalysisAlternativeItem.cs @@ -0,0 +1,167 @@ +using System; +using OxyPlot; +using OxyPlot.Wpf; +using FrameworkInterfaces; +using RMC.BestFit.Models; +using RMC.BestFit.UI; +using System.ComponentModel; +using System.Windows.Media; + +namespace RMC_BestFit +{ + /// + /// Represents a selectable analysis alternative item with associated visualization series for Bayesian analysis results. + /// + /// + /// This class encapsulates an analysis alternative along with its graphical representations including + /// credible intervals, posterior predictive distribution, and posterior mode. It implements property + /// change notification to enable binding in WPF applications and tracks the checked state for UI selection. + /// Implements to allow callers to unsubscribe from the wrapped + /// IAnalysisElement.PropertyChanged event when the item is no longer needed. + /// + public class AnalysisAlternativeItem : IDisposable + { + /// + /// Backing field for the IsChecked property. + /// + private bool _isChecked = false; + + /// + /// Default OxyPlot tracker format string for analysis alternative series. + /// + private const string DefaultTrackerFormatString = "{0}\n{1}: {2}\n{3}: {4}"; + + /// + /// Occurs when a property value changes. + /// + public event PropertyChangedEventHandler PropertyChanged; + + /// + /// Gets the analysis alternative associated with this item. + /// + public IAnalysisElement Alternative { get; } + + /// + /// Gets the area series representing the credible intervals for the analysis. + /// + /// + /// The credible intervals are displayed as a shaded area with translucent blue fill. + /// + public AreaSeries CredibleIntervals { get; } + + /// + /// Gets the line series representing the posterior predictive distribution. + /// + /// + /// The posterior predictive is displayed as a dashed blue line. + /// + public LineSeries PosteriorPredictive { get; } + + /// + /// Gets the line series representing the posterior mode. + /// + /// + /// The posterior mode is displayed as a solid black line. + /// + public LineSeries PosteriorMode { get; } + + /// + /// Gets or sets a value indicating whether this analysis alternative item is checked in the user interface. + /// + /// + /// Setting this property raises the PropertyChanged event when the value changes. + /// + public bool IsChecked + { + get { return _isChecked; } + set + { + if (_isChecked != value) + { + _isChecked = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsChecked))); + } + } + } + + /// + /// Initializes a new instance of the class. + /// + /// The analysis alternative to wrap. + /// + /// This constructor initializes the visualization series with predefined styling for + /// credible intervals, posterior predictive, and posterior mode. It also subscribes + /// to property change events from the alternative to propagate relevant changes. + /// + public AnalysisAlternativeItem(IAnalysisElement alternative) + { + Alternative = alternative; + Alternative.PropertyChanged += Alternative_PropertyChanged; + + CredibleIntervals = new AreaSeries() + { + Name = "CredibleIntervals", + Fill = Color.FromArgb(75, 104, 140, 175), + Color = Color.FromArgb(255, 53, 59, 122), + LineStyle = LineStyle.Solid, + BrokenLineThickness = 1, + StrokeThickness = 1, + Decimator = OxyPlot.Decimator.Decimate, + TrackerFormatString = DefaultTrackerFormatString + }; + + PosteriorPredictive = new LineSeries() + { + Name = "PosteriorPredictive", + Title = "Posterior Predictive", + Color = Colors.Blue, + StrokeThickness = 1, + LineStyle = LineStyle.Dash, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0, + TrackerFormatString = DefaultTrackerFormatString + }; + + PosteriorMode = new LineSeries() + { + Name = "PosteriorMode", + Title = "Posterior Mode", + Color = Colors.Black, + StrokeThickness = 1, + LineStyle = LineStyle.Solid, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0, + TrackerFormatString = DefaultTrackerFormatString + }; + + } + + /// + /// Handles property change events from the associated analysis alternative. + /// + /// The source of the event. + /// The containing the event data. + /// + /// This method propagates property change notifications for AnalysisResults and IsEstimated + /// properties to listeners of this item's PropertyChanged event. + /// + private void Alternative_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Alternative.AnalysisResults) || e.PropertyName == nameof(Alternative.IsEstimated)) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(e.PropertyName)); + } + } + + /// + /// Releases resources held by this instance. Unsubscribes from the wrapped + /// IAnalysisElement.PropertyChanged event to prevent memory leaks + /// when long-lived instances would otherwise keep this + /// wrapper alive via the event subscription. + /// + public void Dispose() + { + Alternative.PropertyChanged -= Alternative_PropertyChanged; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/AverageMethodItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/AverageMethodItem.cs new file mode 100644 index 0000000..97fc696 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/AverageMethodItem.cs @@ -0,0 +1,49 @@ +using RMC.BestFit.Models; +using RMC.BestFit.UI; +using ModelAnalyses = RMC.BestFit.Analyses; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RMC_BestFit +{ + /// + /// Represents an item in a combo box for selecting averaging methods used in composite distribution analysis. + /// + /// + /// This class encapsulates the display properties and underlying value for averaging method options + /// presented to users in the GUI, providing a user-friendly interface for method selection. + /// + public class AverageMethodItem + { + /// + /// Gets the user-friendly display name for the averaging method shown in the combo box. + /// + public string DisplayName { get; } + + /// + /// Gets the underlying enumeration value representing the specific averaging method. + /// + public ModelAnalyses.AverageMethod Value { get; } + + /// + /// Gets the tooltip text that provides additional information about the averaging method when the user hovers over the item. + /// + public string ToolTip { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The user-friendly name to display in the combo box. + /// The averaging method enumeration value this item represents. + /// Optional tooltip text providing additional information about the averaging method. Defaults to an empty string. + public AverageMethodItem(string displayName, ModelAnalyses.AverageMethod value, string tooltip = "") + { + DisplayName = displayName; + Value = value; + ToolTip = tooltip; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/B17CUncertaintyOptionItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/B17CUncertaintyOptionItem.cs new file mode 100644 index 0000000..d7f3a68 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/B17CUncertaintyOptionItem.cs @@ -0,0 +1,39 @@ +using RMC.BestFit.Analyses; + +namespace RMC_BestFit +{ + /// + /// Represents a combo box item that encapsulates Bulletin 17C uncertainty option selection for the user interface. + /// This class provides a display-friendly representation of different uncertainty quantification methods used in Bulletin 17C flood frequency analysis. + /// + public class B17CUncertaintyOptionItem + { + /// + /// Gets the human-readable name displayed to users in the combo box. + /// + public string DisplayName { get; } + + /// + /// Gets the underlying Bulletin 17C uncertainty option enumeration value associated with this item. + /// + public UncertaintyMethod Value { get; } + + /// + /// Gets the tooltip text that provides additional information about the uncertainty option when users hover over the item. + /// + public string ToolTip { get; } + + /// + /// Initializes a new instance of the class with the specified display name, uncertainty option, and optional tooltip. + /// + /// The human-readable name to display in the combo box. + /// The Bulletin 17C uncertainty option enumeration value this item represents. + /// Optional tooltip text providing additional information about this uncertainty option. Defaults to an empty string if not provided. + public B17CUncertaintyOptionItem(string displayName, UncertaintyMethod value, string tooltip = "") + { + DisplayName = displayName; + Value = value; + ToolTip = tooltip; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/BlockFunctionItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/BlockFunctionItem.cs new file mode 100644 index 0000000..fe8623e --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/BlockFunctionItem.cs @@ -0,0 +1,32 @@ +using Numerics.Data; + +namespace RMC_BestFit +{ + /// + /// Represents a combo box item that encapsulates block function type selection for the user interface. + /// This class provides a display-friendly representation of different block aggregation functions used for statistical analysis of grouped data. + /// + public class BlockFunctionItem + { + /// + /// Gets or sets the human-readable name displayed to users in the combo box. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the underlying block function type enumeration value associated with this item. + /// + public BlockFunctionType Value { get; set; } + + /// + /// Initializes a new instance of the class with the specified display name and block function type. + /// + /// The human-readable name to display in the combo box. + /// The block function type enumeration value this item represents. + public BlockFunctionItem(string displayName, BlockFunctionType value) + { + DisplayName = displayName; + Value = value; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/CompositeTypeItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/CompositeTypeItem.cs new file mode 100644 index 0000000..3179eea --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/CompositeTypeItem.cs @@ -0,0 +1,51 @@ +using RMC.BestFit.Models; +using RMC.BestFit.UI; +using ModelAnalyses = RMC.BestFit.Analyses; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RMC_BestFit +{ + /// + /// Represents a selectable composite analysis type item for use in combo box controls. + /// + /// + /// This class encapsulates the display name, composite type value, and optional tooltip + /// for composite analysis types, enabling user-friendly selection in the GUI. + /// + public class CompositeTypeItem + { + /// + /// Gets the display name shown to the user in the combo box. + /// + public string DisplayName { get; } + + /// + /// Gets the underlying composite type enumeration value. + /// + public ModelAnalyses.CompositeType Value { get; } + + /// + /// Gets the tooltip text that provides additional information about the composite type. + /// + public string ToolTip { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The user-friendly display name for the composite type. + /// The underlying composite type enumeration value. + /// Optional tooltip text providing additional information. Defaults to an empty string. + public CompositeTypeItem(string displayName, ModelAnalyses.CompositeType value, string tooltip = "") + { + DisplayName = displayName; + Value = value; + ToolTip = tooltip; + } + } + +} + diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/CopulaEstimationItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/CopulaEstimationItem.cs new file mode 100644 index 0000000..80057f5 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/CopulaEstimationItem.cs @@ -0,0 +1,50 @@ +using Numerics.Distributions.Copulas; +using RMC.BestFit.Models; +using RMC.BestFit.UI; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RMC_BestFit +{ + /// + /// Represents an item in a combo box for selecting copula parameter estimation methods. + /// + /// + /// This class encapsulates the display properties and underlying value for copula estimation method options + /// presented to users in the GUI. Copulas are used to model the dependence structure between random variables, + /// and different estimation methods can be employed to fit copula parameters to data. + /// + public class CopulaEstimationItem + { + /// + /// Gets the user-friendly display name for the copula estimation method shown in the combo box. + /// + public string DisplayName { get; } + + /// + /// Gets the underlying enumeration value representing the specific copula estimation method. + /// + public CopulaEstimationMethod Value { get; } + + /// + /// Gets the tooltip text that provides additional information about the copula estimation method when the user hovers over the item. + /// + public string ToolTip { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The user-friendly name to display in the combo box. + /// The copula estimation method enumeration value this item represents. + /// Optional tooltip text providing additional information about the copula estimation method. Defaults to an empty string. + public CopulaEstimationItem(string displayName, CopulaEstimationMethod value, string tooltip = "") + { + DisplayName = displayName; + Value = value; + ToolTip = tooltip; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/CopulaItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/CopulaItem.cs new file mode 100644 index 0000000..2569197 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/CopulaItem.cs @@ -0,0 +1,38 @@ +using Numerics.Distributions; +using Numerics.Distributions.Copulas; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RMC_BestFit +{ + /// + /// Represents a combo box item that encapsulates copula type selection for the user interface. + /// This class provides a display-friendly representation of copula types used in multivariate statistical analysis. + /// + public class CopulaItem + { + /// + /// Gets or sets the human-readable name displayed to users in the combo box. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the underlying copula type enumeration value associated with this item. + /// + public CopulaType Value { get; set; } + + /// + /// Initializes a new instance of the class with the specified display name and copula type. + /// + /// The human-readable name to display in the combo box. + /// The copula type enumeration value this item represents. + public CopulaItem(string displayName, CopulaType value) + { + DisplayName = displayName; + Value = value; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/CovariateExtensionItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/CovariateExtensionItem.cs new file mode 100644 index 0000000..4f498f0 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/CovariateExtensionItem.cs @@ -0,0 +1,37 @@ +using RMC.BestFit.Models; + +namespace RMC_BestFit +{ + /// + /// Represents a combo box item for selecting ARIMAX covariate-extension methods in the user interface. + /// + /// + /// This class is used to bind covariate-extension options to combo boxes, providing a user-friendly + /// display name and the corresponding enumeration value. + /// The extension method controls how covariate time series are lengthened past the observed range when + /// the ARIMAX model forecasts beyond the training data. + /// + public class CovariateExtensionItem + { + /// + /// Gets or sets the user-friendly name displayed in the combo box for the covariate-extension method. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the enumeration value associated with this combo box item. + /// + public ARIMAX.CovariateExtensionMethod Value { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The user-friendly name to display in the combo box. + /// The enumeration value associated with this item. + public CovariateExtensionItem(string displayName, ARIMAX.CovariateExtensionMethod value) + { + DisplayName = displayName; + Value = value; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/CredibleIntervalItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/CredibleIntervalItem.cs new file mode 100644 index 0000000..ceac51c --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/CredibleIntervalItem.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RMC_BestFit +{ + /// + /// Represents a combo box item for selecting credible interval confidence levels in the user interface. + /// + /// + /// This class is used to bind credible interval options (e.g., 90%, 95%, 99%) to combo boxes, + /// providing a user-friendly display name and the corresponding probability value as a decimal. + /// + public class CredibleIntervalItem + { + /// + /// Gets or sets the user-friendly name displayed in the combo box for the credible interval. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the credible interval probability value (typically between 0.0 and 1.0). + /// + /// + /// For example, a 95% credible interval would have a value of 0.95. + /// + public double Value { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The user-friendly name to display in the combo box. + /// The credible interval probability value associated with this item. + public CredibleIntervalItem(string displayName, double value) + { + DisplayName = displayName; + Value = value; + } + + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/DependencyItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/DependencyItem.cs new file mode 100644 index 0000000..4537f08 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/DependencyItem.cs @@ -0,0 +1,48 @@ +using Numerics.Data.Statistics; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RMC_BestFit +{ + /// + /// Represents an item in a combo box for selecting statistical dependency types between variables. + /// + /// + /// This class encapsulates the display properties and underlying value for dependency type options + /// presented to users in the GUI, facilitating the selection of correlation and dependence structures + /// in statistical analyses. + /// + public class DependencyItem + { + /// + /// Gets the user-friendly display name for the dependency type shown in the combo box. + /// + public string DisplayName { get; } + + /// + /// Gets the underlying enumeration value representing the specific dependency type. + /// + public Probability.DependencyType Value { get; } + + /// + /// Gets the tooltip text that provides additional information about the dependency type when the user hovers over the item. + /// + public string ToolTip { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The user-friendly name to display in the combo box. + /// The dependency type enumeration value this item represents. + /// Optional tooltip text providing additional information about the dependency type. Defaults to an empty string. + public DependencyItem(string displayName, Probability.DependencyType value, string tooltip = "") + { + DisplayName = displayName; + Value = value; + ToolTip = tooltip; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/DepthUnitItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/DepthUnitItem.cs new file mode 100644 index 0000000..1125214 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/DepthUnitItem.cs @@ -0,0 +1,38 @@ +using Numerics.Data; +using RMC.BestFit.Models; +using RMC.BestFit.UI; + +namespace RMC_BestFit +{ + /// + /// Represents a combo box item for selecting depth measurement units in the user interface. + /// + /// + /// This class is used to bind depth unit options to combo boxes, providing a user-friendly + /// display name and the corresponding TimeSeriesDownload.DepthUnit enumeration value. + /// Depth units are typically used for precipitation or water level measurements. + /// + public class DepthUnitItem + { + /// + /// Gets or sets the user-friendly name displayed in the combo box for the depth unit. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the TimeSeriesDownload.DepthUnit enumeration value associated with this combo box item. + /// + public TimeSeriesDownload.DepthUnit Value { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The user-friendly name to display in the combo box. + /// The TimeSeriesDownload.DepthUnit enumeration value associated with this item. + public DepthUnitItem(string displayName, TimeSeriesDownload.DepthUnit value) + { + DisplayName = displayName; + Value = value; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/ExactDataMethodItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/ExactDataMethodItem.cs new file mode 100644 index 0000000..24b9729 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/ExactDataMethodItem.cs @@ -0,0 +1,33 @@ +using RMC.BestFit.Models; +using RMC.BestFit.UI; + +namespace RMC_BestFit +{ + /// + /// Represents a combo box item that encapsulates exact data entry method selection for the user interface. + /// This class provides a display-friendly representation of different methods for entering exact data values. + /// + public class ExactDataMethodItem + { + /// + /// Gets or sets the human-readable name displayed to users in the combo box. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the underlying exact data entry type enumeration value associated with this item. + /// + public InputData.ExactDataEntryType Value { get; set; } + + /// + /// Initializes a new instance of the class with the specified display name and exact data entry type. + /// + /// The human-readable name to display in the combo box. + /// The exact data entry type enumeration value this item represents. + public ExactDataMethodItem(string displayName, InputData.ExactDataEntryType value) + { + DisplayName = displayName; + Value = value; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/GMMEstimationTypeItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/GMMEstimationTypeItem.cs new file mode 100644 index 0000000..94cdbc5 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/GMMEstimationTypeItem.cs @@ -0,0 +1,43 @@ +using RMC.BestFit.Estimation; + +namespace RMC_BestFit +{ + /// + /// Represents a selectable Generalized Method of Moments (GMM) estimation strategy item for use in combo box controls. + /// + /// + /// This class encapsulates the display name, GMM estimation strategy value, and optional tooltip + /// for GMM estimation types, enabling user-friendly selection in the GUI. + /// + public class GMMEstimationTypeItem + { + /// + /// Gets the display name shown to the user in the combo box. + /// + public string DisplayName { get; } + + /// + /// Gets the underlying GMM estimation strategy enumeration value. + /// + public GeneralizedMethodOfMoments.GMMEstimationStrategy Value { get; } + + /// + /// Gets the tooltip text that provides additional information about the estimation strategy. + /// + public string ToolTip { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The user-friendly display name for the GMM estimation strategy. + /// The underlying GMM estimation strategy enumeration value. + /// Optional tooltip text providing additional information. Defaults to an empty string. + public GMMEstimationTypeItem(string displayName, GeneralizedMethodOfMoments.GMMEstimationStrategy value, string tooltip = "") + { + DisplayName = displayName; + Value = value; + ToolTip = tooltip; + } + } +} + diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/InputDataSelectionItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/InputDataSelectionItem.cs new file mode 100644 index 0000000..73444f6 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/InputDataSelectionItem.cs @@ -0,0 +1,125 @@ +using RMC.BestFit.UI; +using System; +using System.ComponentModel; + +namespace RMC_BestFit +{ + /// + /// Represents an optional input-data overlay choice for properties-control ComboBoxes. + /// + /// + /// The item is intentionally UI-only: a null displays as + /// <None> and maps back to an analysis InputData reference of + /// null. Real values are wrapped so their names can + /// be displayed and resorted without creating dummy project elements. + /// + public sealed class InputDataSelectionItem : INotifyPropertyChanged, IDisposable + { + /// + /// The display text used for the optional no-overlay item. + /// + public const string NoneDisplayName = ""; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The wrapped input data element, or null to create the no-overlay item. + /// + /// + /// Real input data elements are observed for changes so + /// WPF bindings can refresh the displayed text and live-sorted position. + /// + public InputDataSelectionItem(InputData inputData) + { + Value = inputData; + if (Value != null) + { + Value.PropertyChanged += InputData_PropertyChanged; + } + } + + /// + /// Occurs when a displayed property value changes. + /// + public event PropertyChangedEventHandler PropertyChanged; + + /// + /// Gets the underlying input data value selected by the ComboBox. + /// + public InputData Value { get; } + + /// + /// Gets the display text shown in the ComboBox. + /// + public string DisplayName + { + get { return Value == null ? NoneDisplayName : Value.Name; } + } + + /// + /// Gets a stable sort key that pins the no-overlay item before real input data. + /// + public string SortKey + { + get { return Value == null ? "0" : "1" + DisplayName; } + } + + /// + /// Creates the UI-only no-overlay selection item. + /// + /// An item whose is null. + /// + /// A factory makes call sites read as intentional optional-overlay behavior rather + /// than an accidental null wrapper. + /// + public static InputDataSelectionItem CreateNone() + { + return new InputDataSelectionItem(null); + } + + /// + /// Releases the wrapped input-data event subscription. + /// + /// + /// Properties controls rebuild their option lists as project collections change; disposing + /// removed wrappers prevents long-lived input data elements from retaining stale UI items. + /// + public void Dispose() + { + if (Value != null) + { + Value.PropertyChanged -= InputData_PropertyChanged; + } + } + + /// + /// Propagates wrapped input-data name changes to ComboBox bindings. + /// + /// The input data element raising the event. + /// The property-changed event data. + /// + /// Only the name affects this wrapper's visible text and sort position. + /// + private void InputData_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(InputData.Name)) + { + OnPropertyChanged(nameof(DisplayName)); + OnPropertyChanged(nameof(SortKey)); + } + } + + /// + /// Raises for the supplied property. + /// + /// The name of the changed property. + /// + /// Centralizing notification keeps rename propagation consistent. + /// + private void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/MathFunctionItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/MathFunctionItem.cs new file mode 100644 index 0000000..bf79260 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/MathFunctionItem.cs @@ -0,0 +1,32 @@ +using Numerics.Data; + +namespace RMC_BestFit +{ + /// + /// Represents a combo box item that encapsulates mathematical function type selection for the user interface. + /// This class provides a display-friendly representation of different mathematical functions available for data transformation and analysis. + /// + public class MathFunctionItem + { + /// + /// Gets or sets the human-readable name displayed to users in the combo box. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the underlying mathematical function type enumeration value associated with this item. + /// + public MathFunctionType Value { get; set; } + + /// + /// Initializes a new instance of the class with the specified display name and mathematical function type. + /// + /// The human-readable name to display in the combo box. + /// The mathematical function type enumeration value this item represents. + public MathFunctionItem(string displayName, MathFunctionType value) + { + DisplayName = displayName; + Value = value; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/MonthItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/MonthItem.cs new file mode 100644 index 0000000..94292f1 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/MonthItem.cs @@ -0,0 +1,33 @@ +namespace RMC_BestFit +{ + /// + /// Represents a combo box item for selecting months in the user interface. + /// + /// + /// This class is used to bind month options to combo boxes, providing a user-friendly + /// display name (e.g., "January", "February") and the corresponding numeric month value. + /// + public class MonthItem + { + /// + /// Gets or sets the user-friendly name displayed in the combo box for the month. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the numeric value representing the month (typically 1-12, where 1 is January). + /// + public int Value { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The user-friendly name to display in the combo box. + /// The numeric month value associated with this item. + public MonthItem(string displayName, int value) + { + DisplayName = displayName; + Value = value; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/ParameterPriorItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/ParameterPriorItem.cs new file mode 100644 index 0000000..6060b9c --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/ParameterPriorItem.cs @@ -0,0 +1,47 @@ +using Numerics.Distributions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RMC_BestFit +{ + /// + /// Represents a parameter prior distribution item for Bayesian analysis. + /// + /// + /// This class encapsulates the name, distribution, and display label for parameter priors + /// used in Bayesian statistical analysis, enabling configuration of prior distributions for model parameters. + /// + public class ParameterPriorItem + { + /// + /// Gets or sets the name of the parameter. + /// + public string Name { get; set; } + + /// + /// Gets or sets the prior distribution associated with the parameter. + /// + public UnivariateDistributionBase Distribution { get; set; } + + /// + /// Gets or sets the display label for the parameter prior in the user interface. + /// + public string Label { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the parameter. + /// The prior distribution for the parameter. + /// The display label for the parameter prior. + public ParameterPriorItem(string name, UnivariateDistributionBase distribution, string label) + { + Name = name; + Distribution = distribution; + Label = label; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/PlottingPositionItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/PlottingPositionItem.cs new file mode 100644 index 0000000..497453d --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/PlottingPositionItem.cs @@ -0,0 +1,46 @@ +namespace RMC_BestFit +{ + /// + /// Represents a combo box item for selecting plotting position formulas in the user interface. + /// + /// + /// This class is used to bind plotting position formula options to combo boxes, providing a user-friendly + /// display name, the formula's alpha parameter value, and a descriptive tooltip for additional context. + /// Plotting positions are used to estimate the cumulative probability of data points in statistical analysis. + /// + public class PlottingPositionItem + { + /// + /// Gets or sets the user-friendly name displayed in the combo box for the plotting position formula. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the alpha parameter value used in the plotting position formula. + /// + /// + /// The plotting position is typically calculated as (i - alpha) / (n + 1 - 2*alpha), + /// where i is the rank and n is the sample size. Different formulas use different alpha values + /// (e.g., Weibull uses 0, Median uses 0.3, Cunnane uses 0.4). + /// + public double Value { get; set; } + + /// + /// Gets or sets the tooltip text that provides additional information about the plotting position formula. + /// + public string ToolTip { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The user-friendly name to display in the combo box. + /// The alpha parameter value for the plotting position formula. + /// The tooltip text providing additional information about the formula. + public PlottingPositionItem(string displayName, double value, string tooltip) + { + DisplayName = displayName; + Value = value; + ToolTip = tooltip; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/PointEstimatorItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/PointEstimatorItem.cs new file mode 100644 index 0000000..0724c52 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/PointEstimatorItem.cs @@ -0,0 +1,52 @@ +using Numerics.Data.Statistics; +using RMC.BestFit.Estimation; +using RMC.BestFit.UI; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RMC_BestFit +{ + /// + /// Represents an item in a combo box for selecting point estimator types used in Bayesian analysis. + /// + /// + /// This class encapsulates the display properties and underlying value for point estimate type options + /// presented to users in the GUI. Point estimators provide single-value estimates from posterior distributions + /// in Bayesian statistical analysis, such as the mean, median, or mode. + /// + public class PointEstimatorItem + { + /// + /// Gets the user-friendly display name for the point estimator type shown in the combo box. + /// + public string DisplayName { get; } + + /// + /// Gets the underlying enumeration value representing the specific point estimate type. + /// + public BayesianAnalysis.PointEstimateType Value { get; } + + /// + /// Gets the tooltip text that provides additional information about the point estimator type when the user hovers over the item. + /// + public string ToolTip { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The user-friendly name to display in the combo box. + /// The point estimate type enumeration value this item represents. + /// Optional tooltip text providing additional information about the point estimator type. Defaults to an empty string. + public PointEstimatorItem(string displayName, BayesianAnalysis.PointEstimateType value, string tooltip = "") + { + DisplayName = displayName; + Value = value; + ToolTip = tooltip; + } + + + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/SamplerTypeItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/SamplerTypeItem.cs new file mode 100644 index 0000000..7395146 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/SamplerTypeItem.cs @@ -0,0 +1,43 @@ +using RMC.BestFit.Estimation; + +namespace RMC_BestFit +{ + /// + /// Represents an item in a combo box for selecting MCMC sampler types used in Bayesian analysis. + /// + /// + /// This class encapsulates the display properties and underlying value for sampler type options + /// presented to users in the GUI. Each sampler type has different characteristics for exploring + /// posterior distributions in Bayesian MCMC analysis. + /// + public class SamplerTypeItem + { + /// + /// Gets the user-friendly display name for the sampler type shown in the combo box. + /// + public string DisplayName { get; } + + /// + /// Gets the underlying enumeration value representing the specific sampler type. + /// + public BayesianAnalysis.SamplerType Value { get; } + + /// + /// Gets the tooltip text that provides additional information about the sampler type when the user hovers over the item. + /// + public string ToolTip { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The user-friendly name to display in the combo box. + /// The sampler type enumeration value this item represents. + /// Optional tooltip text providing additional information about the sampler type. Defaults to an empty string. + public SamplerTypeItem(string displayName, BayesianAnalysis.SamplerType value, string tooltip = "") + { + DisplayName = displayName; + Value = value; + ToolTip = tooltip; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/SmoothingFunctionItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/SmoothingFunctionItem.cs new file mode 100644 index 0000000..1d5b2c6 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/SmoothingFunctionItem.cs @@ -0,0 +1,35 @@ +using Numerics.Data; + +namespace RMC_BestFit +{ + /// + /// Represents a selectable smoothing function item for use in combo box controls. + /// + /// + /// This class encapsulates the display name and corresponding enumeration value for + /// smoothing function types, enabling user-friendly selection in the GUI. + /// + public class SmoothingFunctionItem + { + /// + /// Gets or sets the display name shown to the user in the combo box. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the underlying smoothing function type enumeration value. + /// + public SmoothingFunctionType Value { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The user-friendly display name for the smoothing function. + /// The underlying smoothing function type enumeration value. + public SmoothingFunctionItem(string displayName, SmoothingFunctionType value) + { + DisplayName = displayName; + Value = value; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/TimeBlockItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/TimeBlockItem.cs new file mode 100644 index 0000000..4cd89ee --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/TimeBlockItem.cs @@ -0,0 +1,35 @@ +using Numerics.Data; + +namespace RMC_BestFit +{ + /// + /// Represents a selectable time block window item for use in combo box controls. + /// + /// + /// This class encapsulates the display name and corresponding time block window value, + /// enabling user-friendly selection of time blocking strategies in the GUI. + /// + public class TimeBlockItem + { + /// + /// Gets or sets the display name shown to the user in the combo box. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the underlying time block window value. + /// + public TimeBlockWindow Value { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The user-friendly display name for the time block window. + /// The underlying time block window value. + public TimeBlockItem(string displayName, TimeBlockWindow value) + { + DisplayName = displayName; + Value = value; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/TimeIntervalItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/TimeIntervalItem.cs new file mode 100644 index 0000000..aa99cdb --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/TimeIntervalItem.cs @@ -0,0 +1,35 @@ +using Numerics.Data; + +namespace RMC_BestFit +{ + /// + /// Represents a combo box item for selecting time intervals in the user interface. + /// + /// + /// This class is used to bind time interval options to combo boxes, providing a user-friendly + /// display name and the corresponding TimeInterval enumeration value. + /// + public class TimeIntervalItem + { + /// + /// Gets or sets the user-friendly name displayed in the combo box for the time interval. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the TimeInterval enumeration value associated with this combo box item. + /// + public TimeInterval Value { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The user-friendly name to display in the combo box. + /// The TimeInterval enumeration value associated with this item. + public TimeIntervalItem(string displayName, TimeInterval value) + { + DisplayName = displayName; + Value = value; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/TimeSeriesAlternativeItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/TimeSeriesAlternativeItem.cs new file mode 100644 index 0000000..cb022c0 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/TimeSeriesAlternativeItem.cs @@ -0,0 +1,127 @@ +using OxyPlot; +using OxyPlot.Wpf; +using RMC.BestFit.UI; +using System.ComponentModel; + +namespace RMC_BestFit +{ + /// + /// Represents a selectable time series alternative item with an associated line series for overlaying on the Time Series plot. + /// + /// + /// This class wraps a along with a pre-created for display. + /// Unlike which handles Bayesian analysis results with multiple series, + /// this class only requires a single line series since time series data is raw observational data. + /// The line color is assigned dynamically by the parent control when adding to the plot. + /// + public class TimeSeriesAlternativeItem : INotifyPropertyChanged + { + /// + /// Backing field for the property. + /// + private bool _isChecked = false; + + /// + /// Occurs when a property value changes. + /// + public event PropertyChangedEventHandler PropertyChanged; + + /// + /// Gets the time series element associated with this alternative item. + /// + public TimeSeriesElement Alternative { get; } + + /// + /// Gets the line series used to display this alternative on the time series plot. + /// + /// + /// The series color is not set during construction; it is assigned dynamically by + /// when the alternative is added to the plot, using + /// a unique color from . + /// + public LineSeries TimeSeriesLine { get; } + + /// + /// Gets a value indicating whether this alternative has data available for plotting. + /// + /// + /// Returns true if the alternative's is not null + /// and contains at least one ordinate; otherwise false. + /// + public bool HasData + { + get { return Alternative.TimeSeries != null && Alternative.TimeSeries.Count > 0; } + } + + /// + /// Gets or sets a value indicating whether this time series alternative item is checked in the user interface. + /// + /// + /// Setting this property raises the event when the value changes. + /// + public bool IsChecked + { + get { return _isChecked; } + set + { + if (_isChecked != value) + { + _isChecked = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsChecked))); + } + } + } + + /// + /// Initializes a new instance of the class. + /// + /// The time series element to wrap as an alternative. + /// + /// Creates a with default styling (solid line, stroke thickness 1). + /// Subscribes to the alternative's event + /// to propagate relevant changes (TimeSeries data and Name). + /// + public TimeSeriesAlternativeItem(TimeSeriesElement alternative) + { + Alternative = alternative; + Alternative.PropertyChanged += Alternative_PropertyChanged; + + TimeSeriesLine = new LineSeries() + { + Name = "TimeSeriesLine", + Title = alternative.Name, + StrokeThickness = 1, + LineStyle = LineStyle.Solid, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0 + }; + } + + /// + /// Handles property change events from the associated time series element. + /// + /// The source of the event. + /// The containing the event data. + /// + /// Propagates for TimeSeries and Name + /// property changes to listeners, enabling the parent control to refresh the plot when + /// the alternative's data or name changes. + /// + private void Alternative_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Alternative.TimeSeries) || e.PropertyName == nameof(Alternative.Name)) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(e.PropertyName)); + } + } + + /// + /// Detaches event subscriptions from the wrapped element. + /// Call this before discarding the item to prevent memory leaks. + /// + public void Detach() + { + Alternative.PropertyChanged -= Alternative_PropertyChanged; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/TimeSeriesEntryMethodItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/TimeSeriesEntryMethodItem.cs new file mode 100644 index 0000000..64883a7 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/TimeSeriesEntryMethodItem.cs @@ -0,0 +1,41 @@ +using RMC.BestFit.Models; +using RMC.BestFit.UI; + +namespace RMC_BestFit +{ + /// + /// Represents a combo box item that encapsulates time series data entry method selection for the user interface. + /// This class provides a display-friendly representation of different methods for entering and managing time series data. + /// + public class TimeSeriesEntryMethodItem + { + /// + /// Gets or sets the human-readable name displayed to users in the combo box. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the underlying time series entry method enumeration value associated with this item. + /// + public TimeSeriesElement.TimeSeriesEntryMethod Value { get; set; } + + /// + /// Gets or sets the tooltip text displayed when the user hovers over this item in the combo box. + /// + public string ToolTip { get; set; } + + /// + /// Initializes a new instance of the class with the specified display name, time series entry method, and optional tooltip. + /// + /// The human-readable name to display in the combo box. + /// The time series entry method enumeration value this item represents. + /// Optional tooltip text describing the entry method. + public TimeSeriesEntryMethodItem(string displayName, TimeSeriesElement.TimeSeriesEntryMethod value, string toolTip = "") + { + DisplayName = displayName; + Value = value; + ToolTip = toolTip; + } + } + +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/TimeSeriesTypeItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/TimeSeriesTypeItem.cs new file mode 100644 index 0000000..7c7eb71 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/TimeSeriesTypeItem.cs @@ -0,0 +1,37 @@ +using Numerics.Data; + +namespace RMC_BestFit +{ + /// + /// Represents an item in a combo box for selecting time series data types for download and analysis. + /// + /// + /// This class encapsulates the display properties and underlying value for time series type options + /// presented to users in the GUI, enabling selection of different types of time series data sources + /// for statistical analysis. + /// + public class TimeSeriesTypeItem + { + /// + /// Gets or sets the user-friendly display name for the time series type shown in the combo box. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the underlying enumeration value representing the specific time series type. + /// + public TimeSeriesDownload.TimeSeriesType Value { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The user-friendly name to display in the combo box. + /// The time series type enumeration value this item represents. + public TimeSeriesTypeItem(string displayName, TimeSeriesDownload.TimeSeriesType value) + { + DisplayName = displayName; + Value = value; + + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/TransformTypeItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/TransformTypeItem.cs new file mode 100644 index 0000000..6f39511 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/TransformTypeItem.cs @@ -0,0 +1,36 @@ +using RMC.BestFit.Models; + +namespace RMC_BestFit +{ + /// + /// Represents a combo box item for selecting data transformation types in the user interface. + /// + /// + /// This class is used to bind transformation type options to combo boxes, providing a user-friendly + /// display name and the corresponding Transform enumeration value. Transformations are applied to + /// data before statistical analysis (e.g., logarithmic, Box-Cox, normal score transformations). + /// + public class TransformTypeItem + { + /// + /// Gets or sets the user-friendly name displayed in the combo box for the transformation type. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the Transform enumeration value associated with this combo box item. + /// + public Transform Value { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The user-friendly name to display in the combo box. + /// The Transform enumeration value associated with this item. + public TransformTypeItem(string displayName, Transform value) + { + DisplayName = displayName; + Value = value; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/TrendModelItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/TrendModelItem.cs new file mode 100644 index 0000000..110355d --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/TrendModelItem.cs @@ -0,0 +1,32 @@ +using RMC.BestFit.Models.TrendFunctions.Support; + +namespace RMC_BestFit +{ + /// + /// Represents a combo box item that encapsulates trend model type selection for the user interface. + /// This class provides a display-friendly representation of different statistical trend models available for time series analysis. + /// + public class TrendModelItem + { + /// + /// Gets or sets the human-readable name displayed to users in the combo box. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the underlying trend model type enumeration value associated with this item. + /// + public TrendModelType Value { get; set; } + + /// + /// Initializes a new instance of the class with the specified display name and trend model type. + /// + /// The human-readable name to display in the combo box. + /// The trend model type enumeration value this item represents. + public TrendModelItem(string displayName, TrendModelType value) + { + DisplayName = displayName; + Value = value; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/TrendTypeItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/TrendTypeItem.cs new file mode 100644 index 0000000..99ac0c0 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/TrendTypeItem.cs @@ -0,0 +1,35 @@ +using RMC.BestFit.Models; + +namespace RMC_BestFit +{ + /// + /// Represents a combo box item for selecting ARIMAX trend types in the user interface. + /// + /// + /// This class is used to bind trend type options to combo boxes, providing a user-friendly + /// display name and the corresponding ARIMAX.Trend enumeration value. + /// + public class TrendTypeItem + { + /// + /// Gets or sets the user-friendly name displayed in the combo box for the trend type. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the ARIMAX.Trend enumeration value associated with this combo box item. + /// + public ARIMAX.Trend Value { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The user-friendly name to display in the combo box. + /// The ARIMAX.Trend enumeration value associated with this item. + public TrendTypeItem(string displayName, ARIMAX.Trend value) + { + DisplayName = displayName; + Value = value; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/UnivariateDistributionItem.cs b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/UnivariateDistributionItem.cs new file mode 100644 index 0000000..2180953 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/ComboBoxItems/UnivariateDistributionItem.cs @@ -0,0 +1,40 @@ +using Numerics.Distributions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RMC_BestFit +{ + /// + /// Represents a selectable univariate distribution item for use in combo box controls. + /// + /// + /// This class encapsulates the display name and corresponding enumeration value for + /// univariate distribution types, enabling user-friendly selection in the GUI. + /// + public class UnivariateDistributionItem + { + /// + /// Gets or sets the display name shown to the user in the combo box. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the underlying univariate distribution type enumeration value. + /// + public UnivariateDistributionType Value { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The user-friendly display name for the distribution. + /// The underlying univariate distribution type enumeration value. + public UnivariateDistributionItem(string displayName, UnivariateDistributionType value) + { + DisplayName = displayName; + Value = value; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/AlternativeControl.xaml b/src/RMC.BestFit.App/GUI/Support/Controls/AlternativeControl.xaml new file mode 100644 index 0000000..f7eb813 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/AlternativeControl.xaml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/AlternativeControl.xaml.cs b/src/RMC.BestFit.App/GUI/Support/Controls/AlternativeControl.xaml.cs new file mode 100644 index 0000000..0c63d4a --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/AlternativeControl.xaml.cs @@ -0,0 +1,298 @@ +using FrameworkInterfaces; +using RMC.BestFit.Models; +using RMC.BestFit.UI; +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; + + +namespace RMC_BestFit +{ + /// + /// Represents the method that will handle the event when an analysis alternative is added. + /// + /// The analysis alternative item that was added. + public delegate void AnalysisAddedEventHandler(AnalysisAlternativeItem analysisItem); + + /// + /// Represents the method that will handle the event when an analysis alternative is removed. + /// + /// The analysis alternative item that was removed. + public delegate void AnalysisRemovedEventHandler(AnalysisAlternativeItem analysisItem); + + /// + /// User control for managing and displaying alternative analyses for comparison purposes. + /// Provides functionality to select, check, and compare multiple analysis alternatives + /// against the current analysis element. + /// + public partial class AlternativeControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public AlternativeControl() + { + InitializeComponent(); + } + + /// + /// Identifies the dependency property. + /// + public static DependencyProperty ElementProperty = DependencyProperty.Register(nameof(Element), typeof(IAnalysisElement), typeof(AlternativeControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the current analysis element that alternative analyses are compared against. + /// + public IAnalysisElement Element + { + get { return (IAnalysisElement)GetValue(ElementProperty); } + set { SetValue(ElementProperty, value); } + } + + /// + /// Callback method invoked when the property changes. + /// Handles cleanup of old element handlers and initialization of new element. + /// + /// The dependency object whose property changed. + /// Event arguments containing the old and new property values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as AlternativeControl == null) return; + var thisControl = (AlternativeControl)d; + + // Remove handlers + if (e.OldValue != null) + { + IAnalysisElement oldElement = e.OldValue as IAnalysisElement; + if (oldElement != null) + { + thisControl.UnsubscribeParentCollection(); + thisControl.ClearAnalysisListSubscriptions(); + } + } + + if (e.NewValue == null) return; + var newElement = e.NewValue as IAnalysisElement; + if (newElement == null) return; + + thisControl.LoadAnalyses(); + + } + + /// + /// Tracks the parent collection currently subscribed to for ElementAdded/ElementRemoved + /// events so the subscription can be cleanly torn down when swaps. + /// + private IElementCollection _subscribedParentCollection; + + /// + /// Gets the observable collection of analysis alternative items available for comparison. + /// + public ObservableCollection AnalysisList { get; private set; } = new ObservableCollection(); + + /// + /// Gets or sets an optional runtime-type filter for the alternatives list. When non-null, + /// only sibling elements whose matches this value are + /// surfaced as alternatives — useful when an analysis should only be compared against + /// other analyses of the same kind (e.g. a Coincident Frequency analysis comparing + /// only to other Coincident Frequency analyses, not arbitrary bivariate analyses). + /// + /// + /// Must be set before is assigned (typically in the parent + /// control's constructor) so the filter is in effect when + /// runs. Defaults to null (no filtering — all siblings + /// are accepted, preserving the original behavior used by Composite/Univariate/B17C/etc.). + /// + public Type AlternativeFilterType { get; set; } + + /// + /// Occurs when an analysis alternative is added to the comparison set. + /// + public event AnalysisAddedEventHandler AnalysisAdded; + + /// + /// Occurs when an analysis alternative is removed from the comparison set. + /// + public event AnalysisRemovedEventHandler AnalysisRemoved; + + /// + /// Loads all available analysis alternatives from the parent collection, excluding the current element. + /// Sets up event handlers for collection changes and property changes on individual alternatives. + /// Idempotent: defensively unsubscribes prior subscriptions before re-binding so repeated + /// Element swaps do not accumulate stale handlers (NC-8). + /// + private void LoadAnalyses() + { + UnsubscribeParentCollection(); + ClearAnalysisListSubscriptions(); + AnalysisList.Clear(); + + _subscribedParentCollection = Element.ParentCollection; + _subscribedParentCollection.ElementAdded += OnParentCollectionElementAdded; + _subscribedParentCollection.ElementRemoved += OnParentCollectionElementRemoved; + + foreach (IElement element in Element.ParentCollection) + { + if (element as IAnalysisElement != null && element.NameOnDisk != Element.NameOnDisk && PassesFilter(element)) + { + AnalysisList.Add(new AnalysisAlternativeItem((IAnalysisElement)element)); + AnalysisList.Last().PropertyChanged += Alternative_PropertyChanged; + } + } + } + + /// + /// Handles ElementAdded events from the subscribed ParentCollection. Adds the new + /// element to if it is an IAnalysisElement and passes the + /// optional filter. + /// + /// The element that was added to the parent collection. + private void OnParentCollectionElementAdded(IElement x) + { + if (x as IAnalysisElement != null && PassesFilter(x)) + { + AnalysisList.Add(new AnalysisAlternativeItem((IAnalysisElement)x)); + AnalysisList.Last().PropertyChanged += Alternative_PropertyChanged; + } + } + + /// + /// Handles ElementRemoved events from the subscribed ParentCollection. Removes the + /// matching item from , unhooks its PropertyChanged handler, + /// and notifies subscribers via . + /// + /// The element that was removed from the parent collection. + private void OnParentCollectionElementRemoved(IElement x) + { + if (x as IAnalysisElement != null) + { + AnalysisAlternativeItem item = AnalysisList.FirstOrDefault(alternative => alternative.Alternative.NameOnDisk == x.NameOnDisk); + if (item != null) + { + item.PropertyChanged -= Alternative_PropertyChanged; + AnalysisList.Remove(item); + AnalysisRemoved?.Invoke(item); + item.Dispose(); + } + } + } + + /// + /// Removes ElementAdded/ElementRemoved subscriptions from the previously tracked + /// parent collection. Safe to call when + /// is null. + /// + private void UnsubscribeParentCollection() + { + if (_subscribedParentCollection != null) + { + _subscribedParentCollection.ElementAdded -= OnParentCollectionElementAdded; + _subscribedParentCollection.ElementRemoved -= OnParentCollectionElementRemoved; + _subscribedParentCollection = null; + } + } + + /// + /// Detaches the per-item PropertyChanged handler from every alternative currently + /// in . Called before clearing the list so item-level + /// subscriptions don't outlive the items. + /// + private void ClearAnalysisListSubscriptions() + { + foreach (AnalysisAlternativeItem item in AnalysisList) + { + item.PropertyChanged -= Alternative_PropertyChanged; + item.Dispose(); + } + } + + /// + /// Returns true when the candidate element should be surfaced as an alternative. + /// Honors — when null, all candidates pass. + /// + /// The candidate sibling element being considered. + /// True if the candidate's runtime type matches the configured filter, or no filter is set. + private bool PassesFilter(IElement candidate) + { + if (AlternativeFilterType == null) return true; + return candidate != null && candidate.GetType() == AlternativeFilterType; + } + + /// + /// Handles property changes on analysis alternative items. + /// Raises or events based on + /// the IsChecked state, and refreshes when analysis results or estimation status changes. + /// + /// The analysis alternative item whose property changed. + /// Event arguments containing the property name that changed. + private void Alternative_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + var item = (AnalysisAlternativeItem)sender; + if (e.PropertyName == nameof(AnalysisAlternativeItem.IsChecked)) + { + if (item.IsChecked == true) + { + AnalysisAdded?.Invoke(item); + } + else + { + AnalysisRemoved?.Invoke(item); + } + } + else if (e.PropertyName == nameof(AnalysisAlternativeItem.Alternative.AnalysisResults) || + e.PropertyName == nameof(AnalysisAlternativeItem.Alternative.IsEstimated)) + { + if (item.IsChecked == true) + { + AnalysisAdded?.Invoke(item); + } + } + } + + /// + /// Add all alternatives to plot that are checked on. + /// + public void AddAllChecked() + { + foreach (AnalysisAlternativeItem item in AnalysisList) + { + if (item.IsChecked == true) + { + AnalysisAdded?.Invoke(item); + } + } + } + + /// + /// Remove all alternatives from plot. + /// + public void RemoveAll() + { + foreach (AnalysisAlternativeItem item in AnalysisList) + { + AnalysisRemoved?.Invoke(item); + } + } + + /// + /// Handles the Loaded event of the alternative list box. + /// Sets up the collection view with sorting by analysis name in ascending order. + /// + /// The list view control that was loaded. + /// Event arguments for the loaded event. + private void AlternativeListBox_Loaded(object sender, RoutedEventArgs e) + { + ListView listBox = (ListView)sender; + CollectionViewSource csv = new CollectionViewSource() { Source = AnalysisList, IsLiveSortingRequested = true }; + csv.SortDescriptions.Add(new SortDescription(nameof(AnalysisAlternativeItem.Alternative.Name), ListSortDirection.Ascending)); + var view = csv.View; + view.MoveCurrentToPosition(-1); + listBox.ItemsSource = view; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/AutocorrelationControl.xaml b/src/RMC.BestFit.App/GUI/Support/Controls/AutocorrelationControl.xaml new file mode 100644 index 0000000..cb4afb0 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/AutocorrelationControl.xaml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/AutocorrelationControl.xaml.cs b/src/RMC.BestFit.App/GUI/Support/Controls/AutocorrelationControl.xaml.cs new file mode 100644 index 0000000..e008a99 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/AutocorrelationControl.xaml.cs @@ -0,0 +1,366 @@ +using Numerics; +using Numerics.Data.Statistics; +using OxyPlot; +using OxyPlot.Wpf; +using OxyPlotControls; +using RMC.BestFit.Models; +using RMC.BestFit.Estimation; +using RMC.BestFit.UI; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Media3D; + +namespace RMC_BestFit +{ + /// + /// A user control that displays an autocorrelation function (ACF) plot for parameter samples + /// from a Bayesian analysis, showing the correlation between samples at different lags. + /// + public partial class AutocorrelationControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public AutocorrelationControl() + { + InitializeComponent(); + } + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty AnalysisProperty = DependencyProperty.Register(nameof(Analysis), typeof(BayesianAnalysis), typeof(AutocorrelationControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the Bayesian analysis object that provides data for the autocorrelation visualization. + /// + public BayesianAnalysis Analysis + { + get { return (BayesianAnalysis)GetValue(AnalysisProperty); } + set { SetValue(AnalysisProperty, value); } + } + + /// + /// Handles property change callbacks for the dependency property. + /// Subscribes to property change events on the new analysis object. + /// + /// The dependency object whose property changed. + /// Event data containing the old and new property values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as AutocorrelationControl == null) return; + var thisControl = (AutocorrelationControl)d; + + // Unsubscribe from old element + if (e.OldValue is BayesianAnalysis oldElement) + oldElement.PropertyChanged -= thisControl.Analysis_PropertyChanged; + + if (e.NewValue == null) return; + var newElement = e.NewValue as BayesianAnalysis; + if (newElement == null) return; + + newElement.PropertyChanged += thisControl.Analysis_PropertyChanged; + + // Initialize combo and UI now that Analysis is available + thisControl.LoadParameterComboBox(); + thisControl.UpdatePlot(); + } + + /// + /// Tracks whether the control has been loaded and initialized. + /// + private bool _isLoaded = false; + + /// + /// The plot object. Injected by the parent control via + /// (the parent obtains it from BayesianController.AutocorrelationPlot). + /// + private Plot _plot; + + /// + /// Gets the plot displayed by this control. Set via by the parent control. + /// + public Plot Plot => _plot; + + /// + /// Gets a value indicating whether the plot area has been clicked by the user. + /// + public bool PlotClicked { get; private set; } = false; + + /// + /// Occurs when the control is clicked, providing information about what area was clicked. + /// + public event PreviewControlClickedEventHandler PreviewControlClicked; + + /// + /// Represents the method that will handle the event. + /// + /// True if the plot area was clicked. + /// True if the toolbar area was clicked. + /// The plot object that was clicked. + public delegate void PreviewControlClickedEventHandler(bool plotClicked, bool toolbarClicked, Plot plot); + + /// + /// Occurs when plot properties are requested to be displayed or modified. + /// + public event PlotPropertiesCalledEventHandler PlotPropertiesCalled; + + /// + /// Represents the method that will handle the event. + /// + /// The plot whose properties are being accessed. + /// True if the properties panel should be opened. + /// The property expander control to display. + /// The object whose properties are being displayed. + public delegate void PlotPropertiesCalledEventHandler(Plot plot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject); + + + + /// + /// Handles the Loaded event of the UserControl. Initializes the control on first load + /// by populating the parameter combo box and updating the plot. + /// + /// The source of the event. + /// Event data. + private void UserControl_Loaded(object sender, RoutedEventArgs e) + { + if (_isLoaded == false) + { + // One-time setup: populate combo on first load. + LoadParameterComboBox(); + } + _isLoaded = true; + // Always refresh on load so Analysis changes between unload and reload are applied. + UpdatePlot(); + } + + /// + /// Handles property changes on the Analysis object. Updates the plot and UI elements + /// when relevant analysis properties change. + /// + /// The source of the event. + /// Event data containing the name of the property that changed. + private void Analysis_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Analysis.IsEstimated)) + { + UpdatePlot(); + } + if (e.PropertyName == nameof(Analysis.CredibleIntervalWidth)) + { + // The autocorrelation confidence-interval lines are computed from + // CredibleIntervalWidth at plot time. Refresh the plot when alpha changes. + UpdatePlot(); + } + if (e.PropertyName == nameof(Analysis.Model) || e.PropertyName == nameof(Analysis.ParameterNames)) + { + LoadParameterComboBox(); + } + } + + /// + /// Populates the parameter combo box with the display names of all parameters + /// from the analysis model. + /// + private void LoadParameterComboBox() + { + if (Analysis == null || Analysis.ParameterNames == null) return; + var parms = Analysis.ParameterNames.ToList(); + ParameterComboBox.ItemsSource = null; + ParameterComboBox.ItemsSource = parms; + ParameterComboBox.SelectedIndex = 0; + } + + + /// + /// Handles the SelectionChanged event of the ParameterComboBox. + /// Updates the plot to display the autocorrelation for the newly selected parameter. + /// + /// The source of the event. + /// Event data. + private void ParameterComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isLoaded == true) UpdatePlot(); + } + + /// + /// Handles the PreviewMouseDown event of the UserControl. Performs hit testing + /// to determine if the plot or toolbar was clicked and raises the appropriate event. + /// + /// The source of the event. + /// Event data. + private void UserControl_PreviewMouseDown(object sender, MouseButtonEventArgs e) + { + if (_plot == null) return; + var plotHitResult = VisualTreeHelper.HitTest(_plot, e.GetPosition(_plot)); + var toolbarHitResult = VisualTreeHelper.HitTest(PlotToolbar, e.GetPosition(PlotToolbar)); + PreviewControlClicked?.Invoke(plotHitResult != null, toolbarHitResult != null, _plot); + PlotClicked = plotHitResult != null; + } + + /// + /// Handles the LostFocus event of the UserControl. Resets the PlotClicked state. + /// + /// The source of the event. + /// Event data. + private void UserControl_LostFocus(object sender, RoutedEventArgs e) + { + PlotClicked = false; + } + + /// + /// Handles the PropertiesCalled event from the plot toolbar. + /// Forwards the event to subscribers of the PlotPropertiesCalled event. + /// + /// The plot whose properties are being accessed. + /// True if the properties panel should be opened. + /// The property expander control to display. + /// The object whose properties are being displayed. + private void PlotToolbar_PropertiesCalled(Plot targetPlot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject) + { + PlotPropertiesCalled?.Invoke(targetPlot, openProperties, propertyExpander, selectedObject); + } + + /// + /// Sets the external plot to display in this control. Called by parent controls + /// that own the Plot object (Pattern B). The plot is attached to PlotHost and PlotToolbar. + /// + /// The external plot to display. + public void SetPlot(Plot plot) + { + _plot = plot; + PlotHost.Content = _plot; + PlotToolbar.Plot = _plot; + } + + /// + /// Sets the plot title. Suppresses PropertyChanged so the change is not recorded + /// as an undoable action the title tracks combo selection, not user intent. + /// + private void SetPlotTitle(string title) + { + if (_plot == null) return; + _plot.SuppressPropertyChanged = true; + _plot.Title = title; + _plot.SuppressPropertyChanged = false; + } + + /// + /// Updates the autocorrelation plot with the current parameter data from the analysis results. + /// Displays the autocorrelation function values as bars and overlays confidence interval lines. + /// Adjusts the Y-axis minimum to accommodate negative correlation values. + /// + private void UpdatePlot() + { + if (_plot == null) return; + + // Set plot title to reflect the selected parameter. Suppress PropertyChanged so + // the PlotUndoManager does not record this as an undoable action the title + // tracks combo selection, not user intent. + string paramName = ParameterComboBox.SelectedValue as string ?? ""; + SetPlotTitle(string.IsNullOrEmpty(paramName) ? "Autocorrelation" : $"Autocorrelation of {paramName}"); + + // Lookup-or-create named series/annotations so user-customized styling survives Save/Open. + var acfSeries = _plot.Series.OfType().FirstOrDefault(s => s.Name == "Autocorrelation") + ?? new HistogramSeries + { + Name = "Autocorrelation", + Title = "Autocorrelation", + FillColor = Color.FromArgb(75, 220, 20, 60), + StrokeColor = Color.FromArgb(255, 255, 0, 0), + StrokeThickness = 1, + }; + var lowerCI = _plot.Annotations.OfType().FirstOrDefault(a => a.Name == "LowerCI") + ?? new LineAnnotation + { + Name = "LowerCI", + Type = OxyPlot.Annotations.LineAnnotationType.Horizontal, + Color = Colors.Black, + LineStyle = LineStyle.Dash, + StrokeThickness = 2, + TextLinePosition = 1, + TextHorizontalAlignment = System.Windows.HorizontalAlignment.Right, + TextVerticalAlignment = System.Windows.VerticalAlignment.Top, + }; + var upperCI = _plot.Annotations.OfType().FirstOrDefault(a => a.Name == "UpperCI") + ?? new LineAnnotation + { + Name = "UpperCI", + Type = OxyPlot.Annotations.LineAnnotationType.Horizontal, + Color = Colors.Black, + LineStyle = LineStyle.Dash, + StrokeThickness = 2, + TextLinePosition = 1, + TextHorizontalAlignment = System.Windows.HorizontalAlignment.Right, + TextVerticalAlignment = System.Windows.VerticalAlignment.Bottom, + }; + + // BC2: Clear series/annotations in a single block without an intermediate InvalidatePlot + // so the plot never renders in a transient blank state between clear and repopulate. + _plot.Series.Clear(); + _plot.Annotations.Clear(); + + if (Analysis == null || Analysis.IsEstimated == false || Analysis.Results == null) + { + _plot.InvalidatePlot(true); + return; + } + + if (Analysis.IsEstimated == true) + { + Mouse.OverrideCursor = Cursors.Wait; + try + { + + int index = Math.Max(0, ParameterComboBox.SelectedIndex); + + var acfItems = new List(); + var acf = Analysis.Results.ParameterResults[index].Autocorrelation; + for (int i = 0; i < acf.GetLength(0); i++) + { + acfItems.Add(new OxyPlot.Series.HistogramItem(i, i + 1, acf[i, 1])); + } + + acfSeries.ItemsSource = acfItems; + acfSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0}" + Environment.NewLine + "{3}: {4:0.000000}"; + _plot.Series.Add(acfSeries); + + var ci = Autocorrelation.CorrelationConfidenceInterval(Analysis.OutputLength, Analysis.CredibleIntervalWidth); + var alpha = (1 - Analysis.CredibleIntervalWidth) / 2d; + + lowerCI.Text = (alpha * 100).ToString("F1") + "% CI"; + lowerCI.Y = ci[0]; + + upperCI.Text = ((1 - alpha) * 100).ToString("F1") + "% CI"; + upperCI.Y = ci[1]; + + _plot.Annotations.Add(lowerCI); + _plot.Annotations.Add(upperCI); + + foreach (var axis in _plot.Axes) + { + if (axis.Key == "Yaxis") + { + axis.Minimum = Math.Round((Math.Min(ci[0], Tools.Min(acf.GetColumn(1))) - 0.05) * 20) / 20; + } + } + + _plot.InvalidatePlot(true); + + } + finally + { + Mouse.OverrideCursor = null; + } + } + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/BatchRunWindow.xaml b/src/RMC.BestFit.App/GUI/Support/Controls/BatchRunWindow.xaml new file mode 100644 index 0000000..9bb8d4c --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/BatchRunWindow.xaml @@ -0,0 +1,203 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/BatchRunWindow.xaml.cs b/src/RMC.BestFit.App/GUI/Support/Controls/BatchRunWindow.xaml.cs new file mode 100644 index 0000000..19162fb --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/BatchRunWindow.xaml.cs @@ -0,0 +1,287 @@ +using FrameworkInterfaces; +using RMC.BestFit.Models; +using RMC.BestFit.Analyses; +using RMC.BestFit.UI; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using GenericControls; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Threading; + +namespace RMC_BestFit +{ + /// + /// Window for executing multiple analyses in batch mode with per-item status tracking. + /// Provides functionality to select, queue, and run multiple analyses using + /// with progress tracking, status icons, + /// duration display, and cancellation support. + /// + /// + /// + /// Authors: + /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil + /// + /// + /// The DataGrid displays each analysis with columns for: + /// element icon, name, per-row progress bar, duration (mm:ss), and a status icon + /// (pending clock, spinning arrows for running, green check for success, red X for failure). + /// + /// + /// The batch execution is delegated to from the + /// model library (RMC.BestFit.dll), which handles ordering, error handling, + /// and cancellation. The window subscribes to the runner's events to update the + /// view models on the UI thread via . + /// + /// + public partial class BatchRunWindow : MetroWindow + { + /// + /// The batch analysis runner used to execute analyses. + /// + private BatchAnalysisRunner _runner; + + /// + /// Cancellation token source for the current batch run. + /// + private CancellationTokenSource _cancellationTokenSource; + + /// + /// The view models wrapping each analysis for DataGrid display. + /// + private ObservableCollection _viewModels; + + /// + /// Lookup from the model-layer to its + /// corresponding view model, used for updating status when runner events fire. + /// + private Dictionary _analysisToViewModel; + + /// + /// Initializes a new instance of the class. + /// Sets up command bindings for window close operations. + /// + public BatchRunWindow() + { + InitializeComponent(); + CommandBindings.Add(new CommandBinding(SystemCommands.CloseWindowCommand, OnCloseWindow)); + } + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty AnalysesProperty = DependencyProperty.Register( + nameof(Analyses), typeof(ObservableCollection), typeof(BatchRunWindow), + new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the collection of analysis elements available for batch processing. + /// + public ObservableCollection Analyses + { + get { return (ObservableCollection)GetValue(AnalysesProperty); } + set { SetValue(AnalysesProperty, value); } + } + + /// + /// Callback method invoked when the property changes. + /// Builds the view model collection and sets it as the DataGrid source. + /// + /// The dependency object whose property changed. + /// Event arguments containing the old and new property values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not BatchRunWindow window) return; + if (e.NewValue is not ObservableCollection newCollection) return; + + window.Title = newCollection.Count > 0 + ? "Batch Run " + newCollection[0].ParentCollection.Name + : "Batch Run"; + + window._viewModels = BatchRunCoordinator.BuildViewModels(newCollection); + window._analysisToViewModel = BatchRunCoordinator.BuildAnalysisMap(window._viewModels); + + window.MyDataGrid.ItemsSource = window._viewModels; + } + + /// + /// Handles the close window command. + /// Prevents window closure if a simulation is currently in progress. + /// + /// The target of the command. + /// Executed routed event arguments. + private new void OnCloseWindow(object target, ExecutedRoutedEventArgs e) + { + if (FrameworkUI.ShellPublicVariables.SimulationInProgress == false) + SystemCommands.CloseWindow(this); + } + + /// + /// Handles the SelectionChanged event for the data grid. + /// Enables or disables buttons based on whether items are selected. + /// + /// The source of the event. + /// Selection changed event arguments. + private void MyDataGrid_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (MyDataGrid.SelectedItems.Count <= 0) + { + DeselectButton.IsEnabled = false; + SimulateButton.IsEnabled = false; + } + else if (MyDataGrid.SelectedItems.Count >= 1) + { + DeselectButton.IsEnabled = true; + SimulateButton.IsEnabled = true; + } + } + + /// + /// Handles the Click event for the simulate button. + /// Initiates the batch run process for all selected analyses using + /// . + /// + /// The source of the event. + /// Routed event arguments. + private async void SimulateButton_Click(object sender, RoutedEventArgs e) + { + // Collect selected analyses from view models + var selectedViewModels = MyDataGrid.SelectedItems + .OfType() + .ToList(); + + if (selectedViewModels.Count == 0) return; + + BatchRunCoordinator.ResetForRun(selectedViewModels); + + // Set up UI state + _cancellationTokenSource = new CancellationTokenSource(); + + SummaryTextBlock.Text = "Running batch..."; + + SimulateButton.Visibility = Visibility.Hidden; + CancelButton.Visibility = Visibility.Visible; + + FrameworkUI.ShellPublicVariables.SimulationInProgress = true; + MyDataGrid.IsEnabled = false; + SelectButton.IsEnabled = false; + DeselectButton.IsEnabled = false; + ParallelCheckBox.IsEnabled = false; + + // Create runner and wire events + _runner = new BatchAnalysisRunner(); + using var progressUpdates = new BatchProgressUpdateDispatcher(Dispatcher, _analysisToViewModel); + + _runner.AnalysisStarting += (s, analysis) => + { + progressUpdates.MarkStarting(analysis); + }; + + _runner.AnalysisCompleted += (s, result) => + { + progressUpdates.ApplyResult(result); + }; + + // Wire per-analysis progress (each analysis gets its own reporter in the runner) + _runner.AnalysisProgressChanged += (s, e) => + { + progressUpdates.PostProgress(e.Analysis, e.Progress); + }; + + // Extract model-layer IAnalysis list in selection order via InnerAnalysis bridge + var analyses = BatchRunCoordinator.SelectAnalyses(selectedViewModels); + + bool runParallel = ParallelCheckBox.IsChecked == true; + var options = BatchRunCoordinator.CreateOptions(runParallel, Environment.ProcessorCount); + + // Run the batch + try + { + List results = null; + await WaitCursorHelper.RunWithVisibleWaitCursorAsync(Dispatcher, async () => + { + results = await _runner.RunAsync(analyses, options, null, + _cancellationTokenSource.Token); + await progressUpdates.FlushAsync(); + await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background); + }); + + SummaryTextBlock.Text = BatchRunCoordinator.FormatSummary(results); + } + catch (OperationCanceledException) + { + SummaryTextBlock.Text = "Batch run was canceled."; + } + finally + { + // Re-enable UI + FrameworkUI.ShellPublicVariables.SimulationInProgress = false; + MyDataGrid.IsEnabled = true; + SelectButton.IsEnabled = true; + DeselectButton.IsEnabled = true; + ParallelCheckBox.IsEnabled = true; + + SimulateButton.Visibility = Visibility.Visible; + CancelButton.Visibility = Visibility.Hidden; + Mouse.OverrideCursor = null; + + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = null; + } + } + + /// + /// Handles the Click event for the cancel button. + /// Cancels the batch run and all remaining analyses. + /// + /// The source of the event. + /// Routed event arguments. + private void CancelButton_Click(object sender, RoutedEventArgs e) + { + _cancellationTokenSource?.Cancel(); + _runner?.Cancel(); + } + + /// + /// Handles the KeyDown event for the batch run window. + /// Cancels the batch run when the Escape key is pressed. + /// + /// The source of the event. + /// Key event arguments. + private void BatchRun_KeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + _cancellationTokenSource?.Cancel(); + _runner?.Cancel(); + } + } + + /// + /// Handles the Click event for the select button. + /// Selects all analyses in the data grid and sets focus to the grid. + /// + /// The source of the event. + /// Routed event arguments. + private void SelectButton_Click(object sender, RoutedEventArgs e) + { + MyDataGrid.SelectAll(); + MyDataGrid.Focus(); + } + + /// + /// Handles the Click event for the deselect button. + /// Clears all selections in the data grid. + /// + /// The source of the event. + /// Routed event arguments. + private void DeselectButton_Click(object sender, RoutedEventArgs e) + { + MyDataGrid.UnselectAll(); + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/BayesianOptionsControl.xaml b/src/RMC.BestFit.App/GUI/Support/Controls/BayesianOptionsControl.xaml new file mode 100644 index 0000000..fc22dac --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/BayesianOptionsControl.xaml @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/BayesianOptionsControl.xaml.cs b/src/RMC.BestFit.App/GUI/Support/Controls/BayesianOptionsControl.xaml.cs new file mode 100644 index 0000000..3b4622a --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/BayesianOptionsControl.xaml.cs @@ -0,0 +1,356 @@ +using RMC.BestFit.Estimation; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; + +namespace RMC_BestFit +{ + /// + /// User control for configuring Bayesian analysis simulation options. + /// Provides interface elements for setting MCMC parameters, simulation defaults, + /// and advanced algorithm settings for Bayesian statistical inference. + /// + public partial class BayesianOptionsControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public BayesianOptionsControl() + { + InitializeComponent(); + } + + /// + /// Occurs when property attributes should be displayed for a specific property. + /// + public event ShowPropertyAttributesEventHandler ShowPropertyAttributes; + + /// + /// Represents the method that will handle the event when property attributes should be shown. + /// + /// The name of the property whose attributes should be displayed. + /// The object instance containing the property. + public delegate void ShowPropertyAttributesEventHandler(string propertyName, object classObject); + + /// + /// Identifies the dependency property. + /// + public static DependencyProperty AnalysisProperty = DependencyProperty.Register(nameof(Analysis), typeof(BayesianAnalysis), typeof(BayesianOptionsControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the Bayesian analysis object whose options are being configured. + /// + public BayesianAnalysis Analysis + { + get { return (BayesianAnalysis)GetValue(AnalysisProperty); } + set { SetValue(AnalysisProperty, value); } + } + + /// + /// Gets the collection of sampler type items for the sampler type combo box. + /// + public ObservableCollection SamplerTypeItems { get; private set; } = new ObservableCollection() + { + new SamplerTypeItem("DE-MCzs", BayesianAnalysis.SamplerType.DEMCzs, "Differential Evolution MCMC with snooker update. Recommended default sampler."), + new SamplerTypeItem("DE-MCz", BayesianAnalysis.SamplerType.DEMCz, "Differential Evolution MCMC with z-matrix history."), + new SamplerTypeItem("ARWMH", BayesianAnalysis.SamplerType.ARWMH, "Adaptive Random Walk Metropolis-Hastings with covariance adaptation."), + new SamplerTypeItem("NUTS", BayesianAnalysis.SamplerType.NUTS, "No-U-Turn Sampler. Automatic trajectory tuning via binary tree doubling.") + }; + + /// + /// The previously subscribed analysis for PropertyChanged events. + /// + private BayesianAnalysis _subscribedAnalysis; + + /// + /// Callback method invoked when the property changes. + /// + /// The dependency object whose property changed. + /// Event arguments containing the old and new property values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d is not BayesianOptionsControl thisControl) return; + + // Unsubscribe from old analysis + if (thisControl._subscribedAnalysis != null) + { + thisControl._subscribedAnalysis.PropertyChanged -= thisControl.Analysis_PropertyChanged; + thisControl._subscribedAnalysis = null; + } + + if (e.NewValue == null) return; + if (e.NewValue is not BayesianAnalysis newElement) return; + + // Subscribe to new analysis for sampler type changes + newElement.PropertyChanged += thisControl.Analysis_PropertyChanged; + thisControl._subscribedAnalysis = newElement; + + // Set initial visibility + thisControl.UpdateAdvancedOptionsVisibility(); + } + + /// + /// Handles PropertyChanged events from the analysis to update control visibility when sampler type changes. + /// + /// The source of the event. + /// Property changed event arguments. + private void Analysis_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(BayesianAnalysis.Type)) + { + UpdateAdvancedOptionsVisibility(); + } + } + + /// + /// Updates the visibility of advanced option controls based on the currently selected sampler type. + /// + private void UpdateAdvancedOptionsVisibility() + { + if (Analysis == null) return; + + var type = Analysis.Type; + + // DEMCz/DEMCzs controls + bool isDEMC = type == BayesianAnalysis.SamplerType.DEMCz || type == BayesianAnalysis.SamplerType.DEMCzs; + JumpParameter.Visibility = isDEMC ? Visibility.Visible : Visibility.Collapsed; + JumpThreshold.Visibility = isDEMC ? Visibility.Visible : Visibility.Collapsed; + SnookerThreshold.Visibility = type == BayesianAnalysis.SamplerType.DEMCzs ? Visibility.Visible : Visibility.Collapsed; + NoiseParameter.Visibility = isDEMC ? Visibility.Visible : Visibility.Collapsed; + + // ARWMH controls + bool isARWMH = type == BayesianAnalysis.SamplerType.ARWMH; + ScaleParameter.Visibility = isARWMH ? Visibility.Visible : Visibility.Collapsed; + BetaParameter.Visibility = isARWMH ? Visibility.Visible : Visibility.Collapsed; + + // NUTS controls + bool isNUTS = type == BayesianAnalysis.SamplerType.NUTS; + MaxTreeDepth.Visibility = isNUTS ? Visibility.Visible : Visibility.Collapsed; + } + + /// + /// Identifies the dependency property. + /// + public static DependencyProperty ActualPropertyWidthProperty = DependencyProperty.Register(nameof(ActualPropertyWidth), typeof(double), typeof(BayesianOptionsControl), new UIPropertyMetadata(200d)); + + /// + /// Gets or sets the actual width of property labels in the control layout. + /// + public double ActualPropertyWidth + { + get { return (double)GetValue(ActualPropertyWidthProperty); } + set { SetValue(ActualPropertyWidthProperty, value); } + } + + #region PreviewMouseLeftButtonDown Handlers + + /// + /// Handles the PreviewMouseLeftButtonDown event for the sampler type combo box. + /// Raises the event to display property information. + /// + /// The source of the event. + /// Mouse button event arguments. + private void SamplerType_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + ShowPropertyAttributes?.Invoke(nameof(Analysis.Type), Analysis); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the number of chains input. + /// Raises the event to display property information. + /// + /// The source of the event. + /// Mouse button event arguments. + private void NumberOfChains_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + ShowPropertyAttributes?.Invoke(nameof(Analysis.NumberOfChains), Analysis); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the thinning interval input. + /// Raises the event to display property information. + /// + /// The source of the event. + /// Mouse button event arguments. + private void ThinningInterval_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + ShowPropertyAttributes?.Invoke(nameof(Analysis.ThinningInterval), Analysis); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the warmup iterations input. + /// Raises the event to display property information. + /// + /// The source of the event. + /// Mouse button event arguments. + private void WarmupIterations_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + ShowPropertyAttributes?.Invoke(nameof(Analysis.WarmupIterations), Analysis); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the iterations input. + /// Raises the event to display property information. + /// + /// The source of the event. + /// Mouse button event arguments. + private void Iterations_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + ShowPropertyAttributes?.Invoke(nameof(Analysis.Iterations), Analysis); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the PRNG seed input. + /// Raises the event to display property information. + /// + /// The source of the event. + /// Mouse button event arguments. + private void PRNGSeed_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + ShowPropertyAttributes?.Invoke(nameof(Analysis.PRNGSeed), Analysis); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the initial iterations input. + /// Raises the event to display property information. + /// + /// The source of the event. + /// Mouse button event arguments. + private void InitialIterations_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + ShowPropertyAttributes?.Invoke(nameof(Analysis.InitialIterations), Analysis); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the use simulation defaults checkbox. + /// Raises the event to display property information. + /// + /// The source of the event. + /// Mouse button event arguments. + private void UseSimulationDefaults_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + ShowPropertyAttributes?.Invoke(nameof(Analysis.UseSimulationDefaults), Analysis); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the jump parameter input. + /// Raises the event to display property information. + /// + /// The source of the event. + /// Mouse button event arguments. + private void JumpParameter_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + ShowPropertyAttributes?.Invoke(nameof(Analysis.Jump), Analysis); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the jump threshold input. + /// Raises the event to display property information. + /// + /// The source of the event. + /// Mouse button event arguments. + private void JumpThreshold_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + ShowPropertyAttributes?.Invoke(nameof(Analysis.JumpThreshold), Analysis); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the snooker threshold input. + /// Raises the event to display property information. + /// + /// The source of the event. + /// Mouse button event arguments. + private void SnookerThreshold_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + ShowPropertyAttributes?.Invoke(nameof(Analysis.SnookerThreshold), Analysis); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the noise parameter input. + /// Raises the event to display property information. + /// + /// The source of the event. + /// Mouse button event arguments. + private void NoiseParameter_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + ShowPropertyAttributes?.Invoke(nameof(Analysis.Noise), Analysis); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the scale parameter input. + /// Raises the event to display property information. + /// + /// The source of the event. + /// Mouse button event arguments. + private void ScaleParameter_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + ShowPropertyAttributes?.Invoke(nameof(Analysis.Scale), Analysis); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the beta parameter input. + /// Raises the event to display property information. + /// + /// The source of the event. + /// Mouse button event arguments. + private void BetaParameter_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + ShowPropertyAttributes?.Invoke(nameof(Analysis.Beta), Analysis); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the max tree depth input. + /// Raises the event to display property information. + /// + /// The source of the event. + /// Mouse button event arguments. + private void MaxTreeDepth_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + ShowPropertyAttributes?.Invoke(nameof(Analysis.MaxTreeDepth), Analysis); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the use advanced simulation defaults checkbox. + /// Raises the event to display property information. + /// + /// The source of the event. + /// Mouse button event arguments. + private void UseAdvancedSimulationDefaults_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + ShowPropertyAttributes?.Invoke(nameof(Analysis.UseAdvancedSimulationDefaults), Analysis); + } + + #endregion + + /// + /// Handles the PropertyChanged event for the warmup iterations control. + /// Updates the control's actual property width when the warmup iterations control width changes. + /// + /// The source of the event. + /// Property changed event arguments containing the property name that changed. + private void WarmupIterations_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(WarmupIterations.ActualPropertyWidth)) + { + ActualPropertyWidth = WarmupIterations.ActualPropertyWidth; + } + } + + /// + /// Handles the PropertyChanged event for the sampler type control. + /// Updates the control's actual property width when the sampler type control width changes. + /// + /// The source of the event. + /// Property changed event arguments containing the property name that changed. + private void SamplerType_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(SamplerType.ActualPropertyWidth)) + { + ActualPropertyWidth = SamplerType.ActualPropertyWidth; + } + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/BayesianOutputControl.xaml b/src/RMC.BestFit.App/GUI/Support/Controls/BayesianOutputControl.xaml new file mode 100644 index 0000000..24e0f87 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/BayesianOutputControl.xaml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/BayesianOutputControl.xaml.cs b/src/RMC.BestFit.App/GUI/Support/Controls/BayesianOutputControl.xaml.cs new file mode 100644 index 0000000..2bfa7fc --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/BayesianOutputControl.xaml.cs @@ -0,0 +1,162 @@ +using OxyPlot; +using RMC.BestFit.Estimation; +using RMC.BestFit.UI; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Navigation; +using System.Windows.Shapes; + +namespace RMC_BestFit +{ + /// + /// User control for configuring Bayesian analysis output options. + /// Provides interface elements for setting credible intervals, output length, + /// and point estimator methods for Bayesian statistical analysis results. + /// + public partial class BayesianOutputControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public BayesianOutputControl() + { + InitializeComponent(); + } + + /// + /// Occurs when property attributes should be displayed for a specific property. + /// + public event ShowPropertyAttributesEventHandler ShowPropertyAttributes; + + /// + /// Represents the method that will handle the event when property attributes should be shown. + /// + /// The name of the property whose attributes should be displayed. + /// The object instance containing the property. + public delegate void ShowPropertyAttributesEventHandler(string propertyName, object classObject); + + /// + /// Identifies the dependency property. + /// + public static DependencyProperty AnalysisProperty = DependencyProperty.Register(nameof(Analysis), typeof(BayesianAnalysis), typeof(BayesianOutputControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the Bayesian analysis object whose output options are being configured. + /// + public BayesianAnalysis Analysis + { + get { return (BayesianAnalysis)GetValue(AnalysisProperty); } + set { SetValue(AnalysisProperty, value); } + } + + /// + /// Callback method invoked when the property changes. + /// + /// The dependency object whose property changed. + /// Event arguments containing the old and new property values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as BayesianOutputControl == null) return; + var thisControl = (BayesianOutputControl)d; + + if (e.NewValue == null) return; + var newElement = e.NewValue as BayesianAnalysis; + if (newElement == null) return; + } + + /// + /// Identifies the dependency property. + /// + public static DependencyProperty ActualPropertyWidthProperty = DependencyProperty.Register(nameof(ActualPropertyWidth), typeof(double), typeof(BayesianOutputControl), new UIPropertyMetadata(200d)); + + /// + /// Gets or sets the actual width of property labels in the control layout. + /// + public double ActualPropertyWidth + { + get { return (double)GetValue(ActualPropertyWidthProperty); } + set { SetValue(ActualPropertyWidthProperty, value); } + } + + /// + /// Gets the collection of available credible interval options for Bayesian analysis. + /// Contains predefined interval widths of 90%, 95%, 98%, and 99%. + /// + public ObservableCollection CredibleIntervalItems { get; private set; } = new ObservableCollection() + { + new CredibleIntervalItem("90%", 0.9), + new CredibleIntervalItem("95%", 0.95), + new CredibleIntervalItem("98%", 0.98), + new CredibleIntervalItem("99%", 0.99) + }; + + /// + /// Gets the collection of available point estimator methods for Bayesian analysis. + /// Includes Posterior Mean and Posterior Mode (MAP) estimators. + /// + public ObservableCollection PointEstimatorItems { get; private set; } = new ObservableCollection() + { + new PointEstimatorItem("Posterior Mean", BayesianAnalysis.PointEstimateType.PosteriorMean, "Calculates the average of all posterior samples, providing a balanced summary of the parameter estimates."), + new PointEstimatorItem("Posterior Mode", BayesianAnalysis.PointEstimateType.PosteriorMode, "Selects the most likely parameter values for the posterior distribution (the Maximum A Posteriori estimate)."), + }; + + /// + /// Handles the PreviewMouseLeftButtonDown event for the credible interval input. + /// Raises the event to display property information. + /// + /// The source of the event. + /// Mouse button event arguments. + private void CredibleInterval_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + ShowPropertyAttributes?.Invoke(nameof(Analysis.CredibleIntervalWidth), Analysis); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the output length input. + /// Raises the event to display property information. + /// + /// The source of the event. + /// Mouse button event arguments. + private void OutputLength_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + ShowPropertyAttributes?.Invoke(nameof(Analysis.OutputLength), Analysis); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the point estimator input. + /// Raises the event to display property information. + /// + /// The source of the event. + /// Mouse button event arguments. + private void PointEstimator_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + ShowPropertyAttributes?.Invoke(nameof(Analysis.PointEstimator), Analysis); + } + + /// + /// Handles the PropertyChanged event for the credible interval control. + /// Updates the control's actual property width when the credible interval control width changes. + /// + /// The source of the event. + /// Property changed event arguments containing the property name that changed. + private void CredibleInterval_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(CredibleInterval.ActualPropertyWidth)) + { + ActualPropertyWidth = CredibleInterval.ActualPropertyWidth; + } + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/BivariateHeatMapControl.xaml b/src/RMC.BestFit.App/GUI/Support/Controls/BivariateHeatMapControl.xaml new file mode 100644 index 0000000..7dce69c --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/BivariateHeatMapControl.xaml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/BivariateHeatMapControl.xaml.cs b/src/RMC.BestFit.App/GUI/Support/Controls/BivariateHeatMapControl.xaml.cs new file mode 100644 index 0000000..eae3374 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/BivariateHeatMapControl.xaml.cs @@ -0,0 +1,453 @@ +using Numerics; +using Numerics.Data.Statistics; +using OxyPlot; +using OxyPlot.Wpf; +using OxyPlotControls; +using RMC.BestFit.Models; +using RMC.BestFit.Estimation; +using RMC.BestFit.UI; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Media3D; + +namespace RMC_BestFit +{ + /// + /// A user control that displays a bivariate heat map and contour plot showing the joint distribution + /// of two parameters from a Bayesian analysis. Visualizes parameter correlations and dependencies. + /// + public partial class BivariateHeatMapControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public BivariateHeatMapControl() + { + InitializeComponent(); + } + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty AnalysisProperty = DependencyProperty.Register(nameof(Analysis), typeof(BayesianAnalysis), typeof(BivariateHeatMapControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the Bayesian analysis object that provides data for the bivariate heat map visualization. + /// + public BayesianAnalysis Analysis + { + get { return (BayesianAnalysis)GetValue(AnalysisProperty); } + set { SetValue(AnalysisProperty, value); } + } + + /// + /// Handles property change callbacks for the dependency property. + /// Subscribes to property change events on the new analysis object. + /// + /// The dependency object whose property changed. + /// Event data containing the old and new property values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as BivariateHeatMapControl == null) return; + var thisControl = (BivariateHeatMapControl)d; + + // Unsubscribe from old element + if (e.OldValue is BayesianAnalysis oldElement) + oldElement.PropertyChanged -= thisControl.Analysis_PropertyChanged; + + if (e.NewValue == null) return; + var newElement = e.NewValue as BayesianAnalysis; + if (newElement == null) return; + + newElement.PropertyChanged += thisControl.Analysis_PropertyChanged; + + // Initialize combos and UI now that Analysis is available + thisControl.LoadParameterComboBox(); + thisControl.UpdatePlot(); + } + + /// + /// Gets or sets a value indicating whether the control should use simplified (non-Bayesian) + /// terminology. When true, the plot title omits "Posterior" (e.g. "Joint Density of � and s" + /// instead of "Joint Posterior Density of � and s"). Set to true for B17C bootstrap/GMM + /// contexts where the parameter samples are not posterior draws. + /// + public bool SimpleView { get; set; } = false; + + /// + /// Tracks whether the control has been loaded and initialized. + /// + private bool _isLoaded = false; + + /// + /// The plot object. Injected by the parent control via + /// (the parent obtains it from BayesianController.BivariateHeatMapPlot). + /// + private Plot _plot; + + /// + /// Gets the plot displayed by this control. Set via by the parent control. + /// + public Plot Plot => _plot; + + /// + /// Gets a value indicating whether the plot area has been clicked by the user. + /// + public bool PlotClicked { get; private set; } = false; + + /// + /// Occurs when the control is clicked, providing information about what area was clicked. + /// + public event PreviewControlClickedEventHandler PreviewControlClicked; + + /// + /// Represents the method that will handle the event. + /// + /// True if the plot area was clicked. + /// True if the toolbar area was clicked. + /// The plot object that was clicked. + public delegate void PreviewControlClickedEventHandler(bool plotClicked, bool toolbarClicked, Plot plot); + + /// + /// Occurs when plot properties are requested to be displayed or modified. + /// + public event PlotPropertiesCalledEventHandler PlotPropertiesCalled; + + /// + /// Represents the method that will handle the event. + /// + /// The plot whose properties are being accessed. + /// True if the properties panel should be opened. + /// The property expander control to display. + /// The object whose properties are being displayed. + public delegate void PlotPropertiesCalledEventHandler(Plot plot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject); + + + /// + /// Handles the Loaded event of the UserControl. Initializes the control on first load + /// by populating the parameter combo boxes and updating the plot. + /// + /// The source of the event. + /// Event data. + private void UserControl_Loaded(object sender, RoutedEventArgs e) + { + if (_isLoaded == false) + { + // One-time setup: populate combo on first load. + LoadParameterComboBox(); + } + _isLoaded = true; + // Always refresh on load so Analysis changes between unload and reload are applied. + UpdatePlot(); + } + + /// + /// Handles property changes on the Analysis object. Updates the plot and UI elements + /// when relevant analysis properties change. + /// + /// The source of the event. + /// Event data containing the name of the property that changed. + private void Analysis_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Analysis.IsEstimated)) + { + UpdatePlot(); + } + if (e.PropertyName == nameof(Analysis.Model) || e.PropertyName == nameof(Analysis.ParameterNames)) + { + LoadParameterComboBox(); + } + } + + /// + /// Populates both X and Y parameter combo boxes with the display names of all parameters + /// from the analysis model. Sets the X parameter to the first parameter and the Y parameter + /// to the second parameter by default. + /// + private void LoadParameterComboBox() + { + if (Analysis == null || Analysis.ParameterNames == null) return; + var parms = Analysis.ParameterNames.ToList(); + XParameterComboBox.ItemsSource = null; + XParameterComboBox.ItemsSource = parms; + XParameterComboBox.SelectedIndex = 0; + + YParameterComboBox.ItemsSource = null; + YParameterComboBox.ItemsSource = parms; + YParameterComboBox.SelectedIndex = 1; + } + + /// + /// Handles the SelectionChanged event of the XParameterComboBox. + /// Updates the plot to display the bivariate distribution for the newly selected X parameter. + /// + /// The source of the event. + /// Event data. + private void XParameterComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isLoaded == true) UpdatePlot(); + } + + /// + /// Handles the SelectionChanged event of the YParameterComboBox. + /// Updates the plot to display the bivariate distribution for the newly selected Y parameter. + /// + /// The source of the event. + /// Event data. + private void YParameterComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isLoaded == true) UpdatePlot(); + } + + /// + /// Handles the PreviewMouseDown event of the UserControl. Performs hit testing + /// to determine if the plot or toolbar was clicked and raises the appropriate event. + /// + /// The source of the event. + /// Event data. + private void UserControl_PreviewMouseDown(object sender, MouseButtonEventArgs e) + { + if (_plot == null) return; + var plotHitResult = VisualTreeHelper.HitTest(_plot, e.GetPosition(_plot)); + var toolbarHitResult = VisualTreeHelper.HitTest(PlotToolbar, e.GetPosition(PlotToolbar)); + PreviewControlClicked?.Invoke(plotHitResult != null, toolbarHitResult != null, _plot); + PlotClicked = plotHitResult != null; + } + + /// + /// Handles the LostFocus event of the UserControl. Resets the PlotClicked state. + /// + /// The source of the event. + /// Event data. + private void UserControl_LostFocus(object sender, RoutedEventArgs e) + { + PlotClicked = false; + } + + /// + /// Handles the PropertiesCalled event from the plot toolbar. + /// Forwards the event to subscribers of the PlotPropertiesCalled event. + /// + /// The plot whose properties are being accessed. + /// True if the properties panel should be opened. + /// The property expander control to display. + /// The object whose properties are being displayed. + private void PlotToolbar_PropertiesCalled(Plot targetPlot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject) + { + PlotPropertiesCalled?.Invoke(targetPlot, openProperties, propertyExpander, selectedObject); + } + + /// + /// Sets the external plot to display in this control. Called by parent controls + /// that own the Plot object (Pattern B). The plot is attached to PlotHost and PlotToolbar. + /// Axis title bindings are re-established for X and Y parameter combo boxes. + /// + /// The external plot to display. + public void SetPlot(Plot plot) + { + _plot = plot; + PlotHost.Content = _plot; + PlotToolbar.Plot = _plot; + } + + /// + /// Sets the plot title. Suppresses PropertyChanged so the change is not recorded + /// as an undoable action � the title tracks combo selection, not user intent. + /// + private void SetPlotTitle(string title) + { + if (_plot == null) return; + _plot.SuppressPropertyChanged = true; + _plot.Title = title; + _plot.SuppressPropertyChanged = false; + } + + /// + /// Updates the bivariate heat map and contour plot with the current X and Y parameter data from the analysis results. + /// Creates a 2D density grid by binning the parameter samples, then displays both a heat map and contour lines + /// to visualize the joint distribution and correlation between the two selected parameters. + /// + private void UpdatePlot() + { + if (_plot == null) return; + + // Set plot title and axis titles to reflect the selected parameter pair. Suppress + // PropertyChanged so the PlotUndoManager does not record these as undoable actions + // � they track combo selections, not user intent. + string xParamName = XParameterComboBox.SelectedValue as string ?? ""; + string yParamName = YParameterComboBox.SelectedValue as string ?? ""; + string densityLabel = SimpleView ? "Joint Density" : "Joint Posterior Density"; + string plotTitle = (string.IsNullOrEmpty(xParamName) || string.IsNullOrEmpty(yParamName)) + ? densityLabel + : $"{densityLabel} of {xParamName} and {yParamName}"; + SetPlotTitle(plotTitle); + foreach (var axis in _plot.Axes) + { + if (axis.Position == OxyPlot.Axes.AxisPosition.Bottom) + { + axis.SuppressPropertyChanged = true; + axis.Title = xParamName; + axis.SuppressPropertyChanged = false; + } + else if (axis.Position == OxyPlot.Axes.AxisPosition.Left) + { + axis.SuppressPropertyChanged = true; + axis.Title = yParamName; + axis.SuppressPropertyChanged = false; + } + } + + // Ensure color axis has correct gradient (may be lost during plot settings deserialization) + foreach (var axis in _plot.Axes) + { + if (axis is LinearColorAxis colorAxis) + ApplyHeatMapGradient(colorAxis); + } + + // BC2: Clear series in a single block without an intermediate InvalidatePlot + // so the plot never renders in a transient blank state between clear and repopulate. + _plot.Series.Clear(); + + if (Analysis == null || Analysis.IsEstimated == false || Analysis.Results == null) + { + _plot.InvalidatePlot(true); + return; + } + + if (Analysis.IsEstimated == true) + { + Mouse.OverrideCursor = Cursors.Wait; + try + { + + int Xindex = Math.Max(0, XParameterComboBox.SelectedIndex); + int Yindex = Math.Max(0, YParameterComboBox.SelectedIndex); + + // Get histogram bins + var histX = Analysis.Results.ParameterResults[Xindex].Histogram; + var histY = Analysis.Results.ParameterResults[Yindex].Histogram; + if (histX == null || histY == null) + return; + + int n = histX.NumberOfBins; + double minX = histX[0].LowerBound; + double maxX = histX[n - 1].UpperBound; + double minY = histY[0].LowerBound; + double maxY = histY[n - 1].UpperBound; + + // Create stratification bins + int bins = 16; + double dx = (maxX - minX) / (bins - 1); + double dy = (maxY - minY) / (bins - 1); + + // E7: Guard against degenerate posteriors where all samples are identical + // (dx == 0 or dy == 0). Division by zero would silently map all samples + // to bin 0, producing NaN/Infinity in the data array. + if (dx == 0 || dy == 0) + { + _plot.InvalidatePlot(true); + return; + } + + double[] xVals = new double[bins]; + double[] yVals = new double[bins]; + xVals[0] = minX; + yVals[0] = minY; + for (int i = 1; i < bins; i++) + { + xVals[i] = xVals[i - 1] + dx; + yVals[i] = yVals[i - 1] + dy; + } + + double[,] data = new double[bins, bins]; + double minZ = double.MaxValue; + double maxZ = double.MinValue; + + // Ensure output values are available and match expected length + //if (Analysis.OutputLength != Analysis.Results.Output[0].Values.Length) + // return; + + for (int i = 0; i < Analysis.OutputLength; i++) + { + var x = Analysis.Results.Output[i].Values != null ? Analysis.Results.Output[i].Values[Xindex] : 0; + var y = Analysis.Results.Output[i].Values != null ? Analysis.Results.Output[i].Values[Yindex] : 0; + var xId = Math.Max(0, Math.Min(bins - 1, (int)Math.Floor((x - minX) / dx))); + var yID = Math.Max(0, Math.Min(bins - 1, (int)Math.Floor((y - minY) / dy))); + data[xId, yID] += 1d / Analysis.OutputLength; + minZ = Math.Min(minZ, data[xId, yID]); + maxZ = Math.Max(maxZ, data[xId, yID]); + } + + int zBins = 6; + double dz = (maxZ - minZ) / (zBins - 1); + double[] zVals = new double[zBins]; + zVals[0] = Math.Round(minZ + dz, 5); + for (int i = 1; i < zBins; i++) + zVals[i] = Math.Round(zVals[i - 1] + dz, 5); + + // Exception: series count varies per parameter pair selection � runtime-variable + // inline creation is intentional per the series-preservation guidance. + _plot.Series.Add(new HeatMapSeries() + { + Title = "Bivariate HeatMap", + RenderInLegend = false, + Data = data, + X0 = minX, + X1 = maxX, + Y0 = minY, + Y1 = maxY, + CoordinateDefinition = OxyPlot.Series.HeatMapCoordinateDefinition.Center, + Interpolate = true, + TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000}" + Environment.NewLine + "{3}: {4:0.000000}" + }); + + _plot.Series.Add(new ContourSeries() + { + Title = "Bivariate Contour", + RenderInLegend = false, + Data = data, + RowCoordinates = yVals.ToArray(), + ColumnCoordinates = xVals.ToArray(), + ContourLevels = zVals, + ContourColors = new Color[] { Colors.Black }, + TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000}" + Environment.NewLine + "{3}: {4:0.000000}" + }); + + _plot.InvalidatePlot(true); + + } + finally + { + Mouse.OverrideCursor = null; + } + } + } + + /// + /// Applies the standard blue-to-red diverging gradient to a + /// for the bivariate heat map visualization. + /// + /// The color axis to configure. + private static void ApplyHeatMapGradient(LinearColorAxis colorAxis) + { + colorAxis.PaletteSize = 1000; + colorAxis.HighColor = Colors.Transparent; + colorAxis.LowColor = Colors.Transparent; + var gradientStops = new GradientStopCollection(); + gradientStops.Add(new GradientStop() { Color = Color.FromRgb(215, 25, 28), Offset = 1.0 }); + gradientStops.Add(new GradientStop() { Color = Color.FromRgb(253, 174, 97), Offset = 0.75 }); + gradientStops.Add(new GradientStop() { Color = Color.FromRgb(255, 255, 191), Offset = 0.5 }); + gradientStops.Add(new GradientStop() { Color = Color.FromRgb(171, 217, 233), Offset = 0.25 }); + gradientStops.Add(new GradientStop() { Color = Color.FromRgb(44, 123, 182), Offset = 0.0 }); + colorAxis.GradientStops = gradientStops; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/GMMReportControl.xaml b/src/RMC.BestFit.App/GUI/Support/Controls/GMMReportControl.xaml new file mode 100644 index 0000000..b0ecd70 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/GMMReportControl.xaml @@ -0,0 +1,19 @@ + + + diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/GMMReportControl.xaml.cs b/src/RMC.BestFit.App/GUI/Support/Controls/GMMReportControl.xaml.cs new file mode 100644 index 0000000..4c1e3c7 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/GMMReportControl.xaml.cs @@ -0,0 +1,118 @@ +using RMC.BestFit.UI; +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; + +namespace RMC_BestFit +{ + /// + /// User control for displaying a comprehensive Bulletin 17C GMM estimation report. + /// Shows estimation configuration, GMM fit summary, parameter estimates, uncertainty diagnostics, + /// covariance/correlation matrices, penalty configuration, and identification status. + /// + public partial class GMMReportControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public GMMReportControl() + { + InitializeComponent(); + } + + /// + /// Dependency property for the property. + /// + public static DependencyProperty ElementProperty = DependencyProperty.Register( + nameof(Element), typeof(B17CAnalysis), typeof(GMMReportControl), + new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the B17C analysis element to generate the report from. + /// + public B17CAnalysis Element + { + get { return (B17CAnalysis)GetValue(ElementProperty); } + set { SetValue(ElementProperty, value); } + } + + /// + /// Handles changes to the dependency property. + /// Subscribes to PropertyChanged events and updates the report. + /// + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = d as GMMReportControl; + if (control == null) return; + + if (e.OldValue is B17CAnalysis oldElement) + oldElement.PropertyChanged -= control.Element_PropertyChanged; + + if (e.NewValue is B17CAnalysis newElement) + { + newElement.PropertyChanged += control.Element_PropertyChanged; + control.UpdateReport(); + } + else + { + control.ClearReport(); + } + } + + /// + /// Handles PropertyChanged events from the B17C analysis element. + /// Updates the report when estimation completes, the GMM object changes, or cached report text changes. + /// + private void Element_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(B17CAnalysis.IsEstimated) || + e.PropertyName == nameof(B17CAnalysis.GMM) || + e.PropertyName == nameof(B17CAnalysis.GMMReport)) + { + UpdateReport(); + } + } + + /// + /// Clears the report display. + /// + private void ClearReport() + { + ReportTextBox.Document = new FlowDocument(); + } + + /// + /// Generates and displays the full GMM estimation report by delegating to the model layer. + /// + private void UpdateReport() + { + if (Element == null || !Element.IsEstimated) + { + ClearReport(); + return; + } + + // Use live GMM report if available (has Optimizer from a fresh run); + // fall back to cached report from disk (Optimizer is null after RestoreFromXElement). + string reportText = Element.GMM?.Optimizer != null ? Element.GenerateGMMReport() : Element.GMMReport; + if (string.IsNullOrEmpty(reportText)) + { + ClearReport(); + return; + } + + // Build FlowDocument + var doc = new FlowDocument(); + doc.FontFamily = new System.Windows.Media.FontFamily("Consolas"); + doc.FontSize = 12; + doc.PageWidth = 1200; + + var paragraph = new Paragraph(); + paragraph.Inlines.Add(new Run(reportText)); + doc.Blocks.Add(paragraph); + + ReportTextBox.Document = doc; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/HistogramControl.xaml b/src/RMC.BestFit.App/GUI/Support/Controls/HistogramControl.xaml new file mode 100644 index 0000000..e0e930b --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/HistogramControl.xaml @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/HistogramControl.xaml.cs b/src/RMC.BestFit.App/GUI/Support/Controls/HistogramControl.xaml.cs new file mode 100644 index 0000000..f3b9526 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/HistogramControl.xaml.cs @@ -0,0 +1,418 @@ +using OxyPlot; +using OxyPlot.Wpf; +using OxyPlotControls; +using RMC.BestFit.Models; +using RMC.BestFit.Estimation; +using RMC.BestFit.UI; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Media3D; + +namespace RMC_BestFit +{ + /// + /// A user control that displays a histogram visualization of posterior parameter distributions + /// from a Bayesian analysis, with optional prior distribution overlay. + /// + public partial class HistogramControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public HistogramControl() + { + InitializeComponent(); + } + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty AnalysisProperty = DependencyProperty.Register(nameof(Analysis), typeof(BayesianAnalysis), typeof(HistogramControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the Bayesian analysis object that provides data for the histogram visualization. + /// + public BayesianAnalysis Analysis + { + get { return (BayesianAnalysis)GetValue(AnalysisProperty); } + set { SetValue(AnalysisProperty, value); } + } + + /// + /// Handles property change callbacks for the dependency property. + /// Subscribes to property change events on the new analysis object. + /// + /// The dependency object whose property changed. + /// Event data containing the old and new property values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as HistogramControl == null) return; + var thisControl = (HistogramControl)d; + + // Unsubscribe from old element + if (e.OldValue is BayesianAnalysis oldElement) + oldElement.PropertyChanged -= thisControl.Analysis_PropertyChanged; + + if (e.NewValue == null) return; + var newElement = e.NewValue as BayesianAnalysis; + if (newElement == null) return; + + newElement.PropertyChanged += thisControl.Analysis_PropertyChanged; + + // Initialize combo and UI now that Analysis is available + thisControl.LoadParameterComboBox(); + thisControl.LoadPercentileHeaders(); + thisControl.UpdatePlot(); + } + + /// + /// Gets or sets a value indicating whether the control should display in simplified view mode. + /// When true, hides advanced diagnostic columns like Gelman-Rubin and ESS statistics. + /// + public bool SimpleView { get; set; } = false; + + /// + /// Tracks whether the control has been loaded and initialized. + /// + private bool _isLoaded = false; + + /// + /// The plot object. Injected by the parent control via + /// (the parent obtains it from BayesianController.HistogramPlot). + /// + private Plot _plot; + + /// + /// Gets the plot displayed by this control. Set via by the parent control. + /// + public Plot Plot => _plot; + + /// + /// Gets a value indicating whether the plot area has been clicked by the user. + /// + public bool PlotClicked { get; private set; } = false; + + /// + /// Occurs when the control is clicked, providing information about what area was clicked. + /// + public event PreviewControlClickedEventHandler PreviewControlClicked; + + /// + /// Represents the method that will handle the event. + /// + /// True if the plot area was clicked. + /// True if the toolbar area was clicked. + /// The plot object that was clicked. + public delegate void PreviewControlClickedEventHandler(bool plotClicked, bool toolbarClicked, Plot plot); + + /// + /// Occurs when plot properties are requested to be displayed or modified. + /// + public event PlotPropertiesCalledEventHandler PlotPropertiesCalled; + + /// + /// Represents the method that will handle the event. + /// + /// The plot whose properties are being accessed. + /// True if the properties panel should be opened. + /// The property expander control to display. + /// The object whose properties are being displayed. + public delegate void PlotPropertiesCalledEventHandler(Plot plot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject); + + + + + /// + /// Handles the Loaded event of the UserControl. Initializes the control on first load + /// by populating the parameter combo box, setting up percentile headers, and updating the plot. + /// + /// The source of the event. + /// Event data. + private void UserControl_Loaded(object sender, RoutedEventArgs e) + { + if (_isLoaded == false) + { + // One-time setup: visibility config is set once on first load. + LoadParameterComboBox(); + LoadPercentileHeaders(); + if (SimpleView) + { + ShowPriorDistribution.Visibility = Visibility.Collapsed; + GelmanRubinColumn.Visibility = Visibility.Collapsed; + ESSColumn.Visibility = Visibility.Collapsed; + } + } + _isLoaded = true; + // Always refresh on load so Analysis changes between unload and reload are applied. + UpdatePlot(); + } + + /// + /// Handles property changes on the Analysis object. Updates the plot and UI elements + /// when relevant analysis properties change. + /// + /// The source of the event. + /// Event data containing the name of the property that changed. + private void Analysis_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Analysis.IsEstimated)) + { + UpdatePlot(); + } + if (e.PropertyName == nameof(Analysis.CredibleIntervalWidth)) + { + // Relabel column headers (90% LowerCI -> 95% LowerCI) AND rebind the data + // table so the LowerCI/UpperCI cell values reflect the new alpha. Headers + // alone aren't enough the cached ItemsSource holds stale percentiles. + LoadPercentileHeaders(); + UpdatePlot(); + } + if (e.PropertyName == nameof(Analysis.Model) || e.PropertyName == nameof(Analysis.ParameterNames)) + { + LoadParameterComboBox(); + } + } + + /// + /// Populates the parameter combo box with the display names of all parameters + /// from the analysis model. + /// + private void LoadParameterComboBox() + { + if (Analysis == null || Analysis.ParameterNames == null) return; + var parms = Analysis.ParameterNames.ToList(); + ParameterComboBox.ItemsSource = null; + ParameterComboBox.ItemsSource = parms; + ParameterComboBox.SelectedIndex = 0; + } + + /// + /// Updates the percentile column headers in the data grid based on the current + /// credible interval width from the analysis. + /// + private void LoadPercentileHeaders() + { + if (Analysis == null) return; + var alpha = (1 - Analysis.CredibleIntervalWidth) / 2d; + Percentile1Column.Header = (alpha * 100).ToString("F1") + "%"; + Percentile3Column.Header = ((1 - alpha) * 100).ToString("F1") + "%"; + } + + /// + /// Handles the Checked event of the ShowPriorDistribution checkbox. + /// Updates the plot to display the prior distribution overlay. + /// + /// The source of the event. + /// Event data. + private void ShowPriorDistribution_Checked(object sender, RoutedEventArgs e) + { + if (_isLoaded == true) UpdatePlot(); + } + + /// + /// Handles the Unchecked event of the ShowPriorDistribution checkbox. + /// Updates the plot to remove the prior distribution overlay. + /// + /// The source of the event. + /// Event data. + private void ShowPriorDistribution_Unchecked(object sender, RoutedEventArgs e) + { + if (_isLoaded == true) UpdatePlot(); + } + + /// + /// Handles the SelectionChanged event of the ParameterComboBox. + /// Updates the plot to display the histogram for the newly selected parameter. + /// + /// The source of the event. + /// Event data. + private void ParameterComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isLoaded == true) UpdatePlot(); + } + + /// + /// Handles the PreviewMouseDown event of the UserControl. Performs hit testing + /// to determine if the plot or toolbar was clicked and raises the appropriate event. + /// + /// The source of the event. + /// Event data. + private void UserControl_PreviewMouseDown(object sender, MouseButtonEventArgs e) + { + if (_plot == null) return; + var plotHitResult = VisualTreeHelper.HitTest(_plot, e.GetPosition(_plot)); + var toolbarHitResult = VisualTreeHelper.HitTest(PlotToolbar, e.GetPosition(PlotToolbar)); + PreviewControlClicked?.Invoke(plotHitResult != null, toolbarHitResult != null, _plot); + PlotClicked = plotHitResult != null; + } + + /// + /// Handles the LostFocus event of the UserControl. Resets the PlotClicked state. + /// + /// The source of the event. + /// Event data. + private void UserControl_LostFocus(object sender, RoutedEventArgs e) + { + PlotClicked = false; + } + + /// + /// Handles the PropertiesCalled event from the plot toolbar. + /// Forwards the event to subscribers of the PlotPropertiesCalled event. + /// + /// The plot whose properties are being accessed. + /// True if the properties panel should be opened. + /// The property expander control to display. + /// The object whose properties are being displayed. + private void PlotToolbar_PropertiesCalled(Plot targetPlot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject) + { + PlotPropertiesCalled?.Invoke(targetPlot, openProperties, propertyExpander, selectedObject); + } + + /// + /// Sets the external plot to display in this control. Called by parent controls + /// that own the Plot object (Pattern B). The plot is attached to PlotHost and PlotToolbar. + /// + /// The external plot to display. + public void SetPlot(Plot plot) + { + _plot = plot; + PlotHost.Content = _plot; + PlotToolbar.Plot = _plot; + } + + /// + /// Sets the plot title. Suppresses PropertyChanged so the change is not recorded + /// as an undoable action the title tracks combo selection, not user intent. + /// + private void SetPlotTitle(string title) + { + if (_plot == null) return; + _plot.SuppressPropertyChanged = true; + _plot.Title = title; + _plot.SuppressPropertyChanged = false; + } + + /// + /// Updates the histogram plot with the current parameter data from the analysis results. + /// Displays the posterior distribution as a histogram and optionally overlays the prior distribution. + /// Also updates the summary statistics table. + /// + private void UpdatePlot() + { + if (_plot == null) return; + + // Set plot title and axis title to reflect the selected parameter. Suppress + // PropertyChanged so the PlotUndoManager does not record these as undoable + // actions they track combo selection, not user intent. + string paramName = ParameterComboBox.SelectedValue as string ?? ""; + string histLabel = SimpleView ? "Marginal Histogram" : "Marginal Posterior Histogram"; + SetPlotTitle(string.IsNullOrEmpty(paramName) ? histLabel : $"{histLabel} of {paramName}"); + foreach (var axis in _plot.Axes) + { + if (axis.Position == OxyPlot.Axes.AxisPosition.Bottom) + { + axis.SuppressPropertyChanged = true; + axis.Title = paramName; + axis.SuppressPropertyChanged = false; + } + } + + // Lookup-or-create named series so user-customized styling survives Save/Open. + var priorSeries = _plot.Series.OfType().FirstOrDefault(s => s.Name == "PriorDensity") + ?? new AreaSeries + { + Name = "PriorDensity", + Title = "Prior Density", + Fill = Color.FromArgb(125, 104, 140, 175), + Color = Color.FromArgb(255, 53, 59, 122), + LineStyle = LineStyle.Solid, + BrokenLineThickness = 1, + StrokeThickness = 1, + Decimator = OxyPlot.Decimator.Decimate, + }; + var posteriorSeries = _plot.Series.OfType().FirstOrDefault(s => s.Name == "PosteriorDensity") + ?? new HistogramSeries + { + Name = "PosteriorDensity", + Title = "Posterior Histogram", + FillColor = Color.FromArgb(75, 220, 20, 60), + StrokeColor = Color.FromArgb(255, 255, 0, 0), + StrokeThickness = 1, + }; + + // BC3: Clear series in a single block without an intermediate InvalidatePlot + // so the plot never renders in a transient blank state between clear and repopulate. + _plot.Series.Clear(); + + KDEDataGrid.ItemsSource = null; + KDEDataGrid.ItemsSource = new List() { new Numerics.Sampling.MCMC.ParameterStatistics() { Rhat = double.NaN, ESS = double.NaN, Mean = double.NaN, StandardDeviation = double.NaN, LowerCI = double.NaN, Median = double.NaN, UpperCI = double.NaN } }; + KDEDataGrid.Items.Refresh(); + + if (Analysis == null || Analysis.IsEstimated == false || Analysis.Results == null) + { + _plot.InvalidatePlot(true); + return; + } + + if (Analysis.IsEstimated == true) + { + Mouse.OverrideCursor = Cursors.Wait; + try + { + + int index = Math.Max(0, ParameterComboBox.SelectedIndex); + + if (ShowPriorDistribution.IsChecked == true && Analysis.Model != null) + { + var pdf = Analysis.Model.Parameters[index].PriorDistribution.CreatePDFGraph(); + var priorPoints = new List(); + for (int i = 0; i < pdf.GetLength(0); i++) + priorPoints.Add(new Point3D(pdf[i, 0], 0, pdf[i, 1])); + + priorSeries.ItemsSource = priorPoints; + priorSeries.DataFieldX = nameof(Point3D.X); + priorSeries.DataFieldY = nameof(Point3D.Y); + priorSeries.DataFieldX2 = nameof(Point3D.X); + priorSeries.DataFieldY2 = nameof(Point3D.Z); + priorSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.0000}" + Environment.NewLine + "{3}: {4:0.000000}"; + _plot.Series.Add(priorSeries); + } + + var histogram = Analysis.Results.ParameterResults[index].Histogram; + if (histogram == null || histogram.NumberOfBins == 0) + return; + + double sum = 0; + for (int i = 0; i < histogram.NumberOfBins; i++) + sum += histogram[i].Frequency * histogram.BinWidth; + + var histogramItems = new List(); + for (int i = 0; i < histogram.NumberOfBins; i++) + histogramItems.Add(new OxyPlot.Series.HistogramItem(histogram[i].LowerBound, histogram[i].UpperBound, histogram[i].Frequency / sum * histogram.BinWidth)); + + posteriorSeries.ItemsSource = histogramItems; + posteriorSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.0000}" + Environment.NewLine + "{3}: {4:0.000000}"; + _plot.Series.Add(posteriorSeries); + _plot.InvalidatePlot(true); + + // Update stats table + KDEDataGrid.ItemsSource = new List() { Analysis.Results.ParameterResults[index].SummaryStatistics }; + KDEDataGrid.Items.Refresh(); + + } + finally + { + Mouse.OverrideCursor = null; + } + } + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/InfluenceDiagnosticsControl.xaml b/src/RMC.BestFit.App/GUI/Support/Controls/InfluenceDiagnosticsControl.xaml new file mode 100644 index 0000000..5843743 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/InfluenceDiagnosticsControl.xaml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/InfluenceDiagnosticsControl.xaml.cs b/src/RMC.BestFit.App/GUI/Support/Controls/InfluenceDiagnosticsControl.xaml.cs new file mode 100644 index 0000000..070f651 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/InfluenceDiagnosticsControl.xaml.cs @@ -0,0 +1,1288 @@ +using OxyPlot; +using OxyPlot.Wpf; +using OxyPlotControls; +using RMC.BestFit.Diagnostics; +using RMC.BestFit.Estimation; +using RMC.BestFit.Models; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Xml.Linq; +using BarItem = OxyPlot.Series.BarItem; +using LineAnnotationType = OxyPlot.Annotations.LineAnnotationType; + +namespace RMC_BestFit +{ + /// + /// Defines the operating mode of the . + /// + public enum InfluenceControlMode + { + /// + /// Bayesian mode: shows Leverage (total), Fit Influence, Variance Influence, and Leave-One-Out Diagnostic views. + /// Requires the property. + /// + Bayesian, + + /// + /// GMM mode: shows Leverage (total), Fit Influence, and Variance Influence views. + /// Requires the property. + /// + GMM + } + + /// + /// A user control that displays influence diagnostics as a tornado (horizontal bar) plot. + /// + /// + /// + /// In mode, supports two views: + /// + /// Influence — Hessian leverage decomposition at the MAP estimate, showing + /// each observation's and prior component's share of the total information budget (% of total). + /// Leave-One-Out Diagnostic — Pareto k values from PSIS-LOO-CV, + /// color-coded by diagnostic category with reference lines at k = 0.5, 0.7, and 1.0. + /// + /// + /// + /// In mode, shows Cook's Distance from the + /// Generalized Method of Moments estimator. No view combo box is displayed. + /// The checkbox label changes to "Include Penalties". + /// + /// + /// This control follows the external plot ownership pattern (Pattern B) used by all Bayesian + /// diagnostic controls. The plot is owned by + /// and attached to this control via . The dependency + /// property binds to the instance. + /// + /// + /// Diagnostics are computed lazily on first view and cached until the analysis re-estimates. + /// + /// + public partial class InfluenceDiagnosticsControl : UserControl + { + #region Construction + + /// + /// Initializes a new instance of the class. + /// + public InfluenceDiagnosticsControl() + { + InitializeComponent(); + } + + #endregion + + #region Members + + #region Dependency Properties + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty AnalysisProperty = DependencyProperty.Register( + nameof(Analysis), typeof(BayesianAnalysis), typeof(InfluenceDiagnosticsControl), + new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the Bayesian analysis object that provides data for the influence diagnostics. + /// Used in mode. + /// + public BayesianAnalysis Analysis + { + get { return (BayesianAnalysis)GetValue(AnalysisProperty); } + set { SetValue(AnalysisProperty, value); } + } + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty ModeProperty = DependencyProperty.Register( + nameof(Mode), typeof(InfluenceControlMode), typeof(InfluenceDiagnosticsControl), + new PropertyMetadata(InfluenceControlMode.Bayesian, ModeCallback)); + + /// + /// Gets or sets the operating mode of this control. Default is . + /// + public InfluenceControlMode Mode + { + get { return (InfluenceControlMode)GetValue(ModeProperty); } + set { SetValue(ModeProperty, value); } + } + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty GMMAnalysisProperty = DependencyProperty.Register( + nameof(GMMAnalysis), typeof(GeneralizedMethodOfMoments), typeof(InfluenceDiagnosticsControl), + new PropertyMetadata(null, GMMAnalysisCallback)); + + /// + /// Gets or sets the GMM analysis object that provides Cook's distance diagnostics. + /// Used in mode. + /// + public GeneralizedMethodOfMoments GMMAnalysis + { + get { return (GeneralizedMethodOfMoments)GetValue(GMMAnalysisProperty); } + set { SetValue(GMMAnalysisProperty, value); } + } + + #endregion + + #region Events + + /// + /// Occurs when the control is clicked, providing information about what area was clicked. + /// + public event PreviewControlClickedEventHandler PreviewControlClicked; + + /// + /// Represents the method that will handle the event. + /// + /// True if the plot area was clicked. + /// True if the toolbar area was clicked. + /// The plot object that was clicked. + public delegate void PreviewControlClickedEventHandler(bool plotClicked, bool toolbarClicked, Plot plot); + + /// + /// Occurs when plot properties are requested to be displayed or modified. + /// + public event PlotPropertiesCalledEventHandler PlotPropertiesCalled; + + /// + /// Represents the method that will handle the event. + /// + /// The plot whose properties are being accessed. + /// True if the properties panel should be opened. + /// The property expander control to display. + /// The object whose properties are being displayed. + public delegate void PlotPropertiesCalledEventHandler(Plot plot, bool openProperties, + OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject); + + #endregion + + #region Fields + + /// + /// The plot object — always supplied externally by the parent control via , + /// using BayesianController.InfluenceDiagnosticsPlot. + /// + /// + /// Both Bayesian and GMM modes share the same UI-layer-owned plot (Convention 1 — plot ownership + /// belongs to the UI element, not the App control). The plot's static defaults are placeholders; + /// the App control rewrites the plot title and axis titles dynamically via + /// / in each path, + /// so the same Plot object serves both "Pareto k" (Bayesian LOO Diagnostic view) and + /// "% of Total Information" (Bayesian Leverage view + every GMM view). + /// + private Plot _plot; + + /// + /// Tracks whether the control has been loaded and initialized. + /// + private bool _isLoaded = false; + + /// + /// Cached observation influence diagnostics (LOO-CV Pareto k). Cleared when the analysis re-estimates. + /// + private InfluenceDiagnostics _influenceDiagnostics; + + /// + /// Cached leverage diagnostics (Hessian leverage at MAP). Cleared when the analysis re-estimates. + /// + private LeverageDiagnostics _leverageDiagnostics; + + #endregion + + #region Properties + + /// + /// Gets the plot displayed by this control. Set via or created automatically. + /// + public Plot Plot => _plot; + + /// + /// Gets a value indicating whether the plot area has been clicked by the user. + /// + public bool PlotClicked { get; private set; } = false; + + #endregion + + #endregion + + #region Public Methods + + /// + /// Sets the external plot to display in this control. Called by parent controls + /// that own the Plot object (Pattern B). The plot is attached to PlotHost and PlotToolbar. + /// + /// The external plot to display. + public void SetPlot(Plot plot) + { + _plot = plot; + PlotHost.Content = _plot; + PlotToolbar.Plot = _plot; + } + + #endregion + + #region Private Methods — Event Handlers + + /// + /// Handles property change callbacks for the dependency property. + /// Subscribes to property change events on the new analysis object. + /// + /// The dependency object whose property changed. + /// Event data containing the old and new property values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d is not InfluenceDiagnosticsControl thisControl) return; + + // Unsubscribe from old Analysis + if (e.OldValue is BayesianAnalysis oldElement) + { + oldElement.PropertyChanged -= thisControl.Analysis_PropertyChanged; + } + + // Clear cached diagnostics since the Analysis object changed + thisControl._influenceDiagnostics = null; + thisControl._leverageDiagnostics = null; + + // Subscribe to new Analysis + if (e.NewValue is BayesianAnalysis newElement) + { + newElement.PropertyChanged += thisControl.Analysis_PropertyChanged; + } + + // Update the plot for the new Analysis (handles already-estimated analyses) + if (thisControl._isLoaded) + { + thisControl.UpdatePlot(); + } + } + + /// + /// Handles property change callbacks for the dependency property. + /// Updates the UI layout based on the new mode. + /// + /// The dependency object whose property changed. + /// Event data containing the old and new property values. + private static void ModeCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not InfluenceDiagnosticsControl thisControl) return; + if (thisControl._isLoaded) + { + thisControl.UpdateControlVisibility(); + thisControl.UpdatePlot(); + } + } + + /// + /// Handles property change callbacks for the dependency property. + /// Updates the plot when the GMM analysis changes. + /// + /// The dependency object whose property changed. + /// Event data containing the old and new property values. + private static void GMMAnalysisCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not InfluenceDiagnosticsControl thisControl) return; + if (thisControl._isLoaded) + { + thisControl.UpdatePlot(); + } + } + + /// + /// Handles property changes on the Analysis object. Clears cached diagnostics and + /// updates the plot when the estimation state changes. + /// + /// The source of the event. + /// Event data containing the name of the property that changed. + private void Analysis_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Analysis.IsEstimated)) + { + // Clear cached diagnostics so they are recomputed on next view + _influenceDiagnostics = null; + _leverageDiagnostics = null; + UpdatePlot(); + } + } + + /// + /// Handles the Loaded event of the UserControl. Initializes the control on first load + /// by configuring view visibility and performing the initial update. + /// + /// The source of the event. + /// Event data. + /// + /// The Plot must be supplied externally by the parent control via + /// before this event fires (typically in the parent's ElementCallback). If + /// is null when Loaded runs, exits early via its null guard + /// and the control renders empty. + /// + private void UserControl_Loaded(object sender, RoutedEventArgs e) + { + if (_isLoaded == false) + { + // One-time setup: configure view visibility for the current Mode. + UpdateControlVisibility(); + } + _isLoaded = true; + // Always refresh on load so Analysis/GMMAnalysis changes between unload and reload are applied. + UpdatePlot(); + } + + /// + /// Handles the PreviewMouseDown event of the UserControl. Performs hit testing + /// to determine if the plot or toolbar was clicked and raises the appropriate event. + /// + /// The source of the event. + /// Event data. + private void UserControl_PreviewMouseDown(object sender, MouseButtonEventArgs e) + { + if (_plot == null) return; + var plotHitResult = VisualTreeHelper.HitTest(_plot, e.GetPosition(_plot)); + var toolbarHitResult = VisualTreeHelper.HitTest(PlotToolbar, e.GetPosition(PlotToolbar)); + PreviewControlClicked?.Invoke(plotHitResult != null, toolbarHitResult != null, _plot); + PlotClicked = plotHitResult != null; + } + + /// + /// Handles the LostFocus event of the UserControl. Resets the PlotClicked state. + /// + /// The source of the event. + /// Event data. + private void UserControl_LostFocus(object sender, RoutedEventArgs e) + { + PlotClicked = false; + } + + /// + /// Handles the SelectionChanged event of the ViewComboBox. + /// Updates control visibility and refreshes the plot for the selected view. + /// + /// The source of the event. + /// Event data. + private void ViewComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isLoaded == true) + { + UpdateControlVisibility(); + UpdatePlot(); + } + } + + /// + /// Handles the SelectionChanged event of the TopNComboBox. + /// Refreshes the plot with the new top-N filter. + /// + /// The source of the event. + /// Event data. + private void TopNComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isLoaded == true) UpdatePlot(); + } + + /// + /// Handles the Checked/Unchecked events of the IncludePriorsCheckBox. + /// Refreshes the Influence view to include or exclude prior components. + /// + /// The source of the event. + /// Event data. + private void IncludePriorsCheckBox_Changed(object sender, RoutedEventArgs e) + { + if (_isLoaded == true) UpdatePlot(); + } + + /// + /// Handles the PropertiesCalled event from the plot toolbar. + /// Forwards the event to subscribers of the PlotPropertiesCalled event. + /// + /// The plot whose properties are being accessed. + /// True if the properties panel should be opened. + /// The property expander control to display. + /// The object whose properties are being displayed. + private void PlotToolbar_PropertiesCalled(Plot targetPlot, bool openProperties, + OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject) + { + PlotPropertiesCalled?.Invoke(targetPlot, openProperties, propertyExpander, selectedObject); + } + + #endregion + + #region Private Methods — Plot Update + + /// + /// Main dispatcher that routes to the appropriate view-specific update method + /// based on the current mode and ViewComboBox selection. + /// + /// + /// View indices for Bayesian mode: 0=Leverage (total), 1=Fit Influence, 2=Variance Influence, 3=LOO Diagnostic. + /// View indices for GMM mode: 0=Leverage, 1=Fit Influence, 2=Variance Influence. + /// + private void UpdatePlot() + { + if (_plot == null) return; + + if (Mode == InfluenceControlMode.GMM) + { + int viewIndex = ViewComboBox.SelectedIndex; + switch (viewIndex) + { + case 1: UpdateGMMFitInfluencePlot(); break; + case 2: UpdateGMMVarianceInfluencePlot(); break; + default: UpdateGMMInfluencePlot(); break; + } + } + else + { + int viewIndex = ViewComboBox.SelectedIndex; + switch (viewIndex) + { + case 1: UpdateFitInfluencePlot(); break; + case 2: UpdateVarianceInfluencePlot(); break; + case 3: UpdateLOODiagnosticPlot(); break; + default: UpdateLeveragePlot(); break; + } + } + + _plot.InvalidatePlot(true); + } + + /// + /// Updates the plot to display the Influence view (Hessian leverage decomposition at MAP). + /// Shows each observation's and prior component's share of the total information budget + /// as a percentage. Observation bars use a single blue series; prior bars are colored by + /// . The "Include Prior Components" checkbox toggles prior bar visibility. + /// + private void UpdateLeveragePlot() + { + ClearPlot(); + SetPlotTitle("Leverage (% of Total Information)"); + SetAxisTitle("YAxis", ""); + SetAxisTitle("XAxis", "% of Total Information"); + + if (Analysis == null || Analysis.IsEstimated == false || Analysis.Results == null) + { + SummaryText.Text = ""; + return; + } + + Mouse.OverrideCursor = Cursors.Wait; + try + { + var diagnostics = ComputeAndCacheLeverageDiagnostics(); + if (diagnostics == null || diagnostics.Count == 0) + { + SummaryText.Text = "Leverage diagnostics could not be computed."; + return; + } + + bool includePriors = IncludePriorsCheckBox.IsChecked == true + && diagnostics.PriorComponents != null + && diagnostics.PriorComponents.Length > 0; + + int topN = GetTopN(); + + // Build combined list of ALL items (observations + priors) for unified ranking + var combinedItems = new List(); + + // Add ALL observations (topN applied after combining with priors) + foreach (var obs in diagnostics.Observations) + { + combinedItems.Add(new LeverageBarItem + { + Label = FormatDataLabel(obs.DataType, obs.Name, obs.Index, obs.Value), + Leverage = obs.Leverage, + PercentOfTotal = obs.PercentOfTotal, + IsObservation = true, + PriorType = PriorComponentType.ParameterPrior + }); + } + + // Prior components (if checkbox checked) + if (includePriors) + { + foreach (var comp in diagnostics.PriorComponents) + { + combinedItems.Add(new LeverageBarItem + { + Label = comp.Name, + Leverage = comp.Leverage, + PercentOfTotal = comp.PercentOfTotal, + IsObservation = false, + PriorType = comp.Type + }); + } + } + + // Sort all items together by leverage descending, take top N, then reverse + // for display (most influential at top of tornado chart). + // TopN applies to the combined list so priors appear at their true rank. + var displayOrder = combinedItems + .OrderByDescending(c => c.Leverage) + .Take(topN) + .Reverse() + .ToArray(); + + // Get the CategoryAxis and clear labels + var categoryAxis = GetCategoryAxis(); + if (categoryAxis == null) return; + categoryAxis.ItemsSource = null; + + // Create series: one for observations, one for all priors/penalties + var obsSeries = CreateBarSeries("Observations", Color.FromArgb(75, 220, 20, 60), Color.FromArgb(255, 255, 0, 0)); + var priorSeries = CreateBarSeries("Priors", Color.FromArgb(125, 104, 140, 175), Color.FromArgb(255, 53, 59, 122)); + + for (int i = 0; i < displayOrder.Length; i++) + { + var item = displayOrder[i]; + categoryAxis.Labels.Add(item.Label); + + if (item.IsObservation) + obsSeries.Items.Add(new BarItem { Value = item.PercentOfTotal, CategoryIndex = i }); + else + priorSeries.Items.Add(new BarItem { Value = item.PercentOfTotal, CategoryIndex = i }); + } + + if (obsSeries.Items.Count > 0) + _plot.Series.Add(obsSeries); + if (includePriors && priorSeries.Items.Count > 0) + _plot.Series.Add(priorSeries); + + // Update summary text + double obsPct = diagnostics.TotalLeverage > 0 + ? diagnostics.TotalObservationLeverage / diagnostics.TotalLeverage * 100.0 + : 0; + double priorPct = diagnostics.TotalLeverage > 0 + ? diagnostics.TotalPriorLeverage / diagnostics.TotalLeverage * 100.0 + : 0; + SummaryText.Text = $"p = {diagnostics.NumberOfParameters}. " + + $"Data: {obsPct:F1}% of total information. " + + $"Priors: {priorPct:F1}%."; + } + catch (Exception ex) + { + Debug.WriteLine($"Leverage plot update failed: {ex.Message}"); + SummaryText.Text = "Error computing leverage diagnostics."; + } + finally + { + Mouse.OverrideCursor = null; + } + } + + /// + /// Updates the plot to display the Fit Influence view (Cook's Distance) for Bayesian mode. + /// Bars show each observation's and prior component's fit influence, measuring how much + /// removing the component shifts the MAP parameters. Sorted by fit influence descending. + /// + private void UpdateFitInfluencePlot() + { + UpdateDecomposedView(isFitView: true, isGMM: false); + } + + /// + /// Updates the plot to display the Variance Influence view for Bayesian mode. + /// Bars show each observation's and prior component's variance influence, measuring how much + /// the component contributes to posterior precision. Sorted by variance influence descending. + /// + private void UpdateVarianceInfluencePlot() + { + UpdateDecomposedView(isFitView: false, isGMM: false); + } + + /// + /// Updates the plot to display the Fit Influence view for GMM mode. + /// + private void UpdateGMMFitInfluencePlot() + { + UpdateDecomposedView(isFitView: true, isGMM: true); + } + + /// + /// Updates the plot to display the Variance Influence view for GMM mode. + /// + private void UpdateGMMVarianceInfluencePlot() + { + UpdateDecomposedView(isFitView: false, isGMM: true); + } + + /// + /// Shared implementation for Fit Influence and Variance Influence views across both modes. + /// + /// True for Fit Influence (Cook's D), false for Variance Influence. + /// True for GMM mode, false for Bayesian mode. + private void UpdateDecomposedView(bool isFitView, bool isGMM) + { + ClearPlot(); + string viewName = isFitView ? "Fit Influence" : "Variance Influence"; + SetPlotTitle(isFitView ? "Fit Influence (Cook's Distance)" : "Variance Influence"); + SetAxisTitle("YAxis", ""); + SetAxisTitle("XAxis", viewName); + + LeverageDiagnostics diagnostics; + if (isGMM) + { + if (GMMAnalysis == null || !GMMAnalysis.IsEstimated) { SummaryText.Text = ""; return; } + diagnostics = GMMAnalysis.GetLeverageDiagnostics(); + } + else + { + if (Analysis == null || !Analysis.IsEstimated || Analysis.Results == null) { SummaryText.Text = ""; return; } + diagnostics = ComputeAndCacheLeverageDiagnostics(); + } + + if (diagnostics == null || diagnostics.Count == 0) + { + SummaryText.Text = $"{viewName} could not be computed."; + return; + } + + Mouse.OverrideCursor = Cursors.Wait; + try + { + string checkboxLabel = isGMM ? "Include Penalties" : "Include Prior Components"; + bool includePriors = IncludePriorsCheckBox.IsChecked == true + && diagnostics.PriorComponents != null + && diagnostics.PriorComponents.Length > 0; + int topN = GetTopN(); + + var combinedItems = new List(); + + foreach (var obs in diagnostics.Observations) + { + double metric = isFitView ? obs.FitInfluence : obs.VarianceInfluence; + combinedItems.Add(new LeverageBarItem + { + Label = FormatDataLabel(obs.DataType, obs.Name, obs.Index, obs.Value), + Leverage = metric, + PercentOfTotal = isFitView ? obs.PercentFitOfTotal : obs.PercentVarianceOfTotal, + IsObservation = true, + PriorType = PriorComponentType.ParameterPrior + }); + } + + if (includePriors) + { + foreach (var comp in diagnostics.PriorComponents) + { + double metric = isFitView ? comp.FitInfluence : comp.VarianceInfluence; + combinedItems.Add(new LeverageBarItem + { + Label = comp.Name, + Leverage = metric, + PercentOfTotal = isFitView ? comp.PercentFitOfTotal : comp.PercentVarianceOfTotal, + IsObservation = false, + PriorType = comp.Type + }); + } + } + + var displayOrder = combinedItems + .OrderByDescending(c => c.Leverage) + .Take(topN) + .Reverse() + .ToArray(); + + var categoryAxis = GetCategoryAxis(); + if (categoryAxis == null) return; + categoryAxis.ItemsSource = null; + + var obsSeries = CreateBarSeries("Observations", Color.FromArgb(75, 220, 20, 60), Color.FromArgb(255, 255, 0, 0)); + string priorLabel = isGMM ? "Penalties" : "Priors"; + var priorSeries = CreateBarSeries(priorLabel, Color.FromArgb(125, 104, 140, 175), Color.FromArgb(255, 53, 59, 122)); + + for (int i = 0; i < displayOrder.Length; i++) + { + var item = displayOrder[i]; + categoryAxis.Labels.Add(item.Label); + + if (item.IsObservation) + obsSeries.Items.Add(new BarItem { Value = item.Leverage, CategoryIndex = i }); + else + priorSeries.Items.Add(new BarItem { Value = item.Leverage, CategoryIndex = i }); + } + + if (obsSeries.Items.Count > 0) + _plot.Series.Add(obsSeries); + if (includePriors && priorSeries.Items.Count > 0) + _plot.Series.Add(priorSeries); + + string description = isFitView + ? "Fit influence shows how much each component shifts the best-fit parameters (Cook's Distance)." + : "Variance influence shows how much each component contributes to posterior precision."; + SummaryText.Text = $"p = {diagnostics.NumberOfParameters}. {description}"; + } + catch (Exception ex) + { + Debug.WriteLine($"{viewName} plot update failed: {ex.Message}"); + SummaryText.Text = $"Error computing {viewName.ToLower()}."; + } + finally + { + Mouse.OverrideCursor = null; + } + } + + /// + /// Updates the plot to display the Leave-One-Out Diagnostic view. + /// Bars show the LOO predictive surprise |ELPD-LOO_i| for each observation, sorted by magnitude. + /// Bars are color-coded by the Pareto k diagnostic category: green (k < 0.5) indicates the + /// PSIS approximation is reliable, while orange/red indicate potential issues. + /// + /// + /// ELPD-LOO_i is the expected log predictive density contribution for observation i under + /// leave-one-out cross-validation. More negative values mean the model has more difficulty + /// predicting that observation — indicating higher influence or potential outliers. + /// The absolute value is displayed so bars always point right, with larger bars indicating + /// harder-to-predict observations. + /// + private void UpdateLOODiagnosticPlot() + { + ClearPlot(); + SetPlotTitle("Leave-One-Out Predictive Surprise"); + SetAxisTitle("YAxis", "Observations"); + SetAxisTitle("XAxis", "LOO Predictive Surprise (|\u0112LPD|)"); + + if (Analysis == null || Analysis.IsEstimated == false || Analysis.Results == null) + { + SummaryText.Text = ""; + return; + } + + Mouse.OverrideCursor = Cursors.Wait; + try + { + var diagnostics = ComputeAndCacheObservationDiagnostics(); + if (diagnostics == null || diagnostics.Count == 0) + { + SummaryText.Text = "Leave-one-out diagnostics could not be computed."; + return; + } + + int topN = GetTopN(); + + // Sort by |ELPD-LOO| descending (most surprising first), then take top N + var sorted = diagnostics.Observations + .OrderByDescending(o => Math.Abs(o.ElpdLoo)) + .Take(Math.Min(topN, diagnostics.Count)) + .ToArray(); + + // Reverse so the most surprising observation appears at the top of the plot + var displayOrder = sorted.Reverse().ToArray(); + + // Get the CategoryAxis and clear labels + var categoryAxis = GetCategoryAxis(); + if (categoryAxis == null) return; + categoryAxis.ItemsSource = null; + + // Create one BarSeries per ParetoKCategory for legend support + var seriesGood = CreateBarSeries("Good (k < 0.5)", GetBarFillForCategory(ParetoKCategory.Good), GetBarStrokeForCategory(ParetoKCategory.Good)); + var seriesOK = CreateBarSeries("OK (0.5 \u2264 k < 0.7)", GetBarFillForCategory(ParetoKCategory.OK), GetBarStrokeForCategory(ParetoKCategory.OK)); + var seriesBad = CreateBarSeries("Bad (0.7 \u2264 k < 1.0)", GetBarFillForCategory(ParetoKCategory.Bad), GetBarStrokeForCategory(ParetoKCategory.Bad)); + var seriesVeryBad = CreateBarSeries("Very Bad (k \u2265 1.0)", GetBarFillForCategory(ParetoKCategory.VeryBad), GetBarStrokeForCategory(ParetoKCategory.VeryBad)); + + // Pre-fill all series with zero values, then set the actual value in the correct series + for (int i = 0; i < displayOrder.Length; i++) + { + var obs = displayOrder[i]; + categoryAxis.Labels.Add(GetObservationLabel(obs)); + + double absElpd = Math.Abs(obs.ElpdLoo); + var emptyItem = new BarItem { Value = 0 }; + var valueItem = new BarItem { Value = absElpd }; + + switch (obs.Category) + { + case ParetoKCategory.Good: + seriesGood.Items.Add(valueItem); + seriesOK.Items.Add(emptyItem); + seriesBad.Items.Add(emptyItem); + seriesVeryBad.Items.Add(emptyItem); + break; + case ParetoKCategory.OK: + seriesGood.Items.Add(emptyItem); + seriesOK.Items.Add(valueItem); + seriesBad.Items.Add(emptyItem); + seriesVeryBad.Items.Add(emptyItem); + break; + case ParetoKCategory.Bad: + seriesGood.Items.Add(emptyItem); + seriesOK.Items.Add(emptyItem); + seriesBad.Items.Add(valueItem); + seriesVeryBad.Items.Add(emptyItem); + break; + case ParetoKCategory.VeryBad: + seriesGood.Items.Add(emptyItem); + seriesOK.Items.Add(emptyItem); + seriesBad.Items.Add(emptyItem); + seriesVeryBad.Items.Add(valueItem); + break; + } + } + + // Only add series that have at least one non-zero value (for a clean legend) + if (seriesGood.Items.Any(item => item.Value != 0)) _plot.Series.Add(seriesGood); + if (seriesOK.Items.Any(item => item.Value != 0)) _plot.Series.Add(seriesOK); + if (seriesBad.Items.Any(item => item.Value != 0)) _plot.Series.Add(seriesBad); + if (seriesVeryBad.Items.Any(item => item.Value != 0)) _plot.Series.Add(seriesVeryBad); + + // Update summary text with both LOO and Pareto k information + SummaryText.Text = $"{diagnostics.Count} observations. " + + $"Pareto k: {diagnostics.CountParetoKAbove07} with k \u2265 0.7, " + + $"{diagnostics.CountParetoKAbove05} with k \u2265 0.5. " + + diagnostics.GetReliabilitySummary(); + } + catch (Exception ex) + { + Debug.WriteLine($"LOO diagnostic plot update failed: {ex.Message}"); + SummaryText.Text = "Error computing leave-one-out diagnostics."; + } + finally + { + Mouse.OverrideCursor = null; + } + } + + /// + /// Updates the plot to display GMM Cook's Distance as a tornado chart. + /// Shows each observation's Cook's Distance sorted descending. + /// The "Include Penalties" checkbox controls inclusion of penalty components. + /// + private void UpdateGMMInfluencePlot() + { + ClearPlot(); + SetPlotTitle("Leverage (% of Total Information)"); + SetAxisTitle("YAxis", ""); + SetAxisTitle("XAxis", "% of Total Information"); + + if (GMMAnalysis == null || GMMAnalysis.IsEstimated == false) + { + SummaryText.Text = ""; + return; + } + + Mouse.OverrideCursor = Cursors.Wait; + try + { + var diagnostics = GMMAnalysis.GetLeverageDiagnostics(); + if (diagnostics == null || diagnostics.Count == 0) + { + SummaryText.Text = "Leverage diagnostics could not be computed."; + return; + } + + bool includePriors = IncludePriorsCheckBox.IsChecked == true + && diagnostics.PriorComponents != null + && diagnostics.PriorComponents.Length > 0; + + int topN = GetTopN(); + + // Build combined list of ALL items (observations + penalties) for unified ranking + var combinedItems = new List(); + + foreach (var obs in diagnostics.Observations) + { + combinedItems.Add(new LeverageBarItem + { + Label = FormatDataLabel(obs.DataType, obs.Name, obs.Index, obs.Value), + Leverage = obs.Leverage, + PercentOfTotal = obs.PercentOfTotal, + IsObservation = true, + PriorType = PriorComponentType.ParameterPrior + }); + } + + if (includePriors) + { + foreach (var comp in diagnostics.PriorComponents) + { + combinedItems.Add(new LeverageBarItem + { + Label = comp.Name, + Leverage = comp.Leverage, + PercentOfTotal = comp.PercentOfTotal, + IsObservation = false, + PriorType = comp.Type + }); + } + } + + // Sort by leverage descending, take top N, reverse for display + var displayOrder = combinedItems + .OrderByDescending(c => c.Leverage) + .Take(topN) + .Reverse() + .ToArray(); + + // Get the CategoryAxis and clear labels + var categoryAxis = GetCategoryAxis(); + if (categoryAxis == null) return; + categoryAxis.ItemsSource = null; + + // Create series: one for observations, one for all penalties + var obsSeries = CreateBarSeries("Observations", Color.FromArgb(75, 220, 20, 60), Color.FromArgb(255, 255, 0, 0)); + var priorSeries = CreateBarSeries("Penalties", Color.FromArgb(125, 104, 140, 175), Color.FromArgb(255, 53, 59, 122)); + + for (int i = 0; i < displayOrder.Length; i++) + { + var item = displayOrder[i]; + categoryAxis.Labels.Add(item.Label); + + if (item.IsObservation) + obsSeries.Items.Add(new BarItem { Value = item.PercentOfTotal, CategoryIndex = i }); + else + priorSeries.Items.Add(new BarItem { Value = item.PercentOfTotal, CategoryIndex = i }); + } + + if (obsSeries.Items.Count > 0) + _plot.Series.Add(obsSeries); + if (includePriors && priorSeries.Items.Count > 0) + _plot.Series.Add(priorSeries); + + // Summary + double obsPct = diagnostics.TotalLeverage > 0 + ? diagnostics.TotalObservationLeverage / diagnostics.TotalLeverage * 100.0 + : 0; + double priorPct = diagnostics.TotalLeverage > 0 + ? diagnostics.TotalPriorLeverage / diagnostics.TotalLeverage * 100.0 + : 0; + SummaryText.Text = $"p = {diagnostics.NumberOfParameters}. " + + $"Data: {obsPct:F1}% of total information. " + + $"Penalties: {priorPct:F1}%."; + } + catch (Exception ex) + { + Debug.WriteLine($"GMM influence plot update failed: {ex.Message}"); + SummaryText.Text = "Error computing leverage diagnostics."; + } + finally + { + Mouse.OverrideCursor = null; + } + } + + #endregion + + #region Private Methods — Helpers + + /// + /// Updates the visibility of option bar controls based on the current mode and selected view. + /// GMM mode shows 3 views (Leverage, Fit, Variance). Bayesian mode shows 4 (+ LOO Diagnostic). + /// The "Include Prior Components" / "Include Penalties" checkbox is visible for Leverage/Fit/Variance views. + /// + private void UpdateControlVisibility() + { + if (Mode == InfluenceControlMode.GMM) + { + // GMM mode: show combo with 3 views, hide LOO item + ViewLabel.Visibility = Visibility.Visible; + ViewComboBox.Visibility = Visibility.Visible; + + // Hide the LOO Diagnostic item (index 3) if it exists — GMM only shows first 3 + if (ViewComboBox.Items.Count > 3) + ((ComboBoxItem)ViewComboBox.Items[3]).Visibility = Visibility.Collapsed; + + TopNLabel.Visibility = Visibility.Visible; + TopNComboBox.Visibility = Visibility.Visible; + + int viewIndex = ViewComboBox.SelectedIndex; + IncludePriorsCheckBox.Visibility = (viewIndex <= 2) ? Visibility.Visible : Visibility.Collapsed; + IncludePriorsCheckBox.Content = "Include Penalties"; + } + else + { + // Bayesian mode: all 4 views visible + ViewLabel.Visibility = Visibility.Visible; + ViewComboBox.Visibility = Visibility.Visible; + + if (ViewComboBox.Items.Count > 3) + ((ComboBoxItem)ViewComboBox.Items[3]).Visibility = Visibility.Visible; + + int viewIndex = ViewComboBox.SelectedIndex; + + TopNLabel.Visibility = Visibility.Visible; + TopNComboBox.Visibility = Visibility.Visible; + + // Include Priors checkbox: visible for Leverage (0), Fit (1), Variance (2), hidden for LOO (3) + IncludePriorsCheckBox.Visibility = (viewIndex <= 2) ? Visibility.Visible : Visibility.Collapsed; + IncludePriorsCheckBox.Content = "Include Prior Components"; + } + } + + /// + /// Clears all series, annotations, and category axis labels from the plot. + /// + private void ClearPlot() + { + _plot.Series.Clear(); + _plot.Annotations.Clear(); + + var categoryAxis = GetCategoryAxis(); + if (categoryAxis != null) + { + categoryAxis.ItemsSource = null; + // Assign a fresh Labels list since the default is null + categoryAxis.Labels = new List(); + } + } + + /// + /// Gets the from the plot's axes collection. + /// + /// The CategoryAxis, or null if not found. + private CategoryAxis GetCategoryAxis() + { + return _plot.Axes.OfType().FirstOrDefault(); + } + + /// + /// Sets the title of a named axis, using SuppressPropertyChanged to prevent + /// plot undo tracking from recording this as an undoable + /// "Change Title" action. Axis titles are driven by the view selection and are not user customizations. + /// + /// The Name property of the axis to update. + /// The new axis title. + private void SetAxisTitle(string axisName, string title) + { + var axis = _plot.Axes.FirstOrDefault(a => a.Name == axisName); + if (axis != null) + { + axis.SuppressPropertyChanged = true; + axis.Title = title; + axis.SuppressPropertyChanged = false; + } + } + + /// + /// Sets the plot title, using SuppressPropertyChanged to prevent + /// plot undo tracking from recording this as an undoable + /// action. The plot title is driven by the ViewComboBox selection and is not a user customization. + /// + /// The new plot title. + private void SetPlotTitle(string title) + { + if (_plot == null) return; + _plot.SuppressPropertyChanged = true; + _plot.Title = title; + _plot.SuppressPropertyChanged = false; + } + + /// + /// Lazily computes and caches the observation influence diagnostics (LOO-CV with PSIS). + /// Returns the cached result if already computed. + /// + /// The , or null if computation fails. + private InfluenceDiagnostics ComputeAndCacheObservationDiagnostics() + { + if (_influenceDiagnostics == null && Analysis?.IsEstimated == true) + { + try + { + _influenceDiagnostics = Analysis.ComputeInfluenceDiagnostics(); + } + catch (Exception ex) + { + // E3: Log full exception (including stack trace) so root cause is visible in debug output. + Debug.WriteLine($"Influence diagnostics computation failed ({ex.GetType().Name}): {ex}"); + } + } + return _influenceDiagnostics; + } + + /// + /// Lazily computes and caches the leverage diagnostics (Hessian leverage at MAP). + /// Returns the cached result if already computed. + /// + /// The , or null if computation fails. + private LeverageDiagnostics ComputeAndCacheLeverageDiagnostics() + { + if (_leverageDiagnostics == null && Analysis?.IsEstimated == true) + { + try + { + _leverageDiagnostics = Analysis.ComputeLeverageDiagnostics(); + } + catch (Exception ex) + { + // E3: Log full exception (including stack trace) so root cause is visible in debug output. + Debug.WriteLine($"Leverage diagnostics computation failed ({ex.GetType().Name}): {ex}"); + } + } + return _leverageDiagnostics; + } + + /// + /// Parses the current TopNComboBox selection to get the maximum number of items to display. + /// + /// The top-N count, or for "All". + private int GetTopN() + { + if (TopNComboBox.SelectedItem is ComboBoxItem selectedItem) + { + string content = selectedItem.Content?.ToString() ?? "20"; + if (content == "All") return int.MaxValue; + if (int.TryParse(content, out int n)) return n; + } + return 20; + } + + /// + /// Formats a data label with type prefix, index, and value for any observation type. + /// + /// The data component type (Exact, Interval, etc.). + /// The observation name (typically the time index). + /// The zero-based observation index. + /// The representative observation value. + /// A formatted label like "Exact - 1951 - 135" or "Threshold - 1600 - 300". + private static string FormatDataLabel(DataComponentType dataType, string name, int index, double value) + { + string typeLabel = dataType switch + { + DataComponentType.Exact => "Exact", + DataComponentType.Uncertain => "Uncertain", + DataComponentType.Interval => "Interval", + DataComponentType.LeftCensored => "Threshold", + DataComponentType.RightCensored => "Threshold", + _ => "Obs" + }; + string indexLabel = !string.IsNullOrEmpty(name) ? name : index.ToString(); + return $"{typeLabel} - {indexLabel} - {value:G4}"; + } + + /// + /// Formats the Y-axis label for a single observation influence entry. + /// Delegates to with the observation's component type, name, index, and value. + /// + /// The observation influence data to label. + /// A formatted label string such as "Exact - 1951 - 135". + private static string GetObservationLabel(ObservationInfluence obs) + { + return FormatDataLabel(obs.DataType, obs.Name, obs.Index, obs.Value); + } + + /// + /// Creates a new with the specified title and fill color. + /// Configures the series for horizontal bar display with the standard tracker format. + /// + /// The legend title for the series. + /// The WPF fill color for the bars. + /// The WPF stroke color for the bar outlines. + /// A configured . + private static BarSeries CreateBarSeries(string title, Color fillColor, Color strokeColor) + { + return new BarSeries + { + Title = title, + FillColor = fillColor, + StrokeColor = strokeColor, + StrokeThickness = 1, + IsStacked = false, + TrackerFormatString = "{0}" + Environment.NewLine + "{1}" + Environment.NewLine + "Value: {2:F4}" + }; + } + + /// + /// Adds a vertical dashed reference line annotation to the plot at the specified X value. + /// + /// The X-axis position of the reference line. + /// The label text for the annotation. + /// The WPF color of the reference line. + private void AddVerticalReferenceLine(double x, string text, Color color) + { + _plot.Annotations.Add(new LineAnnotation + { + Type = LineAnnotationType.Vertical, + X = x, + LineStyle = LineStyle.Dash, + Color = color, + Text = text, + TextHorizontalAlignment = System.Windows.HorizontalAlignment.Left, + TextVerticalAlignment = System.Windows.VerticalAlignment.Top, + FontSize = 10, + StrokeThickness = 1.5 + }); + } + + /// + /// Returns the bar fill color for a given . + /// + /// The Pareto k diagnostic category. + /// The WPF Color for the bar fill. + private static Color GetBarFillForCategory(ParetoKCategory category) + { + switch (category) + { + case ParetoKCategory.Good: return Color.FromArgb(75, 76, 175, 80); // Green (alpha=75 matches KDE area fill) + case ParetoKCategory.OK: return Color.FromArgb(75, 255, 193, 7); // Amber + case ParetoKCategory.Bad: return Color.FromArgb(75, 255, 152, 0); // Orange + case ParetoKCategory.VeryBad: return Color.FromArgb(75, 244, 67, 54); // Red + default: return Color.FromArgb(75, 158, 158, 158); // Gray + } + } + + /// + /// Returns the bar stroke color for a given . + /// + /// The Pareto k diagnostic category. + /// The WPF Color for the bar stroke (opaque, darker shade of the fill). + private static Color GetBarStrokeForCategory(ParetoKCategory category) + { + switch (category) + { + case ParetoKCategory.Good: return Color.FromArgb(255, 56, 142, 60); // Dark green + case ParetoKCategory.OK: return Color.FromArgb(255, 255, 160, 0); // Dark amber + case ParetoKCategory.Bad: return Color.FromArgb(255, 230, 126, 0); // Dark orange + case ParetoKCategory.VeryBad: return Color.FromArgb(255, 211, 47, 47); // Dark red + default: return Color.FromArgb(255, 117, 117, 117); // Dark gray + } + } + + #endregion + + #region Private Types + + /// + /// Internal helper for sorting leverage items (observations and priors) together by leverage value. + /// + private class LeverageBarItem + { + /// + /// Gets or sets the Y-axis label for this item. + /// + public string Label { get; set; } + + /// + /// Gets or sets the raw leverage value (gᵀ H⁻¹ g). + /// + public double Leverage { get; set; } + + /// + /// Gets or sets the leverage as a percentage of total information. + /// + public double PercentOfTotal { get; set; } + + /// + /// Gets or sets a value indicating whether this item represents an observation (true) + /// or a prior component (false). + /// + public bool IsObservation { get; set; } + + /// + /// Gets or sets the prior component type. Only meaningful when is false. + /// + public PriorComponentType PriorType { get; set; } + } + + #endregion + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/KernelDensityControl.xaml b/src/RMC.BestFit.App/GUI/Support/Controls/KernelDensityControl.xaml new file mode 100644 index 0000000..9c12970 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/KernelDensityControl.xaml @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/KernelDensityControl.xaml.cs b/src/RMC.BestFit.App/GUI/Support/Controls/KernelDensityControl.xaml.cs new file mode 100644 index 0000000..527c695 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/KernelDensityControl.xaml.cs @@ -0,0 +1,418 @@ +using OxyPlot; +using OxyPlot.Wpf; +using OxyPlotControls; +using RMC.BestFit.Models; +using RMC.BestFit.Estimation; +using RMC.BestFit.UI; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Media3D; + +namespace RMC_BestFit +{ + /// + /// A user control that displays a kernel density estimation (KDE) visualization of posterior parameter distributions + /// from a Bayesian analysis, with optional prior distribution overlay. + /// + public partial class KernelDensityControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public KernelDensityControl() + { + InitializeComponent(); + } + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty AnalysisProperty = DependencyProperty.Register(nameof(Analysis), typeof(BayesianAnalysis), typeof(KernelDensityControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the Bayesian analysis object that provides data for the kernel density visualization. + /// + public BayesianAnalysis Analysis + { + get { return (BayesianAnalysis)GetValue(AnalysisProperty); } + set { SetValue(AnalysisProperty, value); } + } + + /// + /// Handles property change callbacks for the dependency property. + /// Subscribes to property change events on the new analysis object. + /// + /// The dependency object whose property changed. + /// Event data containing the old and new property values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as KernelDensityControl == null) return; + var thisControl = (KernelDensityControl)d; + + // Unsubscribe from old element + if (e.OldValue is BayesianAnalysis oldElement) + oldElement.PropertyChanged -= thisControl.Analysis_PropertyChanged; + + if (e.NewValue == null) return; + var newElement = e.NewValue as BayesianAnalysis; + if (newElement == null) return; + + newElement.PropertyChanged += thisControl.Analysis_PropertyChanged; + + // Initialize combo and UI now that Analysis is available + thisControl.LoadParameterComboBox(); + thisControl.LoadPercentileHeaders(); + thisControl.UpdatePlot(); + } + + /// + /// Gets or sets a value indicating whether the control should display in simplified view mode. + /// When true, hides advanced diagnostic columns like Gelman-Rubin and ESS statistics. + /// + public bool SimpleView { get; set; } = false; + + /// + /// Tracks whether the control has been loaded and initialized. + /// + private bool _isLoaded = false; + + /// + /// The plot object. Injected by the parent control via + /// (the parent obtains it from BayesianController.KernelDensityPlot). + /// + private Plot _plot; + + /// + /// Gets the plot displayed by this control. Set via by the parent control. + /// + public Plot Plot => _plot; + + /// + /// Gets a value indicating whether the plot area has been clicked by the user. + /// + public bool PlotClicked { get; private set; } = false; + + /// + /// Occurs when the control is clicked, providing information about what area was clicked. + /// + public event PreviewControlClickedEventHandler PreviewControlClicked; + + /// + /// Represents the method that will handle the event. + /// + /// True if the plot area was clicked. + /// True if the toolbar area was clicked. + /// The plot object that was clicked. + public delegate void PreviewControlClickedEventHandler(bool plotClicked, bool toolbarClicked, Plot plot); + + /// + /// Occurs when plot properties are requested to be displayed or modified. + /// + public event PlotPropertiesCalledEventHandler PlotPropertiesCalled; + + /// + /// Represents the method that will handle the event. + /// + /// The plot whose properties are being accessed. + /// True if the properties panel should be opened. + /// The property expander control to display. + /// The object whose properties are being displayed. + public delegate void PlotPropertiesCalledEventHandler(Plot plot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject); + + + + + + /// + /// Handles the Loaded event of the UserControl. Initializes the control on first load + /// by populating the parameter combo box, setting up percentile headers, and updating the plot. + /// + /// The source of the event. + /// Event data. + private void UserControl_Loaded(object sender, RoutedEventArgs e) + { + if (_isLoaded == false) + { + // One-time setup: visibility config is set once on first load. + LoadParameterComboBox(); + LoadPercentileHeaders(); + if (SimpleView) + { + ShowPriorDistribution.Visibility = Visibility.Collapsed; + GelmanRubinColumn.Visibility = Visibility.Collapsed; + ESSColumn.Visibility = Visibility.Collapsed; + } + } + _isLoaded = true; + // Always refresh on load so Analysis changes between unload and reload are applied. + UpdatePlot(); + } + + /// + /// Handles property changes on the Analysis object. Updates the plot and UI elements + /// when relevant analysis properties change. + /// + /// The source of the event. + /// Event data containing the name of the property that changed. + private void Analysis_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Analysis.IsEstimated)) + { + UpdatePlot(); + } + if (e.PropertyName == nameof(Analysis.CredibleIntervalWidth)) + { + // Relabel column headers (90% LowerCI -> 95% LowerCI) AND rebind the data + // table so the LowerCI/UpperCI cell values reflect the new alpha. Headers + // alone aren't enough the cached ItemsSource holds stale percentiles. + LoadPercentileHeaders(); + UpdatePlot(); + } + if (e.PropertyName == nameof(Analysis.Model) || e.PropertyName == nameof(Analysis.ParameterNames)) + { + LoadParameterComboBox(); + } + } + + /// + /// Populates the parameter combo box with the display names of all parameters + /// from the analysis model. + /// + private void LoadParameterComboBox() + { + if (Analysis == null || Analysis.ParameterNames == null) return; + var parms = Analysis.ParameterNames.ToList(); + ParameterComboBox.ItemsSource = null; + ParameterComboBox.ItemsSource = parms; + ParameterComboBox.SelectedIndex = 0; + } + + /// + /// Updates the percentile column headers in the data grid based on the current + /// credible interval width from the analysis. + /// + private void LoadPercentileHeaders() + { + if (Analysis == null) return; + var alpha = (1 - Analysis.CredibleIntervalWidth) / 2d; + Percentile1Column.Header = (alpha * 100).ToString("F1") + "%"; + Percentile3Column.Header = ((1 - alpha) * 100).ToString("F1") + "%"; + } + + /// + /// Handles the Checked event of the ShowPriorDistribution checkbox. + /// Updates the plot to display the prior distribution overlay. + /// + /// The source of the event. + /// Event data. + private void ShowPriorDistribution_Checked(object sender, RoutedEventArgs e) + { + if (_isLoaded == true) UpdatePlot(); + } + + /// + /// Handles the Unchecked event of the ShowPriorDistribution checkbox. + /// Updates the plot to remove the prior distribution overlay. + /// + /// The source of the event. + /// Event data. + private void ShowPriorDistribution_Unchecked(object sender, RoutedEventArgs e) + { + if (_isLoaded == true) UpdatePlot(); + } + + /// + /// Handles the SelectionChanged event of the ParameterComboBox. + /// Updates the plot to display the kernel density for the newly selected parameter. + /// + /// The source of the event. + /// Event data. + private void ParameterComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isLoaded == true) UpdatePlot(); + } + + /// + /// Handles the PreviewMouseDown event of the UserControl. Performs hit testing + /// to determine if the plot or toolbar was clicked and raises the appropriate event. + /// + /// The source of the event. + /// Event data. + private void UserControl_PreviewMouseDown(object sender, MouseButtonEventArgs e) + { + if (_plot == null) return; + var plotHitResult = VisualTreeHelper.HitTest(_plot, e.GetPosition(_plot)); + var toolbarHitResult = VisualTreeHelper.HitTest(PlotToolbar, e.GetPosition(PlotToolbar)); + PreviewControlClicked?.Invoke(plotHitResult != null, toolbarHitResult != null, _plot); + PlotClicked = plotHitResult != null; + } + + /// + /// Handles the LostFocus event of the UserControl. Resets the PlotClicked state. + /// + /// The source of the event. + /// Event data. + private void UserControl_LostFocus(object sender, RoutedEventArgs e) + { + PlotClicked = false; + } + + /// + /// Handles the PropertiesCalled event from the plot toolbar. + /// Forwards the event to subscribers of the PlotPropertiesCalled event. + /// + /// The plot whose properties are being accessed. + /// True if the properties panel should be opened. + /// The property expander control to display. + /// The object whose properties are being displayed. + private void PlotToolbar_PropertiesCalled(Plot targetPlot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject) + { + PlotPropertiesCalled?.Invoke(targetPlot, openProperties, propertyExpander, selectedObject); + } + + /// + /// Sets the external plot to display in this control. Called by parent controls + /// that own the Plot object (Pattern B). The plot is attached to PlotHost and PlotToolbar. + /// + /// The external plot to display. + public void SetPlot(Plot plot) + { + _plot = plot; + PlotHost.Content = _plot; + PlotToolbar.Plot = _plot; + } + + /// + /// Sets the plot title. Suppresses PropertyChanged so the change is not recorded + /// as an undoable action the title tracks combo selection, not user intent. + /// + private void SetPlotTitle(string title) + { + if (_plot == null) return; + _plot.SuppressPropertyChanged = true; + _plot.Title = title; + _plot.SuppressPropertyChanged = false; + } + + /// + /// Updates the kernel density plot with the current parameter data from the analysis results. + /// Displays the posterior distribution as a smoothed density curve and optionally overlays the prior distribution. + /// Also updates the summary statistics table. + /// + private void UpdatePlot() + { + if (_plot == null) return; + + // Set plot title and axis title to reflect the selected parameter. Suppress + // PropertyChanged so the PlotUndoManager does not record these as undoable + // actions they track combo selection, not user intent. + string paramName = ParameterComboBox.SelectedValue as string ?? ""; + string densityLabel = SimpleView ? "Marginal Density" : "Marginal Posterior Density"; + SetPlotTitle(string.IsNullOrEmpty(paramName) ? densityLabel : $"{densityLabel} of {paramName}"); + foreach (var axis in _plot.Axes) + { + if (axis.Position == OxyPlot.Axes.AxisPosition.Bottom) + { + axis.SuppressPropertyChanged = true; + axis.Title = paramName; + axis.SuppressPropertyChanged = false; + } + } + + // Lookup-or-create named series so user-customized styling survives Save/Open. + var priorSeries = _plot.Series.OfType().FirstOrDefault(s => s.Name == "PriorDensity") + ?? new AreaSeries + { + Name = "PriorDensity", + Title = "Prior Density", + Fill = Color.FromArgb(125, 104, 140, 175), + Color = Color.FromArgb(255, 53, 59, 122), + LineStyle = LineStyle.Solid, + BrokenLineThickness = 1, + StrokeThickness = 1, + Decimator = OxyPlot.Decimator.Decimate, + }; + var posteriorSeries = _plot.Series.OfType().FirstOrDefault(s => s.Name == "PosteriorDensity") + ?? new AreaSeries + { + Name = "PosteriorDensity", + Title = "Posterior Density", + Fill = Color.FromArgb(75, 220, 20, 60), + Color = Color.FromArgb(255, 255, 0, 0), + LineStyle = LineStyle.Solid, + BrokenLineThickness = 1, + StrokeThickness = 1, + Decimator = OxyPlot.Decimator.Decimate, + }; + + // BC3: Clear series in a single block without an intermediate InvalidatePlot + // so the plot never renders in a transient blank state between clear and repopulate. + _plot.Series.Clear(); + + KDEDataGrid.ItemsSource = null; + KDEDataGrid.ItemsSource = new List() { new Numerics.Sampling.MCMC.ParameterStatistics() { Rhat = double.NaN, ESS = double.NaN, Mean = double.NaN, StandardDeviation = double.NaN, LowerCI = double.NaN, Median = double.NaN, UpperCI = double.NaN } }; + KDEDataGrid.Items.Refresh(); + + if (Analysis == null || Analysis.IsEstimated == false || Analysis.Results == null) + { + _plot.InvalidatePlot(true); + return; + } + + if (Analysis.IsEstimated == true) + { + Mouse.OverrideCursor = Cursors.Wait; + try + { + + int index = Math.Max(0, ParameterComboBox.SelectedIndex); + + if (ShowPriorDistribution.IsChecked == true && Analysis.Model != null) + { + var pdf = Analysis.Model.Parameters[index].PriorDistribution.CreatePDFGraph(); + var priorPoints = new List(); + for (int i = 0; i < pdf.GetLength(0); i++) + priorPoints.Add(new Point3D(pdf[i, 0], 0, pdf[i, 1])); + + priorSeries.ItemsSource = priorPoints; + priorSeries.DataFieldX = nameof(Point3D.X); + priorSeries.DataFieldY = nameof(Point3D.Y); + priorSeries.DataFieldX2 = nameof(Point3D.X); + priorSeries.DataFieldY2 = nameof(Point3D.Z); + priorSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.0000}" + Environment.NewLine + "{3}: {4:0.000000}"; + _plot.Series.Add(priorSeries); + } + + var postPoints = new List(); + for (int i = 0; i < Analysis.Results.ParameterResults[index].KernelDensity.GetLength(0); i++) + postPoints.Add(new Point3D(Analysis.Results.ParameterResults[index].KernelDensity[i, 0], 0, Analysis.Results.ParameterResults[index].KernelDensity[i, 1])); + + posteriorSeries.ItemsSource = postPoints; + posteriorSeries.DataFieldX = nameof(Point3D.X); + posteriorSeries.DataFieldY = nameof(Point3D.Y); + posteriorSeries.DataFieldX2 = nameof(Point3D.X); + posteriorSeries.DataFieldY2 = nameof(Point3D.Z); + posteriorSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.0000}" + Environment.NewLine + "{3}: {4:0.000000}"; + _plot.Series.Add(posteriorSeries); + _plot.InvalidatePlot(true); + + // Update stats table + KDEDataGrid.ItemsSource = new List() { Analysis.Results.ParameterResults[index].SummaryStatistics }; + KDEDataGrid.Items.Refresh(); + + } + finally + { + Mouse.OverrideCursor = null; + } + } + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/MCMCReportControl.xaml b/src/RMC.BestFit.App/GUI/Support/Controls/MCMCReportControl.xaml new file mode 100644 index 0000000..6c70e1b --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/MCMCReportControl.xaml @@ -0,0 +1,19 @@ + + + diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/MCMCReportControl.xaml.cs b/src/RMC.BestFit.App/GUI/Support/Controls/MCMCReportControl.xaml.cs new file mode 100644 index 0000000..d33b763 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/MCMCReportControl.xaml.cs @@ -0,0 +1,117 @@ +using RMC.BestFit.Estimation; +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; + +namespace RMC_BestFit +{ + /// + /// User control for displaying a comprehensive MCMC simulation report. + /// Shows simulation configuration, convergence diagnostics, parameter estimates, + /// covariance/correlation matrices, information criteria, and prior configuration. + /// + public partial class MCMCReportControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public MCMCReportControl() + { + InitializeComponent(); + } + + /// + /// Dependency property for the property. + /// + public static DependencyProperty AnalysisProperty = DependencyProperty.Register( + nameof(Analysis), typeof(BayesianAnalysis), typeof(MCMCReportControl), + new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the Bayesian analysis to generate the report from. + /// + public BayesianAnalysis Analysis + { + get { return (BayesianAnalysis)GetValue(AnalysisProperty); } + set { SetValue(AnalysisProperty, value); } + } + + /// + /// Handles changes to the dependency property. + /// Subscribes to PropertyChanged events and updates the report. + /// + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = d as MCMCReportControl; + if (control == null) return; + + if (e.OldValue is BayesianAnalysis oldAnalysis) + oldAnalysis.PropertyChanged -= control.Analysis_PropertyChanged; + + if (e.NewValue is BayesianAnalysis newAnalysis) + { + newAnalysis.PropertyChanged += control.Analysis_PropertyChanged; + control.UpdateReport(); + } + else + { + control.ClearReport(); + } + } + + /// + /// Handles PropertyChanged events from the Bayesian analysis. + /// Updates the report when estimation completes or results change. + /// + private void Analysis_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(BayesianAnalysis.IsEstimated) || + e.PropertyName == nameof(BayesianAnalysis.Results)) + { + UpdateReport(); + } + } + + /// + /// Clears the report display. + /// + private void ClearReport() + { + ReportTextBox.Document = new FlowDocument(); + } + + /// + /// Generates and displays the full MCMC simulation report. + /// + private void UpdateReport() + { + string report = Analysis?.GenerateReport() ?? string.Empty; + if (string.IsNullOrEmpty(report)) + { + ClearReport(); + return; + } + + DisplayReport(report); + } + + /// + /// Displays report text in the rich text control. + /// + /// The report text to display. + private void DisplayReport(string report) + { + var doc = new FlowDocument(); + doc.FontFamily = new System.Windows.Media.FontFamily("Consolas"); + doc.FontSize = 12; + doc.PageWidth = 1200; + + var paragraph = new Paragraph(); + paragraph.Inlines.Add(new Run(report)); + doc.Blocks.Add(paragraph); + + ReportTextBox.Document = doc; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/MarkovChainTraceControl.xaml b/src/RMC.BestFit.App/GUI/Support/Controls/MarkovChainTraceControl.xaml new file mode 100644 index 0000000..0e57399 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/MarkovChainTraceControl.xaml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/MarkovChainTraceControl.xaml.cs b/src/RMC.BestFit.App/GUI/Support/Controls/MarkovChainTraceControl.xaml.cs new file mode 100644 index 0000000..b7e2cee --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/MarkovChainTraceControl.xaml.cs @@ -0,0 +1,883 @@ +using Numerics; +using Numerics.Data.Statistics; +using OxyPlot; +using OxyPlot.Wpf; +using OxyPlotControls; +using RMC.BestFit.Models; +using RMC.BestFit.Estimation; +using RMC.BestFit.UI; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; + +namespace RMC_BestFit +{ + /// + /// User control for displaying and visualizing Markov Chain Monte Carlo (MCMC) trace plots for Bayesian analysis parameters. + /// This control provides interactive visualization of parameter chains over iterations, with options to filter by parameter and chain. + /// + public partial class MarkovChainTraceControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public MarkovChainTraceControl() + { + InitializeComponent(); + // Hook IsVisibleChanged so the plot is only (re)built when the trace tab is actually + // visible. When the tab is hidden, updates are marked dirty and deferred until the user + // switches back — matching the canonical "lazy tab-gated rendering" pattern in the project coding standards. + this.IsVisibleChanged += UserControl_IsVisibleChanged; + } + + /// + /// Dependency property for the property. + /// + public static readonly DependencyProperty AnalysisProperty = DependencyProperty.Register(nameof(Analysis), typeof(BayesianAnalysis), typeof(MarkovChainTraceControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the Bayesian analysis instance containing the Markov chain data to be visualized. + /// + public BayesianAnalysis Analysis + { + get { return (BayesianAnalysis)GetValue(AnalysisProperty); } + set { SetValue(AnalysisProperty, value); } + } + + /// + /// Callback method invoked when the Analysis dependency property changes. + /// Subscribes to property changed events on the new analysis instance. + /// + /// The dependency object that owns the property. + /// Event data containing the old and new property values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as MarkovChainTraceControl == null) return; + var thisControl = (MarkovChainTraceControl)d; + + // Unsubscribe from old element + if (e.OldValue is BayesianAnalysis oldElement) + oldElement.PropertyChanged -= thisControl.Analysis_PropertyChanged; + + // Analysis cleared — invalidate cache so stale references are dropped, mark dirty so a + // future drain clears the plot. + if (e.NewValue == null) + { + thisControl.InvalidateTraceCache(); + thisControl._isDirty = true; + thisControl.DrainIfReady(); + return; + } + + var newElement = e.NewValue as BayesianAnalysis; + if (newElement == null) return; + + newElement.PropertyChanged += thisControl.Analysis_PropertyChanged; + + // Initialize combos and series collection without firing SelectionChanged → UpdatePlot + // on every intermediate SelectedIndex assignment. A single drain at the end performs + // one UpdatePlot for the coherent new state (gated on visibility by DrainIfReady). + thisControl._suppressUpdates = true; + try + { + thisControl.LoadParameterComboBox(); + thisControl.LoadChainComboBox(); + thisControl.ConstructDefaultLineSeries(); + thisControl.InvalidateTraceCache(); + } + finally + { + thisControl._suppressUpdates = false; + } + + thisControl._isDirty = true; + thisControl.DrainIfReady(); + } + + /// + /// Flag indicating whether the control has been loaded. + /// + private bool _isLoaded = false; + + /// + /// The plot object. Injected by the parent control via + /// (the parent obtains it from BayesianController.MarkovChainTracePlot). + /// + private Plot _plot; + + /// + /// Gets the plot displayed by this control. Set via by the parent control. + /// + public Plot Plot => _plot; + + /// + /// Gets a value indicating whether the plot area was clicked by the user. + /// + public bool PlotClicked { get; private set; } = false; + + /// + /// Raised when the user clicks on the control, providing information about which part was clicked. + /// + public event PreviewControlClickedEventHandler PreviewControlClicked; + + /// + /// Delegate for the event. + /// + /// Indicates whether the plot area was clicked. + /// Indicates whether the toolbar area was clicked. + /// The plot control that was clicked. + public delegate void PreviewControlClickedEventHandler(bool plotClicked, bool toolbarClicked, Plot plot); + + /// + /// Raised when the plot properties dialog is requested. + /// + public event PlotPropertiesCalledEventHandler PlotPropertiesCalled; + + /// + /// Delegate for the event. + /// + /// The plot control for which properties are requested. + /// Indicates whether the properties dialog should be opened. + /// The property expander control to use. + /// The currently selected object in the properties dialog. + public delegate void PlotPropertiesCalledEventHandler(Plot plot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject); + + /// + /// List of line series representing the trace plots for each Markov chain. + /// + private List _traceSeriesList = new List(); + + /// + /// Gate for rapid-fire events during load. While true, is a no-op, + /// so / assigning + /// SelectedIndex = 0 does not trigger redundant UpdatePlot calls. + /// + private bool _suppressUpdates = false; + + /// + /// Set to true when some input (Analysis, IsEstimated, NumberOfChains, parameter or chain + /// selection, warmup toggle) changes. Drained by exactly once per + /// coherent change batch, provided the control is loaded AND visible. Collapses the cascade + /// of PropertyChanged events during analysis attach into a single call. + /// + private bool _isDirty = false; + + /// + /// Pre-built DataPoint[] per (parameter, chain). Indexed as + /// _traceCache[parameterIndex][chainIndex]. Built once per (Analysis, WarmUp) state + /// and reused across parameter/chain-filter switches so switching only costs an + /// ItemsSource reference swap instead of a full walk of MarkovChains. + /// + private DataPoint[][][] _traceCache; + + /// + /// The startE offset (warmup-excluded sample start index) that + /// was built for. The cache is stale if the current IncludeWarmUp state produces a different + /// startE. + /// + private int _cachedStartE = -1; + +#if DEBUG + // Baseline instrumentation — remove once MCMC trace perf work is complete. + private static int _updatePlotCallCount; +#endif + + /// + /// Event handler for when the user control is loaded. Initializes combo boxes and plots. + /// + /// The source of the event. + /// Event data. + private void UserControl_Loaded(object sender, RoutedEventArgs e) + { + if (_isLoaded == false) + { + // One-time setup: build series list and combo boxes on first load. + _suppressUpdates = true; + try + { + LoadParameterComboBox(); + LoadChainComboBox(); + ConstructDefaultLineSeries(); + } + finally + { + _suppressUpdates = false; + } + } + _isLoaded = true; + // Always mark dirty so DrainIfReady refreshes on every load, + // applying Analysis changes that occurred while the control was unloaded. + _isDirty = true; + DrainIfReady(); + } + + /// + /// Event handler for visibility changes on this control. When the tab hosting the trace + /// plot becomes visible, any pending update (marked dirty while invisible) is drained. + /// When the tab becomes invisible, this is a no-op — updates simply accumulate as dirty. + /// + /// The source of the event. + /// Event data containing the new IsVisible value. + private void UserControl_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) + { +#if DEBUG && OXYPLOT_SOURCE_DIAGNOSTICS + // Each Debug.WriteLine emitted by the OxyPlot wheel-stack trace adds ~0.5–1ms of + // VS-Output dispatch overhead. With ~50 lines per wheel tick, that masked the real + // perf and made the plot ~30–50ms slower per interaction during investigation. + // Re-enable manually (set both flags to true) only when actively diagnosing. + const bool enableTraceDiagnostics = false; + if (enableTraceDiagnostics && this.IsVisible) + { + OxyPlot.Wpf.PlotViewBase.InvalidatePlotDiagnosticsTitleFilter = "Trace"; + OxyPlot.Wpf.PlotViewBase.InvalidatePlotDiagnosticsEnabled = true; + OxyPlot.Wpf.Plot.InvalidatePlotPhaseDiagnosticsEnabled = true; + } + else + { + OxyPlot.Wpf.PlotViewBase.InvalidatePlotDiagnosticsEnabled = false; + OxyPlot.Wpf.Plot.InvalidatePlotPhaseDiagnosticsEnabled = false; + } +#endif + DrainIfReady(); + } + + /// + /// Event handler for property changes in the Analysis object. Updates the control when relevant properties change. + /// + /// The source of the event. + /// Event data containing the property name that changed. + private void Analysis_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + bool requiresCacheRebuild = false; + + if (e.PropertyName == nameof(Analysis.IsEstimated)) + { + requiresCacheRebuild = true; + _isDirty = true; + } + if (e.PropertyName == nameof(Analysis.Model) || e.PropertyName == nameof(Analysis.ParameterNames)) + { + _suppressUpdates = true; + try { LoadParameterComboBox(); } finally { _suppressUpdates = false; } + requiresCacheRebuild = true; + _isDirty = true; + } + if (e.PropertyName == nameof(Analysis.NumberOfChains)) + { + _suppressUpdates = true; + try + { + LoadChainComboBox(); + ConstructDefaultLineSeries(); + } + finally { _suppressUpdates = false; } + requiresCacheRebuild = true; + _isDirty = true; + } + + if (requiresCacheRebuild) + { + InvalidateTraceCache(); + } + + DrainIfReady(); + } + + /// + /// Populates the parameter combo box with available model parameters from the analysis. + /// + private void LoadParameterComboBox() + { + if (Analysis == null || Analysis.ParameterNames == null) return; + var parms = Analysis.ParameterNames.ToList(); + ParameterComboBox.ItemsSource = null; + ParameterComboBox.ItemsSource = parms; + ParameterComboBox.SelectedIndex = 0; + } + + /// + /// Populates the chain combo box with available Markov chains from the analysis. + /// + private void LoadChainComboBox() + { + if (Analysis == null) return; + List cmbxList = new List(); + cmbxList.Add("All Chains"); + for (int i = 1; i <= Analysis.NumberOfChains; i++) + cmbxList.Add("Chain " + i.ToString()); + ChainComboBox.ItemsSource = null; + ChainComboBox.ItemsSource = cmbxList; + ChainComboBox.SelectedIndex = 0; + } + + /// + /// Event handler for parameter combo box selection changes. Updates the plot to display the selected parameter. + /// + /// The source of the event. + /// Event data containing selection change information. + private void ParameterComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_suppressUpdates) return; + if (_isLoaded == false) return; + // Parameter index changes which series slice is shown but not the underlying cache shape. + _isDirty = true; + DrainIfReady(); + } + + /// + /// Event handler for chain combo box selection changes. Updates the plot to display the selected chain(s). + /// + /// The source of the event. + /// Event data containing selection change information. + private void ChainComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_suppressUpdates) return; + if (_isLoaded == false) return; + // Chain filter changes which series are added to the plot, but not the cache shape. + _isDirty = true; + DrainIfReady(); + } + + /// + /// Event handler for when the "Include Warm-up" checkbox is checked. Updates the plot to include warm-up iterations. + /// + /// The source of the event. + /// Event data. + private void IncludeWarmUpCheckBox_Checked(object sender, RoutedEventArgs e) + { + if (_suppressUpdates) return; + if (_isLoaded == false) return; + // Warmup toggle changes startE, which changes the DataPoint x-values and count. Cache is stale. + InvalidateTraceCache(); + _isDirty = true; + DrainIfReady(); + } + + /// + /// Event handler for when the "Include Warm-up" checkbox is unchecked. Updates the plot to exclude warm-up iterations. + /// + /// The source of the event. + /// Event data. + private void IncludeWarmUpCheckBox_Unchecked(object sender, RoutedEventArgs e) + { + if (_suppressUpdates) return; + if (_isLoaded == false) return; + InvalidateTraceCache(); + _isDirty = true; + DrainIfReady(); + } + + /// + /// Event handler for mouse down events on the control. Determines if the plot or toolbar was clicked and raises the PreviewControlClicked event. + /// + /// The source of the event. + /// Event data containing mouse button information. + private void UserControl_PreviewMouseDown(object sender, MouseButtonEventArgs e) + { + if (_plot == null) return; + var plotHitResult = VisualTreeHelper.HitTest(_plot, e.GetPosition(_plot)); + var toolbarHitResult = VisualTreeHelper.HitTest(PlotToolbar, e.GetPosition(PlotToolbar)); + PreviewControlClicked?.Invoke(plotHitResult != null, toolbarHitResult != null, _plot); + PlotClicked = plotHitResult != null; + } + + /// + /// Event handler for when the control loses focus. Resets the PlotClicked flag. + /// + /// The source of the event. + /// Event data. + private void UserControl_LostFocus(object sender, RoutedEventArgs e) + { + PlotClicked = false; + } + + /// + /// Event handler for when plot properties are requested from the toolbar. Raises the PlotPropertiesCalled event. + /// + /// The plot for which properties are requested. + /// Indicates whether to open the properties dialog. + /// The property expander control to use. + /// The currently selected object. + private void PlotToolbar_PropertiesCalled(Plot targetPlot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject) + { + PlotPropertiesCalled?.Invoke(targetPlot, openProperties, propertyExpander, selectedObject); + } + + /// + /// Sets the external plot to display in this control. Called by parent controls + /// that own the Plot object (Pattern B). The plot is attached to PlotHost and PlotToolbar. + /// + /// The external plot to display. + public void SetPlot(Plot plot) + { +#if DEBUG + // Unhook previous plot's model Updated event (if any) so we don't leak. + if (_plot != null && _plot.ActualModel != null) + { +#pragma warning disable CS0618 // PlotModel.Updated is obsolete but still fires; used only for perf diagnostics. + _plot.ActualModel.Updated -= OnPlotModelUpdated_DebugPerf; +#pragma warning restore CS0618 + } +#endif + _plot = plot; + PlotHost.Content = _plot; + PlotToolbar.Plot = _plot; + +#if DEBUG + // Hook model Updated event to measure inter-frame intervals during zoom/pan. + // UpdatePlot() only runs on parameter/chain changes — zoom/pan bypasses it and + // re-renders via the WPF composition pipeline. Updated fires per render pass, + // so logging short intervals reveals interaction frame rate. + if (_plot != null && _plot.ActualModel != null) + { +#pragma warning disable CS0618 + _plot.ActualModel.Updated += OnPlotModelUpdated_DebugPerf; +#pragma warning restore CS0618 + _interRenderSw = new Stopwatch(); + _interRenderSw.Start(); + } +#endif + } + +#if DEBUG + private Stopwatch _interRenderSw; + + /// + /// Handles the DebugPerf event for OnPlotModelUpdated. + /// + /// The event source. + /// The event data. + /// + /// This member is wired by the WPF control or its backing event handlers. + /// + private void OnPlotModelUpdated_DebugPerf(object sender, EventArgs e) + { + if (_interRenderSw == null) return; + long ms = _interRenderSw.ElapsedMilliseconds; + _interRenderSw.Restart(); + // Only log rapid successive renders (< 200ms apart) — these indicate active + // zoom/pan interaction. Single isolated renders (load, parameter switch) are + // already captured by the UpdatePlot instrumentation. + if (ms > 0 && ms < 200) + { + Debug.WriteLine($"[MCMCTrace RENDER-interval] interval={ms,4}ms ({(ms > 0 ? 1000.0 / ms : 0):F1} fps if sustained)"); + } + } +#endif + + + /// + /// Returns true if contains exactly the first + /// items of in order, by reference equality. Used by + /// to skip mutation when the + /// visible-series membership hasn't changed (only data has). + /// + private static bool SeriesMembershipMatches( + System.Collections.ObjectModel.ObservableCollection series, + System.Collections.Generic.IList expected, + int count) + { + if (series == null || expected == null) return false; + if (series.Count != count) return false; + for (int i = 0; i < count; i++) + { + if (!ReferenceEquals(series[i], expected[i])) return false; + } + return true; + } + + /// + /// Constructs default line series for each Markov chain with unique colors and styling. + /// + private void ConstructDefaultLineSeries() + { + if (Analysis == null) return; + _traceSeriesList.Clear(); + var r = new Random(209); + // TrackerFormatString is static (does not depend on Analysis state), so set it once here + // rather than on every UpdatePlot assignment. + const string trackerFormat = "{0}" + "\n" + "{1}: {2:0}" + "\n" + "{3}: {4:0.000000}"; + for (int i = 1; i <= Analysis.NumberOfChains; i++) + { + LineSeries newLineSeries = new LineSeries() + { + Name = "MarkovChain_" + i.ToString(), + Title = "Chain " + i.ToString(), + LineStyle = LineStyle.Solid, + Color = Color.FromRgb(System.Convert.ToByte((r.Next(0, 255) + 255) / (double)2), System.Convert.ToByte((r.Next(0, 255) + 255) / (double)2), System.Convert.ToByte((r.Next(0, 255) + 255) / (double)2)), + StrokeThickness = 1.5, + // Opt this series out of tracker hit-tests entirely. Trace plots show tens of + // thousands of MCMC samples per chain; a per-mouse-move O(n) nearest-point + // scan across 20 chains saturates the UI thread. IsHitTestEnabled=false short- + // circuits before GetNearestPoint is ever called, eliminating the cost. + // (wpf-framework OxyPlot Phase 1 item 1.1.) + IsHitTestEnabled = false, + TrackerFormatString = trackerFormat, + // Activates wpf-framework's fused extract-decimate-transform fast path in + // OxyPlot.Series.LineSeries.RenderPoints. When the axes are linear (true here) + // and X is monotonically increasing (true here — X is iteration index), the + // fused path transforms only the 4 surviving points per pixel column (first, + // last, min, max) rather than all 1751 points per series per frame. For a 20- + // chain trace with a ~1000px plot that drops per-frame transforms from ~35k + // to ~4k and makes wheel zoom, pan, and zoom-extents ~100x cheaper. + Decimator = OxyPlot.Decimator.Decimate + }; + _traceSeriesList.Add(newLineSeries); + } + } + + /// + /// Invalidates the cached trace DataPoint[] arrays. Call when the underlying data or + /// its sampling window (warmup inclusion) changes — the cache will be rebuilt on the next + /// . + /// + private void InvalidateTraceCache() + { + _traceCache = null; + _cachedStartE = -1; + } + + /// + /// Returns whether the cached DataPoint[] arrays match the current Analysis + + /// IncludeWarmUp state (and therefore can be reused). + /// + /// true if is valid for the current state. + private bool IsTraceCacheValid() + { + if (_traceCache == null) return false; + if (Analysis == null || Analysis.Results == null) return false; + int currentStartE = IncludeWarmUpCheckBox.IsChecked == true ? 0 : Analysis.WarmupIterations - 1; + return _cachedStartE == currentStartE; + } + + /// + /// Builds — one DataPoint[] per (parameter, chain) — + /// by walking Analysis.Results.MarkovChains exactly once for all parameters and chains + /// at the current warmup state. Subsequent parameter / chain-filter switches then only need + /// to swap the cached array into LineSeries.ItemsSource. + /// + private void BuildTraceCache() + { + if (Analysis == null || Analysis.IsEstimated == false || Analysis.Results == null) + { + _traceCache = null; + return; + } + if (Analysis.NumberOfChains != Analysis.Results.MarkovChains.Count()) + { + _traceCache = null; + return; + } + + int chainCount = Analysis.NumberOfChains; + // Infer parameter count from the first sample of the first chain. + var firstChain = Analysis.Results.MarkovChains[0]; + if (firstChain == null || firstChain.Count == 0 || firstChain[0].Values == null) + { + _traceCache = null; + return; + } + int paramCount = firstChain[0].Values.Length; + int startE = IncludeWarmUpCheckBox.IsChecked == true ? 0 : Analysis.WarmupIterations - 1; + int count = Analysis.Iterations - startE; + if (count <= 0) + { + _traceCache = null; + return; + } + + var cache = new DataPoint[paramCount][][]; + for (int p = 0; p < paramCount; p++) + { + cache[p] = new DataPoint[chainCount][]; + } + // Chain-major outer loop: each chain's samples are visited once, filling all parameter + // slots in that chain. This keeps the inner access pattern cache-friendly (one + // List indexer dereference per sample, then write all parameters). + for (int c = 0; c < chainCount; c++) + { + var chain = Analysis.Results.MarkovChains[c]; + // Pre-allocate all parameter arrays for this chain. + var chainArrays = new DataPoint[paramCount][]; + for (int p = 0; p < paramCount; p++) + { + chainArrays[p] = new DataPoint[count]; + } + for (int j = 0; j < count; j++) + { + int idx = startE + j; + var values = chain[idx].Values; + double x = idx + 1; + if (values == null) + { + for (int p = 0; p < paramCount; p++) + { + chainArrays[p][j] = new DataPoint(x, 0); + } + } + else + { + for (int p = 0; p < paramCount; p++) + { + chainArrays[p][j] = new DataPoint(x, values[p]); + } + } + } + for (int p = 0; p < paramCount; p++) + { + cache[p][c] = chainArrays[p]; + } + } + + _traceCache = cache; + _cachedStartE = startE; + } + + /// + /// Coalescing drain: if is set AND the control is loaded AND visible, + /// clears the dirty flag and calls exactly once. Otherwise a no-op. + /// This is the single seam through which all change signals flow, ensuring cascading + /// PropertyChanged / SelectionChanged events collapse into one paint per coherent state. + /// + /// Name of the caller (auto-filled by the compiler via + /// ). Used only for Debug-only perf logging. + private void DrainIfReady([CallerMemberName] string trigger = "") + { + if (_suppressUpdates) return; + if (!_isDirty) return; + if (!_isLoaded) return; + if (!IsVisible) return; + _isDirty = false; + UpdatePlot(trigger); + } + + /// + /// Sets the plot title. Suppresses PropertyChanged so the change is not recorded + /// as an undoable action — the title tracks combo selection, not user intent. + /// + private void SetPlotTitle(string title) + { + if (_plot == null) return; + _plot.SuppressPropertyChanged = true; + _plot.Title = title; + _plot.SuppressPropertyChanged = false; + } + + /// + /// Updates the plot with the latest Markov chain trace data based on current selection criteria. + /// Displays trace plots for the selected parameter and chain(s), optionally including warm-up iterations. + /// + /// Name of the calling method (auto-filled by the compiler via CallerMemberName). Used only for Debug-only perf logging. + private void UpdatePlot([CallerMemberName] string trigger = "") + { + if (_plot == null) return; + +#if DEBUG + int callId = System.Threading.Interlocked.Increment(ref _updatePlotCallCount); + var totalSw = Stopwatch.StartNew(); + long extractMs = -1; + long invalidateMs = -1; + int chainsRendered = 0; + int totalPoints = 0; + int pointsPerChain = 0; + string exitReason = "ok"; +#endif + + // Set plot title and axis title to reflect the selected parameter. Suppress + // PropertyChanged so the PlotUndoManager does not record these as undoable + // actions — they track combo selection, not user intent. + string paramName = ParameterComboBox.SelectedValue as string ?? ""; + SetPlotTitle(string.IsNullOrEmpty(paramName) ? "Trace" : $"Trace of {paramName}"); + foreach (var axis in _plot.Axes) + { + if (axis.Position == OxyPlot.Axes.AxisPosition.Left) + { + axis.SuppressPropertyChanged = true; + axis.Title = paramName; + axis.SuppressPropertyChanged = false; + } + } + + // Suppress notifications during bulk series update to prevent per-series re-renders + _plot.SuppressPropertyChanged = true; + try + { + // Note: deliberately do NOT clear _plot.Series here. Clearing and re-adding + // every parameter switch / warmup toggle triggers WPF logical-tree manipulation + // for each chain (20 series × 6 inherited DPs = 120 DP updates per click). The + // series objects in _traceSeriesList are reused across UpdatePlot calls; their + // ItemsSource is swapped in place below. We only mutate _plot.Series when the + // chain count or visible-chain selection actually changes. + _plot.Annotations.Clear(); + + if (Analysis == null || Analysis.IsEstimated == false || Analysis.Results == null) + { +#if DEBUG + exitReason = Analysis == null ? "no-analysis" + : Analysis.IsEstimated == false ? "not-estimated" + : "no-results"; +#endif + // No data — clear any series that may be present from a prior session. + if (_plot.Series.Count > 0) + { + _plot.Series.Clear(); + } + return; + } + + if (Analysis.IsEstimated == true) + { + Mouse.OverrideCursor = Cursors.Wait; + try + { + bool chainCountChanged = _traceSeriesList.Count != Analysis.NumberOfChains; + if (chainCountChanged) + { + ConstructDefaultLineSeries(); + // Chain count changed → cache shape no longer matches; force rebuild below. + InvalidateTraceCache(); + } + + // FIX this HACK: It shouldn't happen but is + if (Analysis.NumberOfChains != Analysis.Results.MarkovChains.Count()) + { +#if DEBUG + exitReason = "chain-count-mismatch"; +#endif + return; + } + +#if DEBUG + var extractSw = Stopwatch.StartNew(); +#endif + if (!IsTraceCacheValid()) + { + BuildTraceCache(); + } + if (_traceCache == null) + { +#if DEBUG + exitReason = "cache-build-failed"; +#endif + return; + } + + int parameterIndex = Math.Max(0, ParameterComboBox.SelectedIndex); + int chainIndex = Math.Max(0, ChainComboBox.SelectedIndex); + + // Defensive bounds check in case the cache was built for a different param count + // (e.g., during an in-flight Analysis.Model switch). + if (parameterIndex >= _traceCache.Length) + { +#if DEBUG + exitReason = "param-index-out-of-range"; +#endif + return; + } + + if (chainIndex == 0) + { + var paramCache = _traceCache[parameterIndex]; + + // Update ItemsSource on every persisted series (no Series.Add traffic). + for (int i = 0; i < Analysis.NumberOfChains && i < paramCache.Length; i++) + { + _traceSeriesList[i].ItemsSource = paramCache[i]; +#if DEBUG + pointsPerChain = paramCache[i]?.Length ?? 0; + totalPoints += pointsPerChain; + chainsRendered++; +#endif + } + + // Reconcile _plot.Series with _traceSeriesList only when membership + // differs (chain count change, or previously single-chain mode). + if (!SeriesMembershipMatches(_plot.Series, _traceSeriesList, _traceSeriesList.Count)) + { + _plot.Series.Clear(); + for (int i = 0; i < _traceSeriesList.Count; i++) + { + _plot.Series.Add(_traceSeriesList[i]); + } + } + } + else + { + chainIndex = ChainComboBox.SelectedIndex - 1; + var paramCache = _traceCache[parameterIndex]; + if (chainIndex >= 0 && chainIndex < paramCache.Length && chainIndex < _traceSeriesList.Count) + { + _traceSeriesList[chainIndex].ItemsSource = paramCache[chainIndex]; + + // Single-chain mode: reconcile _plot.Series to hold exactly that one series. + var only = _traceSeriesList[chainIndex]; + if (_plot.Series.Count != 1 || !ReferenceEquals(_plot.Series[0], only)) + { + _plot.Series.Clear(); + _plot.Series.Add(only); + } +#if DEBUG + pointsPerChain = paramCache[chainIndex]?.Length ?? 0; + totalPoints = pointsPerChain; + chainsRendered = 1; +#endif + } + } + +#if DEBUG + extractSw.Stop(); + extractMs = extractSw.ElapsedMilliseconds; +#endif + } + finally + { + Mouse.OverrideCursor = null; + } + } + } + finally + { + _plot.SuppressPropertyChanged = false; +#if DEBUG + var invalidateSw = Stopwatch.StartNew(); +#endif + _plot.InvalidatePlot(true); +#if DEBUG + invalidateSw.Stop(); + invalidateMs = invalidateSw.ElapsedMilliseconds; + totalSw.Stop(); + long syncTotalMs = totalSw.ElapsedMilliseconds; + Debug.WriteLine( + $"[MCMCTrace #{callId,4} {trigger,-32}] chains={chainsRendered,2} pts/chain={pointsPerChain,6} totalPts={totalPoints,7} " + + $"extract={extractMs,5}ms invalidate={invalidateMs,5}ms sync-total={syncTotalMs,5}ms {(exitReason == "ok" ? "" : "[" + exitReason + "]")}"); + + // Async paint timing: InvalidatePlot() only QUEUES the render. The actual + // WPF render pass (where points are transformed to screen coords and + // StreamGeometry built) happens on the dispatcher at Render priority. + // A continuation queued at ContextIdle priority fires AFTER the render + // pass completes, so paintSw.Elapsed captures the queued-render cost. + int capturedCallId = callId; + string capturedTrigger = trigger; + int capturedPoints = totalPoints; + var paintSw = Stopwatch.StartNew(); + Dispatcher.BeginInvoke(new Action(() => + { + paintSw.Stop(); + Debug.WriteLine( + $"[MCMCTrace #{capturedCallId,4} {capturedTrigger,-32}] async-paint={paintSw.ElapsedMilliseconds,5}ms (pts={capturedPoints})"); + }), System.Windows.Threading.DispatcherPriority.ContextIdle); +#endif + } + } + + + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/MeanLikelihoodControl.xaml b/src/RMC.BestFit.App/GUI/Support/Controls/MeanLikelihoodControl.xaml new file mode 100644 index 0000000..9e0b1a3 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/MeanLikelihoodControl.xaml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/MeanLikelihoodControl.xaml.cs b/src/RMC.BestFit.App/GUI/Support/Controls/MeanLikelihoodControl.xaml.cs new file mode 100644 index 0000000..e869e63 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/MeanLikelihoodControl.xaml.cs @@ -0,0 +1,257 @@ +using OxyPlot; +using OxyPlot.Wpf; +using OxyPlotControls; +using RMC.BestFit.Models; +using RMC.BestFit.Estimation; +using RMC.BestFit.UI; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Navigation; +using System.Windows.Shapes; +namespace RMC_BestFit +{ + /// + /// User control for displaying and visualizing the mean log-likelihood values across iterations in a Bayesian analysis. + /// This control provides a time-series plot showing how the mean log-likelihood evolves during the MCMC sampling process. + /// + public partial class MeanLikelihoodControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public MeanLikelihoodControl() + { + InitializeComponent(); + } + + /// + /// Dependency property for the property. + /// + public static readonly DependencyProperty AnalysisProperty = DependencyProperty.Register(nameof(Analysis), typeof(BayesianAnalysis), typeof(MeanLikelihoodControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the Bayesian analysis instance containing the mean log-likelihood data to be visualized. + /// + public BayesianAnalysis Analysis + { + get { return (BayesianAnalysis)GetValue(AnalysisProperty); } + set { SetValue(AnalysisProperty, value); } + } + + /// + /// Callback method invoked when the Analysis dependency property changes. + /// Subscribes to property changed events on the new analysis instance. + /// + /// The dependency object that owns the property. + /// Event data containing the old and new property values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as MeanLikelihoodControl == null) return; + var thisControl = (MeanLikelihoodControl)d; + + // Unsubscribe from old element + if (e.OldValue is BayesianAnalysis old) + old.PropertyChanged -= thisControl.Analysis_PropertyChanged; + + if (e.NewValue == null) return; + var newElement = e.NewValue as BayesianAnalysis; + if (newElement == null) return; + + newElement.PropertyChanged += thisControl.Analysis_PropertyChanged; + + } + + /// + /// Flag indicating whether the control has been loaded. + /// + private bool _isLoaded = false; + + /// + /// The plot object. Injected by the parent control via + /// (the parent obtains it from BayesianController.MeanLikelihoodPlot). + /// + private Plot _plot; + + /// + /// Gets the plot displayed by this control. Set via by the parent control. + /// + public Plot Plot => _plot; + + /// + /// Gets a value indicating whether the plot area was clicked by the user. + /// + public bool PlotClicked { get; private set; } = false; + + /// + /// Raised when the user clicks on the control, providing information about which part was clicked. + /// + public event PreviewControlClickedEventHandler PreviewControlClicked; + + /// + /// Delegate for the event. + /// + /// Indicates whether the plot area was clicked. + /// Indicates whether the toolbar area was clicked. + /// The plot control that was clicked. + public delegate void PreviewControlClickedEventHandler(bool plotClicked, bool toolbarClicked, Plot plot); + + /// + /// Raised when the plot properties dialog is requested. + /// + public event PlotPropertiesCalledEventHandler PlotPropertiesCalled; + + /// + /// Delegate for the event. + /// + /// The plot control for which properties are requested. + /// Indicates whether the properties dialog should be opened. + /// The property expander control to use. + /// The currently selected object in the properties dialog. + public delegate void PlotPropertiesCalledEventHandler(Plot plot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject); + + + /// + /// Event handler for when the user control is loaded. Initializes the plot with available data. + /// + /// The source of the event. + /// Event data. + private void UserControl_Loaded(object sender, RoutedEventArgs e) + { + if (_isLoaded == false) + { + // One-time setup (none needed for this control beyond UpdatePlot). + } + _isLoaded = true; + // Always refresh on load so Analysis changes between unload and reload are applied. + UpdatePlot(); + } + + /// + /// Event handler for property changes in the Analysis object. Updates the plot when the estimation status changes. + /// + /// The source of the event. + /// Event data containing the property name that changed. + private void Analysis_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Analysis.IsEstimated)) + { + UpdatePlot(); + } + } + + /// + /// Event handler for mouse down events on the control. Determines if the plot or toolbar was clicked and raises the PreviewControlClicked event. + /// + /// The source of the event. + /// Event data containing mouse button information. + private void UserControl_PreviewMouseDown(object sender, MouseButtonEventArgs e) + { + if (_plot == null) return; + var plotHitResult = VisualTreeHelper.HitTest(_plot, e.GetPosition(_plot)); + var toolbarHitResult = VisualTreeHelper.HitTest(PlotToolbar, e.GetPosition(PlotToolbar)); + PreviewControlClicked?.Invoke(plotHitResult != null, toolbarHitResult != null, _plot); + PlotClicked = plotHitResult != null; + } + + /// + /// Event handler for when the control loses focus. Resets the PlotClicked flag. + /// + /// The source of the event. + /// Event data. + private void UserControl_LostFocus(object sender, RoutedEventArgs e) + { + PlotClicked = false; + } + + /// + /// Event handler for when plot properties are requested from the toolbar. Raises the PlotPropertiesCalled event. + /// + /// The plot for which properties are requested. + /// Indicates whether to open the properties dialog. + /// The property expander control to use. + /// The currently selected object. + private void PlotToolbar_PropertiesCalled(Plot targetPlot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject) + { + PlotPropertiesCalled?.Invoke(targetPlot, openProperties, propertyExpander, selectedObject); + } + + /// + /// Sets the external plot to display in this control. Called by parent controls + /// that own the Plot object (Pattern B). The plot is attached to PlotHost and PlotToolbar. + /// + /// The external plot to display. + public void SetPlot(Plot plot) + { + _plot = plot; + PlotHost.Content = _plot; + PlotToolbar.Plot = _plot; + } + + /// + /// Updates the plot with the latest mean log-likelihood data from the analysis results. + /// Displays the evolution of mean log-likelihood across all iterations. + /// + private void UpdatePlot() + { + if (_plot == null) return; + + // Lookup-or-create named series so user-customized styling survives Save/Open. + var lineSeries = _plot.Series.OfType().FirstOrDefault(s => s.Name == "MeanLogLikelihood") + ?? new LineSeries + { + Name = "MeanLogLikelihood", + Title = "Mean Log-Likelihood", + LineStyle = LineStyle.Solid, + Color = Colors.Crimson, + StrokeThickness = 2, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0, + }; + + // BC2: Clear series in a single block without an intermediate InvalidatePlot + // so the plot never renders in a transient blank state between clear and repopulate. + _plot.Series.Clear(); + + if (Analysis == null || Analysis.IsEstimated == false || Analysis.Results == null) + { + _plot.InvalidatePlot(true); + return; + } + + if (Analysis.IsEstimated == true) + { + Mouse.OverrideCursor = Cursors.Wait; + try + { + + var points = new List(); + for (int i = 0; i < Analysis.Results.MeanLogLikelihood.Count; i++) + points.Add(new DataPoint(i + 1, Analysis.Results.MeanLogLikelihood[i])); + + lineSeries.ItemsSource = points; + lineSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0}" + Environment.NewLine + "{3}: {4:0.000000}"; + _plot.Series.Add(lineSeries); + _plot.InvalidatePlot(true); + + } + finally + { + Mouse.OverrideCursor = null; + } + } + } + + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/OrdinatesControl.xaml b/src/RMC.BestFit.App/GUI/Support/Controls/OrdinatesControl.xaml new file mode 100644 index 0000000..fe41ddc --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/OrdinatesControl.xaml @@ -0,0 +1,27 @@ + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/OrdinatesControl.xaml.cs b/src/RMC.BestFit.App/GUI/Support/Controls/OrdinatesControl.xaml.cs new file mode 100644 index 0000000..c04d976 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/OrdinatesControl.xaml.cs @@ -0,0 +1,365 @@ +using NumericControls; +using Numerics.Data; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Text; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Navigation; +using System.Windows.Shapes; + +namespace RMC_BestFit +{ + /// + /// A user control that provides an interactive interface for managing double value ordinates. + /// This control displays a data grid where users can enter and modify ordinate values for analysis. + /// + /// + /// + /// Authors: + /// + /// + /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil + /// + /// + /// + /// + public partial class OrdinatesControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// Sets up the control and subscribes to collection change events for ordinate row items. + /// + public OrdinatesControl() + { + InitializeComponent(); + _rowItems.CollectionChanged += RowItems_CollectionChanged; + DataGrid.PreviewPasteData += DataGrid_PreviewPasteData; + DataGrid.DataPasted += DataGrid_DataPasted; + } + + /// + /// Identifies the dependency property. + /// This property represents the ordinates to be displayed and edited. + /// + public static readonly DependencyProperty OrdinatesProperty = DependencyProperty.Register(nameof(Ordinates), typeof(ObservableCollection), typeof(OrdinatesControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the ordinates for this control. + /// + public ObservableCollection Ordinates + { + get { return (ObservableCollection)GetValue(OrdinatesProperty); } + set { SetValue(OrdinatesProperty, value); } + } + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty RowItemTypeProperty = DependencyProperty.Register( + nameof(RowItemType), typeof(Type), typeof(OrdinatesControl), new PropertyMetadata(typeof(OrdinateRowItem))); + + /// + /// Gets or sets the concrete subclass used to wrap each + /// ordinate value. Defaults to ; callers pass + /// typeof(XOrdinateRowItem) or typeof(YOrdinateRowItem) via XAML + /// (using {x:Type ...}) to drive the auto-generated column header text via + /// . + /// + /// + /// The supplied type must derive from and expose a public + /// constructor of the shape (ObservableCollection<object> parentList, double value) + /// — both and meet this + /// contract. Reflection-based construction is used so XAML can configure the type + /// without a generic at the control class. + /// + public Type RowItemType + { + get { return (Type)GetValue(RowItemTypeProperty); } + set { SetValue(RowItemTypeProperty, value); } + } + + /// + /// Constructs a row item of the configured using + /// reflection. Centralizes the Activator.CreateInstance call so the three + /// callers (initial element load + Add branch of ) + /// share the same factory path. + /// + /// The ordinate value to wrap. + /// A newly-allocated row item of type . + private OrdinateRowItem CreateRowItem(double value) + { + return (OrdinateRowItem)Activator.CreateInstance(RowItemType, _rowItems, value); + } + + /// + /// Callback method invoked when the property changes. + /// Handles cleanup of the old ordinates and initialization of the new ordinates, including event subscriptions and data grid population. + /// + /// The dependency object whose property changed. + /// Event arguments containing the old and new property values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as OrdinatesControl == null) return; + var thisControl = (OrdinatesControl)d; + + // Remove old stuff + // Clear old row items + for (int i = thisControl._rowItems.Count - 1; i >= 0; i--) + { + thisControl._rowItems.RemoveAt(i); + } + // Clear data grid + thisControl.DataGrid.ItemsSource = null; + // Remove handlers + var oldOrdinates = (ObservableCollection)e.OldValue; + if (oldOrdinates != null) + oldOrdinates.CollectionChanged -= thisControl.Ordinates_CollectionChanged; + + // Set up new stuff + if (e.NewValue == null) return; + var newOrdinates = e.NewValue as ObservableCollection; + if (newOrdinates != null) + { + // Add row items + foreach (double ordinate in newOrdinates) + { + thisControl._rowItems.Add(thisControl.CreateRowItem(ordinate)); + } + // Set data grid + thisControl.DataGrid.ItemsSource = thisControl._rowItems; + // Set RowType so the grid can create new rows even when empty. + // Uses RowItemType so click-to-add rows are X/Y-typed when configured. + thisControl.DataGrid.RowType = thisControl.RowItemType; + // Add handlers + newOrdinates.CollectionChanged += thisControl.Ordinates_CollectionChanged; + } + + } + + /// + /// Flag to suppress UI updates when model changes are being propagated to prevent circular update loops. + /// + private bool _suppressUIUpdate = false; + + /// + /// Flag to suppress model updates when UI changes are being propagated to prevent circular update loops. + /// + private bool _suppressModelUpdate = false; + + /// + /// Observable collection of ordinate row items displayed in the data grid. + /// + private ObservableCollection _rowItems = new ObservableCollection(); + + /// + /// Handles the automatic column generation event for the data grid. + /// Configures column styling, width, and tooltips for the column. + /// + /// The data grid generating the column. + /// Event arguments containing the column being generated. + private void DataGrid_AutoGeneratingColumn(object sender, DataGridAutoGeneratingColumnEventArgs e) + { + if (e.PropertyName == nameof(OrdinateRowItem.Value)) + { + e.Column.MinWidth = 20; + e.Column.Width = new DataGridLength(1, DataGridLengthUnitType.Star); + ((DataGridTextColumn)e.Column).CellStyle = (Style)FindResource("Right_CellStyle"); + + // Create header style based on Center_ColumnHeaderStyle with tooltip + // Note: Must use Style constructor with basedOn parameter to preserve the style inheritance chain + var baseHeaderStyle = (Style)FindResource("Center_ColumnHeaderStyle"); + var headerStyle = new Style(typeof(DataGridColumnHeader), baseHeaderStyle); + + headerStyle.Setters.Add(new Setter(ToolTipProperty, new TextBlock() + { + Text = "Enter the ordinate values in ascending order.", + FontWeight = FontWeights.Normal, + TextAlignment = TextAlignment.Left, + TextWrapping = TextWrapping.Wrap + })); + ((DataGridTextColumn)e.Column).HeaderStyle = headerStyle; + } + } + + /// + /// Handles the addition of new rows to the data grid. + /// Synchronizes the addition of new ordinates to the underlying model. + /// + /// The index where the first new row was added. + /// The number of rows that were added. + private void DataGrid_RowsAdded(int startRowIndex, int nRows) + { + _suppressUIUpdate = true; + //bool wasSuppressed = Ordinates.SuppressCollectionChanged; + //Ordinates.SuppressCollectionChanged = true; + for (int i = 0; i < nRows; i++) + { + Ordinates.Insert(startRowIndex + i, ((OrdinateRowItem)_rowItems[startRowIndex + i]).Value); + } + //Ordinates.SuppressCollectionChanged = wasSuppressed; + //if (!wasSuppressed) + //{ + //Ordinates.RaiseCollectionChangedReset(); + //} + _suppressUIUpdate = false; + } + + /// + /// Handles the deletion of rows from the data grid. + /// Synchronizes the removal of ordinates from the underlying model. + /// + /// The list of row indices that were deleted. + private void DataGrid_RowsDeleted(List rowindices) + { + _suppressUIUpdate = true; + //bool wasSuppressed = Ordinates.SuppressCollectionChanged; + //Ordinates.SuppressCollectionChanged = true; + for (int i = rowindices.Count - 1; i >= 0; i--) + { + Ordinates.RemoveAt(rowindices[i]); + } + //Ordinates.SuppressCollectionChanged = wasSuppressed; + //if (!wasSuppressed) + //{ + // Ordinates.RaiseCollectionChangedReset(); + //} + _suppressUIUpdate = false; + } + + /// + /// Handles collection change events for the row items. + /// Subscribes or unsubscribes property change handlers for items as they are added or removed. + /// + /// The observable collection that changed. + /// Event arguments containing information about the collection change. + private void RowItems_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + if (e.Action == NotifyCollectionChangedAction.Add) + { + foreach (OrdinateRowItem item in e.NewItems) + { + item.PropertyChanged += OrdinateRowItem_PropertyChanged; + } + } + else if (e.Action == NotifyCollectionChangedAction.Remove) + { + foreach (OrdinateRowItem item in e.OldItems) + { + item.PropertyChanged -= OrdinateRowItem_PropertyChanged; + } + } + } + + /// + /// Handles property change events for individual ordinate row items. + /// Updates the underlying model when a value changes in the UI. + /// + /// The ordinate row item that changed. + /// Event arguments containing the name of the property that changed. + private void OrdinateRowItem_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(OrdinateRowItem.Value)) return; + if (_suppressModelUpdate) return; + + int rowIndex = _rowItems.IndexOf((OrdinateRowItem)sender); + if (rowIndex >= 0 && rowIndex < Ordinates.Count) + { + _suppressUIUpdate = true; + Ordinates[rowIndex] = ((OrdinateRowItem)sender).Value; + _suppressUIUpdate = false; + } + } + + /// + /// Handles collection change events for the ordinates in the model. + /// Synchronizes changes from the model to the UI by updating the row items collection. + /// + /// The ordinates collection that changed. + /// Event arguments containing information about the collection change. + private void Ordinates_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + if (_suppressUIUpdate == false) + { + if (e.Action == NotifyCollectionChangedAction.Add) + { + for (int i = 0; i < e.NewItems.Count; i++) + { + _rowItems.Insert(e.NewStartingIndex + i, CreateRowItem((double)e.NewItems[i])); + } + } + else if (e.Action == NotifyCollectionChangedAction.Remove) + { + for (int i = e.OldItems.Count - 1; i >= 0; i--) + { + _rowItems.RemoveAt(e.OldStartingIndex + i); + } + } + else if (e.Action == NotifyCollectionChangedAction.Replace) + { + _suppressModelUpdate = true; + for (int i = 0; i < e.NewItems.Count; i++) + { + ((OrdinateRowItem)_rowItems[e.NewStartingIndex + i]).Value = (double)e.NewItems[i]; + } + _suppressModelUpdate = false; + } + else if (e.Action == NotifyCollectionChangedAction.Reset) + { + // Rebuild UI row items from the model state + _suppressModelUpdate = true; + _rowItems.Clear(); + if (Ordinates != null) + { + foreach (double ordinate in Ordinates) + { + _rowItems.Add(CreateRowItem(ordinate)); + } + } + DataGrid.ItemsSource = _rowItems; + _suppressModelUpdate = false; + } + } + } + + /// + /// Handles the preview paste data event from the data grid. + /// Suppresses collection changed events during the paste operation to prevent + /// individual events for each cell change. + /// + /// The clipboard data being pasted. + /// Reference parameter to cancel the paste operation if needed. + private void DataGrid_PreviewPasteData(string[][] clipboardData, ref bool cancelPaste) + { + //if (Ordinates != null) + // Ordinates.SuppressCollectionChanged = true; + Mouse.OverrideCursor = Cursors.Wait; + } + + /// + /// Handles the data pasted event from the data grid. + /// Re-enables collection changed events and raises a single Reset event to notify + /// listeners that the collection has been modified. + /// + private void DataGrid_DataPasted() + { + //if (Ordinates != null) + //{ + // Ordinates.SuppressCollectionChanged = false; + // Ordinates.RaiseCollectionChangedReset(); + //} + Mouse.OverrideCursor = null; + } + + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/ParameterPriorsControl.xaml b/src/RMC.BestFit.App/GUI/Support/Controls/ParameterPriorsControl.xaml new file mode 100644 index 0000000..3051178 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/ParameterPriorsControl.xaml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/ParameterPriorsControl.xaml.cs b/src/RMC.BestFit.App/GUI/Support/Controls/ParameterPriorsControl.xaml.cs new file mode 100644 index 0000000..cbc473b --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/ParameterPriorsControl.xaml.cs @@ -0,0 +1,209 @@ +using NumericControls.Distributions.Univariate; +using Numerics.Distributions; +using RMC.BestFit.Models; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Navigation; +using System.Windows.Shapes; + +namespace RMC_BestFit +{ + /// + /// User control for configuring and managing prior probability distributions for model parameters in Bayesian analysis. + /// Provides an interactive interface for selecting and editing prior distributions for each parameter. + /// + public partial class ParameterPriorsControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public ParameterPriorsControl() + { + InitializeComponent(); + } + + /// + /// Raised when the user requests to view property attributes for a model property. + /// + public event ShowPropertyAttributesEventHandler ShowPropertyAttributes; + + /// + /// Delegate for the event. + /// + /// The name of the property whose attributes are requested. + /// The object instance containing the property. + public delegate void ShowPropertyAttributesEventHandler(string propertyName, object classObject); + + /// + /// Dependency property for the property. + /// + public static DependencyProperty ModelProperty = DependencyProperty.Register(nameof(Model), typeof(IModel), typeof(ParameterPriorsControl), new PropertyMetadata(null, ModelCallback)); + + /// + /// Gets or sets the statistical model whose parameter priors are being configured. + /// + public IModel Model + { + get { return (IModel)GetValue(ModelProperty); } + set { SetValue(ModelProperty, value); } + } + + /// + /// Callback method invoked when the Model dependency property changes. + /// Subscribes to property changed events on the new model and updates the data grid. + /// + /// The dependency object that owns the property. + /// Event data containing the old and new property values. + private static void ModelCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as ParameterPriorsControl == null) return; + var thisControl = (ParameterPriorsControl)d; + + // Unsubscribe from old model to prevent memory leaks and stale event handlers + if (e.OldValue is IModel oldModel) + oldModel.PropertyChanged -= thisControl.Model_PropertyChanged; + + if (e.NewValue == null) return; + var newElement = e.NewValue as IModel; + if (newElement == null) return; + + newElement.PropertyChanged += thisControl.Model_PropertyChanged; + thisControl.UpdateDataGrid(); + } + + /// + /// Event handler for property changes in the Model object. Updates the control when model parameters change. + /// + /// The source of the event. + /// Event data containing the property name that changed. + private void Model_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Model.Parameters) || e.PropertyName == nameof(Model.SetDefaultParameters)) + { + UpdateDataGrid(); + } + } + + /// + /// Event handler for mouse down events on the "Use Prior Distribution Defaults" control. + /// Raises the ShowPropertyAttributes event to display related property information. + /// + /// The source of the event. + /// Event data containing mouse button information. + private void UsePriorDistributionDefaults_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + ShowPropertyAttributes?.Invoke(nameof(Model.UseDefaultFlatPriors), Model); + } + + /// + /// Event handler for mouse down events on the prior distributions data grid. + /// Raises the ShowPropertyAttributes event to display parameter information. + /// + /// The source of the event. + /// Event data containing mouse button information. + private void PriorDistributionsDataGrid_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + ShowPropertyAttributes?.Invoke(nameof(Model.Parameters), Model); + } + + /// + /// Event handler for when the distribution selector popup is opened. + /// Initializes the distribution selector with available prior distributions and the current parameter's distribution. + /// + /// The source of the event. + /// Event data. + private void DistributionPopup_Opened(object sender, EventArgs e) + { + if (Model == null) return; + Popup p = (Popup)sender; + if (p.Child == null) return; + var b = (Border)p.Child; + DistributionSelectorControl dsc = (DistributionSelectorControl)b.Child; + UnivariateDistributionBase dist = null; + if(p.DataContext.GetType() == typeof(ModelParameter)) + { + dist = ((ModelParameter)p.DataContext).PriorDistribution.Clone(); + } + + var PriorDistributionsList = new List() + { + new Exponential(), + new GammaDistribution(), + new GeneralizedBeta(), + new LnNormal(), + new LogNormal(), + new Normal(), + new Pert(), + new StudentT(), + new Triangular(), + new TruncatedNormal(), + new Uniform() + }; + + dsc.Distributions = PriorDistributionsList; + dsc.SelectedDistribution = dist; + + } + + /// + /// Event handler for when the distribution selector popup is closed. + /// Applies the selected distribution to the parameter if valid and updates the data grid. + /// + /// The source of the event. + /// Event data. + private void DistributionPopup_Closed(object sender, EventArgs e) + { + if (Model == null) return; + Popup p = (Popup)sender; + if (p.Child == null) return; + var b = (Border)p.Child; + DistributionSelectorControl dsc = (DistributionSelectorControl)b.Child; + if (p.DataContext.GetType() == typeof(ModelParameter) && dsc.SelectedDistribution.ParametersValid == true) + { + ((ModelParameter)p.DataContext).PriorDistribution = dsc.SelectedDistribution.Clone(); + } + dsc.SelectedDistribution = null; + UpdateDataGrid(); + } + + /// + /// Updates the data grid with the current model parameters and their prior distributions. + /// Configures column headers with tooltips and refreshes the display. + /// + private void UpdateDataGrid() + { + PriorDistributionsDataGrid.ItemsSource = null; + if (Model == null || Model.Parameters == null) return; + + PriorDistributionsDataGrid.ItemsSource = Model.Parameters; + + // Parameter Column Header + var style = new Style(typeof(DataGridColumnHeader), ParameterNameColumn.HeaderStyle); + style.Setters.Add(new Setter(ToolTipProperty, new TextBlock() { Text = "The model parameter names.", FontWeight = System.Windows.FontWeights.Normal, TextAlignment = TextAlignment.Left, TextWrapping = TextWrapping.Wrap })); + ParameterNameColumn.HeaderStyle = style; + + // Distribution Column Header + style = new Style(typeof(DataGridColumnHeader), ParameterDistributionColumn.HeaderStyle); + style.Setters.Add(new Setter(ToolTipProperty, new TextBlock() { Text = "Set the prior distributions of the model parameters.", FontWeight = System.Windows.FontWeights.Normal, TextAlignment = TextAlignment.Left, TextWrapping = TextWrapping.Wrap })); + ParameterDistributionColumn.HeaderStyle = style; + + PriorDistributionsDataGrid.Items.Refresh(); + + } + + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/ParameterSetsControl.xaml b/src/RMC.BestFit.App/GUI/Support/Controls/ParameterSetsControl.xaml new file mode 100644 index 0000000..0f01712 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/ParameterSetsControl.xaml @@ -0,0 +1,23 @@ + + + + + diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/ParameterSetsControl.xaml.cs b/src/RMC.BestFit.App/GUI/Support/Controls/ParameterSetsControl.xaml.cs new file mode 100644 index 0000000..731cf1b --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/ParameterSetsControl.xaml.cs @@ -0,0 +1,149 @@ +using RMC.BestFit.Models; +using RMC.BestFit.Estimation; +using RMC.BestFit.UI; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Navigation; +using System.Windows.Shapes; + +namespace RMC_BestFit +{ + /// + /// User control for displaying parameter sets (posterior samples) from a Bayesian analysis in a tabular format. + /// Provides a data grid view showing all sampled parameter values and their associated log-likelihood values. + /// + public partial class ParameterSetsControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public ParameterSetsControl() + { + InitializeComponent(); + } + + /// + /// Dependency property for the property. + /// + public static DependencyProperty AnalysisProperty = DependencyProperty.Register(nameof(Analysis), typeof(BayesianAnalysis), typeof(ParameterSetsControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the Bayesian analysis instance containing the parameter sets to be displayed. + /// + public BayesianAnalysis Analysis + { + get { return (BayesianAnalysis)GetValue(AnalysisProperty); } + set { SetValue(AnalysisProperty, value); } + } + + /// + /// Callback method invoked when the Analysis dependency property changes. + /// Subscribes to property changed events on the new analysis instance. + /// + /// The dependency object that owns the property. + /// Event data containing the old and new property values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as ParameterSetsControl == null) return; + var thisControl = (ParameterSetsControl)d; + + if (e.NewValue == null) return; + var newElement = e.NewValue as BayesianAnalysis; + if (newElement == null) return; + + newElement.PropertyChanged += thisControl.Analysis_PropertyChanged; + + } + + /// + /// Gets or sets a value indicating whether the log-likelihood column should be displayed in the parameter sets table. + /// Default is true. + /// + public bool ShowLikelihoodColumn { get; set; } = true; + + /// + /// Flag indicating whether the control has been loaded. + /// + private bool _isLoaded = false; + + /// + /// Event handler for when the user control is loaded. Initializes the parameter set data grid. + /// + /// The source of the event. + /// Event data. + private void UserControl_Loaded(object sender, RoutedEventArgs e) + { + if (_isLoaded == false) + { + BindParameterSetDataGrid(); + } + _isLoaded = true; + } + + /// + /// Event handler for property changes in the Analysis object. Updates the data grid when the estimation status changes. + /// + /// The source of the event. + /// Event data containing the property name that changed. + private void Analysis_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Analysis.IsEstimated)) + { + BindParameterSetDataGrid(); + } + } + + /// + /// Binds the parameter set data to the data grid viewer. + /// Creates a data table containing all parameter values and optional log-likelihood values from the analysis results. + /// + private void BindParameterSetDataGrid() + { + ParameterSetTableViewer.DataView = null; + if (Analysis == null || Analysis.Results == null) return; + + if (Analysis.IsEstimated == true) + { + var names = Analysis.ParameterNames; + int numParams = Analysis.NumberOfEstimatedParameters; + if (names == null || numParams == 0) return; + + System.Data.DataTable dt = new System.Data.DataTable("ParameterSetTable"); + + for (int i = 0; i < numParams; i++) + { + dt.Columns.Add(new System.Data.DataColumn(names[i], typeof(double))); + } + if (ShowLikelihoodColumn) + dt.Columns.Add(new System.Data.DataColumn("Log-Likelihood", typeof(double))); + + foreach (var pSet in Analysis.Results.Output) + { + var data = new object[ShowLikelihoodColumn ? numParams + 1 : numParams]; + for (int i = 0; i < numParams; i++) + data[i] = pSet.Values != null ? pSet.Values[i] : 0; + + if (ShowLikelihoodColumn) + data[numParams] = pSet.Fitness; + + dt.Rows.Add(data); + } + + ParameterSetTableViewer.DataView = new DatabaseManager.InMemoryReader(dt).GetTableManager(dt.TableName); + } + + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/QuantilePriorsControl.xaml b/src/RMC.BestFit.App/GUI/Support/Controls/QuantilePriorsControl.xaml new file mode 100644 index 0000000..3ecd2ea --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/QuantilePriorsControl.xaml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/Support/Controls/QuantilePriorsControl.xaml.cs b/src/RMC.BestFit.App/GUI/Support/Controls/QuantilePriorsControl.xaml.cs new file mode 100644 index 0000000..a0adda5 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/Controls/QuantilePriorsControl.xaml.cs @@ -0,0 +1,245 @@ +using NumericControls.Distributions.Univariate; +using Numerics.Distributions; +using RMC.BestFit.Models; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Navigation; +using System.Windows.Shapes; + +namespace RMC_BestFit +{ + /// + /// A user control that provides an interactive interface for configuring quantile priors in distribution fitting. + /// This control allows users to select quantiles, assign prior distributions to those quantiles, and configure + /// related settings for Bayesian analysis. + /// + public partial class QuantilePriorsControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public QuantilePriorsControl() + { + InitializeComponent(); + } + + /// + /// Occurs when a property's attributes should be displayed to the user. + /// This event is raised when the user requests additional information about a specific property. + /// + public event ShowPropertyAttributesEventHandler ShowPropertyAttributes; + + /// + /// Represents the method that will handle the event. + /// + /// The name of the property whose attributes should be displayed. + /// The object instance containing the property. + public delegate void ShowPropertyAttributesEventHandler(string propertyName, object classObject); + + /// + /// Identifies the dependency property. + /// This property controls the visibility of the single quantile checkbox in the UI. + /// + public static DependencyProperty ShowSingleQuantileCheckBoxProperty = DependencyProperty.Register(nameof(ShowSingleQuantileCheckBox), typeof(bool), typeof(QuantilePriorsControl), new PropertyMetadata(true)); + + /// + /// Gets or sets a value indicating whether the single quantile checkbox should be displayed. + /// + /// true if the single quantile checkbox should be shown; otherwise, false. The default is true. + public bool ShowSingleQuantileCheckBox + { + get { return (bool)GetValue(ShowSingleQuantileCheckBoxProperty); } + set { SetValue(ShowSingleQuantileCheckBoxProperty, value); } + } + + /// + /// Identifies the dependency property. + /// This property represents the data model containing quantile prior configuration. + /// + public static DependencyProperty ModelProperty = DependencyProperty.Register(nameof(Model), typeof(IQuantilePriors), typeof(QuantilePriorsControl), new PropertyMetadata(null, ModelCallback)); + + /// + /// Gets or sets the quantile priors model that provides the data for this control. + /// + /// An instance containing the quantile prior configuration. + public IQuantilePriors Model + { + get { return (IQuantilePriors)GetValue(ModelProperty); } + set { SetValue(ModelProperty, value); } + } + + /// + /// Callback method invoked when the property changes. + /// Subscribes to property change events and updates the data grid with the new model data. + /// + /// The dependency object whose property changed. + /// Event arguments containing the old and new property values. + private static void ModelCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as QuantilePriorsControl == null) return; + var thisControl = (QuantilePriorsControl)d; + + // Unsubscribe from old model to prevent memory leaks and stale event handlers + if (e.OldValue is IModel oldModel) + oldModel.PropertyChanged -= thisControl.Model_PropertyChanged; + + if (e.NewValue == null) return; + var newElement = e.NewValue as IModel; + if (newElement == null) return; + + newElement.PropertyChanged += thisControl.Model_PropertyChanged; + thisControl.UpdateDataGrid(); + } + + /// + /// Gets a list of predefined quantile options available for selection. + /// These values represent common exceedance probabilities used in statistical analysis. + /// + /// A list of double values ranging from 0.1 (10%) to 0.000001 (0.0001%). + public List QuantileOptions => new List() { 0.1, 0.05, 0.02, 0.01, 0.005, 0.002, 0.001, 0.0005, 0.0002, 0.0001, 0.00005, 0.00002, 0.00001, 0.000005, 0.000002, 0.000001 }; + + /// + /// Handles property change events for the model. + /// Updates the data grid when the quantile priors collection changes. + /// + /// The model that raised the property changed event. + /// Event arguments containing the name of the property that changed. + private void Model_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Model.QuantilePriors) || e.PropertyName == nameof(Model.SetDefaultQuantilePriors)) + { + UpdateDataGrid(); + } + } + + /// + /// Handles the opened event for the distribution selection popup. + /// Initializes the distribution selector with available prior distributions and sets the current selection. + /// + /// The popup control that was opened. + /// Event arguments for the popup opened event. + private void DistributionPopup_Opened(object sender, EventArgs e) + { + if (Model == null) return; + Popup p = (Popup)sender; + if (p.Child == null) return; + var b = (Border)p.Child; + DistributionSelectorControl dsc = (DistributionSelectorControl)b.Child; + UnivariateDistributionBase dist = null; + if (p.DataContext.GetType() == typeof(QuantilePrior)) + { + dist = ((QuantilePrior)p.DataContext).Distribution.Clone(); + } + + var PriorDistributionsList = new List() + { + new GammaDistribution(), + new GeneralizedBeta(), + new LnNormal(), + new LogNormal(), + new Normal(), + new Pert(), + new StudentT(), + new Triangular(), + new TruncatedNormal(), + new Uniform() + }; + + dsc.Distributions = PriorDistributionsList; + dsc.SelectedDistribution = dist; + + } + + /// + /// Handles the closed event for the distribution selection popup. + /// Saves the selected distribution to the model if valid parameters were provided and refreshes the data grid. + /// + /// The popup control that was closed. + /// Event arguments for the popup closed event. + private void DistributionPopup_Closed(object sender, EventArgs e) + { + if (Model == null) return; + Popup p = (Popup)sender; + if (p.Child == null) return; + var b = (Border)p.Child; + DistributionSelectorControl dsc = (DistributionSelectorControl)b.Child; + if (p.DataContext.GetType() == typeof(QuantilePrior) && dsc.SelectedDistribution.ParametersValid == true) + { + ((QuantilePrior)p.DataContext).Distribution = dsc.SelectedDistribution.Clone(); + } + dsc.SelectedDistribution = null; + UpdateDataGrid(); + } + + /// + /// Updates the data grid with the current quantile priors from the model. + /// Configures column headers, tooltips, and binds the data source to the grid. + /// + private void UpdateDataGrid() + { + PriorDistributionsDataGrid.ItemsSource = null; + if (Model == null || Model.QuantilePriors == null) return; + + PriorDistributionsDataGrid.ItemsSource = Model.QuantilePriors; + + // Parameter Column Header + var style = new Style(typeof(DataGridColumnHeader), QuantileColumn.HeaderStyle); + style.Setters.Add(new Setter(ToolTipProperty, new TextBlock() { Text = "The exceedance probability of the quantile prior.", FontWeight = System.Windows.FontWeights.Normal, TextAlignment = TextAlignment.Left, TextWrapping = TextWrapping.Wrap })); + QuantileColumn.HeaderStyle = style; + + // Distribution Column Header + style = new Style(typeof(DataGridColumnHeader), QuantileDistributionColumn.HeaderStyle); + style.Setters.Add(new Setter(ToolTipProperty, new TextBlock() { Text = "Set the prior distributions of the distribution quantile.", FontWeight = System.Windows.FontWeights.Normal, TextAlignment = TextAlignment.Left, TextWrapping = TextWrapping.Wrap })); + QuantileDistributionColumn.HeaderStyle = style; + + PriorDistributionsDataGrid.Items.Refresh(); + } + + /// + /// Handles the preview mouse left button down event for the use single quantile control. + /// Raises the event to display property information for the UseSingleQuantile property. + /// + /// The control that was clicked. + /// Event arguments for the mouse button event. + private void UseSingleQuantile_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + ShowPropertyAttributes?.Invoke(nameof(Model.UseSingleQuantile), Model); + } + + /// + /// Handles the preview mouse left button down event for the enable prior quantile distributions control. + /// Raises the event to display property information for the EnableQuantilePriors property. + /// + /// The control that was clicked. + /// Event arguments for the mouse button event. + private void EnablePriorQuantileDistributions_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + ShowPropertyAttributes?.Invoke(nameof(Model.EnableQuantilePriors), Model); + } + + /// + /// Handles the preview mouse left button down event for the prior distributions data grid. + /// Raises the event to display property information for the QuantilePriors collection. + /// + /// The data grid that was clicked. + /// Event arguments for the mouse button event. + private void PriorDistributionsDataGrid_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + ShowPropertyAttributes?.Invoke(nameof(Model.QuantilePriors), Model); + } + + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/DataGrids/BatchRunItemViewModel.cs b/src/RMC.BestFit.App/GUI/Support/DataGrids/BatchRunItemViewModel.cs new file mode 100644 index 0000000..2a11cdd --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/DataGrids/BatchRunItemViewModel.cs @@ -0,0 +1,274 @@ +using FrameworkInterfaces; +using RMC.BestFit.UI; +using System; +using System.ComponentModel; +using System.Windows.Media; + +namespace RMC_BestFit +{ + /// + /// Represents the execution status of a batch run item. + /// + public enum BatchRunStatus + { + /// + /// The analysis is queued and waiting to run. + /// + Pending, + + /// + /// The analysis is currently executing. + /// + Running, + + /// + /// The analysis completed successfully. + /// + Succeeded, + + /// + /// The analysis failed with an error. + /// + Failed, + + /// + /// The analysis was canceled before completion. + /// + Canceled + } + + /// + /// View model wrapping an and for display + /// in the DataGrid, providing status tracking, progress, + /// and duration properties with change notification. + /// + /// + /// + /// Authors: + /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil + /// + /// + /// Each row in the batch run DataGrid is backed by an instance of this class. + /// The property drives the status icon column via + /// , and the + /// property drives the per-row progress bar. + /// + /// + public class BatchRunItemViewModel : INotifyPropertyChanged + { + /// + /// Backing field for . + /// + private BatchRunStatus _status = BatchRunStatus.Pending; + + /// + /// Backing field for . + /// + private double _progress; + + /// + /// Backing field for . + /// + private string _statusMessage = "Waiting..."; + + /// + /// Backing field for . + /// + private TimeSpan? _duration; + + /// + /// Initializes a new instance of the class. + /// + /// The element displayed in the DataGrid row. + /// The analysis to execute, cast from the element. + /// + /// Thrown when or is null. + /// + public BatchRunItemViewModel(IElement element, IAnalysisElement analysis) + { + Element = element ?? throw new ArgumentNullException(nameof(element)); + Analysis = analysis ?? throw new ArgumentNullException(nameof(analysis)); + } + + /// + public event PropertyChangedEventHandler PropertyChanged; + + /// + /// Gets the underlying for this row. + /// + public IElement Element { get; } + + /// + /// Gets the instance for this row. + /// + public IAnalysisElement Analysis { get; } + + /// + /// Gets the display name of the element, used for the Name column. + /// + public string DisplayName => Element.DisplayName; + + /// + /// Gets the element image, used for the icon column. + /// + public ImageSource ElementImage => Element.ElementImage; + + /// + /// Gets or sets the current execution status of this analysis. + /// + /// + /// One of the values. The default is + /// . + /// + /// + /// Changing this property updates the status icon in the DataGrid via + /// the . + /// + public BatchRunStatus Status + { + get => _status; + set + { + if (_status != value) + { + _status = value; + RaisePropertyChanged(nameof(Status)); + RaisePropertyChanged(nameof(ProgressText)); + RaisePropertyChanged(nameof(IsProgressIndeterminate)); + } + } + } + + /// + /// Gets or sets the current progress of this analysis as a percentage (0-100). + /// + /// + /// A value between 0 and 100 inclusive. The default is 0. + /// + /// + /// This value is bound to the per-row progress bar, which is only visible + /// when is . + /// + public double Progress + { + get => _progress; + set + { + if (Math.Abs(_progress - value) > 0.01) + { + _progress = value; + RaisePropertyChanged(nameof(Progress)); + RaisePropertyChanged(nameof(ProgressText)); + RaisePropertyChanged(nameof(IsProgressIndeterminate)); + } + } + } + + /// + /// Gets a value indicating whether the row progress bar should show indeterminate motion. + /// + /// + /// true while the analysis is running before measurable progress begins or + /// after the analysis reports the processing-results phase. + /// + public bool IsProgressIndeterminate + { + get + { + if (_status != BatchRunStatus.Running) return false; + return _progress <= 0.01 || _progress >= 99; + } + } + + /// + /// Gets a formatted text representation of the current progress, + /// suitable for display as an overlay on the per-row progress bar. + /// + /// + /// An empty string when the status is not ; + /// a percentage string like "45% Complete" when running and progress is under 100; + /// "Processing Results..." when progress reaches the processing-results phase. + /// + public string ProgressText + { + get + { + if (_status != BatchRunStatus.Running) + return ""; + if (_progress <= 0.01) + return "Starting..."; + if (_progress >= 99) + return "Processing Results..."; + return _progress.ToString("N0") + "% Complete"; + } + } + + /// + /// Gets or sets a human-readable status message for this analysis. + /// + /// + /// A descriptive string such as "Waiting...", "Running...", "Complete", + /// or an error message. The default is "Waiting...". + /// + public string StatusMessage + { + get => _statusMessage; + set + { + if (_statusMessage != value) + { + _statusMessage = value; + RaisePropertyChanged(nameof(StatusMessage)); + } + } + } + + /// + /// Gets or sets the wall-clock duration of the analysis execution. + /// + /// + /// null before the analysis starts; the elapsed time after completion. + /// + public TimeSpan? Duration + { + get => _duration; + set + { + if (_duration != value) + { + _duration = value; + RaisePropertyChanged(nameof(Duration)); + RaisePropertyChanged(nameof(DurationText)); + } + } + } + + /// + /// Gets a formatted string representation of in mm:ss format. + /// + /// + /// An empty string if the duration is null; otherwise the duration formatted + /// as "mm:ss" (e.g., "02:15" for 2 minutes and 15 seconds). + /// + public string DurationText + { + get + { + if (_duration == null) return ""; + var d = _duration.Value; + if (d.TotalHours >= 1) + return d.ToString(@"h\:mm\:ss"); + return d.ToString(@"mm\:ss"); + } + } + + /// + /// Raises the event for the specified property. + /// + /// The name of the property that changed. + private void RaisePropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/DataGrids/DSSPathnameItem.cs b/src/RMC.BestFit.App/GUI/Support/DataGrids/DSSPathnameItem.cs new file mode 100644 index 0000000..3a1899f --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/DataGrids/DSSPathnameItem.cs @@ -0,0 +1,59 @@ +namespace RMC_BestFit +{ + /// + /// Represents a Data Storage System (DSS) pathname item, which contains the six standard parts + /// (A through F) used to uniquely identify and organize data records in HEC-DSS databases. + /// DSS pathnames follow the format: /A/B/C/D/E/F/ + /// + public class DSSPathnameItem + { + /// + /// Part A: Typically represents the project or study area name. + /// + public string PartA; + + /// + /// Part B: Typically represents the location or basin identifier. + /// + public string PartB; + + /// + /// Part C: Typically represents the parameter or data type (e.g., FLOW, STAGE, PRECIP). + /// + public string PartC; + + /// + /// Part D: Typically represents the time interval or date range. + /// + public string PartD; + + /// + /// Part E: Typically represents the time interval for regular time series data. + /// + public string PartE; + + /// + /// Part F: Typically represents the version or scenario identifier. + /// + public string PartF; + + /// + /// Initializes a new instance of the class with the specified pathname parts. + /// + /// Part A of the pathname (project/study area). + /// Part B of the pathname (location/basin). + /// Part C of the pathname (parameter/data type). + /// Part D of the pathname (time interval/date range). + /// Part E of the pathname (time interval for time series). + /// Part F of the pathname (version/scenario). + public DSSPathnameItem(string partA, string partB, string partC, string partD, string partE, string partF) + { + PartA = partA; + PartB = partB; + PartC = partC; + PartD = partD; + PartE = partE; + PartF = partF; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/DataGrids/ExactDataRowItem.cs b/src/RMC.BestFit.App/GUI/Support/DataGrids/ExactDataRowItem.cs new file mode 100644 index 0000000..286ff3d --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/DataGrids/ExactDataRowItem.cs @@ -0,0 +1,208 @@ +using GenericControls; +using RMC.BestFit.Models; +using RMC.BestFit.UI; +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; + +namespace RMC_BestFit +{ + /// + /// Represents a data grid row item for exact data observations, providing a view model wrapper for precise + /// measurements or observations with known values used in statistical distribution fitting. + /// + public class ExactDataRowItem : DataGridRowItem + { + /// + /// Initializes a new instance of the class with default values. + /// + public ExactDataRowItem() : base(null) { } + + /// + /// Initializes a new instance of the class with the specified collection and exact data. + /// + /// The observable collection that contains this row item. + /// The exact data object to be wrapped by this row item. + /// The data series that contains the ordinate, used for clone-and-replace on edits. + /// Optional flag indicating whether to display DateTime instead of Index in the grid. Default is false. + public ExactDataRowItem(ObservableCollection observableCollection, ExactData ordinate, DataSeries series, bool showDateTime = false) : base(observableCollection) + { + _showDateTime = showDateTime; + _ordinate = ordinate; + _series = series; + } + + /// + /// Indicates whether the DateTime property should be displayed instead of the Index property in the grid. + /// + private bool _showDateTime; + + /// + /// The underlying exact data model object. + /// + private ExactData _ordinate; + + /// + /// The data series containing the ordinate, used for the clone-and-replace pattern. + /// + private DataSeries _series; + + /// + /// Gets the date and time associated with this observation. + /// + public DateTime DateTime => _ordinate.DateTime; + + /// + /// Gets or sets the index position of this observation in the data series. + /// When set, creates a clone of the underlying data, modifies the clone, and replaces + /// it in the data series to fire a CollectionChanged Replace event for undo support. + /// + public int Index + { + get { return _ordinate.Index; } + set + { + if (_ordinate.Index != value) + { + var clone = _ordinate.Clone(); + clone.Index = value; + ReplaceOrdinate(clone); + NotifyPropertyChanged(nameof(Index)); + } + } + } + + /// + /// Gets or sets the exact measured value of this observation. + /// When set, creates a clone of the underlying data, modifies the clone, and replaces + /// it in the data series to fire a CollectionChanged Replace event for undo support. + /// + public double Value + { + get { return _ordinate.Value; } + set + { + if (_ordinate.Value != value) + { + var clone = _ordinate.Clone(); + clone.Value = value; + ReplaceOrdinate(clone); + NotifyPropertyChanged(nameof(Value)); + } + } + } + + /// + /// Gets the plotting position of this observation, used for graphical representation in probability plots. + /// + public double PlottingPosition => _ordinate.PlottingPosition; + + /// + /// Gets a value indicating whether this observation is classified as a low outlier in the dataset. + /// + public bool IsLowOutlier => _ordinate.IsLowOutlier; + + /// + /// Updates the underlying ordinate reference and notifies the UI of all property changes. + /// Called by the CollectionChanged(Replace) handler during undo/redo to sync the RowItem + /// with the restored model data without triggering a clone-and-replace back to the series. + /// + /// The restored ordinate from the model. + public void SetOrdinate(ExactData newOrdinate) + { + _ordinate = newOrdinate; + NotifyPropertyChanged(nameof(Index)); + NotifyPropertyChanged(nameof(DateTime)); + NotifyPropertyChanged(nameof(Value)); + NotifyPropertyChanged(nameof(PlottingPosition)); + NotifyPropertyChanged(nameof(IsLowOutlier)); + } + + /// + /// Notifies the grid that the computed plotting position changed on the wrapped ordinate. + /// + /// + /// Plotting positions are recomputed in bulk by without replacing + /// row objects, so the row wrapper forwards the computed-property notification to WPF. + /// + public void RefreshPlottingPosition() + { + NotifyPropertyChanged(nameof(PlottingPosition)); + } + + /// + /// Replaces the current ordinate in the data series with a new clone. + /// This fires a CollectionChanged Replace event which the UndoableCollectionBridge records. + /// + /// The cloned and modified ordinate to replace the current one. + private void ReplaceOrdinate(ExactData newOrdinate) + { + if (_series == null) return; + int idx = _series.IndexOf(_ordinate); + if (idx >= 0) + { + _series[idx] = newOrdinate; + _ordinate = newOrdinate; + } + } + + /// + /// Adds validation rules for the exact data row item properties. + /// Validates that the index is within valid range and the value is a valid number. + /// + public override void AddValidationRules() + { + AddRule(nameof(Index), () => Index < -100000, "The index must be between -100,000 and +100,000."); + AddRule(nameof(Index), () => Index > 100000, "The index must be between -100,000 and +100,000."); + AddRule(nameof(Value), () => double.IsNaN(Value) || double.IsInfinity(Value), "The value must be a number.", new[] { nameof(Value) }); + } + + /// + /// Determines whether the specified property should be displayed in the data grid based on the display mode. + /// + /// The name of the property to check. + /// True if the property should be displayed; otherwise, false. + public override bool IsGridDisplayable(string propertyName) + { + if (propertyName == nameof(Index)) + return !_showDateTime; + if (propertyName == nameof(DateTime)) + return _showDateTime; + return true; + } + + /// + /// Gets the display name for the specified property to be shown in the data grid header. + /// + /// The name of the property. + /// The display name for the property, or null if not recognized. + public override string PropertyDisplayName(string propertyName) + { + if (propertyName == nameof(Index)) + { + return "Index"; + } + if (propertyName == nameof(DateTime)) + { + return "Date Time"; + } + else if (propertyName == nameof(Value)) + { + return "Value"; + } + else if (propertyName == nameof(IsLowOutlier)) + { + return "Low Outlier"; + } + else if (propertyName == nameof(PlottingPosition)) + { + return "Plotting Position"; + } + else + { + return null; + } + } + + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/DataGrids/IntervalDataRowItem.cs b/src/RMC.BestFit.App/GUI/Support/DataGrids/IntervalDataRowItem.cs new file mode 100644 index 0000000..548c647 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/DataGrids/IntervalDataRowItem.cs @@ -0,0 +1,266 @@ +using GenericControls; +using RMC.BestFit.Models; +using RMC.BestFit.UI; +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics; + +namespace RMC_BestFit +{ + /// + /// Represents a data grid row item for interval data observations, where each observation is characterized + /// by a lower bound, most likely value, and upper bound. This is commonly used for representing + /// historical or paleological data with uncertain measurements or estimates. + /// + public class IntervalDataRowItem : DataGridRowItem + { + /// + /// Initializes a new instance of the class with default values. + /// + public IntervalDataRowItem() : base(null) { } + + /// + /// Initializes a new instance of the class with the specified collection and interval data. + /// + /// The observable collection that contains this row item. + /// The interval data object to be wrapped by this row item. + /// The data frame containing all data series, used for validation to prevent index overlap. + /// The data series that contains the ordinate, used for clone-and-replace on edits. + public IntervalDataRowItem(ObservableCollection observableCollection, IntervalData ordinate, DataFrame dataframe, DataSeries series) : base(observableCollection) + { + _ordinate = ordinate; + _dataFrame = dataframe; + _series = series; + } + + /// + /// The underlying interval data model object. + /// + private IntervalData _ordinate; + + /// + /// The data frame containing all data series, used for cross-validation. + /// + private DataFrame _dataFrame; + + /// + /// The data series containing the ordinate, used for the clone-and-replace pattern. + /// + private DataSeries _series; + + /// + /// Gets or sets the index position of this observation in the data series. + /// When set, creates a clone of the underlying data, modifies the clone, and replaces + /// it in the data series to fire a CollectionChanged Replace event for undo support. + /// + public int Index + { + get { return _ordinate.Index; } + set + { + if (_ordinate.Index != value) + { + var clone = _ordinate.Clone(); + clone.Index = value; + ReplaceOrdinate(clone); + NotifyPropertyChanged(nameof(Index)); + } + } + } + + /// + /// Gets or sets the lower bound value of the interval estimate. + /// When set, creates a clone of the underlying data, modifies the clone, and replaces + /// it in the data series to fire a CollectionChanged Replace event for undo support. + /// + public double LowerValue + { + get { return _ordinate.LowerValue; } + set + { + if (_ordinate.LowerValue != value) + { + var clone = _ordinate.Clone(); + clone.LowerValue = value; + ReplaceOrdinate(clone); + NotifyPropertyChanged(nameof(LowerValue)); + } + } + } + + /// + /// Gets or sets the most likely value within the interval estimate. + /// When set, creates a clone of the underlying data, modifies the clone, and replaces + /// it in the data series to fire a CollectionChanged Replace event for undo support. + /// + public double Value + { + get { return _ordinate.Value; } + set + { + if (_ordinate.Value != value) + { + var clone = _ordinate.Clone(); + clone.Value = value; + ReplaceOrdinate(clone); + NotifyPropertyChanged(nameof(Value)); + } + } + } + + /// + /// Gets or sets the upper bound value of the interval estimate. + /// When set, creates a clone of the underlying data, modifies the clone, and replaces + /// it in the data series to fire a CollectionChanged Replace event for undo support. + /// + public double UpperValue + { + get { return _ordinate.UpperValue; } + set + { + if (_ordinate.UpperValue != value) + { + var clone = _ordinate.Clone(); + clone.UpperValue = value; + ReplaceOrdinate(clone); + NotifyPropertyChanged(nameof(UpperValue)); + } + } + } + + /// + /// Gets the plotting position of this observation, used for graphical representation in probability plots. + /// + public double PlottingPosition => _ordinate.PlottingPosition; + + /// + /// Updates the underlying ordinate reference and notifies the UI of all property changes. + /// Called by the CollectionChanged(Replace) handler during undo/redo to sync the RowItem + /// with the restored model data without triggering a clone-and-replace back to the series. + /// + /// The restored ordinate from the model. + public void SetOrdinate(IntervalData newOrdinate) + { + _ordinate = newOrdinate; + NotifyPropertyChanged(nameof(Index)); + NotifyPropertyChanged(nameof(LowerValue)); + NotifyPropertyChanged(nameof(Value)); + NotifyPropertyChanged(nameof(UpperValue)); + NotifyPropertyChanged(nameof(PlottingPosition)); + } + + /// + /// Notifies the grid that the computed plotting position changed on the wrapped ordinate. + /// + /// + /// Plotting positions are recomputed in bulk by without replacing + /// row objects, so the row wrapper forwards the computed-property notification to WPF. + /// + public void RefreshPlottingPosition() + { + NotifyPropertyChanged(nameof(PlottingPosition)); + } + + /// + /// Replaces the current ordinate in the data series with a new clone. + /// This fires a CollectionChanged Replace event which the UndoableCollectionBridge records. + /// + /// The cloned and modified ordinate to replace the current one. + private void ReplaceOrdinate(IntervalData newOrdinate) + { + if (_series == null) return; + int idx = _series.IndexOf(_ordinate); + if (idx >= 0) + { + _series[idx] = newOrdinate; + _ordinate = newOrdinate; + } + } + + /// + /// Adds validation rules for the interval data row item properties. + /// Validates that indexes are unique, within valid range, do not overlap with other data types, + /// and that the interval bounds are properly ordered (lower < most likely < upper). + /// + public override void AddValidationRules() + { + AddRule(nameof(Index), IsIndexInvalid, "The interval data cannot overlap with the exact or uncertain data."); + AddRule(nameof(Index), () => UniqueRule(nameof(Index), "The index must be unique."), "The index must be unique."); + AddRule(nameof(Index), () => Index < -100000, "The index must be between -100,000 and +100,000."); + AddRule(nameof(Index), () => Index > 100000, "The index must be between -100,000 and +100,000."); + AddRule(nameof(LowerValue), () => LowerValue >= Value, "The lower value must be less than the most likely value.", new[] { nameof(Value) }); + AddRule(nameof(UpperValue), () => Value >= UpperValue, "The upper value must be greater than the most likely value.", new[] { nameof(Value) }); + AddRule(nameof(LowerValue), () => LowerValue >= UpperValue, "The lower value must be less than the upper value.", new[] { nameof(UpperValue) }); + AddRule(nameof(UpperValue), () => LowerValue >= UpperValue, "The upper value must be greater than the lower value.", new[] { nameof(LowerValue) }); + AddRule(nameof(LowerValue), () => double.IsNaN(LowerValue) || double.IsInfinity(LowerValue), "The lower value must be a number.", new[] { nameof(LowerValue) }); + AddRule(nameof(Value), () => double.IsNaN(Value) || double.IsInfinity(Value), "The most likely value must be a number.", new[] { nameof(Value) }); + AddRule(nameof(UpperValue), () => double.IsNaN(UpperValue) || double.IsInfinity(UpperValue), "The upper value must be a number.", new[] { nameof(UpperValue) });; + } + + /// + /// Validates that the index does not overlap with exact or uncertain data in the data frame. + /// + /// True if the index overlaps with existing exact or uncertain data; otherwise, false. + private bool IsIndexInvalid() + { + if (_dataFrame.ExactSeries.Count > 0) + { + for (int i = 0; i < _dataFrame.ExactSeries.Count; i++) + if (Index == ((ExactData)_dataFrame.ExactSeries[i]).Index) + return true; + } + if (_dataFrame.UncertainSeries.Count > 0) + { + for (int i = 0; i < _dataFrame.UncertainSeries.Count; i++) + if (Index == ((UncertainData)_dataFrame.UncertainSeries[i]).Index) + return true; + } + return false; + } + + /// + /// Determines whether the specified property should be displayed in the data grid. + /// + /// The name of the property to check. + /// Always returns true, indicating all properties are displayable. + public override bool IsGridDisplayable(string propertyName) + { + return true; + } + + /// + /// Gets the display name for the specified property to be shown in the data grid header. + /// + /// The name of the property. + /// The display name for the property, or null if not recognized. + public override string PropertyDisplayName(string propertyName) + { + if (propertyName == nameof(Index)) + { + return "Index"; + } + else if (propertyName == nameof(LowerValue)) + { + return "Lower"; + } + else if (propertyName == nameof(Value)) + { + return "Most Likely"; + } + else if (propertyName == nameof(UpperValue)) + { + return "Upper"; + } + else if (propertyName == nameof(PlottingPosition)) + { + return "Plotting Position"; + } + else + { + return null; + } + } + + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/DataGrids/OrdinateRowItem.cs b/src/RMC.BestFit.App/GUI/Support/DataGrids/OrdinateRowItem.cs new file mode 100644 index 0000000..dd0423c --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/DataGrids/OrdinateRowItem.cs @@ -0,0 +1,88 @@ +using GenericControls; +using System.Collections.ObjectModel; + +namespace RMC_BestFit +{ + /// + /// Base row wrapper for a single double value in an ordinate . + /// Inherits so the cell participates in the validation + /// framework (auto-generated columns, ordering checks, error highlighting). Mirrors the + /// canonical pattern from WPF-Framework's ProbabilityOrdinateRowItem. + /// + /// + /// Subclassed by and so each + /// auto-generated column gets the right header ("X Value" or "Y Value"). Shared validation + /// (OrderRule) and column-display logic live here. + /// + public class OrdinateRowItem : DataGridRowItem + { + /// + /// The backing field for the property. + /// + private double _value; + + /// + /// Initializes a new instance of the class with default values. + /// Required by when adding new rows from the click-to-add row. + /// + public OrdinateRowItem() : base(null) { } + + /// + /// Initializes a new instance of the class with the specified value. + /// + /// The observable collection that contains this row item — used by + /// the base class's OrderRule machinery to compare against neighbors. + /// The ordinate value. + public OrdinateRowItem(ObservableCollection parentList, double value) : base(parentList) + { + _value = value; + } + + /// Gets or sets the row value. + public double Value + { + get { return _value; } + set + { + if (_value != value) + { + _value = value; + NotifyPropertyChanged(nameof(Value)); + } + } + } + + /// + /// Adds validation rules for the ordinate row item properties. + /// Validates that values are in strict ascending order — duplicates are not allowed + /// because both X and Y ordinate vectors are used as bivariate-grid row/column keys. + /// + public override void AddValidationRules() + { + AddRule(nameof(Value), () => OrderRule(x => x.Value, nameof(Value), true, false), "Ordinate values must be in ascending order."); + } + + /// + /// Determines whether the specified property should be displayed in the data grid. + /// + /// The name of the property to check. + /// true for ; false for inherited / framework properties. + public override bool IsGridDisplayable(string propertyName) + { + return propertyName == nameof(Value); + } + + /// + /// Gets the display name for the specified property to be shown in the data grid header. + /// Default implementation returns "Value"; subclasses (, + /// ) override to provide axis-specific column headers. + /// + /// The name of the property. + /// The display name for the property, or null if not recognized. + public override string PropertyDisplayName(string propertyName) + { + if (propertyName == nameof(Value)) return "Value"; + return null; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/DataGrids/ThresholdDataRowItem.cs b/src/RMC.BestFit.App/GUI/Support/DataGrids/ThresholdDataRowItem.cs new file mode 100644 index 0000000..f7f8a97 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/DataGrids/ThresholdDataRowItem.cs @@ -0,0 +1,257 @@ +using GenericControls; +using RMC.BestFit.Models; +using RMC.BestFit.UI; +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; + +namespace RMC_BestFit +{ + /// + /// Represents a data grid row item for threshold data, providing a view model wrapper for threshold observations + /// used in statistical analysis of values exceeding specified thresholds over defined time periods. + /// + public class ThresholdDataRowItem : DataGridRowItem + { + /// + /// Initializes a new instance of the class with default values. + /// + public ThresholdDataRowItem() : base(null) { } + + /// + /// Initializes a new instance of the class with the specified collection and threshold data. + /// + /// The observable collection that contains this row item. + /// The threshold data object to be wrapped by this row item. + /// The data series that contains the ordinate, used for clone-and-replace on edits. + public ThresholdDataRowItem(ObservableCollection observableCollection, ThresholdData ordinate, DataSeries series) : base(observableCollection) + { + _collection = observableCollection; + _ordinate = ordinate; + _series = series; + } + + /// + /// The underlying threshold data model object. + /// + private ThresholdData _ordinate; + + /// + /// The observable collection that contains this row item. + /// + private ObservableCollection _collection; + + /// + /// The data series containing the ordinate, used for the clone-and-replace pattern. + /// + private DataSeries _series; + + /// + /// Gets or sets the starting index of the threshold period. + /// When set, creates a clone of the underlying data, modifies the clone, and replaces + /// it in the data series to fire a CollectionChanged Replace event for undo support. + /// + public int StartIndex + { + get { return _ordinate.StartIndex; } + set + { + if (_ordinate.StartIndex != value) + { + var clone = _ordinate.Clone(); + clone.StartIndex = value; + ReplaceOrdinate(clone); + NotifyPropertyChanged(nameof(StartIndex)); + } + } + } + + /// + /// Gets or sets the ending index of the threshold period. + /// When set, creates a clone of the underlying data, modifies the clone, and replaces + /// it in the data series to fire a CollectionChanged Replace event for undo support. + /// + public int EndIndex + { + get { return _ordinate.EndIndex; } + set + { + if (_ordinate.EndIndex != value) + { + var clone = _ordinate.Clone(); + clone.EndIndex = value; + ReplaceOrdinate(clone); + NotifyPropertyChanged(nameof(EndIndex)); + } + } + } + + /// + /// Gets or sets the threshold value used for comparison. + /// When set, creates a clone of the underlying data, modifies the clone, and replaces + /// it in the data series to fire a CollectionChanged Replace event for undo support. + /// + public double Value + { + get { return _ordinate.Value; } + set + { + if (_ordinate.Value != value) + { + var clone = _ordinate.Clone(); + clone.Value = value; + ReplaceOrdinate(clone); + NotifyPropertyChanged(nameof(Value)); + } + } + } + + /// + /// Gets or sets the number of observations above the threshold value within the specified period. + /// When set, creates a clone of the underlying data, modifies the clone, and replaces + /// it in the data series to fire a CollectionChanged Replace event for undo support. + /// + public int NumberAbove + { + get { return _ordinate.NumberAbove; } + set + { + if (_ordinate.NumberAbove != value) + { + var clone = _ordinate.Clone(); + clone.NumberAbove = value; + ReplaceOrdinate(clone); + NotifyPropertyChanged(nameof(NumberAbove)); + } + } + } + + /// + /// Updates the underlying ordinate reference and notifies the UI of all property changes. + /// Called by the CollectionChanged(Replace) handler during undo/redo to sync the RowItem + /// with the restored model data without triggering a clone-and-replace back to the series. + /// + /// The restored ordinate from the model. + public void SetOrdinate(ThresholdData newOrdinate) + { + _ordinate = newOrdinate; + NotifyPropertyChanged(nameof(StartIndex)); + NotifyPropertyChanged(nameof(EndIndex)); + NotifyPropertyChanged(nameof(Value)); + NotifyPropertyChanged(nameof(NumberAbove)); + } + + /// + /// Replaces the current ordinate in the data series with a new clone. + /// This fires a CollectionChanged Replace event which the UndoableCollectionBridge records. + /// + /// The cloned and modified ordinate to replace the current one. + private void ReplaceOrdinate(ThresholdData newOrdinate) + { + if (_series == null) return; + int idx = _series.IndexOf(_ordinate); + if (idx >= 0) + { + _series[idx] = newOrdinate; + _ordinate = newOrdinate; + } + } + + /// + /// Adds validation rules for the threshold data row item properties. + /// Validates that periods do not overlap, indexes are within valid range and in order, + /// values are valid numbers, and the number above does not exceed the threshold duration. + /// + public override void AddValidationRules() + { + AddRule(nameof(StartIndex), IsStartOverlapping, "The threshold periods cannot overlap."); + AddRule(nameof(EndIndex), IsEndOverlapping, "The threshold periods cannot overlap."); + AddRule(nameof(StartIndex), () => OrderRule((x) => x.StartIndex, nameof(StartIndex), true, false), "Start indexes must be in ascending order."); + AddRule(nameof(EndIndex), () => OrderRule((x) => x.EndIndex, nameof(EndIndex), true, false), "End indexes must be in ascending order."); + AddRule(nameof(StartIndex), () => StartIndex < -100000, "The start index must be between -100,000 and +100,000."); + AddRule(nameof(StartIndex), () => StartIndex > 100000, "The start index must be between -100,000 and +100,000."); + AddRule(nameof(EndIndex), () => EndIndex < -100000, "The end index must be between -100,000 and +100,000."); + AddRule(nameof(EndIndex), () => EndIndex > 100000, "The end index must be between -100,000 and +100,000."); + AddRule(nameof(StartIndex), () => StartIndex > EndIndex, "The start index must be less than or equal to the end index.", new[] { nameof(EndIndex) }); + AddRule(nameof(EndIndex), () => StartIndex > EndIndex, "The start index must be less than or equal to the end index.", new[] { nameof(StartIndex) }); + AddRule(nameof(Value), () => double.IsNaN(Value) || double.IsInfinity(Value), "The value must be a number.", new[] { nameof(Value) }); + AddRule(nameof(NumberAbove), () => NumberAbove > _ordinate.Duration, "The number above must be less than or equal to the threshold duration.", new[] { nameof(NumberAbove) }); + } + + /// + /// Determines whether this threshold period overlaps with any other period in the collection. + /// Two intervals overlap if neither ends before the other starts. + /// + /// True if this period overlaps with another period; otherwise, false. + private bool IsOverlapping() + { + for (int i = 0; i < _collection.Count; i++) + { + var other = (ThresholdDataRowItem)_collection[i]; + if (other == this) continue; + // Two intervals [a,b] and [c,d] overlap if NOT (b < c OR d < a) + if (!(EndIndex < other.StartIndex || other.EndIndex < StartIndex)) + return true; + } + return false; + } + + /// + /// Determines whether the start index of this threshold period overlaps with other periods in the collection. + /// + /// True if this period overlaps with another period; otherwise, false. + private bool IsStartOverlapping() + { + return IsOverlapping(); + } + + /// + /// Determines whether the end index of this threshold period overlaps with other periods in the collection. + /// + /// True if this period overlaps with another period; otherwise, false. + private bool IsEndOverlapping() + { + return IsOverlapping(); + } + + /// + /// Determines whether the specified property should be displayed in the data grid. + /// + /// The name of the property to check. + /// Always returns true, indicating all properties are displayable. + public override bool IsGridDisplayable(string propertyName) + { + return true; + } + + /// + /// Gets the display name for the specified property to be shown in the data grid header. + /// + /// The name of the property. + /// The display name for the property, or null if not recognized. + public override string PropertyDisplayName(string propertyName) + { + if (propertyName == nameof(StartIndex)) + { + return "Start Index"; + } + if (propertyName == nameof(EndIndex)) + { + return "End Index"; + } + else if (propertyName == nameof(Value)) + { + return "Value"; + } + else if (propertyName == nameof(NumberAbove)) + { + return "No. Above"; + } + else + { + return null; + } + } + + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/DataGrids/TrendModelRowItem.cs b/src/RMC.BestFit.App/GUI/Support/DataGrids/TrendModelRowItem.cs new file mode 100644 index 0000000..ce5f32e --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/DataGrids/TrendModelRowItem.cs @@ -0,0 +1,70 @@ +using RMC.BestFit.Models.TrendFunctions.Support; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RMC_BestFit +{ + /// + /// Represents a trend model row item for display in a data grid or list, providing a mapping + /// between a human-readable display name and a trend model type enumeration value. + /// Used for selecting trend models in the user interface for time series analysis. + /// + public class TrendModelRowItem : INotifyPropertyChanged + { + /// + /// The backing field for the Name property. + /// + private string _name; + + /// + /// The backing field for the Value property. + /// + private TrendModelType _value; + + /// + /// Gets or sets the display name of the trend model shown to the user. + /// + public string Name + { + get { return _name; } + set + { + _name = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name))); + } + } + + /// + /// Gets or sets the enumeration value representing the type of trend model. + /// + public TrendModelType Value + { + get { return _value; } + set + { + _value = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value))); + } + } + + /// + /// Initializes a new instance of the class with the specified display name and model type. + /// + /// The human-readable name to display in the user interface. + /// The trend model type enumeration value. + public TrendModelRowItem(string displayName, TrendModelType value) + { + Name = displayName; + Value = value; + } + + /// + /// Occurs when a property value changes. + /// + public event PropertyChangedEventHandler PropertyChanged; + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/DataGrids/UncertainDataRowItem.cs b/src/RMC.BestFit.App/GUI/Support/DataGrids/UncertainDataRowItem.cs new file mode 100644 index 0000000..bfb83f6 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/DataGrids/UncertainDataRowItem.cs @@ -0,0 +1,208 @@ +using GenericControls; +using Numerics.Distributions; +using RMC.BestFit.Models; +using RMC.BestFit.UI; +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; + +namespace RMC_BestFit +{ + /// + /// Represents a data grid row item for uncertain data observations, where each observation is represented + /// by a probability distribution rather than a single value. This is used when measurement uncertainty + /// is explicitly modeled in statistical analysis. + /// + public class UncertainDataRowItem : DataGridRowItem + { + /// + /// Initializes a new instance of the class with default values. + /// + public UncertainDataRowItem() : base(null) { } + + /// + /// Initializes a new instance of the class with the specified collection and uncertain data. + /// + /// The observable collection that contains this row item. + /// The uncertain data object to be wrapped by this row item. + /// The data frame containing all data series, used for validation to prevent index overlap. + /// The data series that contains the ordinate, used for clone-and-replace on edits. + public UncertainDataRowItem(ObservableCollection observableCollection, UncertainData ordinate, DataFrame dataframe, DataSeries series) : base(observableCollection) + { + _ordinate = ordinate; + _dataFrame = dataframe; + _series = series; + } + + /// + /// The underlying uncertain data model object. + /// + private UncertainData _ordinate; + + /// + /// The data frame containing all data series, used for cross-validation. + /// + private DataFrame _dataFrame; + + /// + /// The data series containing the ordinate, used for the clone-and-replace pattern. + /// + private DataSeries _series; + + /// + /// Gets or sets the index position of this observation in the data series. + /// When set, creates a clone of the underlying data, modifies the clone, and replaces + /// it in the data series to fire a CollectionChanged Replace event for undo support. + /// + public int Index + { + get { return _ordinate.Index; } + set + { + if (_ordinate.Index != value) + { + var clone = _ordinate.Clone(); + clone.Index = value; + ReplaceOrdinate(clone); + NotifyPropertyChanged(nameof(Index)); + } + } + } + + /// + /// Gets or sets the probability distribution representing the uncertainty in this observation. + /// When set, creates a clone of the underlying data, modifies the clone, and replaces + /// it in the data series to fire a CollectionChanged Replace event for undo support. + /// + public UnivariateDistributionBase Distribution + { + get { return _ordinate.Distribution; } + set + { + if (_ordinate.Distribution != value) + { + var clone = _ordinate.Clone(); + clone.Distribution = value; + ReplaceOrdinate(clone); + NotifyPropertyChanged(nameof(Distribution)); + } + } + } + + /// + /// Gets the plotting position of this observation, used for graphical representation in probability plots. + /// + public double PlottingPosition => _ordinate.PlottingPosition; + + /// + /// Updates the underlying ordinate reference and notifies the UI of all property changes. + /// Called by the CollectionChanged(Replace) handler during undo/redo to sync the RowItem + /// with the restored model data without triggering a clone-and-replace back to the series. + /// + /// The restored ordinate from the model. + public void SetOrdinate(UncertainData newOrdinate) + { + _ordinate = newOrdinate; + NotifyPropertyChanged(nameof(Index)); + NotifyPropertyChanged(nameof(Distribution)); + NotifyPropertyChanged(nameof(PlottingPosition)); + } + + /// + /// Notifies the grid that the computed plotting position changed on the wrapped ordinate. + /// + /// + /// Plotting positions are recomputed in bulk by without replacing + /// row objects, so the row wrapper forwards the computed-property notification to WPF. + /// + public void RefreshPlottingPosition() + { + NotifyPropertyChanged(nameof(PlottingPosition)); + } + + /// + /// Replaces the current ordinate in the data series with a new clone. + /// This fires a CollectionChanged Replace event which the UndoableCollectionBridge records. + /// + /// The cloned and modified ordinate to replace the current one. + private void ReplaceOrdinate(UncertainData newOrdinate) + { + if (_series == null) return; + int idx = _series.IndexOf(_ordinate); + if (idx >= 0) + { + _series[idx] = newOrdinate; + _ordinate = newOrdinate; + } + } + + /// + /// Adds validation rules for the uncertain data row item properties. + /// Validates that the index does not overlap with exact or interval data, is within valid range, + /// and the distribution parameters are valid. + /// + public override void AddValidationRules() + { + AddRule(nameof(Index), IsIndexInvalid, "The uncertain data cannot overlap with the exact data."); + AddRule(nameof(Index), () => Index < -100000, "The index must be between -100,000 and +100,000."); + AddRule(nameof(Index), () => Index > 100000, "The index must be between -100,000 and +100,000."); + AddRule(nameof(Distribution), () => Distribution.ValidateParameters(Distribution.GetParameters, false) != null, "The distribution parameters are invalid.", new[] { nameof(Distribution) }); + } + + /// + /// Validates that the index does not overlap with exact or interval data in the data frame. + /// + /// True if the index overlaps with existing exact or interval data; otherwise, false. + private bool IsIndexInvalid() + { + if (_dataFrame.ExactSeries.Count > 0) + { + for (int i = 0; i < _dataFrame.ExactSeries.Count; i++) + if (Index == ((ExactData)_dataFrame.ExactSeries[i]).Index) + return true; + } + if (_dataFrame.IntervalSeries.Count > 0) + { + for (int i = 0; i < _dataFrame.IntervalSeries.Count; i++) + if (Index == ((IntervalData)_dataFrame.IntervalSeries[i]).Index) + return true; + } + return false; + } + + /// + /// Determines whether the specified property should be displayed in the data grid. + /// + /// The name of the property to check. + /// Always returns true, indicating all properties are displayable. + public override bool IsGridDisplayable(string propertyName) + { + return true; + } + + /// + /// Gets the display name for the specified property to be shown in the data grid header. + /// + /// The name of the property. + /// The display name for the property, or null if not recognized. + public override string PropertyDisplayName(string propertyName) + { + if (propertyName == nameof(Index)) + { + return "Index"; + } + else if (propertyName == nameof(Distribution)) + { + return "Distribution"; + } + else if (propertyName == nameof(PlottingPosition)) + { + return "Plotting Position"; + } + else + { + return null; + } + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/DataGrids/XOrdinateRowItem.cs b/src/RMC.BestFit.App/GUI/Support/DataGrids/XOrdinateRowItem.cs new file mode 100644 index 0000000..251dd95 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/DataGrids/XOrdinateRowItem.cs @@ -0,0 +1,43 @@ +using System.Collections.ObjectModel; + +namespace RMC_BestFit +{ + /// + /// Row wrapper for a primary (X-axis) ordinate value in the coincident frequency + /// analysis properties control. Inherits all validation behavior from + /// ; only overrides + /// so the auto-generated DataGrid column header reads "X Value". + /// + public class XOrdinateRowItem : OrdinateRowItem + { + /// + /// Initializes a new with default values. Required + /// by when adding a row from the + /// click-to-add row factory (which uses the type's parameterless constructor). + /// + public XOrdinateRowItem() : base() { } + + /// + /// Initializes a new with the specified parent + /// list and value. + /// + /// The observable collection that contains this row item. + /// The X ordinate value. + public XOrdinateRowItem(ObservableCollection parentList, double value) + : base(parentList, value) + { + } + + /// + /// Returns the column header for the auto-generated DataGrid column. "X Value" + /// for , null for any other property. + /// + /// The property name. + /// "X Value" for ; null otherwise. + public override string PropertyDisplayName(string propertyName) + { + if (propertyName == nameof(Value)) return "X Value"; + return null; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/DataGrids/YOrdinateRowItem.cs b/src/RMC.BestFit.App/GUI/Support/DataGrids/YOrdinateRowItem.cs new file mode 100644 index 0000000..b937e42 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/DataGrids/YOrdinateRowItem.cs @@ -0,0 +1,43 @@ +using System.Collections.ObjectModel; + +namespace RMC_BestFit +{ + /// + /// Row wrapper for a secondary (Y-axis) ordinate value in the coincident frequency + /// analysis properties control. Inherits all validation behavior from + /// ; only overrides + /// so the auto-generated DataGrid column header reads "Y Value". + /// + public class YOrdinateRowItem : OrdinateRowItem + { + /// + /// Initializes a new with default values. Required + /// by when adding a row from the + /// click-to-add row factory (which uses the type's parameterless constructor). + /// + public YOrdinateRowItem() : base() { } + + /// + /// Initializes a new with the specified parent + /// list and value. + /// + /// The observable collection that contains this row item. + /// The Y ordinate value. + public YOrdinateRowItem(ObservableCollection parentList, double value) + : base(parentList, value) + { + } + + /// + /// Returns the column header for the auto-generated DataGrid column. "Y Value" + /// for , null for any other property. + /// + /// The property name. + /// "Y Value" for ; null otherwise. + public override string PropertyDisplayName(string propertyName) + { + if (propertyName == nameof(Value)) return "Y Value"; + return null; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/DateValuePoint.cs b/src/RMC.BestFit.App/GUI/Support/DateValuePoint.cs new file mode 100644 index 0000000..9a09ab9 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/DateValuePoint.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RMC_BestFit +{ + /// + /// Represents a data point that associates a date and time with a numerical value. + /// + /// + /// This class is commonly used for time-series data, where observations are recorded at specific + /// points in time. It is particularly useful for plotting temporal data or analyzing trends over time. + /// + public class DateValuePoint + { + /// + /// Gets or sets the date and time of the observation. + /// + public DateTime DateTime { get; set; } + + /// + /// Gets or sets the numerical value associated with the date and time. + /// + public double Value { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The date and time of the observation. + /// The numerical value associated with the date and time. + public DateValuePoint(DateTime dateTime, double value) + { + DateTime = dateTime; + Value = value; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/FittedDistributionStatistic.cs b/src/RMC.BestFit.App/GUI/Support/FittedDistributionStatistic.cs new file mode 100644 index 0000000..2952607 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/FittedDistributionStatistic.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using RMC.BestFit.Models; + +namespace RMC_BestFit +{ + /// + /// Represents a statistical measure for a fitted probability distribution. + /// + /// + /// This class encapsulates a named statistic with one or more numerical values and associated tooltips + /// for display in the user interface. It is typically used to present goodness-of-fit measures, + /// parameter estimates, or other distribution-related statistics. + /// + public class FittedDistributionStatistic + { + /// + /// Gets the name of the statistic. + /// + public string Name { get; } + + /// + /// Gets the array of numerical values for this statistic. + /// + /// + /// Multiple values may be provided when the statistic represents a collection of related measures, + /// such as parameter estimates or multiple test results. + /// + public double[] Value { get; } + + /// + /// Gets the array of tooltip strings corresponding to each value. + /// + /// + /// Tooltips provide additional information or context for each statistic value when displayed in the user interface. + /// + public string[] ToolTip { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the statistic. + /// An array of numerical values for the statistic. + /// An array of tooltip strings for each value. + public FittedDistributionStatistic(string name, double[] value, string[] toolTip) + { + Name = name; + Value = value; + ToolTip = toolTip; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/FrequencyCurvePoint.cs b/src/RMC.BestFit.App/GUI/Support/FrequencyCurvePoint.cs new file mode 100644 index 0000000..d0fddf5 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/FrequencyCurvePoint.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RMC_BestFit +{ + /// + /// Represents a point on a frequency curve with associated confidence bounds and statistical measures. + /// + /// + /// This class encapsulates data for a single point on a frequency curve, including upper and lower confidence bounds, + /// predictive intervals, mode values, and optional coordinate or datetime information. + /// + public class FrequencyCurvePoint + { + /// + /// Gets or sets the probability value for this frequency curve point. + /// + public double Probability { get; set; } + + /// + /// Gets or sets the upper confidence bound value. + /// + public double Upper { get; set; } + + /// + /// Gets or sets the lower confidence bound value. + /// + public double Lower { get; set; } + + /// + /// Gets or sets the predictive interval value. + /// + public double Predictive { get; set; } + + /// + /// Gets or sets the mode value for the distribution. + /// + public double Mode { get; set; } + + /// + /// Gets or sets the X-coordinate value for plotting purposes. + /// + public double X { get; set; } + + /// + /// Gets or sets the Y-coordinate value for plotting purposes. + /// + public double Y { get; set; } + + /// + /// Gets or sets the date and time associated with this frequency curve point. + /// + public DateTime DateTime { get; set; } + + /// + /// Initializes a new instance of the class with probability and statistical bounds. + /// + /// The probability value for this point. + /// The upper confidence bound. + /// The lower confidence bound. + /// The predictive interval value. + /// The mode value of the distribution. + public FrequencyCurvePoint(double probability, double upper, double lower, double predictive, double mode) + { + Probability = probability; + Upper = upper; + Lower = lower; + Predictive = predictive; + Mode = mode; + } + + /// + /// Initializes a new instance of the class with X-Y coordinates and statistical bounds. + /// + /// The X-coordinate value. + /// The Y-coordinate value. + /// The upper confidence bound. + /// The lower confidence bound. + /// The predictive interval value. + /// The mode value of the distribution. + public FrequencyCurvePoint(double x, double y, double upper, double lower, double predictive, double mode) + { + X = x; + Y = y; + Upper = upper; + Lower = lower; + Predictive = predictive; + Mode = mode; + } + + /// + /// Initializes a new instance of the class with a datetime value and statistical bounds. + /// + /// The date and time associated with this point. + /// The upper confidence bound. + /// The lower confidence bound. + /// The predictive interval value. + /// The mode value of the distribution. + public FrequencyCurvePoint(DateTime dateTime, double upper, double lower, double predictive, double mode) + { + DateTime = dateTime; + Upper = upper; + Lower = lower; + Predictive = predictive; + Mode = mode; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/HypothesisTestResult.cs b/src/RMC.BestFit.App/GUI/Support/HypothesisTestResult.cs new file mode 100644 index 0000000..f10efb6 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/HypothesisTestResult.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RMC_BestFit +{ + /// + /// Represents the result of a statistical hypothesis test. + /// + /// + /// This class encapsulates the essential components of a hypothesis test result, including + /// the test name, p-value, significance level, and statistical inference conclusions. + /// It is typically used for goodness-of-fit tests in distribution fitting analysis. + /// + public class HypothesisTestResult + { + /// + /// Gets or sets the name of the hypothesis test. + /// + /// + /// Examples include "Kolmogorov-Smirnov", "Anderson-Darling", or "Chi-Square" tests. + /// + public string Name { get; set; } + + /// + /// Gets or sets the p-value of the hypothesis test. + /// + /// + /// The p-value represents the probability of obtaining test results at least as extreme as + /// the observed results, assuming the null hypothesis is true. + /// + public string PValue { get; set; } + + /// + /// Gets or sets the significance level used for the test. + /// + /// + /// Common significance levels include 0.05, 0.01, or other alpha values used to determine + /// whether to reject the null hypothesis. + /// + public string Significance { get; set; } + + /// + /// Gets or sets the statistical inference or conclusion of the test. + /// + /// + /// This typically indicates whether the null hypothesis is rejected or not rejected, + /// along with any relevant interpretation of the test results. + /// + public string Inference { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the hypothesis test. + /// The p-value of the test. + /// The significance level used (optional, defaults to empty string). + /// The statistical inference or conclusion (optional, defaults to empty string). + public HypothesisTestResult(string name, string pValue, string significance = "", string inference = "") + { + Name = name; + PValue = pValue; + Significance = significance; + Inference = inference; + } + + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/OnlineHelpLauncher.cs b/src/RMC.BestFit.App/GUI/Support/OnlineHelpLauncher.cs new file mode 100644 index 0000000..4e5abbc --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/OnlineHelpLauncher.cs @@ -0,0 +1,137 @@ +using Numerics.Data; +using System; +using System.Diagnostics; +using System.Threading.Tasks; + +namespace RMC_BestFit +{ + /// + /// Identifies the result of attempting to open an online RMC-BestFit Help resource. + /// + /// + /// The result distinguishes an expected offline condition from a successful browser launch. + /// Unexpected connectivity or process-launch failures are propagated to the caller. + /// + internal enum OnlineHelpLaunchResult + { + /// + /// The Help resource was passed to the operating system for opening. + /// + Opened, + + /// + /// The connectivity check did not find an available Internet connection. + /// + NoInternet + } + + /// + /// Checks Internet connectivity and opens online RMC-BestFit Help resources in the default browser. + /// + /// + /// Production calls reuse the bounded, asynchronous connectivity check provided by + /// . The dependency-taking overload provides deterministic + /// unit-test coverage without requiring live network access or starting an external process. + /// + internal static class OnlineHelpLauncher + { + /// + /// Canonical URL for the RMC-BestFit version 1.0 online user guide. + /// + internal const string UserGuideUrl = "https://usace-rmc.github.io/RMC-Software-Documentation/docs/desktop-applications/rmc-bestfit/users-guide/v1.0/welcome-to-rmc-bestfit/"; + + /// + /// Canonical URL for the RMC-BestFit technical reference on the main branch. + /// + internal const string TechnicalReferenceUrl = "https://github.com/USACE-RMC/RMC-BestFit/blob/main/docs/index.md"; + + /// + /// Canonical URL for the RMC-BestFit example projects on the main branch. + /// + internal const string ExampleProjectsUrl = "https://github.com/USACE-RMC/RMC-BestFit/tree/main/examples"; + + /// + /// Canonical concept DOI URL for all archived RMC-BestFit releases on Zenodo. + /// + internal const string ZenodoConceptDoiUrl = "https://doi.org/10.5281/zenodo.21301036"; + + /// + /// Checks Internet connectivity and opens an online Help resource in the default browser. + /// + /// Absolute HTTPS URL of the Help resource to open. + /// + /// A task whose result is when the browser launch + /// is requested, or when no connection is available. + /// + /// + /// Thrown when is empty or is not an absolute HTTPS URL. + /// + /// + /// Thrown when Windows cannot start the default browser. + /// + /// + /// Thrown when the process cannot be started. + /// + /// + /// The URL is opened through the Windows shell so the user's configured default browser is honored. + /// Unexpected failures are intentionally propagated for the WPF caller to log and report. + /// + internal static Task TryOpenAsync(string url) + { + return TryOpenAsync( + url, + TimeSeriesDownload.IsConnectedToInternet, + processStartInfo => Process.Start(processStartInfo)); + } + + /// + /// Checks Internet connectivity and opens an online Help resource using supplied dependencies. + /// + /// Absolute HTTPS URL of the Help resource to open. + /// Asynchronous function that reports whether Internet connectivity is available. + /// Action that starts the configured browser process. + /// + /// A task whose result is when the browser launch + /// is requested, or when no connection is available. + /// + /// + /// Thrown when is empty or is not an absolute HTTPS URL. + /// + /// + /// Thrown when or is null. + /// + /// + /// This overload isolates external effects for unit testing. Exceptions from either supplied + /// dependency are not suppressed so the UI layer can provide consistent logging and notification. + /// + internal static async Task TryOpenAsync( + string url, + Func> isInternetAvailable, + Action processLauncher) + { + if (string.IsNullOrWhiteSpace(url)) + { + throw new ArgumentException("A Help resource URL is required.", nameof(url)); + } + if (!Uri.TryCreate(url, UriKind.Absolute, out Uri helpUri) || helpUri.Scheme != Uri.UriSchemeHttps) + { + throw new ArgumentException("The Help resource URL must be an absolute HTTPS URL.", nameof(url)); + } + if (isInternetAvailable == null) throw new ArgumentNullException(nameof(isInternetAvailable)); + if (processLauncher == null) throw new ArgumentNullException(nameof(processLauncher)); + + if (!await isInternetAvailable()) + { + return OnlineHelpLaunchResult.NoInternet; + } + + processLauncher(new ProcessStartInfo + { + FileName = helpUri.AbsoluteUri, + UseShellExecute = true + }); + + return OnlineHelpLaunchResult.Opened; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/SummaryStatistic.cs b/src/RMC.BestFit.App/GUI/Support/SummaryStatistic.cs new file mode 100644 index 0000000..0736bb5 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/SummaryStatistic.cs @@ -0,0 +1,59 @@ +namespace RMC_BestFit +{ + /// + /// Represents a summary statistic with an optional secondary value and significance indicator. + /// + /// + /// This class encapsulates a named statistical measure with one or two numerical values and an optional + /// significance indicator. It is commonly used to display descriptive statistics such as mean, median, + /// standard deviation, or other statistical measures in the user interface. + /// + public class SummaryStatistic + { + /// + /// Gets or sets the name of the summary statistic. + /// + /// + /// Examples include "Mean", "Median", "Standard Deviation", "Minimum", "Maximum", etc. + /// + public string Name { get; set; } + + /// + /// Gets or sets the primary numerical value of the statistic. + /// + public double Value { get; set; } + + /// + /// Gets or sets an optional secondary numerical value for the statistic. + /// + /// + /// This property defaults to double.NaN when not specified. It can be used for statistics + /// that require two values, such as confidence intervals or paired measurements. + /// + public double Value2 { get; set; } + + /// + /// Gets or sets an optional significance indicator or additional context for the statistic. + /// + /// + /// This property can be used to indicate statistical significance, provide units, + /// or offer other contextual information about the statistic. + /// + public string Significance { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the summary statistic. + /// The primary numerical value of the statistic. + /// An optional secondary numerical value (defaults to double.NaN). + /// An optional significance indicator or context (defaults to empty string). + public SummaryStatistic(string name, double value, double value2 = double.NaN, string significance = "") + { + Name = name; + Value = value; + Value2 = value2; + Significance = significance; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/TermsAndConditionsDocumentFactory.cs b/src/RMC.BestFit.App/GUI/Support/TermsAndConditionsDocumentFactory.cs new file mode 100644 index 0000000..1df7cb4 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/TermsAndConditionsDocumentFactory.cs @@ -0,0 +1,101 @@ +using System; +using System.Windows; +using System.Windows.Documents; +using System.Windows.Navigation; + +namespace RMC_BestFit +{ + /// + /// Creates the RMC-BestFit license and citation document displayed by the Terms and Conditions window. + /// + /// + /// The document reproduces the repository's Zero-Clause BSD license verbatim and keeps the optional + /// citation guidance separate so citation cannot be interpreted as a condition of the license grant. + /// + internal static class TermsAndConditionsDocumentFactory + { + /// + /// User-visible label for the RMC-BestFit Zenodo concept DOI hyperlink. + /// + internal const string ZenodoLinkLabel = "https://doi.org/10.5281/zenodo.21301036"; + + /// + /// Creates the RMC-BestFit license and citation document. + /// + /// Handler invoked when the Zenodo concept DOI hyperlink is activated. + /// A formatted document containing the 0BSD license and optional citation guidance. + /// + /// Thrown when is null. + /// + /// + /// Browser navigation remains with the application controller so the document factory has no network, + /// process, or message-box side effects and can be tested deterministically. + /// + internal static FlowDocument Create(RequestNavigateEventHandler citationLinkHandler) + { + if (citationLinkHandler == null) throw new ArgumentNullException(nameof(citationLinkHandler)); + + var document = new FlowDocument(); + document.Blocks.Add(CreateHeading("RMC-BestFit License and Citation", 20d)); + document.Blocks.Add(CreateParagraph( + "RMC-BestFit is free and open-source software developed by the U.S. Army Corps of Engineers Risk Management Center and made available under the Zero-Clause BSD (0BSD) License.")); + + document.Blocks.Add(CreateHeading("Zero-Clause BSD License", 16d)); + document.Blocks.Add(CreateParagraph( + "Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.")); + document.Blocks.Add(CreateParagraph( + "THE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.")); + + document.Blocks.Add(CreateHeading("Citation", 16d)); + document.Blocks.Add(CreateParagraph( + "Citation is not required by the 0BSD License. If you use RMC-BestFit in research, engineering analysis, technical reports, or other published work, citation is appreciated. For reproducibility, please cite the specific version of RMC-BestFit that you used. Citation metadata and archived releases are available through Zenodo.")); + + var hyperlink = new Hyperlink(new Run(ZenodoLinkLabel)) + { + NavigateUri = new Uri(OnlineHelpLauncher.ZenodoConceptDoiUrl, UriKind.Absolute) + }; + hyperlink.RequestNavigate += citationLinkHandler; + document.Blocks.Add(new Paragraph(hyperlink) + { + Margin = new Thickness(0, 0, 0, 10) + }); + + return document; + } + + /// + /// Creates a formatted heading paragraph. + /// + /// Heading text. + /// Heading font size in device-independent units. + /// The formatted heading paragraph. + /// + /// A zero bottom margin keeps the heading visually attached to the body paragraph that follows it. + /// + private static Paragraph CreateHeading(string text, double fontSize) + { + return new Paragraph(new Run(text)) + { + FontSize = fontSize, + FontWeight = FontWeights.SemiBold, + Margin = new Thickness(0) + }; + } + + /// + /// Creates a formatted body paragraph. + /// + /// Paragraph text. + /// The formatted body paragraph. + /// + /// Consistent spacing separates the license, disclaimer, and citation sections without altering their text. + /// + private static Paragraph CreateParagraph(string text) + { + return new Paragraph(new Run(text)) + { + Margin = new Thickness(0, 0, 0, 10) + }; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/TimeSeriesResultTimeline.cs b/src/RMC.BestFit.App/GUI/Support/TimeSeriesResultTimeline.cs new file mode 100644 index 0000000..1dccbcf --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/TimeSeriesResultTimeline.cs @@ -0,0 +1,54 @@ +using Numerics.Data; +using System; +using System.Collections.Generic; + +namespace RMC_BestFit +{ + /// + /// Builds DateTime timelines for time-series analysis result curves. + /// + /// + /// Analysis result arrays store values by step index. The App layer owns converting those + /// steps back to display dates from the active response series and its time interval. + /// + public static class TimeSeriesResultTimeline + { + /// + /// Creates a sequence of display dates starting at the first observation in the source series. + /// + /// The time series whose start date and interval define the display timeline. + /// The number of dates to create. + /// A list containing dates. + /// Thrown when is null. + /// Thrown when is negative. + /// Thrown when dates are requested from an empty or irregular source series. + /// + /// The first result step aligns to the first observed timestamp. Subsequent steps advance by + /// , which also extends future forecast dates. + /// + public static List CreateDates(TimeSeries source, int count) + { + if (source == null) + throw new ArgumentNullException(nameof(source)); + if (count < 0) + throw new ArgumentOutOfRangeException(nameof(count), "The number of dates cannot be negative."); + if (source.TimeInterval == TimeInterval.Irregular) + throw new ArgumentException("Time series analysis results require a regular time interval.", nameof(source)); + if (count > 0 && source.Count == 0) + throw new ArgumentException("The source time series must contain at least one observation.", nameof(source)); + + var dates = new List(count); + if (count == 0) + return dates; + + DateTime date = source[0].Index; + for (int i = 0; i < count; i++) + { + dates.Add(date); + date = TimeSeries.AddTimeInterval(date, source.TimeInterval); + } + + return dates; + } + } +} diff --git a/src/RMC.BestFit.App/GUI/Support/WaitCursorHelper.cs b/src/RMC.BestFit.App/GUI/Support/WaitCursorHelper.cs new file mode 100644 index 0000000..6d35c07 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/Support/WaitCursorHelper.cs @@ -0,0 +1,93 @@ +using System; +using System.Threading.Tasks; +using System.Windows.Input; +using System.Windows.Threading; + +namespace RMC_BestFit +{ + /// + /// Provides helpers for showing a visible wait cursor around synchronous WPF work. + /// + /// + /// Some lazy plot updates run synchronously on the UI thread. Updating the cursor before + /// the work starts gives the operating system a chance to show the wait cursor even when + /// the mouse is stationary. + /// + internal static class WaitCursorHelper + { + /// + /// Runs an action with the WPF wait cursor visible for the duration of the work. + /// + /// The dispatcher that owns the UI work. + /// The synchronous action to run while the wait cursor is shown. + /// Thrown when or is null. + /// + /// The previous override cursor is restored at background priority so the render-priority + /// wait-cursor frame is not immediately cleared by fast operations. + /// + internal static void RunWithVisibleWaitCursor(Dispatcher dispatcher, Action action) + { + if (dispatcher == null) throw new ArgumentNullException(nameof(dispatcher)); + if (action == null) throw new ArgumentNullException(nameof(action)); + + Cursor previousCursor = Mouse.OverrideCursor; + Mouse.OverrideCursor = Cursors.Wait; + Mouse.UpdateCursor(); + + try + { + action(); + } + finally + { + _ = dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + { + if (Mouse.OverrideCursor == Cursors.Wait) + { + Mouse.OverrideCursor = previousCursor; + Mouse.UpdateCursor(); + } + })); + } + } + + /// + /// Runs asynchronous work with the WPF wait cursor visible before the work begins. + /// + /// The dispatcher that owns the UI work. + /// The asynchronous action to run while the wait cursor is shown. + /// A task that completes when the asynchronous action completes. + /// Thrown when or is null. + /// + /// Yielding once at background priority lets WPF process higher-priority input and + /// render work so the wait cursor and newly visible progress controls appear before + /// analysis startup code can occupy the UI thread. + /// + internal static async Task RunWithVisibleWaitCursorAsync(Dispatcher dispatcher, Func action) + { + if (dispatcher == null) throw new ArgumentNullException(nameof(dispatcher)); + if (action == null) throw new ArgumentNullException(nameof(action)); + + Cursor previousCursor = Mouse.OverrideCursor; + Mouse.OverrideCursor = Cursors.Wait; + Mouse.UpdateCursor(); + + try + { + await dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background); + await action(); + } + finally + { + _ = dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + { + if (Mouse.OverrideCursor == Cursors.Wait) + { + Mouse.OverrideCursor = previousCursor; + Mouse.UpdateCursor(); + } + })); + } + } + } +} diff --git a/src/RMC.BestFit.App/GUI/TimeSeriesAnalysis/TimeSeriesAnalysisControl.xaml b/src/RMC.BestFit.App/GUI/TimeSeriesAnalysis/TimeSeriesAnalysisControl.xaml new file mode 100644 index 0000000..4eb865e --- /dev/null +++ b/src/RMC.BestFit.App/GUI/TimeSeriesAnalysis/TimeSeriesAnalysisControl.xaml @@ -0,0 +1,263 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/TimeSeriesAnalysis/TimeSeriesAnalysisControl.xaml.cs b/src/RMC.BestFit.App/GUI/TimeSeriesAnalysis/TimeSeriesAnalysisControl.xaml.cs new file mode 100644 index 0000000..b55ab8b --- /dev/null +++ b/src/RMC.BestFit.App/GUI/TimeSeriesAnalysis/TimeSeriesAnalysisControl.xaml.cs @@ -0,0 +1,1494 @@ +using Numerics.Sampling; +using OxyPlot.Wpf; +using OxyPlot; +using OxyPlotControls; +using FrameworkInterfaces; +using RMC.BestFit.Models; +using ModelAnalyses = RMC.BestFit.Analyses; +using RMC.BestFit.Estimation; +using RMC.BestFit.UI; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; +using System.Xml.Linq; +using FrameworkUI; +using System.Windows.Media.Media3D; +using Numerics.Distributions; +using Numerics.Data.Statistics; +using Numerics; +using System.Windows.Data; +using Numerics.Data; + +namespace RMC_BestFit +{ + /// + /// User control for displaying and managing time series analysis results, including plots, residuals, + /// and statistical diagnostics for ARIMAX models with Bayesian estimation. + /// + public partial class TimeSeriesAnalysisControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public TimeSeriesAnalysisControl() + { + InitializeComponent(); + DataContext = this; + _colorHexCodes = GenericControls.GeneralMethods.RandomColorsLongList; + // DataGrid Binding.StringFormat must be set before first render. + SetColumnStringFormats(); + } + + /// + /// Dependency property for the Element property. This represents the TimeSeriesAnalysis model being displayed. + /// + public static DependencyProperty ElementProperty = DependencyProperty.Register(nameof(Element), typeof(TimeSeriesAnalysis), typeof(TimeSeriesAnalysisControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the TimeSeriesAnalysis element that contains the model and analysis results. + /// + public TimeSeriesAnalysis Element + { + get { return (TimeSeriesAnalysis)GetValue(ElementProperty); } + set { SetValue(ElementProperty, value); } + } + + /// + /// Callback method invoked when the Element dependency property changes. + /// Handles attaching and detaching event handlers for the new and old elements. + /// + /// The dependency object that owns the property. + /// Event arguments containing the old and new property values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as TimeSeriesAnalysisControl == null) return; + var thisControl = (TimeSeriesAnalysisControl)d; + + // Remove handlers + if (e.OldValue != null) + { + TimeSeriesAnalysis oldElement = e.OldValue as TimeSeriesAnalysis; + if (oldElement != null) + { + oldElement.PropertyChanged -= thisControl.Element_PropertyChanged; + + // Unsubscribe adaptive date-format handlers from the old element's plot models (TSA1). + if (oldElement.TimeSeriesPlot?.ActualModel != null) + oldElement.TimeSeriesPlot.ActualModel.Updated -= thisControl.TimeSeriesPlotModelUpdated; + if (oldElement.ResidualPlot?.ActualModel != null) + oldElement.ResidualPlot.ActualModel.Updated -= thisControl.ResidualPlotModelUpdated; + + thisControl.TimeSeriesPlotHost.Content = null; + thisControl.ResidualPlotHost.Content = null; + thisControl.ResidualHistogramPlotHost.Content = null; + thisControl.ResidualQQPlotHost.Content = null; + thisControl.ResidualACFPlotHost.Content = null; + thisControl.ResidualPACFPlotHost.Content = null; + thisControl.PlotToolbar.Plot = null; + thisControl.ResidualsPlotToolbar.Plot = null; + thisControl.HistogramPlotToolbar.Plot = null; + thisControl.QQPlotToolbar.Plot = null; + thisControl.ACFPlotToolbar.Plot = null; + thisControl.PACFPlotToolbar.Plot = null; + // NOTE: PropertiesCalled is wired in XAML for every toolbar no programmatic -= needed. + } + } + + if (e.NewValue == null) return; + var newElement = e.NewValue as TimeSeriesAnalysis; + if (newElement == null) return; + + // Reset _isLoaded so the next Loaded event triggers a full data refresh for + // the new element (TSA2). + thisControl._isLoaded = false; + + // Subscribe Element-scoped handler + newElement.PropertyChanged += thisControl.Element_PropertyChanged; + + // Attach plots, wire toolbars, and wire Bayesian sub-control plots inside a + // bridge-suspension block. PropertiesCalled / PlotPropertiesCalled are wired + // in XAML no programmatic += needed. + using (newElement.SuspendPlotBridges()) + { + thisControl.TimeSeriesPlotHost.Content = newElement.TimeSeriesPlot; + thisControl.PlotToolbar.Plot = newElement.TimeSeriesPlot; + thisControl.ResidualPlotHost.Content = newElement.ResidualPlot; + thisControl.ResidualsPlotToolbar.Plot = newElement.ResidualPlot; + thisControl.ResidualHistogramPlotHost.Content = newElement.ResidualHistogramPlot; + thisControl.HistogramPlotToolbar.Plot = newElement.ResidualHistogramPlot; + thisControl.ResidualQQPlotHost.Content = newElement.ResidualQQPlot; + thisControl.QQPlotToolbar.Plot = newElement.ResidualQQPlot; + thisControl.ResidualACFPlotHost.Content = newElement.ResidualACFPlot; + thisControl.ACFPlotToolbar.Plot = newElement.ResidualACFPlot; + thisControl.ResidualPACFPlotHost.Content = newElement.ResidualPACFPlot; + thisControl.PACFPlotToolbar.Plot = newElement.ResidualPACFPlot; + + thisControl.HistogramControl.SetPlot(newElement.BayesianPlots.HistogramPlot); + thisControl.KernelDensityControl.SetPlot(newElement.BayesianPlots.KernelDensityPlot); + thisControl.AutocorrelationControl.SetPlot(newElement.BayesianPlots.AutocorrelationPlot); + thisControl.MarkovChainTraceControl.SetPlot(newElement.BayesianPlots.MarkovChainTracePlot); + thisControl.MeanLikelihoodControl.SetPlot(newElement.BayesianPlots.MeanLikelihoodPlot); + thisControl.BivariateHeatMapControl.SetPlot(newElement.BayesianPlots.BivariateHeatMapPlot); + thisControl.InfluenceDiagnosticsControl.SetPlot(newElement.BayesianPlots.InfluenceDiagnosticsPlot); + + // Re-establish axis-title Bindings wiped by DeserializePlotSettings (must be + // inside suspension to prevent "Change Title" undo entries from axis PropertyChanged) + thisControl.BindAxisTitles(); + } + + // Subscribe adaptive date-format handlers to the new element's plot ActualModels (TSA1). + // These are Element-scoped (attached to plots owned by the element), so they are + // wired here rather than in Loaded/Unloaded. + if (newElement.TimeSeriesPlot?.ActualModel != null) + newElement.TimeSeriesPlot.ActualModel.Updated += thisControl.TimeSeriesPlotModelUpdated; + if (newElement.ResidualPlot?.ActualModel != null) + newElement.ResidualPlot.ActualModel.Updated += thisControl.ResidualPlotModelUpdated; + } + + /// + /// Binds axis titles on Element-owned plots to the TimeSeriesData.UnitLabel property. + /// Called after plot attachment and when TimeSeriesData changes. + /// + /// + /// The time-series Y-axis reflects the unit of the series; the X-axis is a DateTimeAxis + /// and remains titled by the factory default. The residual-vs-fitted plot X-axis also + /// follows the modeled series unit label, while residual diagnostic plots retain their + /// factory-default residual, density, quantile, ACF, and PACF titles. + /// + private void BindAxisTitles() + { + if (Element == null) return; + + var yAxis = Element.TimeSeriesPlot?.Axes.FirstOrDefault(a => a.Key == "Yaxis"); + if (yAxis != null) + { + if (Element.TimeSeriesData != null) + PlotAxisTitleDefaults.BindTitleIfDefault(yAxis, Element.TimeSeriesData, nameof(TimeSeriesElement.UnitLabel), Element.TimeSeriesData.UnitLabel); + else + PlotAxisTitleDefaults.SetTitleIfDefault(yAxis, string.Empty); + } + + var residualYAxis = ResidualPlot?.Axes.FirstOrDefault(a => a.Key == "Yaxis"); + if (residualYAxis != null) + PlotAxisTitleDefaults.SetTitleIfDefault(residualYAxis, "Residual"); + + var residualXAxis = ResidualPlot?.Axes.FirstOrDefault(a => a.Key == "Xaxis"); + if (residualXAxis != null) + { + if (Element.TimeSeriesData != null) + PlotAxisTitleDefaults.BindTitleIfDefault(residualXAxis, Element.TimeSeriesData, nameof(TimeSeriesElement.UnitLabel), Element.TimeSeriesData.UnitLabel); + else + PlotAxisTitleDefaults.SetTitleIfDefault(residualXAxis, string.Empty); + } + } + + /// Convenience accessor for the Element-owned time series plot. + private Plot TimeSeriesPlot => Element?.TimeSeriesPlot; + /// Convenience accessor for the Element-owned residual plot. + private Plot ResidualPlot => Element?.ResidualPlot; + /// Convenience accessor for the Element-owned residual histogram plot. + private Plot ResidualHistogramPlot => Element?.ResidualHistogramPlot; + /// Convenience accessor for the Element-owned residual QQ plot. + private Plot ResidualQQPlot => Element?.ResidualQQPlot; + /// Convenience accessor for the Element-owned residual ACF plot. + private Plot ResidualACFPlot => Element?.ResidualACFPlot; + /// Convenience accessor for the Element-owned residual PACF plot. + private Plot ResidualPACFPlot => Element?.ResidualPACFPlot; + + /// + /// Gets a value indicating whether a plot has been clicked by the user. + /// + public bool PlotClicked { get; private set; } = false; + + /// + /// Event raised when plot properties are requested to be displayed or modified. + /// + public event PlotPropertiesCalledEventHandler PlotPropertiesCalled; + + /// + /// Delegate for the PlotPropertiesCalled event. + /// + /// The plot whose properties are being called. + /// Indicates whether to open the properties panel. + /// The property expander control. + /// The selected object in the plot. + public delegate void PlotPropertiesCalledEventHandler(Plot plot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject); + + /// + /// Event raised when the preview control is clicked. + /// + public event PreviewControlClickedEventHandler PreviewControlClicked; + + /// + /// Delegate for the PreviewControlClicked event. + /// + /// Indicates whether the plot area was clicked. + /// Indicates whether the toolbar was clicked. + /// The plot control that was involved in the click. + public delegate void PreviewControlClickedEventHandler(bool plotClicked, bool toolbarClicked, Plot plot); + + /// + /// Indicates whether the control has completed its initial load operations. + /// Set to false by ElementCallback when a new element is assigned so the next + /// Loaded event re-runs one-time setup steps (TSA2). + /// + private bool _isLoaded = false; + + /// + /// Array of residuals from the ARIMAX model fit. + /// + private double[] _residuals = null; + + /// + /// Array of color hex codes used for distinguishing multiple series in plots. + /// + private string[] _colorHexCodes; + + #region Plot Series + + #endregion + + /// + /// Handles the Loaded event of the UserControl. Initializes plots, data grids, and settings when the control is first loaded. + /// + /// The source of the event. + /// The event data. + private void UserControl_Loaded(object sender, RoutedEventArgs e) + { + if (Element == null) return; + + // Data refresh on every load. Plot hosts, toolbars, and Element.PropertyChanged + // are wired once per Element in ElementCallback. + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try + { + using (Element.SuspendPlotBridges()) + { + BindAxisTitles(); + } + UpdatePlots(); + BindTimeSeriesDataGrid(); + if (!_isLoaded) SetTableColumnHeaders(); + BindSummaryStatisticsDataGrid(); + } + finally + { + Element.IsUndoEnabled = wasUndoEnabled; + } + _isLoaded = true; + } + + /// + /// Handles the Unloaded event of the user control. Resets so the + /// next Loaded event triggers a full data refresh (TSA2). + /// + /// The source of the event. + /// The event data. + private void UserControl_Unloaded(object sender, RoutedEventArgs e) + { + // Reset _isLoaded so the next Loaded event re-runs data-refresh steps. + // Element-scoped lifecycle (PropertyChanged, plot hosts, toolbars) is owned by + // ElementCallback and survives unload/reload cycles no additional teardown needed. + _isLoaded = false; + } + + /// + /// Handles property changes on the Element object and updates the appropriate UI components. + /// + /// The source of the event. + /// The event data containing the name of the property that changed. + private void Element_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + // Marshal to UI thread if called from a background thread. + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke(new Action(() => Element_PropertyChanged(sender, e))); + return; + } + + if (!ReferenceEquals(sender, Element)) return; + + if (e.PropertyName == nameof(Element.TimeSeriesData)) + { + using (Element.SuspendPlotBridges()) + { + BindAxisTitles(); + } + } + if (e.PropertyName == nameof(Element.AnalysisResults)) + { + UpdatePlots(); + BindTimeSeriesDataGrid(); + BindSummaryStatisticsDataGrid(); + ResetWaitCursor(); + } + if (e.PropertyName == nameof(Element.BayesianAnalysis.CredibleIntervalWidth)) + { + SetTableColumnHeaders(); + } + // TrainingTimeSteps / ForecastingTimeSteps changes are owned by the analysis + // layer (Phase 6 whitelist + Phase 3b reprocess pattern). The AnalysisResults + // PropertyChanged branch above refreshes plots and tables when the rebuild + // completes no separate App-side trigger needed. Note: TrainingTimeSteps + // changes the fit and follows the ClearResults path (no wait cursor); only + // ForecastSteps reprocesses. + if ((e.PropertyName == nameof(Element.BayesianAnalysis.PointEstimator) + || e.PropertyName == nameof(Element.BayesianAnalysis.CredibleIntervalWidth) + || e.PropertyName == nameof(Element.ForecastSteps) + || e.PropertyName == nameof(Element.CovariateExtension)) + && Element.IsEstimated) + { + // Cursor wait while the model layer's ReprocessIfEstimated runs the recompute + // on TaskScheduler.Default. Reset is wired to the AnalysisResults notification above. + Mouse.OverrideCursor = Cursors.Wait; + } + } + + /// + /// Clears when it is currently set to . + /// Marshals onto the UI thread when called from a background-thread PropertyChanged + /// notification (the model layer's ReprocessIfEstimated path uses + /// TaskScheduler.Default). + /// + private void ResetWaitCursor() + { + // Reset at Background priority so the Render-priority cursor frame from the + // earlier `Mouse.OverrideCursor = Cursors.Wait` is guaranteed to flush before + // the reset runs. Without this, fast reprocesses (UpdatePointEstimateResultsAsync) + // can reset the cursor before the OS visually picks up the change the user + // sees no wait cursor at all when the mouse is stationary. + Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + { + if (Mouse.OverrideCursor == Cursors.Wait) + Mouse.OverrideCursor = null; + })); + } + + #region Plots + + /// + /// Handles the LostFocus event of the UserControl. Resets the PlotClicked property. + /// + /// The source of the event. + /// The event data. + private void UserControl_LostFocus(object sender, RoutedEventArgs e) + { + PlotClicked = false; + } + + /// + /// Handles the PreviewMouseDown event to detect clicks on plots and toolbars. + /// + /// The source of the event. + /// The event data. + private void UserControl_PreviewMouseDown(object sender, MouseButtonEventArgs e) + { + Plot plot = GetCurrentPlot(); + OxyPlotToolbar toolbar = GetCurrentPlotToolbar(); + System.Windows.Media.HitTestResult plotHitResult = null; + System.Windows.Media.HitTestResult toolbarHitResult = null; + if (plot != null) + { + plotHitResult = VisualTreeHelper.HitTest(plot, e.GetPosition(plot)); + } + if (toolbar != null) + { + toolbarHitResult = VisualTreeHelper.HitTest(toolbar, e.GetPosition(toolbar)); + } + PreviewControlClicked?.Invoke(plotHitResult != null, toolbarHitResult != null, plot); + PlotClicked = plotHitResult != null; + } + + /// + /// Handles the PropertiesCalled event from the plot toolbar and forwards it to the control's event handler. + /// + /// The plot whose properties are being requested. + /// Indicates whether to open the properties panel. + /// The property expander control. + /// The selected object in the plot. + private void PlotToolbar_PropertiesCalled(Plot targetPlot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject) + { + PlotPropertiesCalled?.Invoke(targetPlot, openProperties, propertyExpander, selectedObject); + } + + /// + /// Gets the currently selected plot based on which tab is active. + /// + /// The currently selected Plot control, or null if no plot tab is selected. + public Plot GetCurrentPlot() + { + if (PlotTabItem.IsSelected == true) + { + return TimeSeriesPlot; + } + else if (ResidualsPlotTab.IsSelected == true) + { + return ResidualPlot; + } + else if (ResidualHistogramTab.IsSelected == true) + { + return ResidualHistogramPlot; + } + else if (ResidualQQTab.IsSelected == true) + { + return ResidualQQPlot; + } + else if (ResidualACFTab.IsSelected == true) + { + return ResidualACFPlot; + } + else if (ResidualPACFTab.IsSelected == true) + { + return ResidualPACFPlot; + } + else if (KernelDensityTabItem.IsSelected == true) + { + return KernelDensityControl.Plot; + } + else if (HistogramTabItem.IsSelected == true) + { + return HistogramControl.Plot; + } + else if (BivariateTabItem.IsSelected == true) + { + return BivariateHeatMapControl.Plot; + } + else if (MeanLikelihoodTabItem.IsSelected == true) + { + return MeanLikelihoodControl.Plot; + } + else if (AutocorrelationTabItem.IsSelected == true) + { + return AutocorrelationControl.Plot; + } + else if (MarkovChainTabItem.IsSelected == true) + { + return MarkovChainTraceControl.Plot; + } + else if (InfluenceDiagnosticsTabItem.IsSelected == true) + { + return InfluenceDiagnosticsControl.Plot; + } + return null; + } + + /// + /// Gets the toolbar for the currently selected plot based on which tab is active. + /// + /// The toolbar for the currently selected plot, or null if no plot tab is selected. + public OxyPlotToolbar GetCurrentPlotToolbar() + { + if (PlotTabItem.IsSelected == true) + { + return PlotToolbar; + } + else if (ResidualsPlotTab.IsSelected == true) + { + return ResidualsPlotToolbar; + } + else if (ResidualHistogramTab.IsSelected == true) + { + return HistogramPlotToolbar; + } + else if (ResidualQQTab.IsSelected == true) + { + return QQPlotToolbar; + } + else if (ResidualACFTab.IsSelected == true) + { + return ACFPlotToolbar; + } + else if (ResidualPACFTab.IsSelected == true) + { + return PACFPlotToolbar; + } + else if (KernelDensityTabItem.IsSelected == true) + { + return KernelDensityControl.PlotToolbar; + } + else if (HistogramTabItem.IsSelected == true) + { + return HistogramControl.PlotToolbar; + } + else if (BivariateTabItem.IsSelected == true) + { + return BivariateHeatMapControl.PlotToolbar; + } + else if (MeanLikelihoodTabItem.IsSelected == true) + { + return MeanLikelihoodControl.PlotToolbar; + } + else if (AutocorrelationTabItem.IsSelected == true) + { + return AutocorrelationControl.PlotToolbar; + } + else if (MarkovChainTabItem.IsSelected == true) + { + return MarkovChainTraceControl.PlotToolbar; + } + else if (InfluenceDiagnosticsTabItem.IsSelected == true) + { + return InfluenceDiagnosticsControl.PlotToolbar; + } + return null; + } + + /// + /// Handles the Updated event of the time series plot model. Adjusts the date/time axis format based on the date range displayed. + /// + /// The source of the event. + /// The event data. + private void TimeSeriesPlotModelUpdated(object sender, EventArgs e) + { + if (!ReferenceEquals(sender, Element?.TimeSeriesPlot?.ActualModel)) return; + + foreach (var axis in TimeSeriesPlot.Axes) + { + if (axis.Key == "Xaxis" && axis as OxyPlot.Wpf.DateTimeAxis != null) + { + var dateTimeAxis = (OxyPlot.Axes.DateTimeAxis)axis.InternalAxis; + double min = dateTimeAxis.ActualMinimum; + double max = dateTimeAxis.ActualMaximum; + var dateSpan = dateTimeAxis.ConvertToDateTime(max).Subtract(dateTimeAxis.ConvertToDateTime(min)); + if (dateSpan.TotalDays > 365 * 4) + { + dateTimeAxis.StringFormat = "yyyy"; + } + else if (dateSpan.TotalDays > 365) + { + dateTimeAxis.StringFormat = "MMM-yyyy"; + } + else if (dateSpan.TotalDays > 90) + { + dateTimeAxis.StringFormat = "MMM-yyyy"; + } + else if (dateSpan.TotalDays > 2) + { + dateTimeAxis.StringFormat = "dd-MMM"; + } + else + { + dateTimeAxis.StringFormat = "HH:mm"; + } + + break; + } + } + } + + /// + /// Handles the Updated event of the residual plot model. Adjusts the date/time axis format based on the date range displayed. + /// + /// The source of the event. + /// The event data. + private void ResidualPlotModelUpdated(object sender, EventArgs e) + { + if (!ReferenceEquals(sender, Element?.ResidualPlot?.ActualModel)) return; + + foreach (var axis in ResidualPlot.Axes) + { + if (axis.Key == "Xaxis" && axis as OxyPlot.Wpf.DateTimeAxis != null) + { + var dateTimeAxis = (OxyPlot.Axes.DateTimeAxis)axis.InternalAxis; + double min = dateTimeAxis.ActualMinimum; + double max = dateTimeAxis.ActualMaximum; + var dateSpan = dateTimeAxis.ConvertToDateTime(max).Subtract(dateTimeAxis.ConvertToDateTime(min)); + if (dateSpan.TotalDays > 365 * 4) + { + dateTimeAxis.StringFormat = "yyyy"; + } + else if (dateSpan.TotalDays > 365) + { + dateTimeAxis.StringFormat = "MMM-yyyy"; + } + else if (dateSpan.TotalDays > 90) + { + dateTimeAxis.StringFormat = "MMM-yyyy"; + } + else if (dateSpan.TotalDays > 2) + { + dateTimeAxis.StringFormat = "dd-MMM"; + } + else + { + dateTimeAxis.StringFormat = "HH:mm"; + } + + break; + } + } + } + + /// + /// Updates all diagnostic plots with the latest analysis results. + /// + private void UpdatePlots() + { + UpdateTimeSeriesPlot(); + GetResiduals(); + UpdateResidualsPlot(); + UpdateHistogramPlot(); + UpdateQQPlot(); + UpdateACFPlot(); + UpdatePACFPlot(); + } + + /// + /// Updates the time series plot with the observed data, fitted curves, and credible intervals. + /// + /// + /// All series and annotation mutations live inside a single SuspendPlotBridges + /// block so plot-undo bridges do not record programmatic changes. InvalidatePlot(true) + /// fires once as the last statement inside the block, and RebuildSeriesAndAnnotationBridges + /// is called after the block closes to reattach bridges to the new series/annotations. + /// + private void UpdateTimeSeriesPlot() + { + if (TimeSeriesPlot == null) return; + + // Look up named series/annotations before Clear(). If found (from deserialization + // or a prior update), reuse so user-customized styling survives Save/Open. Otherwise + // create with defaults. Matches canonical TimeSeriesControl pattern. + var timeSeriesLine = TimeSeriesPlot.Series.OfType().FirstOrDefault(s => s.Name == "TimeSeries") + ?? new LineSeries + { + Name = "TimeSeries", + Title = "Time Series", + Color = Colors.Red, + MarkerFill = Colors.Transparent, + StrokeThickness = 2, + LineStyle = OxyPlot.LineStyle.Dot, + }; + var trainingCredibleIntervals = TimeSeriesPlot.Series.OfType().FirstOrDefault(s => s.Name == "TrainingCredibleIntervals") + ?? new AreaSeries + { + Name = "TrainingCredibleIntervals", + Fill = Color.FromArgb(75, 104, 140, 175), + Color = Color.FromArgb(255, 53, 59, 122), + LineStyle = LineStyle.Solid, + BrokenLineThickness = 1, + StrokeThickness = 1, + Decimator = OxyPlot.Decimator.Decimate, + }; + var predictionCredibleIntervals = TimeSeriesPlot.Series.OfType().FirstOrDefault(s => s.Name == "PredictionCredibleIntervals") + ?? new AreaSeries + { + Name = "PredictionCredibleIntervals", + Fill = Color.FromArgb(75, 220, 20, 60), + Color = Color.FromArgb(255, 255, 0, 0), + LineStyle = LineStyle.Solid, + BrokenLineThickness = 1, + StrokeThickness = 1, + Decimator = OxyPlot.Decimator.Decimate, + }; + var posteriorPredictive = TimeSeriesPlot.Series.OfType().FirstOrDefault(s => s.Name == "PosteriorPredictive") + ?? new LineSeries + { + Name = "PosteriorPredictive", + Title = "Posterior Predictive", + Color = Colors.Blue, + StrokeThickness = 2, + LineStyle = LineStyle.Dash, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0, + }; + var posteriorMode = TimeSeriesPlot.Series.OfType().FirstOrDefault(s => s.Name == "PosteriorMode") + ?? new LineSeries + { + Name = "PosteriorMode", + Title = "Posterior Mode", + Color = Colors.Black, + StrokeThickness = 2, + LineStyle = LineStyle.Solid, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0, + }; + string timeSeriesTracker = "{0}" + Environment.NewLine + + "{1}: {2}" + Environment.NewLine + + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + var trainingLine = TimeSeriesPlot.Annotations.OfType().FirstOrDefault(a => a.Name == "TrainingLine") + ?? new LineAnnotation + { + Name = "TrainingLine", + Text = "End of Training Period", + Type = OxyPlot.Annotations.LineAnnotationType.Vertical, + Color = Colors.Black, + LineStyle = LineStyle.Dash, + StrokeThickness = 3, + TextLinePosition = 0.3, + TextHorizontalAlignment = System.Windows.HorizontalAlignment.Right, + TextVerticalAlignment = System.Windows.VerticalAlignment.Bottom, + }; + + using (Element?.SuspendPlotBridges()) + { + TimeSeriesPlot.Series.Clear(); + for (int i = TimeSeriesPlot.Annotations.Count - 1; i >= 0; i--) + { + if (TimeSeriesPlot.Annotations[i].Name == "TrainingLine") + { + TimeSeriesPlot.Annotations.RemoveAt(i); + break; + } + } + + if (Element != null && Element.TimeSeriesData != null && Element.TimeSeriesData.TimeSeries != null + && Element.ARIMAX != null && Element.ARIMAX.TimeSeries != null && Element.ARIMAX.TimeSeries.Count > 0) + { + // Add Curves + if (Element.BayesianAnalysis != null && Element.BayesianAnalysis.IsEstimated == true && Element.AnalysisResults != null) + { + var ciPoints = new List(); + var prdPoints = new List(); + var mdPoints = new List(); + + // first get mode curve + var modeDates = TimeSeriesResultTimeline.CreateDates(Element.ARIMAX.TimeSeries, Element.AnalysisResults.ModeCurve.Length); + for (int i = 0; i < Element.AnalysisResults.ModeCurve.Length; i++) + { + var index = modeDates[i].ToOADate(); + var md = Element.AnalysisResults.ModeCurve[i]; + mdPoints.Add(new OxyPlot.DataPoint(index, md)); + } + + // next get the full prediction + var predictionDates = TimeSeriesResultTimeline.CreateDates(Element.ARIMAX.TimeSeries, Element.AnalysisResults.MeanCurve.Length); + for (int i = 0; i < Element.AnalysisResults.MeanCurve.Length; i++) + { + var index = predictionDates[i].ToOADate(); + var lo = Element.AnalysisResults.ConfidenceIntervals[i, 1]; + var up = Element.AnalysisResults.ConfidenceIntervals[i, 2]; + var prd = Element.AnalysisResults.MeanCurve[i]; + ciPoints.Add(new Point3D(index, lo, up)); + prdPoints.Add(new OxyPlot.DataPoint(index, prd)); + } + + // Split CI points at the training boundary so the training fit and the + // validation+forecast prediction render in distinct colors. Include the + // boundary index in BOTH sublists so the two AreaSeries touch without a seam. + int splitIdx = Math.Max(0, Math.Min(ciPoints.Count - 1, Element.ARIMAX.TrainingTimeSteps - 1)); + var trainingCi = ciPoints.GetRange(0, splitIdx + 1); + var predictionCi = splitIdx < ciPoints.Count - 1 + ? ciPoints.GetRange(splitIdx, ciPoints.Count - splitIdx) + : null; + + var ciPct = (Element.BayesianAnalysis.CredibleIntervalWidth * 100).ToString("F0"); + + // Training Credible Intervals (light blue) + trainingCredibleIntervals.ItemsSource = trainingCi; + trainingCredibleIntervals.DataFieldX = "X"; + trainingCredibleIntervals.DataFieldY = "Y"; + trainingCredibleIntervals.DataFieldX2 = "X"; + trainingCredibleIntervals.DataFieldY2 = "Z"; + trainingCredibleIntervals.Title = ciPct + "% Credible Intervals Training"; + trainingCredibleIntervals.TrackerFormatString = timeSeriesTracker; + TimeSeriesPlot.Series.Add(trainingCredibleIntervals); + + // Prediction Credible Intervals (light red) only if there's a non-trivial + // validation + forecast window beyond the training cutoff. + if (predictionCi != null) + { + predictionCredibleIntervals.ItemsSource = predictionCi; + predictionCredibleIntervals.DataFieldX = "X"; + predictionCredibleIntervals.DataFieldY = "Y"; + predictionCredibleIntervals.DataFieldX2 = "X"; + predictionCredibleIntervals.DataFieldY2 = "Z"; + predictionCredibleIntervals.Title = ciPct + "% Credible Intervals Prediction"; + predictionCredibleIntervals.TrackerFormatString = timeSeriesTracker; + TimeSeriesPlot.Series.Add(predictionCredibleIntervals); + } + + // Predictive + posteriorPredictive.ItemsSource = prdPoints; + posteriorPredictive.TrackerFormatString = timeSeriesTracker; + TimeSeriesPlot.Series.Add(posteriorPredictive); + + // Point Estimator + posteriorMode.Title = Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"; + posteriorMode.ItemsSource = mdPoints; + posteriorMode.TrackerFormatString = timeSeriesTracker; + TimeSeriesPlot.Series.Add(posteriorMode); + } + + timeSeriesLine.ItemsSource = Element.TimeSeriesData.TimeSeries; + timeSeriesLine.Mapping = item => { var o = (Numerics.Data.SeriesOrdinate)item; return new OxyPlot.DataPoint(OxyPlot.Axes.DateTimeAxis.ToDouble(o.Index), o.Value); }; + timeSeriesLine.TrackerFormatString = timeSeriesTracker; + TimeSeriesPlot.Series.Add(timeSeriesLine); + + int trainIdx = Element.ARIMAX.TrainingTimeSteps - 1; + if (trainIdx >= 0 && trainIdx < Element.ARIMAX.TimeSeries.Count) + { + trainingLine.X = Element.ARIMAX.TimeSeries[trainIdx].Index.ToOADate(); + TimeSeriesPlot.Annotations.Add(trainingLine); + } + } + + TimeSeriesPlot.InvalidatePlot(true); + } + Element?.RebuildSeriesAndAnnotationBridges(TimeSeriesPlot); + } + + /// + /// Handles the AnalysisAdded event from the alternative selector. Adds the alternative analysis curves to the time series plot. + /// + /// The analysis alternative item that was added. + private void AlternativeSelector_AnalysisAdded(AnalysisAlternativeItem analysisItem) + { + // Remove series + TimeSeriesPlot.Series.Remove(analysisItem.CredibleIntervals); + TimeSeriesPlot.Series.Remove(analysisItem.PosteriorPredictive); + TimeSeriesPlot.Series.Remove(analysisItem.PosteriorMode); + + var alternative = (TimeSeriesAnalysis)analysisItem.Alternative; + if (alternative.BayesianAnalysis.IsEstimated == true && alternative.AnalysisResults != null) + { + // Get unique color + var index = TimeSeriesPlot.Series.Count; + Color lineColor = (Color)ColorConverter.ConvertFromString(_colorHexCodes[index]); + Color fillColor = Color.FromArgb(100, lineColor.R, lineColor.G, lineColor.B); + + for (int i = 4; i < _colorHexCodes.Length; i++) + { + var templineColor = (Color)ColorConverter.ConvertFromString(_colorHexCodes[i]); + var tempfillColor = Color.FromArgb(100, templineColor.R, templineColor.G, templineColor.B); + bool colorExists = false; + for (int j = 0; j < TimeSeriesPlot.Series.Count; j++) + { + if (TimeSeriesPlot.Series[j].Name.Contains("Mode") && TimeSeriesPlot.Series[j].Color == templineColor) + { + colorExists = true; + break; + } + } + if (colorExists == false || i == _colorHexCodes.Length - 1) + { + lineColor = templineColor; + fillColor = tempfillColor; + break; + } + } + + var ciPoints = new List(); + var prdPoints = new List(); + var mdPoints = new List(); + string timeSeriesTracker = "{0}" + Environment.NewLine + + "{1}: {2}" + Environment.NewLine + + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + + // first get mode curve + var modeDates = TimeSeriesResultTimeline.CreateDates(alternative.ARIMAX.TimeSeries, alternative.AnalysisResults.ModeCurve.Length); + for (int i = 0; i < alternative.AnalysisResults.ModeCurve.Length; i++) + { + var idx = modeDates[i].ToOADate(); + var md = alternative.AnalysisResults.ModeCurve[i]; + mdPoints.Add(new OxyPlot.DataPoint(idx, md)); + } + + // next get full prediction + var predictionDates = TimeSeriesResultTimeline.CreateDates(alternative.ARIMAX.TimeSeries, alternative.AnalysisResults.MeanCurve.Length); + for (int i = 0; i < alternative.AnalysisResults.MeanCurve.Length; i++) + { + var idx = predictionDates[i].ToOADate(); + var lo = alternative.AnalysisResults.ConfidenceIntervals[i, 1]; + var up = alternative.AnalysisResults.ConfidenceIntervals[i, 2]; + var prd = alternative.AnalysisResults.MeanCurve[i]; + ciPoints.Add(new Point3D(idx, lo, up)); + prdPoints.Add(new OxyPlot.DataPoint(idx, prd)); + } + + // Credible Intervals + analysisItem.CredibleIntervals.Name = "CredibleIntervals_" + index; + analysisItem.CredibleIntervals.Title = alternative.Name + " - " + (alternative.BayesianAnalysis.CredibleIntervalWidth * 100).ToString("F0") + "% Credible Intervals"; + analysisItem.CredibleIntervals.ItemsSource = ciPoints; + analysisItem.CredibleIntervals.DataFieldX = "X"; + analysisItem.CredibleIntervals.DataFieldY = "Y"; + analysisItem.CredibleIntervals.DataFieldX2 = "X"; + analysisItem.CredibleIntervals.DataFieldY2 = "Z"; + analysisItem.CredibleIntervals.Fill = fillColor; + analysisItem.CredibleIntervals.Color = Colors.Transparent; + analysisItem.CredibleIntervals.TrackerFormatString = timeSeriesTracker; + TimeSeriesPlot.Series.Add(analysisItem.CredibleIntervals); + + // Predictive + analysisItem.PosteriorPredictive.Name = "PosteriorPredictive_" + index; + analysisItem.PosteriorPredictive.Title = alternative.Name + " - Posterior Predictive"; + analysisItem.PosteriorPredictive.ItemsSource = prdPoints; + analysisItem.PosteriorPredictive.Color = lineColor; + analysisItem.PosteriorPredictive.TrackerFormatString = timeSeriesTracker; + TimeSeriesPlot.Series.Add(analysisItem.PosteriorPredictive); + + // Point Estimator + analysisItem.PosteriorMode.Name = "PosteriorMode_" + index; + analysisItem.PosteriorMode.Title = alternative.Name + " - " + (alternative.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"); + analysisItem.PosteriorMode.ItemsSource = mdPoints; + analysisItem.PosteriorMode.Color = lineColor; + analysisItem.PosteriorMode.TrackerFormatString = timeSeriesTracker; + TimeSeriesPlot.Series.Add(analysisItem.PosteriorMode); + + TimeSeriesPlot.InvalidatePlot(true); + + } + + } + + /// + /// Handles the AnalysisRemoved event from the alternative selector. Removes the alternative analysis curves from the time series plot. + /// + /// The analysis alternative item that was removed. + private void AlternativeSelector_AnalysisRemoved(AnalysisAlternativeItem analysisItem) + { + TimeSeriesPlot.Series.Remove(analysisItem.CredibleIntervals); + TimeSeriesPlot.Series.Remove(analysisItem.PosteriorPredictive); + TimeSeriesPlot.Series.Remove(analysisItem.PosteriorMode); + TimeSeriesPlot.InvalidatePlot(true); + } + + /// + /// Handles the MouseLeftButtonUp event on the text block. Toggles the filter visibility. + /// + /// The source of the event. + /// The event data. + private void TextBlock_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + ShowFilterToggleButton.IsChecked = !ShowFilterToggleButton.IsChecked; + } + + /// + /// Calculates and stores the residuals from the fitted ARIMAX model using the current parameter values. + /// + private void GetResiduals() + { + _residuals = null; + if (Element == null || Element.BayesianAnalysis == null || Element.IsValid == false || Element.BayesianAnalysis.IsEstimated == false) return; + _residuals = Element.ARIMAX.Residuals(Element.ARIMAX.Parameters.Select(x => x.Value).ToArray()); + } + + /// + /// Updates the residuals plot with the current residuals from the model fit. + /// + private void UpdateResidualsPlot() + { + if (ResidualPlot == null) return; + + var residualsSeries = ResidualPlot.Series.OfType().FirstOrDefault(s => s.Name == "Residuals") + ?? new ScatterPointSeries + { + Name = "Residuals", + Title = "Residuals", + MarkerFill = Colors.Black, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle, + }; + var zeroLine = ResidualPlot.Annotations.OfType().FirstOrDefault(a => a.Name == "ZeroLine") + ?? new LineAnnotation + { + Name = "ZeroLine", + Text = "", + Type = OxyPlot.Annotations.LineAnnotationType.LinearEquation, + Color = Colors.Black, + LineStyle = LineStyle.Dash, + StrokeThickness = 2, + TextLinePosition = 0.9, + Intercept = 0, + Slope = 0, + TextHorizontalAlignment = System.Windows.HorizontalAlignment.Right, + TextVerticalAlignment = System.Windows.VerticalAlignment.Top, + }; + + using (Element?.SuspendPlotBridges()) + { + ResidualPlot.Series.Clear(); + for (int i = ResidualPlot.Annotations.Count - 1; i >= 0; i--) + { + if (ResidualPlot.Annotations[i].Name == "ZeroLine") + { + ResidualPlot.Annotations.RemoveAt(i); + break; + } + } + + if (Element != null && Element.BayesianAnalysis != null && Element.IsValid == true && Element.BayesianAnalysis.IsEstimated == true) + { + var points = new List(); + for (int i = 0; i < Element.ARIMAX.TrainingTimeSteps; i++) + { + points.Add(new DataPoint(Element.ARIMAX.TrainingTimeSeries[i].Index.ToOADate(), _residuals[i])); + } + + residualsSeries.ItemsSource = points; + residualsSeries.Mapping = item => { var d = (DataPoint)item; return new OxyPlot.Series.ScatterPoint(d.X, d.Y); }; + residualsSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + ResidualPlot.Series.Add(residualsSeries); + ResidualPlot.Annotations.Add(zeroLine); + } + + ResidualPlot.InvalidatePlot(true); + } + Element?.RebuildSeriesAndAnnotationBridges(ResidualPlot); + } + + /// + /// Updates the histogram plot showing the distribution of residuals with an overlaid normal density curve. + /// + private void UpdateHistogramPlot() + { + if (ResidualHistogramPlot == null) return; + + var histogramSeries = ResidualHistogramPlot.Series.OfType().FirstOrDefault(s => s.Name == "Residuals") + ?? new HistogramSeries + { + Name = "Residuals", + Title = "Residuals", + FillColor = Color.FromArgb(125, 104, 140, 175), + StrokeColor = Color.FromArgb(255, 53, 59, 122), + StrokeThickness = 1, + }; + var normalDensitySeries = ResidualHistogramPlot.Series.OfType().FirstOrDefault(s => s.Name == "NormalDensity") + ?? new LineSeries + { + Name = "NormalDensity", + Title = "Normal Density", + Color = Colors.Black, + StrokeThickness = 1, + LineStyle = LineStyle.Solid, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0, + }; + + using (Element?.SuspendPlotBridges()) + { + ResidualHistogramPlot.Series.Clear(); + + if (Element != null && Element.BayesianAnalysis != null && Element.IsValid == true && Element.BayesianAnalysis.IsEstimated == true) + { + var values = _residuals; + var histogram = new Histogram(values); + var histItems = new List(); + double sum = 0; + for (int i = 0; i < histogram.NumberOfBins; i++) + sum += histogram[i].Frequency * histogram.BinWidth; + for (int i = 0; i < histogram.NumberOfBins; i++) + histItems.Add(new OxyPlot.Series.HistogramItem(histogram[i].LowerBound, histogram[i].UpperBound, histogram[i].Frequency * histogram.BinWidth / sum)); + + histogramSeries.ItemsSource = histItems; + histogramSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:" + UserSettings.ValueStringFormat + "}" + Environment.NewLine + "{3}: {4:0.000000000}"; + ResidualHistogramPlot.Series.Add(histogramSeries); + + var normal = new Normal(0, Element.ARIMAX.Parameters.Last().Value); + var normalPdf = normal.CreatePDFGraph(); + var normalPdfPoints = new List(); + for (int i = 0; i < normalPdf.GetLength(0); i++) + normalPdfPoints.Add(new DataPoint(normalPdf[i, 0], normalPdf[i, 1])); + + normalDensitySeries.ItemsSource = normalPdfPoints; + normalDensitySeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.0000}" + Environment.NewLine + "{3}: {4:0.000000}"; + ResidualHistogramPlot.Series.Add(normalDensitySeries); + } + + ResidualHistogramPlot.InvalidatePlot(true); + } + Element?.RebuildSeriesAndAnnotationBridges(ResidualHistogramPlot); + } + + /// + /// Updates the Q-Q (quantile-quantile) plot for assessing normality of residuals. + /// + private void UpdateQQPlot() + { + if (ResidualQQPlot == null) return; + + var qqSeries = ResidualQQPlot.Series.OfType().FirstOrDefault(s => s.Name == "Residuals") + ?? new ScatterPointSeries + { + Name = "Residuals", + Title = "Residuals", + MarkerFill = Colors.Black, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle, + }; + var qqLine = ResidualQQPlot.Annotations.OfType().FirstOrDefault(a => a.Name == "OneToOneLine") + ?? new LineAnnotation + { + Name = "OneToOneLine", + Text = "1:1 Line", + Type = OxyPlot.Annotations.LineAnnotationType.LinearEquation, + Color = Colors.Black, + LineStyle = LineStyle.Dash, + StrokeThickness = 2, + TextLinePosition = 0.9, + Intercept = 0, + Slope = 1, + TextHorizontalAlignment = System.Windows.HorizontalAlignment.Right, + TextVerticalAlignment = System.Windows.VerticalAlignment.Top, + }; + + using (Element?.SuspendPlotBridges()) + { + ResidualQQPlot.Series.Clear(); + for (int i = ResidualQQPlot.Annotations.Count - 1; i >= 0; i--) + { + if (ResidualQQPlot.Annotations[i].Name == "OneToOneLine") + { + ResidualQQPlot.Annotations.RemoveAt(i); + break; + } + } + + if (Element != null && Element.BayesianAnalysis != null && Element.IsValid == true && Element.BayesianAnalysis.IsEstimated == true) + { + var values = new double[_residuals.Length]; + Array.Copy(_residuals, values, _residuals.Length); + Array.Sort(values); + var muSigma = Statistics.MeanStandardDeviation(values); + var normal = new Normal(muSigma.Item1, muSigma.Item2); + var pp = PlottingPositions.Weibull(values.Length); + var qq = new List(); + for (int i = 0; i < values.Length; i++) + { + qq.Add(new DataPoint(normal.InverseCDF(pp[i]), values[i])); + } + + qqSeries.ItemsSource = qq; + qqSeries.Mapping = item => { var d = (DataPoint)item; return new OxyPlot.Series.ScatterPoint(d.X, d.Y); }; + qqSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + ResidualQQPlot.Series.Add(qqSeries); + ResidualQQPlot.Annotations.Add(qqLine); + } + + ResidualQQPlot.InvalidatePlot(true); + } + Element?.RebuildSeriesAndAnnotationBridges(ResidualQQPlot); + } + + /// + /// Updates the ACF (Autocorrelation Function) plot to check for correlation in residuals at different lags. + /// + private void UpdateACFPlot() + { + if (ResidualACFPlot == null) return; + + var acfSeries = ResidualACFPlot.Series.OfType().FirstOrDefault(s => s.Name == "Autocorrelation") + ?? new HistogramSeries + { + Name = "Autocorrelation", + Title = "Autocorrelation", + FillColor = Color.FromArgb(125, 104, 140, 175), + StrokeColor = Color.FromArgb(255, 53, 59, 122), + StrokeThickness = 1, + }; + var acfLowerLine = ResidualACFPlot.Annotations.OfType().FirstOrDefault(a => a.Name == "LowerCI") + ?? new LineAnnotation + { + Name = "LowerCI", + Text = "2.5% CI", + Type = OxyPlot.Annotations.LineAnnotationType.Horizontal, + Color = Colors.Black, + LineStyle = LineStyle.Dash, + StrokeThickness = 2, + TextLinePosition = 1, + TextHorizontalAlignment = System.Windows.HorizontalAlignment.Right, + TextVerticalAlignment = System.Windows.VerticalAlignment.Top, + }; + var acfUpperLine = ResidualACFPlot.Annotations.OfType().FirstOrDefault(a => a.Name == "UpperCI") + ?? new LineAnnotation + { + Name = "UpperCI", + Text = "97.5% CI", + Type = OxyPlot.Annotations.LineAnnotationType.Horizontal, + Color = Colors.Black, + LineStyle = LineStyle.Dash, + StrokeThickness = 2, + TextLinePosition = 1, + TextHorizontalAlignment = System.Windows.HorizontalAlignment.Right, + TextVerticalAlignment = System.Windows.VerticalAlignment.Bottom, + }; + + using (Element?.SuspendPlotBridges()) + { + ResidualACFPlot.Series.Clear(); + for (int i = ResidualACFPlot.Annotations.Count - 1; i >= 0; i--) + { + if (ResidualACFPlot.Annotations[i].Name == "LowerCI" || ResidualACFPlot.Annotations[i].Name == "UpperCI") + { + ResidualACFPlot.Annotations.RemoveAt(i); + } + } + + if (Element != null && Element.BayesianAnalysis != null && Element.IsValid == true && Element.BayesianAnalysis.IsEstimated == true) + { + var acfItems = new List(); + var acf = Autocorrelation.Function(_residuals); + for (int i = 0; i < acf.GetLength(0); i++) + { + acfItems.Add(new OxyPlot.Series.HistogramItem(i, i + 1, acf[i, 1])); + } + + acfSeries.ItemsSource = acfItems; + acfSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0}" + Environment.NewLine + "{3}: {4:0.000000}"; + ResidualACFPlot.Series.Add(acfSeries); + + var ci = Autocorrelation.CorrelationConfidenceInterval(_residuals.Length); + acfLowerLine.Y = ci[0]; + acfUpperLine.Y = ci[1]; + ResidualACFPlot.Annotations.Add(acfLowerLine); + ResidualACFPlot.Annotations.Add(acfUpperLine); + + foreach (var axis in ResidualACFPlot.Axes) + { + if (axis.Key == "Yaxis") + { + axis.Minimum = Math.Round((Math.Min(ci[0], Tools.Min(acf.GetColumn(1))) - 0.1) * 20) / 20; + } + } + } + + ResidualACFPlot.InvalidatePlot(true); + } + Element?.RebuildSeriesAndAnnotationBridges(ResidualACFPlot); + } + + /// + /// Updates the PACF (Partial Autocorrelation Function) plot to check for partial correlation in residuals at different lags. + /// + private void UpdatePACFPlot() + { + if (ResidualPACFPlot == null) return; + + var pacfSeries = ResidualPACFPlot.Series.OfType().FirstOrDefault(s => s.Name == "PartialAutocorrelation") + ?? new HistogramSeries + { + Name = "PartialAutocorrelation", + Title = "Partial Autocorrelation", + FillColor = Color.FromArgb(125, 104, 140, 175), + StrokeColor = Color.FromArgb(255, 53, 59, 122), + StrokeThickness = 1, + }; + var pacfLowerLine = ResidualPACFPlot.Annotations.OfType().FirstOrDefault(a => a.Name == "LowerCI") + ?? new LineAnnotation + { + Name = "LowerCI", + Text = "2.5% CI", + Type = OxyPlot.Annotations.LineAnnotationType.Horizontal, + Color = Colors.Black, + LineStyle = LineStyle.Dash, + StrokeThickness = 2, + TextLinePosition = 1, + TextHorizontalAlignment = System.Windows.HorizontalAlignment.Right, + TextVerticalAlignment = System.Windows.VerticalAlignment.Top, + }; + var pacfUpperLine = ResidualPACFPlot.Annotations.OfType().FirstOrDefault(a => a.Name == "UpperCI") + ?? new LineAnnotation + { + Name = "UpperCI", + Text = "97.5% CI", + Type = OxyPlot.Annotations.LineAnnotationType.Horizontal, + Color = Colors.Black, + LineStyle = LineStyle.Dash, + StrokeThickness = 2, + TextLinePosition = 1, + TextHorizontalAlignment = System.Windows.HorizontalAlignment.Right, + TextVerticalAlignment = System.Windows.VerticalAlignment.Bottom, + }; + + using (Element?.SuspendPlotBridges()) + { + ResidualPACFPlot.Series.Clear(); + for (int i = ResidualPACFPlot.Annotations.Count - 1; i >= 0; i--) + { + if (ResidualPACFPlot.Annotations[i].Name == "LowerCI" || ResidualPACFPlot.Annotations[i].Name == "UpperCI") + { + ResidualPACFPlot.Annotations.RemoveAt(i); + } + } + + if (Element != null && Element.BayesianAnalysis != null && Element.IsValid == true && Element.BayesianAnalysis.IsEstimated == true) + { + var pacfItems = new List(); + var pacf = Autocorrelation.Function(_residuals, -1, Autocorrelation.Type.Partial); + for (int i = 0; i < pacf.GetLength(0); i++) + { + pacfItems.Add(new OxyPlot.Series.HistogramItem(i, i + 1, pacf[i, 1])); + } + + pacfSeries.ItemsSource = pacfItems; + pacfSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0}" + Environment.NewLine + "{3}: {4:0.000000}"; + ResidualPACFPlot.Series.Add(pacfSeries); + + var ci = Autocorrelation.CorrelationConfidenceInterval(_residuals.Length); + pacfLowerLine.Y = ci[0]; + pacfUpperLine.Y = ci[1]; + ResidualPACFPlot.Annotations.Add(pacfLowerLine); + ResidualPACFPlot.Annotations.Add(pacfUpperLine); + + foreach (var axis in ResidualPACFPlot.Axes) + { + if (axis.Key == "Yaxis") + { + axis.Maximum = Math.Round((Math.Max(ci[1], Tools.Max(pacf.GetColumn(1))) + 0.1) * 20) / 20; + axis.Minimum = Math.Round((Math.Min(ci[0], Tools.Min(pacf.GetColumn(1))) - 0.1) * 20) / 20; + } + } + } + + ResidualPACFPlot.InvalidatePlot(true); + } + Element?.RebuildSeriesAndAnnotationBridges(ResidualPACFPlot); + } + + #endregion + + /// + /// Binds the time series data grid with the analysis results including predictions and credible intervals. + /// + private void BindTimeSeriesDataGrid() + { + TimeSeriesTable.ItemsSource = null; + + if (Element == null || Element.BayesianAnalysis == null || Element.BayesianAnalysis.IsEstimated != true || Element.AnalysisResults == null) return; + + ModeColumn.Header = Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"; + + if (Element.ARIMAX?.TimeSeries == null || Element.ARIMAX.TimeSeries.Count == 0) return; + + var dates = TimeSeriesResultTimeline.CreateDates(Element.ARIMAX.TimeSeries, Element.AnalysisResults.MeanCurve.Length); + var curvePoints = new List(); + for (int i = 0; i < Element.AnalysisResults.MeanCurve.Length; i++) + { + var lo = Element.AnalysisResults.ConfidenceIntervals[i, 1]; + var up = Element.AnalysisResults.ConfidenceIntervals[i, 2]; + var prd = Element.AnalysisResults.MeanCurve[i]; + var md = i < Element.AnalysisResults.ModeCurve.Length ? Element.AnalysisResults.ModeCurve[i] : double.NaN; + curvePoints.Add(new FrequencyCurvePoint(dates[i], up, lo, prd, md)); + } + TimeSeriesTable.ItemsSource = curvePoints; + TimeSeriesTable.Items.Refresh(); + } + + /// + /// Sets the column headers for the time series table based on the credible interval width. + /// + private void SetTableColumnHeaders() + { + double alpha = (1 - Element.BayesianAnalysis.CredibleIntervalWidth) / 2; + UpperColumn.Header = ((1 - alpha) * 100).ToString("F1") + "% CI"; + LowerColumn.Header = (alpha * 100).ToString("F1") + "% CI"; + } + + /// + /// Sets the string format for numeric columns in the time series table based on user settings. + /// + private void SetColumnStringFormats() + { + // Update the column binding string format + UpperColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + LowerColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + PredictiveColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + ModeColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + } + + /// + /// Binds the summary statistics data grid with model parameters, goodness-of-fit metrics, and significance indicators. + /// + private void BindSummaryStatisticsDataGrid() + { + SummaryStatisticsTable.ItemsSource = null; + + if (Element == null || Element.BayesianAnalysis == null) + return; + + StatValueColumn.Header = Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"; + + var summaryStats = new List(); + if (Element.BayesianAnalysis.IsEstimated == false || Element.AnalysisResults == null) + { + for (int i = 0; i < Element.ARIMAX.Parameters.Count; i++) + { + summaryStats.Add(new SummaryStatistic(Element.ARIMAX.Parameters[i].DisplayName, double.NaN)); + } + summaryStats.Add(new SummaryStatistic("AIC", double.NaN)); + summaryStats.Add(new SummaryStatistic("BIC", double.NaN)); + summaryStats.Add(new SummaryStatistic("DIC", double.NaN)); + summaryStats.Add(new SummaryStatistic("WAIC", double.NaN)); + summaryStats.Add(new SummaryStatistic("LOO-CV", double.NaN)); + summaryStats.Add(new SummaryStatistic("RMSE", double.NaN)); + } + else + { + for (int i = 0; i < Element.ARIMAX.Parameters.Count; i++) + { + string signif = ""; + if (i < Element.ARIMAX.Parameters.Count - 1) + { + var x = Element.BayesianAnalysis.Results.Output.Select(set => set.Values[i]).ToArray(); + var kde = new KernelDensity(x); + double p = kde.CCDF(0); + if (p > 0.999 || p < 0.001) + { + signif = "***"; + } + else if (p > 0.99 || p < 0.01) + { + signif = "**"; + } + else if (p > 0.95 || p < 0.05) + { + signif = "*"; + } + else if (p > 0.9 || p < 0.1) + { + signif = "."; + } + else + { + signif = " "; + } + } + summaryStats.Add(new SummaryStatistic(Element.ARIMAX.Parameters[i].DisplayName, Element.ARIMAX.Parameters[i].Value, double.NaN, signif)); + } + summaryStats.Add(new SummaryStatistic("AIC", Element.AnalysisResults.AIC)); + summaryStats.Add(new SummaryStatistic("BIC", Element.AnalysisResults.BIC)); + summaryStats.Add(new SummaryStatistic("DIC", Element.AnalysisResults.DIC)); + summaryStats.Add(new SummaryStatistic("WAIC", Element.BayesianAnalysis.WAIC)); + summaryStats.Add(new SummaryStatistic("LOO-CV", Element.BayesianAnalysis.LOOIC)); + summaryStats.Add(new SummaryStatistic("RMSE", Element.AnalysisResults.RMSE)); + } + + SummaryStatisticsTable.ItemsSource = summaryStats; + SummaryStatisticsTable.Items.Refresh(); + } + + /// + /// Handles the LoadingRow event for the summary statistics table. Adds visual separators before certain rows. + /// + /// The source of the event. + /// The event data containing the row being loaded. + private void SummaryStatisticsTable_LoadingRow(object sender, DataGridRowEventArgs e) + { + if (((SummaryStatistic)e.Row.DataContext).Name == "Minimum" || + ((SummaryStatistic)e.Row.DataContext).Name == "AIC") + { + e.Row.BorderThickness = new Thickness(0, 2, 0, 0); + e.Row.SetResourceReference(Border.BorderBrushProperty, "EnvironmentWindowText"); + } + else + { + e.Row.BorderThickness = new Thickness(0, 0, 0, 0); + } + } + } +} diff --git a/src/RMC.BestFit.App/GUI/TimeSeriesAnalysis/TimeSeriesAnalysisPropertiesControl.xaml b/src/RMC.BestFit.App/GUI/TimeSeriesAnalysis/TimeSeriesAnalysisPropertiesControl.xaml new file mode 100644 index 0000000..5523e69 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/TimeSeriesAnalysis/TimeSeriesAnalysisPropertiesControl.xaml @@ -0,0 +1,380 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/TimeSeriesData/TimeSeries/TimeSeriesPropertiesControl.xaml.cs b/src/RMC.BestFit.App/GUI/TimeSeriesData/TimeSeries/TimeSeriesPropertiesControl.xaml.cs new file mode 100644 index 0000000..6c55ea8 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/TimeSeriesData/TimeSeries/TimeSeriesPropertiesControl.xaml.cs @@ -0,0 +1,675 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using GenericControls; +using Hec.Dss; +using Numerics.Data; +using FrameworkInterfaces; +using RMC.BestFit.Models; +using RMC.BestFit.UI; + +namespace RMC_BestFit +{ + /// + /// User control for editing time series properties including entry method, data sources, + /// and configuration options for various time series data providers (Manual, HEC-DSS, GHCN, USGS). + /// + public partial class TimeSeriesPropertiesControl : UserControl + { + /// + /// Maximum time the properties panel waits for an external data download before returning control to the user. + /// + /// + /// Provider requests are also bounded in Numerics, but this UI-level limit prevents the + /// application from appearing to churn indefinitely on locked-down networks. + /// + private static readonly TimeSpan ExternalDownloadTimeout = TimeSpan.FromSeconds(30); + + /// + /// Maximum time the properties panel waits for instantaneous external data downloads. + /// + /// + /// Instantaneous provider payloads can be much larger than daily or peak downloads, so they + /// need a longer UI-level cancellation window than the default external download timeout. + /// + private static readonly TimeSpan InstantaneousExternalDownloadTimeout = TimeSpan.FromSeconds(180); + + /// + /// Maximum time the properties panel waits for GHCN daily station-file downloads. + /// + /// + /// NOAA NCEI can take several minutes to return station-file response headers even when the + /// data are reachable and parse quickly after the response starts. + /// + private static readonly TimeSpan GhcnExternalDownloadTimeout = TimeSpan.FromSeconds(300); + + /// + /// Initializes a new instance of the class. + /// + public TimeSeriesPropertiesControl() + { + InitializeComponent(); + DataContext = this; + } + + #region Members + + /// + /// Stores the previous name value for validation purposes. + /// + private string _previousName; + + /// + /// Dependency property for the time series element. + /// + public static DependencyProperty ElementProperty = DependencyProperty.Register(nameof(Element), typeof(TimeSeriesElement), typeof(TimeSeriesPropertiesControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the time series element whose properties are being edited. + /// + public TimeSeriesElement Element + { + get { return (TimeSeriesElement)GetValue(ElementProperty); } + set { SetValue(ElementProperty, value); } + } + + /// + /// Callback method invoked when the Element property changes. + /// + /// The dependency object whose property changed. + /// Event arguments containing the old and new values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as TimeSeriesPropertiesControl == null) return; + var thisControl = (TimeSeriesPropertiesControl)d; + + if (e.NewValue == null) return; + var newElement = e.NewValue as TimeSeriesElement; + if (newElement == null) return; + + thisControl._previousName = newElement.Name; + thisControl.PropertyAttributes.GetClassAttributes(newElement); + thisControl.UpdateProcessDataButtonText(); + } + + /// + /// Dependency property for the existing names property. + /// + public static DependencyProperty ExistingNamesProperty = DependencyProperty.Register(nameof(ExistingNames), typeof(string[]), typeof(TimeSeriesPropertiesControl), new FrameworkPropertyMetadata(new string[] { })); + + /// + /// Gets the array of existing time series names for validation purposes. + /// + public string[] ExistingNames + { + get { return (string[])GetValue(ExistingNamesProperty); } + private set { SetValue(ExistingNamesProperty, value); } + } + + /// + /// Cached list of available time series entry method options. + /// + private static readonly List _entryMethodOptions = new List + { + new TimeSeriesEntryMethodItem("Manual Entry", TimeSeriesElement.TimeSeriesEntryMethod.Manual, "Manually enter time series data"), + new TimeSeriesEntryMethodItem("HEC-DSS", TimeSeriesElement.TimeSeriesEntryMethod.HECDSS, "Import from HEC Data Storage System file"), + new TimeSeriesEntryMethodItem("USGS", TimeSeriesElement.TimeSeriesEntryMethod.USGS, "United States Geological Survey"), + new TimeSeriesEntryMethodItem("GHCN", TimeSeriesElement.TimeSeriesEntryMethod.GHCN, "Global Historical Climatology Network (NOAA)"), + new TimeSeriesEntryMethodItem("ABOM", TimeSeriesElement.TimeSeriesEntryMethod.ABOM, "Australian Bureau of Meteorology"), + new TimeSeriesEntryMethodItem("CHMN", TimeSeriesElement.TimeSeriesEntryMethod.CHMN, "Canadian Hydrometric Monitoring Network") + }; + + /// + /// Gets the list of available time series entry method options. + /// + public static List EntryMethodOptions => _entryMethodOptions; + + /// + /// Cached list of available depth unit options for precipitation and snow data. + /// + private static readonly List _depthUnitOptions = new List + { + new DepthUnitItem("Millimeters", TimeSeriesDownload.DepthUnit.Millimeters), + new DepthUnitItem("Centimeters", TimeSeriesDownload.DepthUnit.Centimeters), + new DepthUnitItem("Inches", TimeSeriesDownload.DepthUnit.Inches), + }; + + /// + /// Gets the list of available depth unit options for precipitation and snow data. + /// + public static List DepthUnitOptions => _depthUnitOptions; + + /// + /// Cached list of available time interval options for manual time series entry. + /// + private static readonly List _timeIntervalOptions = new List + { + new TimeIntervalItem("15-Min", TimeInterval.FifteenMinute), + new TimeIntervalItem("30-Min", TimeInterval.ThirtyMinute), + new TimeIntervalItem("1-Hr", TimeInterval.OneHour), + new TimeIntervalItem("6-Hr", TimeInterval.SixHour), + new TimeIntervalItem("12-Hr", TimeInterval.TwelveHour), + new TimeIntervalItem("1-Day", TimeInterval.OneDay), + new TimeIntervalItem("1-Month", TimeInterval.OneMonth), + new TimeIntervalItem("1-Quarter", TimeInterval.OneQuarter), + new TimeIntervalItem("1-Year", TimeInterval.OneYear), + new TimeIntervalItem("Irregular", TimeInterval.Irregular), + }; + + /// + /// Gets the list of available time interval options for manual time series entry. + /// + public static List TimeIntervalOptions => _timeIntervalOptions; + + /// + /// Cached list of available GHCN time series type options. + /// + private static readonly List _ghcnSeriesTypes = new List + { + new TimeSeriesTypeItem("Daily Precipitation", TimeSeriesDownload.TimeSeriesType.DailyPrecipitation), + new TimeSeriesTypeItem("Daily Snow", TimeSeriesDownload.TimeSeriesType.DailySnow) + }; + + /// + /// Cached list of available USGS time series type options. + /// + private static readonly List _usgsSeriesTypes = new List + { + new TimeSeriesTypeItem("Daily Discharge", TimeSeriesDownload.TimeSeriesType.DailyDischarge), + new TimeSeriesTypeItem("Daily Stage", TimeSeriesDownload.TimeSeriesType.DailyStage), + new TimeSeriesTypeItem("Instantaneous Discharge", TimeSeriesDownload.TimeSeriesType.InstantaneousDischarge), + new TimeSeriesTypeItem("Instantaneous Stage", TimeSeriesDownload.TimeSeriesType.InstantaneousStage), + new TimeSeriesTypeItem("Peak Discharge", TimeSeriesDownload.TimeSeriesType.PeakDischarge), + new TimeSeriesTypeItem("Peak Stage", TimeSeriesDownload.TimeSeriesType.PeakStage), + new TimeSeriesTypeItem("Measured Discharge", TimeSeriesDownload.TimeSeriesType.MeasuredDischarge), + new TimeSeriesTypeItem("Measured Stage", TimeSeriesDownload.TimeSeriesType.MeasuredStage) + }; + + /// + /// Cached list of available CHMN time series type options. + /// + private static readonly List _chmnSeriesTypes = new List + { + new TimeSeriesTypeItem("Daily Discharge", TimeSeriesDownload.TimeSeriesType.DailyDischarge), + new TimeSeriesTypeItem("Daily Stage", TimeSeriesDownload.TimeSeriesType.DailyStage), + new TimeSeriesTypeItem("Instantaneous Discharge", TimeSeriesDownload.TimeSeriesType.InstantaneousDischarge), + new TimeSeriesTypeItem("Instantaneous Stage", TimeSeriesDownload.TimeSeriesType.InstantaneousStage), + new TimeSeriesTypeItem("Peak Discharge", TimeSeriesDownload.TimeSeriesType.PeakDischarge), + new TimeSeriesTypeItem("Peak Stage", TimeSeriesDownload.TimeSeriesType.PeakStage), + }; + + /// + /// Cached list of available ABOM time series type options. + /// + private static readonly List _abomSeriesTypes = new List + { + new TimeSeriesTypeItem("Daily Discharge", TimeSeriesDownload.TimeSeriesType.DailyDischarge), + new TimeSeriesTypeItem("Daily Stage", TimeSeriesDownload.TimeSeriesType.DailyStage), + new TimeSeriesTypeItem("Daily Precipitation", TimeSeriesDownload.TimeSeriesType.DailyPrecipitation), + new TimeSeriesTypeItem("Instantaneous Discharge", TimeSeriesDownload.TimeSeriesType.InstantaneousDischarge), + new TimeSeriesTypeItem("Instantaneous Stage", TimeSeriesDownload.TimeSeriesType.InstantaneousStage), + }; + + #endregion + + /// + /// Handles the PreviewMouseLeftButtonDown event on the Name field to display property attributes. + /// + /// The object that raised the event. + /// Event arguments. + private void Name_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element == null) return; + PropertyAttributes.GetPropertyAttributes(nameof(Element.Name), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the Description field to display property attributes. + /// + /// The object that raised the event. + /// Event arguments. + private void Description_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element == null) return; + PropertyAttributes.GetPropertyAttributes(nameof(Element.Description), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the CreationDate field to display property attributes. + /// + /// The object that raised the event. + /// Event arguments. + private void CreationDate_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element == null) return; + PropertyAttributes.GetPropertyAttributes(nameof(Element.CreationDate), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the LastModified field to display property attributes. + /// + /// The object that raised the event. + /// Event arguments. + private void LastModified_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element == null) return; + PropertyAttributes.GetPropertyAttributes(nameof(Element.LastModified), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the UnitLabel field to display property attributes. + /// + /// The object that raised the event. + /// Event arguments. + private void UnitLabel_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element == null) return; + PropertyAttributes.GetPropertyAttributes(nameof(Element.UnitLabel), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the EntryMethod field to display property attributes. + /// + /// The object that raised the event. + /// Event arguments. + private void EntryMethod_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element == null) return; + PropertyAttributes.GetPropertyAttributes(nameof(Element.EntryMethod), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the HEC-DSS Filename field to display property attributes. + /// + /// The object that raised the event. + /// Event arguments. + private void HECDSSFilename_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element == null) return; + PropertyAttributes.GetPropertyAttributes(nameof(Element.HECDSSFullFilename), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the HEC-DSS Pathname field to display property attributes. + /// + /// The object that raised the event. + /// Event arguments. + private void HECDSSPathname_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element == null) return; + PropertyAttributes.GetPropertyAttributes(nameof(Element.HECDSSDataPathname), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the DataType field to display property attributes. + /// + /// The object that raised the event. + /// Event arguments. + private void DataType_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element == null) return; + PropertyAttributes.GetPropertyAttributes(nameof(Element.SeriesType), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the USGS Site Number field to display property attributes. + /// + /// The object that raised the event. + /// Event arguments. + private void USGSSiteNumber_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element == null) return; + PropertyAttributes.GetPropertyAttributes(nameof(Element.USGSSiteNumber), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the GHCN Site Number field to display property attributes. + /// + /// The object that raised the event. + /// Event arguments. + private void GHCNSiteNumber_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element == null) return; + PropertyAttributes.GetPropertyAttributes(nameof(Element.GHCNSiteNumber), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the CHMN Site Number field to display property attributes. + /// + /// The object that raised the event. + /// Event arguments. + private void CHMNSiteNumber_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element == null) return; + PropertyAttributes.GetPropertyAttributes(nameof(Element.CHMNSiteNumber), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the ABOM Site Number field to display property attributes. + /// + /// The object that raised the event. + /// Event arguments. + private void ABOMSiteNumber_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element == null) return; + PropertyAttributes.GetPropertyAttributes(nameof(Element.ABOMSiteNumber), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the Depth Unit Selector field to display property attributes. + /// + /// The object that raised the event. + /// Event arguments. + private void DepthUnitSelector_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element == null) return; + PropertyAttributes.GetPropertyAttributes(nameof(Element.DepthUnit), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the Time Interval field to display property attributes. + /// + /// The object that raised the event. + /// Event arguments. + private void TimeInterval_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element == null) return; + PropertyAttributes.GetPropertyAttributes(nameof(Element.TimeInterval), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the Start Date Time field to display property attributes. + /// + /// The object that raised the event. + /// Event arguments. + private void StartDateTime_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (Element == null) return; + PropertyAttributes.GetPropertyAttributes(nameof(Element.StartDateTime), Element); + } + + /// + /// Handles the GotFocus event on the Name field to store the current name and populate existing names for validation. + /// + /// The object that raised the event. + /// Event arguments. + private void Name_GotFocus(object sender, RoutedEventArgs e) + { + if (Element == null) return; + _previousName = Element.Name; + ExistingNames = Element.ParentCollection.GetElementNames(Element).ToArray(); + } + + /// + /// Handles the LostFocus event on the Name field to restore the previous name if validation fails. + /// + /// The object that raised the event. + /// Event arguments. + private void Name_LostFocus(object sender, RoutedEventArgs e) + { + if (ElementName.NameTextBox.IsValid) return; + if (Element == null) return; + Element.Name = _previousName; + } + + /// + /// Updates the process data button text and tooltip based on the current entry method. + /// HEC-DSS uses "Import" since it reads from a local file; all other external sources use "Download". + /// + private void UpdateProcessDataButtonText() + { + if (Element == null) return; + if (Element.EntryMethod == TimeSeriesElement.TimeSeriesEntryMethod.HECDSS) + { + ProcessDataButtonText.Text = "Import"; + ProcessDataButton.ToolTip = "Import Time Series Data"; + } + else + { + ProcessDataButtonText.Text = "Download"; + ProcessDataButton.ToolTip = "Download Time Series Data"; + } + } + + /// + /// Handles the SelectionChanged event of the Entry Method combo box to show/hide relevant fields based on the selected entry method. + /// + /// The object that raised the event. + /// Event arguments. + private void EntryMethodComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (Element == null) return; + + UpdateProcessDataButtonText(); + var comboBox = (ComboBox)DataType.InnerContent; + + if (Element.EntryMethod == TimeSeriesElement.TimeSeriesEntryMethod.Manual) + { + TimeIntervalSelector.Visibility = Visibility.Visible; + StartDateTime.Visibility = Visibility.Visible; + HECDSSFilename.Visibility = Visibility.Collapsed; + HECDSSPathname.Visibility = Visibility.Collapsed; + GHCNSiteNumber.Visibility = Visibility.Collapsed; + DepthUnitSelector.Visibility = Visibility.Collapsed; + USGSSiteNumber.Visibility = Visibility.Collapsed; + CHMNSiteNumber.Visibility = Visibility.Collapsed; + ABOMSiteNumber.Visibility = Visibility.Collapsed; + DataType.Visibility = Visibility.Collapsed; + ProcessDataButton.Visibility = Visibility.Collapsed; + } + else if (Element.EntryMethod == TimeSeriesElement.TimeSeriesEntryMethod.HECDSS) + { + TimeIntervalSelector.Visibility = Visibility.Collapsed; + StartDateTime.Visibility = Visibility.Collapsed; + HECDSSFilename.Visibility = Visibility.Visible; + HECDSSPathname.Visibility = Visibility.Visible; + GHCNSiteNumber.Visibility = Visibility.Collapsed; + DepthUnitSelector.Visibility = Visibility.Collapsed; + USGSSiteNumber.Visibility = Visibility.Collapsed; + CHMNSiteNumber.Visibility = Visibility.Collapsed; + ABOMSiteNumber.Visibility = Visibility.Collapsed; + DataType.Visibility = Visibility.Collapsed; + ProcessDataButton.Visibility = Visibility.Visible; + } + else if (Element.EntryMethod == TimeSeriesElement.TimeSeriesEntryMethod.GHCN) + { + TimeIntervalSelector.Visibility = Visibility.Collapsed; + StartDateTime.Visibility = Visibility.Collapsed; + HECDSSFilename.Visibility = Visibility.Collapsed; + HECDSSPathname.Visibility = Visibility.Collapsed; + GHCNSiteNumber.Visibility = Visibility.Visible; + DepthUnitSelector.Visibility = Visibility.Visible; + USGSSiteNumber.Visibility = Visibility.Collapsed; + CHMNSiteNumber.Visibility = Visibility.Collapsed; + ABOMSiteNumber.Visibility = Visibility.Collapsed; + DataType.Visibility = Visibility.Visible; + ProcessDataButton.Visibility = Visibility.Visible; + + comboBox.ItemsSource = _ghcnSeriesTypes; + DefaultSeriesTypeIfNeeded(_ghcnSeriesTypes); + } + else if (Element.EntryMethod == TimeSeriesElement.TimeSeriesEntryMethod.USGS) + { + TimeIntervalSelector.Visibility = Visibility.Collapsed; + StartDateTime.Visibility = Visibility.Collapsed; + HECDSSFilename.Visibility = Visibility.Collapsed; + HECDSSPathname.Visibility = Visibility.Collapsed; + GHCNSiteNumber.Visibility = Visibility.Collapsed; + DepthUnitSelector.Visibility = Visibility.Collapsed; + USGSSiteNumber.Visibility = Visibility.Visible; + CHMNSiteNumber.Visibility = Visibility.Collapsed; + ABOMSiteNumber.Visibility = Visibility.Collapsed; + DataType.Visibility = Visibility.Visible; + ProcessDataButton.Visibility = Visibility.Visible; + + comboBox.ItemsSource = _usgsSeriesTypes; + DefaultSeriesTypeIfNeeded(_usgsSeriesTypes); + } + else if (Element.EntryMethod == TimeSeriesElement.TimeSeriesEntryMethod.CHMN) + { + TimeIntervalSelector.Visibility = Visibility.Collapsed; + StartDateTime.Visibility = Visibility.Collapsed; + HECDSSFilename.Visibility = Visibility.Collapsed; + HECDSSPathname.Visibility = Visibility.Collapsed; + GHCNSiteNumber.Visibility = Visibility.Collapsed; + DepthUnitSelector.Visibility = Visibility.Collapsed; + USGSSiteNumber.Visibility = Visibility.Collapsed; + CHMNSiteNumber.Visibility = Visibility.Visible; + ABOMSiteNumber.Visibility = Visibility.Collapsed; + DataType.Visibility = Visibility.Visible; + ProcessDataButton.Visibility = Visibility.Visible; + + comboBox.ItemsSource = _chmnSeriesTypes; + DefaultSeriesTypeIfNeeded(_chmnSeriesTypes); + } + else if (Element.EntryMethod == TimeSeriesElement.TimeSeriesEntryMethod.ABOM) + { + TimeIntervalSelector.Visibility = Visibility.Collapsed; + StartDateTime.Visibility = Visibility.Collapsed; + HECDSSFilename.Visibility = Visibility.Collapsed; + HECDSSPathname.Visibility = Visibility.Collapsed; + GHCNSiteNumber.Visibility = Visibility.Collapsed; + DepthUnitSelector.Visibility = Element.SeriesType == TimeSeriesDownload.TimeSeriesType.DailyPrecipitation + ? Visibility.Visible : Visibility.Collapsed; + USGSSiteNumber.Visibility = Visibility.Collapsed; + CHMNSiteNumber.Visibility = Visibility.Collapsed; + ABOMSiteNumber.Visibility = Visibility.Visible; + DataType.Visibility = Visibility.Visible; + ProcessDataButton.Visibility = Visibility.Visible; + + comboBox.ItemsSource = _abomSeriesTypes; + DefaultSeriesTypeIfNeeded(_abomSeriesTypes); + } + } + + /// + /// If the current is not available in the new + /// data type list, defaults to the first item. Otherwise, keeps the current selection. + /// + /// The list of available data type options for the current entry method. + private void DefaultSeriesTypeIfNeeded(List list) + { + if (!list.Any(t => t.Value == Element.SeriesType)) + { + Element.SeriesType = list[0].Value; + } + + // Force the ComboBox to re-resolve its SelectedValue against the new ItemsSource. + // Setting ItemsSource clears WPF's internal selection; the TwoWay binding alone + // doesn't reliably re-select the matching item from a completely new list. + var comboBox = (ComboBox)DataType.InnerContent; + comboBox.SelectedValue = Element.SeriesType; + } + + /// + /// Handles the Click event of the Process Data button to download time series data from the selected source. + /// + /// The object that raised the event. + /// Event arguments. + private async void ProcessDataButton_Click(object sender, RoutedEventArgs e) + { + if (Element == null) return; + ProcessDataButton.IsEnabled = false; + Mouse.OverrideCursor = Cursors.Wait; + TimeSpan timeout = GetExternalDownloadTimeout(); + try + { + using (var cts = new CancellationTokenSource(timeout)) + { + await Element.Download(cts.Token); + } + } + catch (OperationCanceledException) + { + GenericControls.MessageBox.Show( + $"The download did not complete within {timeout.TotalSeconds:0} seconds. " + + "The selected data provider may be blocked or unavailable from this network.", + "Download timed out", + MessageBoxButton.OK, + MessageBoxImage.Error); + return; + } + catch (Exception ex) + { + GenericControls.MessageBox.Show("An error occurred: " + ex.Message, "Error!", MessageBoxButton.OK, MessageBoxImage.Error); + return; + } + finally + { + Mouse.OverrideCursor = null; + ProcessDataButton.IsEnabled = true; + } + } + + /// + /// Gets the UI cancellation timeout for the selected external download. + /// + /// The timeout to apply to the current download request. + /// + /// Instantaneous discharge and stage requests use a longer timeout because their provider + /// payloads are larger and may take longer to stream through agency networks. + /// + private TimeSpan GetExternalDownloadTimeout() + { + if (Element?.EntryMethod == TimeSeriesElement.TimeSeriesEntryMethod.GHCN) + { + return GhcnExternalDownloadTimeout; + } + + if (Element != null && + (Element.SeriesType == TimeSeriesDownload.TimeSeriesType.InstantaneousDischarge || + Element.SeriesType == TimeSeriesDownload.TimeSeriesType.InstantaneousStage)) + { + return InstantaneousExternalDownloadTimeout; + } + + return ExternalDownloadTimeout; + } + + /// + /// Handles the Click event of the Pathname button to open the DSS path selector window. + /// + /// The object that raised the event. + /// Event arguments. + private void PathNameButton_Click(object sender, RoutedEventArgs e) + { + if (Element == null) return; + if (File.Exists(Element.HECDSSFullFilename)) + { + var dssControl = new DSSPathSelectorWindow() { FullFileName = Element.HECDSSFullFilename }; + if (dssControl.ShowDialog() == true) + { + Element.HECDSSDataPathname = dssControl.SelectedPathname; + } + } + + } + + /// + /// Handles the SelectionChanged event of the Data Type combo box. + /// + /// The object that raised the event. + /// Event arguments. + private void DataType_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (Element == null) return; + + // Show DepthUnitSelector for precipitation types when ABOM is selected. + // GHCN always shows DepthUnitSelector (set in EntryMethodComboBox_SelectionChanged). + if (Element.EntryMethod == TimeSeriesElement.TimeSeriesEntryMethod.ABOM) + { + DepthUnitSelector.Visibility = Element.SeriesType == TimeSeriesDownload.TimeSeriesType.DailyPrecipitation + ? Visibility.Visible : Visibility.Collapsed; + } + } + + + } +} diff --git a/src/RMC.BestFit.App/GUI/UnivariateAnalysis/B17C/B17CAnalysisControl.xaml b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/B17C/B17CAnalysisControl.xaml new file mode 100644 index 0000000..a5c9263 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/B17C/B17CAnalysisControl.xaml @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/UnivariateAnalysis/B17C/B17CAnalysisControl.xaml.cs b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/B17C/B17CAnalysisControl.xaml.cs new file mode 100644 index 0000000..9f616f8 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/B17C/B17CAnalysisControl.xaml.cs @@ -0,0 +1,908 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using OxyPlot; +using OxyPlot.Wpf; +using OxyPlotControls; +using RMC.BestFit.Models; +using RMC.BestFit.Analyses; +using RMC.BestFit.Estimation; +using RMC.BestFit.UI; +using System.ComponentModel; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Media3D; +using System.Xml.Linq; +using System.Windows.Data; +using FrameworkUI; +using FrameworkInterfaces; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Threading; +using System.Runtime.Serialization.Formatters; + +namespace RMC_BestFit +{ + /// + /// User control for displaying B17C frequency analysis results, including frequency curves, + /// kernel density plots, histograms, and bivariate heat maps. This control provides visualization + /// and tabular display of Bulletin 17C statistical analysis results. + /// + public partial class B17CAnalysisControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public B17CAnalysisControl() + { + InitializeComponent(); + DataContext = this; + _colorHexCodes = GenericControls.GeneralMethods.RandomColorsLongList; + // DataGrid Binding.StringFormat must be set before first render. + SetColumnStringFormats(); + } + + /// + /// Dependency property for the Element property. + /// + public static DependencyProperty ElementProperty = DependencyProperty.Register(nameof(Element), typeof(B17CAnalysis), typeof(B17CAnalysisControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the B17C analysis element associated with this control. + /// + public B17CAnalysis Element + { + get { return (B17CAnalysis)GetValue(ElementProperty); } + set { SetValue(ElementProperty, value); } + } + + /// + /// Callback method invoked when the Element dependency property changes. + /// Handles event subscription and unsubscription for the new and old element values. + /// + /// The dependency object that changed. + /// Event arguments containing the old and new property values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as B17CAnalysisControl == null) return; + var thisControl = (B17CAnalysisControl)d; + + // Remove handlers and detach plots from old element + if (e.OldValue is B17CAnalysis oldElement) + { + oldElement.PropertyChanged -= thisControl.Element_PropertyChanged; + thisControl.FrequencyPlotHost.Content = null; + thisControl.FrequencyPlotToolbar.Plot = null; + // NOTE: PropertiesCalled is wired in XAML — no programmatic -= needed. + } + + if (e.NewValue == null) return; + var newElement = e.NewValue as B17CAnalysis; + if (newElement == null) return; + + // Subscribe Element-scoped handler + newElement.PropertyChanged += thisControl.Element_PropertyChanged; + + // Attach plots, wire toolbars, bind axis titles, and wire Bayesian sub-control + // plots inside a bridge-suspension block. PropertiesCalled / PlotPropertiesCalled + // are wired in XAML — no programmatic += needed. + using (newElement.SuspendPlotBridges()) + { + thisControl.FrequencyPlotHost.Content = newElement.FrequencyPlot; + thisControl.FrequencyPlotToolbar.Plot = newElement.FrequencyPlot; + + thisControl.BindAxisTitles(); + + // B17C uses GMM (Generalized Method of Moments) for parameter estimation, not Bayesian MCMC. + // Uncertainty quantification is performed via parametric bootstrap on the link-function residuals, + // not posterior samples. As a result, the Autocorrelation, MarkovChainTrace, and MeanLikelihood + // Bayesian sub-controls are intentionally omitted from this control. KernelDensity, Histogram, + // BivariateHeatMap, and InfluenceDiagnostics are reused — the latter operates in GMM mode + // (Mode="GMM" in XAML, GMMAnalysis dependency-property bound to the analysis's GMM estimator) + // and switches axis titles dynamically via SetAxisTitle/SetPlotTitle in its UpdatePlot path. + thisControl.KernelDensityControl.SetPlot(newElement.BayesianPlots.KernelDensityPlot); + thisControl.HistogramControl.SetPlot(newElement.BayesianPlots.HistogramPlot); + thisControl.BivariateHeatMapControl.SetPlot(newElement.BayesianPlots.BivariateHeatMapPlot); + thisControl.InfluenceDiagnosticsControl.SetPlot(newElement.BayesianPlots.InfluenceDiagnosticsPlot); + } + } + + /// + /// Gets a value indicating whether a plot has been clicked. + /// + public bool PlotClicked { get; private set; } = false; + + /// + /// Raised when plot properties are requested to be displayed or modified. + /// + public event PlotPropertiesCalledEventHandler PlotPropertiesCalled; + + /// + /// Delegate for the event. + /// + /// The plot whose properties are being accessed. + /// Indicates whether to open the properties panel. + /// The property expander to use for displaying properties. + /// The currently selected object in the plot. + public delegate void PlotPropertiesCalledEventHandler(Plot plot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject); + + /// + /// Event raised when the preview control is clicked. + /// + public event PreviewControlClickedEventHandler PreviewControlClicked; + + /// + /// Delegate for handling preview control clicked events. + /// + /// Indicates whether the plot area was clicked. + /// Indicates whether the toolbar was clicked. + /// The plot that was clicked. + public delegate void PreviewControlClickedEventHandler(bool plotClicked, bool toolbarClicked, Plot plot); + + /// + /// Array of color hex codes used for series coloring. + /// + private string[] _colorHexCodes; + + + + /// + /// Handles the Loaded event of the user control. Initializes plots and data grids on first load. + /// + /// The source of the event. + /// Event arguments. + private void UserControl_Loaded(object sender, RoutedEventArgs e) + { + if (Element == null) return; + + // Data refresh on every load. Plot hosts, toolbars, axis-title bindings, and + // Element.PropertyChanged are wired once per Element in ElementCallback. + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try + { + UpdateFrequencyPlot(); + BindFrequencyCurveDataGrid(); + SetFrequencyCurveTableColumnHeaders(); + BindSummaryStatisticsDataGrid(); + } + finally + { + Element.IsUndoEnabled = wasUndoEnabled; + } + } + + /// + /// Handles the Unloaded event of the user control. Unsubscribes all element event handlers + /// to prevent memory leaks and stale event callbacks. + /// + /// The source of the event. + /// Event arguments. + private void UserControl_Unloaded(object sender, RoutedEventArgs e) + { + // Pattern B (canonical): Element-scoped lifecycle (PropertyChanged, plot + // hosts, toolbars) is owned by ElementCallback and survives unload/reload + // cycles. This control has no control-scoped subscriptions to tear down, + // so Unloaded is intentionally a no-op. + } + + /// + /// Handles property changed events from the Element. Updates plots and data grids when analysis results change. + /// + /// The source of the event. + /// Event arguments containing the property name that changed. + private void Element_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + // Marshal to UI thread if called from a background thread (model-layer + // RaisePropertyChange does not marshal; MCMC completion fires on a worker thread). + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke(new Action(() => Element_PropertyChanged(sender, e))); + return; + } + + if (e.PropertyName == nameof(Element.AnalysisResults)) + { + BindFrequencyCurveDataGrid(); + BindSummaryStatisticsDataGrid(); + UpdateFrequencyPlot(); + ResetWaitCursor(); + } + if (e.PropertyName == nameof(Element.BayesianAnalysis.CredibleIntervalWidth)) + { + SetFrequencyCurveTableColumnHeaders(); + } + if (e.PropertyName == nameof(Element.InputData)) + { + using (Element.SuspendPlotBridges()) + { + BindAxisTitles(); + } + UpdateFrequencyPlot(); + } + if ((e.PropertyName == nameof(Element.BayesianAnalysis.PointEstimator) + || e.PropertyName == nameof(Element.BayesianAnalysis.CredibleIntervalWidth) + || e.PropertyName == nameof(Element.ProbabilityOrdinates)) + && Element.IsEstimated) + { + // Cursor wait while the model layer's ReprocessIfEstimated runs the recompute + // on TaskScheduler.Default. Reset is wired to the AnalysisResults notification above. + Mouse.OverrideCursor = Cursors.Wait; + } + } + + /// + /// Clears when it is currently set to . + /// Marshals onto the UI thread when called from a background-thread PropertyChanged + /// notification (the model layer's ReprocessIfEstimated path uses + /// TaskScheduler.Default). + /// + private void ResetWaitCursor() + { + // Reset at Background priority so the Render-priority cursor frame from the + // earlier `Mouse.OverrideCursor = Cursors.Wait` is guaranteed to flush before + // the reset runs. Without this, fast reprocesses (UpdatePointEstimateResultsAsync) + // can reset the cursor before the OS visually picks up the change — the user + // sees no wait cursor at all when the mouse is stationary. + Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + { + if (Mouse.OverrideCursor == Cursors.Wait) + Mouse.OverrideCursor = null; + })); + } + + #region Plots + + // PreviewSaved removed — element now owns plots and saves them directly in Save(). + + /// + /// Binds the frequency plot Y-axis title to the InputData.UnitLabel property. + /// Called after element attachment and when InputData changes. + /// + private void BindAxisTitles() + { + if (Element == null) return; + + // Frequency plot: Y-axis title = UnitLabel + var freqYAxis = Element.FrequencyPlot?.Axes.FirstOrDefault(a => a.Key == "Yaxis"); + if (freqYAxis != null) + { + if (Element.InputData != null) + PlotAxisTitleDefaults.BindTitleIfDefault(freqYAxis, Element.InputData, nameof(InputData.UnitLabel), Element.InputData.UnitLabel); + else + PlotAxisTitleDefaults.SetTitleIfDefault(freqYAxis, string.Empty); + } + } + + /// + /// Handles the LostFocus event of the user control. Resets the PlotClicked flag. + /// + /// The source of the event. + /// Event arguments. + private void UserControl_LostFocus(object sender, RoutedEventArgs e) + { + PlotClicked = false; + } + + /// + /// Handles the PreviewMouseDown event of the user control. Determines if a plot or toolbar was clicked + /// and raises the appropriate event. + /// + /// The source of the event. + /// Mouse button event arguments. + private void UserControl_PreviewMouseDown(object sender, MouseButtonEventArgs e) + { + Plot plot = GetCurrentPlot(); + OxyPlotToolbar toolbar = GetCurrentPlotToolbar(); + System.Windows.Media.HitTestResult plotHitResult = null; + System.Windows.Media.HitTestResult toolbarHitResult = null; + if (plot != null) + { + plotHitResult = VisualTreeHelper.HitTest(plot, e.GetPosition(plot)); + } + if (toolbar != null) + { + toolbarHitResult = VisualTreeHelper.HitTest(toolbar, e.GetPosition(toolbar)); + } + PreviewControlClicked?.Invoke(plotHitResult != null, toolbarHitResult != null, plot); + PlotClicked = plotHitResult != null; + } + + /// + /// Handles the properties called event from the plot toolbar by forwarding it to subscribers. + /// + /// The plot whose properties are being accessed. + /// Indicates whether to open the properties panel. + /// The property expander to use. + /// The selected object in the plot. + private void PlotToolbar_PropertiesCalled(Plot targetPlot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject) + { + PlotPropertiesCalled?.Invoke(targetPlot, openProperties, propertyExpander, selectedObject); + } + + /// + /// Gets the currently selected plot based on which tab is active. + /// + /// The currently selected plot, or null if no plot is selected. + public Plot GetCurrentPlot() + { + if (FrequencyResultsTabItem.IsSelected == true) + { + return Element?.FrequencyPlot; + } + else if (KernelDensityTabItem.IsSelected == true) + { + return KernelDensityControl.Plot; + } + else if (HistogramTabItem.IsSelected == true) + { + return HistogramControl.Plot; + } + else if (BivariateTabItem.IsSelected == true) + { + return BivariateHeatMapControl.Plot; + } + else if (InfluenceTabItem.IsSelected == true) + { + return InfluenceDiagnosticsControl.Plot; + } + return null; + } + + /// + /// Gets the toolbar for the currently selected plot based on which tab is active. + /// + /// The toolbar for the currently selected plot, or null if no toolbar is available. + public OxyPlotToolbar GetCurrentPlotToolbar() + { + if (FrequencyResultsTabItem.IsSelected == true) + { + return FrequencyPlotToolbar; + } + else if (KernelDensityTabItem.IsSelected == true) + { + return KernelDensityControl.PlotToolbar; + } + else if (HistogramTabItem.IsSelected == true) + { + return HistogramControl.PlotToolbar; + } + else if (BivariateTabItem.IsSelected == true) + { + return BivariateHeatMapControl.PlotToolbar; + } + else if (InfluenceTabItem.IsSelected == true) + { + return InfluenceDiagnosticsControl.PlotToolbar; + } + return null; + } + + /// + /// Updates the frequency plot with current analysis results, including data series and fitted curves. + /// Filters data based on whether the Y-axis is logarithmic. + /// + private void UpdateFrequencyPlot() + { + var plot = Element?.FrequencyPlot; + if (plot == null) return; + + //var _upperEMA = new double[] { 12619.9864194, 11782.5706101, 10731.9334542, 9977.8772295, 9257.195421, 8353.4073954, 7705.0585317, 7085.7770685, 6309.7666737, 5753.5811499, 5222.8497774, 4558.3715505, 4081.6831725, 3623.9275407, 3036.9614379, 2591.8125345, 2124.369657, 1828.0832226, 1407.6975357, 1071.7446477, 907.0869561, 720.1777659, 595.9935624, 482.2186881, 418.8820542 }; + //var _lowerEMA = new double[] { 6068.5341507, 5925.6324102, 5725.4227251, 5564.7209184, 5395.4475558, 5157.3327345, 4965.2840088, 4761.8914929, 4473.6102744, 4239.0039294, 3988.1606166, 3627.9995253, 3330.8579175, 3010.2443484, 2549.747631, 2174.434113, 1772.9783406, 1520.4105225, 1164.7971414, 873.9362442, 723.5050134, 542.8057413, 417.5851356, 302.5246695, 240.2499231 }; + //var _computedEMA = new double[] { 8797.3443312, 8420.9236185, 7922.0715246, 7543.8158703, 7164.8324691, 6662.7550689, 6282.1037964, 5900.6709747, 5395.068108, 5011.3047972, 4626.1001511, 4113.8116407, 3722.9294361, 3327.7175622, 2794.7151663, 2378.0475015, 1939.884402, 1665.5634645, 1280.9789607, 972.1849074, 817.4013537, 637.3618677, 515.1825078, 402.2005095, 339.3764133 }; + + //Color emaLineColor = (Color)ColorConverter.ConvertFromString(_colorHexCodes[4]); + //Color emaFillColor = Color.FromArgb(100, emaLineColor.R, emaLineColor.G, emaLineColor.B); + + //var ciEMA = new AreaSeries + //{ + // Name = "EMAIntervals", + // Title = "EMA - 90% Confidence Intervals", + // Fill = emaFillColor, + // Color = emaLineColor, + // LineStyle = LineStyle.Dot, + // BrokenLineThickness = 1, + // StrokeThickness = 1, + //}; + + //var modeEMA = new LineSeries + //{ + // Name = "EMAMode", + // Title = "EMA - Computed", + // Color = emaLineColor, + // StrokeThickness = 0, + // LineStyle = LineStyle.Solid, + // MarkerSize = 2, + // MarkerType = MarkerType.Diamond, + // MarkerStroke = Colors.Black, + // MarkerStrokeThickness = 1, + //}; + + + // Lookup-or-create named series so user-customized styling survives Save/Open. + var credibleIntervals = plot.Series.OfType().FirstOrDefault(s => s.Name == "CredibleIntervals") + ?? new AreaSeries + { + Name = "CredibleIntervals", + Fill = Color.FromArgb(75, 104, 140, 175), + Color = Color.FromArgb(255, 53, 59, 122), + LineStyle = LineStyle.Solid, + BrokenLineThickness = 1, + StrokeThickness = 1, + }; + var posteriorPredictive = plot.Series.OfType().FirstOrDefault(s => s.Name == "PosteriorPredictive") + ?? new LineSeries + { + Name = "PosteriorPredictive", + Title = "Expected Probability", + Color = Colors.Blue, + StrokeThickness = 1, + LineStyle = LineStyle.Dash, + }; + var posteriorMode = plot.Series.OfType().FirstOrDefault(s => s.Name == "PosteriorMode") + ?? new LineSeries + { + Name = "PosteriorMode", + Title = "Computed", + Color = Colors.Black, + StrokeThickness = 1, + LineStyle = LineStyle.Solid, + }; + var frequencyExactData = plot.Series.OfType().FirstOrDefault(s => s.Name == "ExactData") + ?? new ScatterPointSeries + { + Name = "ExactData", + Title = "Exact Data", + MarkerFill = Colors.Black, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle, + }; + var frequencyLowOutlierData = plot.Series.OfType().FirstOrDefault(s => s.Name == "LowOutlierData") + ?? new ScatterPointSeries + { + Name = "LowOutlierData", + Title = "Low Outlier Data", + MarkerStroke = Colors.Red, + MarkerStrokeThickness = 2, + MarkerSize = 3, + MarkerType = MarkerType.Cross, + }; + var frequencyUncertainData = plot.Series.OfType().FirstOrDefault(s => s.Name == "UncertainData") + ?? new ScatterErrorSeries + { + Name = "UncertainData", + Title = "Uncertain Data", + MarkerFill = Colors.Green, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Diamond, + }; + var frequencyIntervalData = plot.Series.OfType().FirstOrDefault(s => s.Name == "IntervalData") + ?? new ScatterErrorSeries + { + Name = "IntervalData", + Title = "Interval Data", + MarkerFill = Colors.Cyan, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle, + }; + var quantilePriors = plot.Series.OfType().FirstOrDefault(s => s.Name == "QuantilePrior") + ?? new ScatterErrorSeries + { + Name = "QuantilePrior", + Title = "Quantile Prior", + MarkerFill = Colors.Red, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Square, + }; + + using (Element.SuspendPlotBridges()) + { + plot.Series.Clear(); + + if (Element.InputData != null && Element.BayesianAnalysis != null) + { + // Add Curves + if (Element.IsEstimated == true && Element.AnalysisResults != null) + { + //var emaCIPoints = new List(); + //var emaMdPoints = new List(); + + var ciPoints = new List(); + var prdPoints = new List(); + var mdPoints = new List(); + for (int i = 0; i < Element.ProbabilityOrdinates.Count; i++) + { + var p = Element.ProbabilityOrdinates[i]; + var up = Element.AnalysisResults.ConfidenceIntervals[i, 1]; + var lo = Element.AnalysisResults.ConfidenceIntervals[i, 0]; + var prd = Element.AnalysisResults.MeanCurve[i]; + var md = Element.AnalysisResults.ModeCurve[i]; + ciPoints.Add(new Point3D(p, lo, up)); + prdPoints.Add(new Point(p, prd)); + mdPoints.Add(new Point(p, md)); + + //emaCIPoints.Add(new Point3D(p, _lowerEMA[i], _upperEMA[i])); + //emaMdPoints.Add(new Point(p, _computedEMA[i])); + } + + // Credible Intervals + credibleIntervals.ItemsSource = ciPoints; + credibleIntervals.DataFieldX = "X"; + credibleIntervals.DataFieldY = "Y"; + credibleIntervals.DataFieldX2 = "X"; + credibleIntervals.DataFieldY2 = "Z"; + credibleIntervals.Title = (Element.BayesianAnalysis.CredibleIntervalWidth * 100).ToString("F0") + "% Confidence Intervals"; + credibleIntervals.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + plot.Series.Add(credibleIntervals); + + // Predictive + posteriorPredictive.ItemsSource = prdPoints; + posteriorPredictive.DataFieldX = "X"; + posteriorPredictive.DataFieldY = "Y"; + posteriorPredictive.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + plot.Series.Add(posteriorPredictive); + + // Point Estimator + posteriorMode.ItemsSource = mdPoints; + posteriorMode.DataFieldX = "X"; + posteriorMode.DataFieldY = "Y"; + posteriorMode.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + plot.Series.Add(posteriorMode); + + //// EMA Credible Intervals + //ciEMA.ItemsSource = emaCIPoints; + //ciEMA.DataFieldX = "X"; + //ciEMA.DataFieldY = "Y"; + //ciEMA.DataFieldX2 = "X"; + //ciEMA.DataFieldY2 = "Z"; + //plot.Series.Add(ciEMA); + + //// EAM Point Estimator + //modeEMA.ItemsSource = emaMdPoints; + //modeEMA.DataFieldX = "X"; + //modeEMA.DataFieldY = "Y"; + //plot.Series.Add(modeEMA); + } + + // See if Y-axis is logarithmic + bool logAxis = false; + foreach (var axis in plot.Axes) + { + if (axis.Key == "Yaxis" && axis as LogarithmicAxis != null) + { + logAxis = true; + break; + } + } + + // Exact data + frequencyExactData.ItemsSource = logAxis == true ? Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == false && x.Value > 1E-16) : Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == false); + frequencyExactData.DataFieldX = nameof(ExactData.PlottingPosition); + frequencyExactData.DataFieldY = nameof(ExactData.Value); + frequencyExactData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.ExactSeries.Count > 0) plot.Series.Add(frequencyExactData); + + // Low outliers data + frequencyLowOutlierData.ItemsSource = logAxis == true ? Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == true && x.Value > 1E-16) : Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == true); + frequencyLowOutlierData.DataFieldX = nameof(ExactData.PlottingPosition); + frequencyLowOutlierData.DataFieldY = nameof(ExactData.Value); + frequencyLowOutlierData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.NumberOfLowOutliers > 0) plot.Series.Add(frequencyLowOutlierData); + + // Uncertain data + frequencyUncertainData.ItemsSource = logAxis == true ? Element.InputData.DataFrame.UncertainSeries : Element.InputData.DataFrame.UncertainSeries.Where(x => x.Value > 1E-16); + frequencyUncertainData.DataFieldX = nameof(UncertainData.PlottingPosition); + frequencyUncertainData.DataFieldY = nameof(UncertainData.Value); + frequencyUncertainData.DataFieldLowerErrorY = nameof(UncertainData.LowerValue); + frequencyUncertainData.DataFieldUpperErrorY = nameof(UncertainData.UpperValue); + frequencyUncertainData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.UncertainSeries.Count > 0) plot.Series.Add(frequencyUncertainData); + + // Interval data + frequencyIntervalData.ItemsSource = logAxis == true ? Element.InputData.DataFrame.IntervalSeries : Element.InputData.DataFrame.IntervalSeries.Where(x => x.Value > 1E-16); + frequencyIntervalData.DataFieldX = nameof(IntervalData.PlottingPosition); + frequencyIntervalData.DataFieldY = nameof(IntervalData.Value); + frequencyIntervalData.DataFieldLowerErrorY = nameof(IntervalData.LowerValue); + frequencyIntervalData.DataFieldUpperErrorY = nameof(IntervalData.UpperValue); + frequencyIntervalData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.IntervalSeries.Count > 0) plot.Series.Add(frequencyIntervalData); + + // Quantile penalties + quantilePriors.ItemsSource = Element.QuantilePenalties; + quantilePriors.DataFieldX = nameof(QuantilePenalty.AEP); + quantilePriors.DataFieldY = nameof(QuantilePenalty.MeanValue); + quantilePriors.DataFieldLowerErrorY = nameof(QuantilePenalty.LowerValue); + quantilePriors.DataFieldUpperErrorY = nameof(QuantilePenalty.UpperValue); + quantilePriors.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.QuantilePenalties.Where(q => q.Enabled).Count() > 0) plot.Series.Add(quantilePriors); + + // Add Alternatives + if (Element.IsEstimated == true && Element.AnalysisResults != null) + AlternativeSelector.AddAllChecked(); + } + + plot.InvalidatePlot(true); + } + Element.RebuildSeriesAndAnnotationBridges(plot); + } + + /// + /// Handles the AnalysisAdded event from the alternative selector. Adds the analysis alternative's + /// curves to the frequency plot with unique colors. + /// + /// The analysis alternative item to add to the plot. + private void AlternativeSelector_AnalysisAdded(AnalysisAlternativeItem analysisItem) + { + var plot = Element?.FrequencyPlot; + if (plot == null) return; + + // Remove series + plot.Series.Remove(analysisItem.CredibleIntervals); + plot.Series.Remove(analysisItem.PosteriorPredictive); + plot.Series.Remove(analysisItem.PosteriorMode); + + var alternative = analysisItem.Alternative; + if (alternative.AnalysisResults != null) + { + // Get unique color + var index = plot.Series.Count; + Color lineColor = (Color)ColorConverter.ConvertFromString(_colorHexCodes[index]); + Color fillColor = Color.FromArgb(100, lineColor.R, lineColor.G, lineColor.B); + + for (int i = 4; i < _colorHexCodes.Length; i++) + { + var templineColor = (Color)ColorConverter.ConvertFromString(_colorHexCodes[i]); + var tempfillColor = Color.FromArgb(100, templineColor.R, templineColor.G, templineColor.B); + bool colorExists = false; + for (int j = 0; j < plot.Series.Count; j++) + { + if (plot.Series[j].Name.Contains("Mode") && plot.Series[j].Color == templineColor) + { + colorExists = true; + break; + } + } + if (colorExists == false || i == _colorHexCodes.Length - 1) + { + lineColor = templineColor; + fillColor = tempfillColor; + break; + } + } + + var ciPoints = new List(); + var prdPoints = new List(); + var mdPoints = new List(); + for (int i = 0; i < ((IProbabilityOrdinates)alternative).ProbabilityOrdinates.Count; i++) + { + var p = ((IProbabilityOrdinates)alternative).ProbabilityOrdinates[i]; + var up = alternative.AnalysisResults.ConfidenceIntervals[i, 1]; + var lo = alternative.AnalysisResults.ConfidenceIntervals[i, 0]; + var prd = alternative.AnalysisResults.MeanCurve[i]; + var md = alternative.AnalysisResults.ModeCurve[i]; + ciPoints.Add(new Point3D(p, lo, up)); + prdPoints.Add(new Point(p, prd)); + mdPoints.Add(new Point(p, md)); + } + + // Series labels + double ciWidth = alternative.BayesianAnalysis.CredibleIntervalWidth; + string ciLabel = "% Credible Intervals"; + string predLabel = " - Posterior Predictive"; + string pointLabel = " - " + (alternative.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"); + if (alternative.GetType() == typeof(B17CAnalysis)) + { + ciLabel = "% Confidence Intervals"; + predLabel = " - Expected Probability"; + pointLabel = " - " + (alternative.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Mean Parameters" : "Computed"); + } + + // Credible Intervals + analysisItem.CredibleIntervals.Name = "CredibleIntervals_" + index; + analysisItem.CredibleIntervals.Title = alternative.Name + " - " + (ciWidth * 100).ToString("F0") + ciLabel; + analysisItem.CredibleIntervals.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + analysisItem.CredibleIntervals.ItemsSource = ciPoints; + analysisItem.CredibleIntervals.DataFieldX = "X"; + analysisItem.CredibleIntervals.DataFieldY = "Y"; + analysisItem.CredibleIntervals.DataFieldX2 = "X"; + analysisItem.CredibleIntervals.DataFieldY2 = "Z"; + analysisItem.CredibleIntervals.Fill = fillColor; + analysisItem.CredibleIntervals.Color = Colors.Transparent; + plot.Series.Add(analysisItem.CredibleIntervals); + + // Predictive + analysisItem.PosteriorPredictive.Name = "PosteriorPredictive_" + index; + analysisItem.PosteriorPredictive.Title = alternative.Name + predLabel; + analysisItem.PosteriorPredictive.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + analysisItem.PosteriorPredictive.ItemsSource = prdPoints; + analysisItem.PosteriorPredictive.DataFieldX = "X"; + analysisItem.PosteriorPredictive.DataFieldY = "Y"; + analysisItem.PosteriorPredictive.Color = lineColor; + plot.Series.Add(analysisItem.PosteriorPredictive); + + // Point Estimator + analysisItem.PosteriorMode.Name = "PosteriorMode_" + index; + analysisItem.PosteriorMode.Title = alternative.Name + pointLabel; + analysisItem.PosteriorMode.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + analysisItem.PosteriorMode.ItemsSource = mdPoints; + analysisItem.PosteriorMode.DataFieldX = "X"; + analysisItem.PosteriorMode.DataFieldY = "Y"; + analysisItem.PosteriorMode.Color = lineColor; + plot.Series.Add(analysisItem.PosteriorMode); + + plot.InvalidatePlot(true); + + } + + } + + /// + /// Handles the AnalysisRemoved event from the alternative selector. Removes the analysis alternative's + /// curves from the frequency plot. + /// + /// The analysis alternative item to remove from the plot. + private void AlternativeSelector_AnalysisRemoved(AnalysisAlternativeItem analysisItem) + { + var plot = Element?.FrequencyPlot; + if (plot == null) return; + plot.Series.Remove(analysisItem.CredibleIntervals); + plot.Series.Remove(analysisItem.PosteriorPredictive); + plot.Series.Remove(analysisItem.PosteriorMode); + plot.InvalidatePlot(true); + } + + /// + /// Handles the MouseLeftButtonUp event on a TextBlock. Toggles the show filter button. + /// + /// The source of the event. + /// Mouse button event arguments. + private void TextBlock_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + ShowFilterToggleButton.IsChecked = !ShowFilterToggleButton.IsChecked; + } + + #endregion + + /// + /// Binds the frequency curve data grid to the current analysis results, displaying probability ordinates + /// and corresponding quantile values. + /// + private void BindFrequencyCurveDataGrid() + { + FrequencyCurveTable.ItemsSource = null; + + if (Element == null || Element.BayesianAnalysis == null) + return; + + ModeColumn.Header = Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Mean Parameters" : "Computed"; + + if (Element.BayesianAnalysis.IsEstimated != true || Element.AnalysisResults == null) + return; + + var curvePoints = new List(); + for (int i = 0; i < Element.ProbabilityOrdinates.Count; i++) + { + var p = Element.ProbabilityOrdinates[i]; + var up = Element.AnalysisResults.ConfidenceIntervals[i, 1]; + var lo = Element.AnalysisResults.ConfidenceIntervals[i, 0]; + var prd = Element.AnalysisResults.MeanCurve[i]; + var md = Element.AnalysisResults.ModeCurve[i]; + curvePoints.Add(new FrequencyCurvePoint(p, up, lo, prd, md)); + } + FrequencyCurveTable.ItemsSource = curvePoints; + FrequencyCurveTable.Items.Refresh(); + } + + /// + /// Sets the frequency curve table column headers based on the credible interval width. + /// + private void SetFrequencyCurveTableColumnHeaders() + { + double alpha = (1 - Element.BayesianAnalysis.CredibleIntervalWidth) / 2; + UpperColumn.Header = ((1 - alpha) * 100).ToString("F1") + "% CI"; + LowerColumn.Header = (alpha * 100).ToString("F1") + "% CI"; + } + + /// + /// Sets the string format for numeric columns in the frequency curve table based on user settings. + /// + private void SetColumnStringFormats() + { + // Update the column binding string format + UpperColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + LowerColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + PredictiveColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + ModeColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + } + + /// + /// Binds the summary statistics data grid to the current analysis results, displaying distribution + /// parameters and statistical measures. + /// + private void BindSummaryStatisticsDataGrid() + { + SummaryStatisticsTable.ItemsSource = null; + + // Move the Element null check ABOVE the StatValueColumn.Header dereference so a future + // caller that bypasses the existing pre-check (all current call sites already guard) + // doesn't NRE on Element.BayesianAnalysis. The original NaN-row branch below covers the + // not-yet-estimated case visually. + if (Element == null) return; + + StatValueColumn.Header = Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Mean Parameters" : "Computed"; + + var summaryStats = new List(); + if (Element.IsEstimated != true || Element.AnalysisResults == null) + { + + for (int i = 0; i < Element.Bulletin17CDistribution.Parameters.Count; i++) + { + summaryStats.Add(new SummaryStatistic(Element.Bulletin17CDistribution.Parameters[i].DisplayName, double.NaN)); + } + summaryStats.Add(new SummaryStatistic("Minimum", double.NaN)); + summaryStats.Add(new SummaryStatistic("Maximum", double.NaN)); + summaryStats.Add(new SummaryStatistic("Mean", double.NaN)); + summaryStats.Add(new SummaryStatistic("Std Dev", double.NaN)); + summaryStats.Add(new SummaryStatistic("Skewness", double.NaN)); + summaryStats.Add(new SummaryStatistic("Kurtosis", double.NaN)); + summaryStats.Add(new SummaryStatistic("Pseudo AIC", double.NaN)); + summaryStats.Add(new SummaryStatistic("Pseudo BIC", double.NaN)); + summaryStats.Add(new SummaryStatistic("RMSE", double.NaN)); + } + else + { + for (int i = 0; i < Element.Bulletin17CDistribution.Parameters.Count; i++) + { + summaryStats.Add(new SummaryStatistic(Element.Bulletin17CDistribution.Parameters[i].DisplayName, Element.Bulletin17CDistribution.Parameters[i].Value)); + } + var min = Element.Bulletin17CDistribution.Distribution.Minimum; + var max = Element.Bulletin17CDistribution.Distribution.Maximum; + + summaryStats.Add(new SummaryStatistic("Minimum", min < -1E12 ? double.NegativeInfinity : min)); + summaryStats.Add(new SummaryStatistic("Maximum", max > 1E12 ? double.PositiveInfinity : max)); + summaryStats.Add(new SummaryStatistic("Mean", Element.Bulletin17CDistribution.Distribution.Mean)); + summaryStats.Add(new SummaryStatistic("Std Dev", Element.Bulletin17CDistribution.Distribution.StandardDeviation)); + summaryStats.Add(new SummaryStatistic("Skewness", Element.Bulletin17CDistribution.Distribution.Skewness)); + summaryStats.Add(new SummaryStatistic("Kurtosis", Element.Bulletin17CDistribution.Distribution.Kurtosis)); + summaryStats.Add(new SummaryStatistic("Pseudo AIC", Element.AnalysisResults.AIC)); + summaryStats.Add(new SummaryStatistic("Pseudo BIC", Element.AnalysisResults.BIC)); + summaryStats.Add(new SummaryStatistic("RMSE", Element.AnalysisResults.RMSE)); + } + + SummaryStatisticsTable.ItemsSource = summaryStats; + SummaryStatisticsTable.Items.Refresh(); + } + + /// + /// Handles the LoadingRow event of the summary statistics table. Adds visual separators between + /// different groups of statistics. + /// + /// The source of the event. + /// Data grid row event arguments. + private void SummaryStatisticsTable_LoadingRow(object sender, DataGridRowEventArgs e) + { + if (((SummaryStatistic)e.Row.DataContext).Name == "Minimum" || + ((SummaryStatistic)e.Row.DataContext).Name == "Pseudo AIC") + { + e.Row.BorderThickness = new Thickness(0, 2, 0, 0); + e.Row.SetResourceReference(Border.BorderBrushProperty, "EnvironmentWindowText"); + } + else + { + e.Row.BorderThickness = new Thickness(0, 0, 0, 0); + } + } + } +} diff --git a/src/RMC.BestFit.App/GUI/UnivariateAnalysis/B17C/B17CAnalysisPropertiesControl.xaml b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/B17C/B17CAnalysisPropertiesControl.xaml new file mode 100644 index 0000000..841a7f2 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/B17C/B17CAnalysisPropertiesControl.xaml @@ -0,0 +1,491 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/UnivariateAnalysis/B17C/B17CAnalysisPropertiesControl.xaml.cs b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/B17C/B17CAnalysisPropertiesControl.xaml.cs new file mode 100644 index 0000000..3b4662b --- /dev/null +++ b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/B17C/B17CAnalysisPropertiesControl.xaml.cs @@ -0,0 +1,640 @@ +using FrameworkInterfaces; +using GenericControls; +using RMC.BestFit.Models; +using RMC.BestFit.UI; +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Input; +using Numerics.Distributions; +using Numerics.Utilities; +using System.Collections.Generic; +using System.Windows.Controls.Primitives; +using System.Linq; +using RMC.BestFit.Analyses; +using RMC.BestFit.Estimation; +using NumericControls; +using System.Windows.Media; + +namespace RMC_BestFit +{ + /// + /// User control for editing B17C frequency analysis properties, including input data selection, + /// distribution parameters, parameter priors, quantile priors, and uncertainty analysis settings. + /// This control provides the interface for configuring and executing Bulletin 17C statistical analyses. + /// + public partial class B17CAnalysisPropertiesControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public B17CAnalysisPropertiesControl() + { + InitializeComponent(); + DataContext = this; + this.Unloaded += UserControl_Unloaded; + } + + /// + /// Stores the previous name value for validation and rollback purposes. + /// + private string _previousName; + + /// + /// Cached reference to the distribution ComboBox for programmatic selection during undo. + /// + private ComboBox _distributionComboBox; + + /// + /// Tracks the currently subscribed InputDataCollection for clean unsubscription. + /// + private IElementCollection _subscribedInputDataCollection; + + /// + /// Dependency property for the Element property. + /// + public static DependencyProperty ElementProperty = DependencyProperty.Register(nameof(Element), typeof(B17CAnalysis), typeof(B17CAnalysisPropertiesControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the B17C analysis element being edited by this control. + /// + public B17CAnalysis Element + { + get { return (B17CAnalysis)GetValue(ElementProperty); } + set { SetValue(ElementProperty, value); } + } + + /// + /// Callback method invoked when the Element dependency property changes. + /// Initializes property attributes, loads input data, and binds data grids. + /// + /// The dependency object that changed. + /// Event arguments containing the old and new property values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as B17CAnalysisPropertiesControl == null) return; + var thisControl = (B17CAnalysisPropertiesControl)d; + + // Unsubscribe from old element + if (e.OldValue is B17CAnalysis oldElement) + { + oldElement.PropertyChanged -= thisControl.Element_PropertyChanged; + } + + if (e.NewValue == null) return; + var newElement = e.NewValue as B17CAnalysis; + if (newElement == null) return; + + // Initialize previous name for rollback safety + thisControl._previousName = newElement.Name; + + newElement.PropertyChanged += thisControl.Element_PropertyChanged; + thisControl.PropertyAttributes.GetClassAttributes(newElement); + thisControl.LoadInputData(); + thisControl.ParametersTable.ItemsSource = newElement.ParameterPenalties; + thisControl.QuantileTable.ItemsSource = newElement.QuantilePenalties; + } + + /// + /// Dependency property for the existing names property. + /// + public static DependencyProperty ExistingNamesProperty = DependencyProperty.Register(nameof(ExistingNames), typeof(string[]), typeof(B17CAnalysisPropertiesControl), new FrameworkPropertyMetadata(new string[] { })); + + /// + /// Array of existing names for this element type. + /// + public string[] ExistingNames + { + get { return (string[])GetValue(ExistingNamesProperty); } + private set { SetValue(ExistingNamesProperty, value); } + } + + /// + /// Gets the observable collection of available input data sets from the project. + /// + public ObservableCollection InputDataList { get; private set; } = new ObservableCollection(); + + /// + /// Gets the observable collection of available univariate distributions for B17C analysis. + /// + public ObservableCollection DistributionList { get; private set; } = new ObservableCollection() + { + new UnivariateDistributionItem("Exponential", UnivariateDistributionType.Exponential), + new UnivariateDistributionItem("Gamma", UnivariateDistributionType.GammaDistribution), + new UnivariateDistributionItem("Log-Normal", UnivariateDistributionType.LogNormal), + new UnivariateDistributionItem("Log-Pearson Type III", UnivariateDistributionType.LogPearsonTypeIII), + new UnivariateDistributionItem("Normal", UnivariateDistributionType.Normal), + new UnivariateDistributionItem("Pearson Type III", UnivariateDistributionType.PearsonTypeIII), + }; + + /// + /// Backing field for the property. Allocated once to avoid creating a new list on every XAML binding call. + /// + private static readonly List _aepOptions = new List() { 0.1, 0.05, 0.02, 0.01, 0.005, 0.002, 0.001, 0.0005, 0.0002, 0.0001, 0.00005, 0.00002, 0.00001, 0.000005, 0.000002, 0.000001 }; + + /// + /// Gets the list of available annual exceedance probability (AEP) options for quantile priors. + /// + public List AEPOptions => _aepOptions; + + /// + /// Gets the observable collection of available confidence interval widths. + /// + public ObservableCollection CredibleIntervalItems { get; private set; } = new ObservableCollection() + { + new CredibleIntervalItem("90%", 0.9), + new CredibleIntervalItem("95%", 0.95), + new CredibleIntervalItem("98%", 0.98), + new CredibleIntervalItem("99%", 0.99) + }; + + /// + /// Gets the observable collection of available Generalized Method of Moments (GMM) estimation strategies. + /// + public ObservableCollection EstimationMethodList { get; private set; } = new ObservableCollection() + { + new GMMEstimationTypeItem("One-Step", GeneralizedMethodOfMoments.GMMEstimationStrategy.OneStep), + new GMMEstimationTypeItem("Two-Step", GeneralizedMethodOfMoments.GMMEstimationStrategy.TwoStep), + new GMMEstimationTypeItem("Iterative", GeneralizedMethodOfMoments.GMMEstimationStrategy.Iterative), + }; + + /// + /// Gets the observable collection of available uncertainty quantification methods for B17C analysis. + /// + public ObservableCollection UncertaintyMethodList { get; private set; } = new ObservableCollection() + { + new B17CUncertaintyOptionItem("Multivariate Normal", RMC.BestFit.Analyses.UncertaintyMethod.MultivariateNormal, "Parameters are sampled using the asymptotic covariance matrix and the first-order accurate multivariate normal method."), + new B17CUncertaintyOptionItem("Linked Multivariate Normal", RMC.BestFit.Analyses.UncertaintyMethod.LinkedMultivariateNormal, "Parameters are sampled using variance-stabilizing link transformations with the multivariate normal method."), + new B17CUncertaintyOptionItem("Bootstrap", RMC.BestFit.Analyses.UncertaintyMethod.Bootstrap, "Parameters are sampled using the first-order accurate parametric bootstrap method."), + new B17CUncertaintyOptionItem("Bias-Corrected Bootstrap", RMC.BestFit.Analyses.UncertaintyMethod.BiasCorrectedBootstrap, "Parameters are sampled using the second-order accurate and bias-corrected bootstrap method."), + }; + + /// + /// Gets the observable collection of available point estimator options. + /// + public ObservableCollection PointEstimatorItems { get; private set; } = new ObservableCollection() + { + new PointEstimatorItem("Computed", BayesianAnalysis.PointEstimateType.PosteriorMode, "The computed parameter values for the distribution using the Generalized Method of Moments (GMM)."), + new PointEstimatorItem("Mean Parameters", BayesianAnalysis.PointEstimateType.PosteriorMean, "Calculates the average of all samples, providing a balanced summary of the parameter estimates."), + }; + + #region Property Attributes + + /// + /// Handles the PreviewMouseLeftButtonDown event on the Name field. Displays property attributes for the Name property. + /// + /// The source of the event. + /// Mouse button event arguments. + private void Name_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.Name), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the Description field. Displays property attributes for the Description property. + /// + /// The source of the event. + /// Mouse button event arguments. + private void Description_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.Description), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the CreationDate field. Displays property attributes for the CreationDate property. + /// + /// The source of the event. + /// Mouse button event arguments. + private void CreationDate_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.CreationDate), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the LastModified field. Displays property attributes for the LastModified property. + /// + /// The source of the event. + /// Mouse button event arguments. + private void LastModified_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.LastModified), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the InputData field. Displays property attributes for the InputData property. + /// + /// The source of the event. + /// Mouse button event arguments. + private void InputData_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.InputData), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the Distribution field. Displays property attributes for the Distribution property. + /// + /// The source of the event. + /// Mouse button event arguments. + private void Distribution_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.Bulletin17CDistribution.Distribution), Element.Bulletin17CDistribution); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the ParametersTable. Displays default attributes for parameter information. + /// + /// The source of the event. + /// Mouse button event arguments. + private void ParametersTable_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.SetDefaultAttributes("Parameter Information", "Enter distribution parameter information, such as USGS regional skew data."); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the QuantileTable. Displays default attributes for quantile information. + /// + /// The source of the event. + /// Mouse button event arguments. + private void QuantileTable_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.SetDefaultAttributes("Quantile Information", "Enter distribution quantile information, such as USGS regional quantile regression data."); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the UncertaintyMethod field. Displays property attributes for the UncertaintyMethod property. + /// + /// The source of the event. + /// Mouse button event arguments. + private void UncertaintyMethod_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.UncertaintyMethod), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the ConfidenceInterval field. Displays default attributes for confidence interval width. + /// + /// The source of the event. + /// Mouse button event arguments. + private void ConfidenceInterval_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.SetDefaultAttributes("Confidence Interval", "Specifies the confidence interval width. For example, a 90% confidence interval indicates that the true value lies within the interval with 90% probability."); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the OutputLength field. Displays default attributes for output length. + /// + /// The source of the event. + /// Mouse button event arguments. + private void OutputLength_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.SetDefaultAttributes("Output Length", "Specifies the number of parameter sets to output. For example, outputting 10,000 sets is recommended to ensure an accurate 90% confidence interval."); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the PRNGSeed field. Displays default attributes for the random number generator seed. + /// + /// The source of the event. + /// Mouse button event arguments. + private void PRNGSeed_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.SetDefaultAttributes("PRNG Seed", "Specifies the pseudo random number generator seed for the uncertainty analysis, ensuring repeatability."); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the PointEstimator field. Displays default attributes for point estimator selection. + /// + /// The source of the event. + /// Mouse button event arguments. + private void PointEstimator_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.SetDefaultAttributes("Point Estimator", "Specifies the point estimator for summarizing the parent distribution. Choose 'Mean Parameters' for the average or 'Computed' for the GMM fit."); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the ProbabilityOrdinatesControl. Displays default attributes for probability ordinates. + /// + /// The source of the event. + /// Mouse button event arguments. + private void ProbabilityOrdinatesControl_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.SetDefaultAttributes("Probability Ordinates", "The exceedance probabilities used for plotting the probability distribution."); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on a TabItem. Displays class-level attributes for the Element. + /// + /// The source of the event. + /// Mouse button event arguments. + private void TabItem_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetClassAttributes(Element); + } + + #endregion + + /// + /// Handles the GotFocus event on the Name field. Stores the current name and retrieves existing element names for validation. + /// + /// The source of the event. + /// Event arguments. + private void Name_GotFocus(object sender, RoutedEventArgs e) + { + _previousName = Element.Name; + ExistingNames = Element.ParentCollection.GetElementNames(Element).ToArray(); + } + + /// + /// Handles the LostFocus event on the Name field. Reverts to the previous name if the new name is invalid. + /// + /// The source of the event. + /// Event arguments. + private void Name_LostFocus(object sender, RoutedEventArgs e) + { + if (ElementName.NameTextBox.IsValid) + return; + if (Element != null) + Element.Name = _previousName; + } + + /// + /// Handles the Unloaded event of the user control. Unsubscribes control-scoped event handlers + /// to prevent stale callbacks during visual-tree cycles. + /// + /// + /// Element.PropertyChanged is element-scoped and is managed exclusively in + /// so it survives Unload/Reload visual-tree cycles. + /// Only the InputDataCollection subscription (control-scoped) is released here. + /// + /// The source of the event. + /// Event arguments. + private void UserControl_Unloaded(object sender, RoutedEventArgs e) + { + UnsubscribeInputDataCollection(); + } + + /// + /// Handles property changed events from the Element. + /// + /// The source of the event. + /// Event arguments containing the property name that changed. + private void Element_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + // Marshal to the UI thread if called from a background thread (the model-layer + // RaisePropertyChange does not marshal, and run-time notifications can arrive on + // worker threads). Rebinding ItemsSource cross-thread throws InvalidOperationException. + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke(new Action(() => Element_PropertyChanged(sender, e))); + return; + } + + // Distribution object replaced (e.g., during undo restoration) — explicitly rebind + // penalty grids and sync distribution combo box. Do NOT rely on WPF multi-level binding + // path refresh for nested object replacement. + if (e.PropertyName == nameof(Element.Bulletin17CDistribution)) + { + ParametersTable.ItemsSource = null; + ParametersTable.ItemsSource = Element.ParameterPenalties; + QuantileTable.ItemsSource = null; + QuantileTable.ItemsSource = Element.QuantilePenalties; + if (_distributionComboBox != null) + _distributionComboBox.SelectedValue = Element.Bulletin17CDistribution?.DistributionType; + } + // Parameter penalties list was rebuilt (distribution type changed or SetDefaultParameters ran). + // "Parameters" and "SetDefaultParameters" are inner Bulletin17CDistribution property names + // forwarded via B17CAnalysis.PropertyChanged — no matching public property on B17CAnalysis, + // so they remain as literal strings. + if (e.PropertyName == nameof(Element.ParameterPenalties) || e.PropertyName == "Parameters" || e.PropertyName == "SetDefaultParameters") + { + ParametersTable.ItemsSource = null; + ParametersTable.ItemsSource = Element.ParameterPenalties; + } + // Quantile penalties list was rebuilt + if (e.PropertyName == nameof(Element.QuantilePenalties)) + { + QuantileTable.ItemsSource = null; + QuantileTable.ItemsSource = Element.QuantilePenalties; + } + } + + /// + /// Loads the available input data from the project into the InputDataList collection. + /// Subscribes to element addition and removal events to keep the list synchronized. + /// + private void LoadInputData() + { + UnsubscribeInputDataCollection(); + InputDataList.Clear(); + foreach (IElementCollection collection in Element.ParentCollection.ParentProject.ElementCollections) + { + if (collection.GetType() == typeof(InputDataCollection)) + { + _subscribedInputDataCollection = collection; + collection.ElementAdded += OnInputDataElementAdded; + collection.ElementRemoved += OnInputDataElementRemoved; + foreach (IElement element in collection) + InputDataList.Add((InputData)element); + break; + } + } + } + + /// + /// Handles an input data element being added to the project. + /// + private void OnInputDataElementAdded(IElement x) => InputDataList.Add((InputData)x); + + /// + /// Handles an input data element being removed from the project. + /// + private void OnInputDataElementRemoved(IElement x) => InputDataList.Remove((InputData)x); + + /// + /// Unsubscribes from the currently tracked InputDataCollection to prevent memory leaks. + /// + private void UnsubscribeInputDataCollection() + { + if (_subscribedInputDataCollection != null) + { + _subscribedInputDataCollection.ElementAdded -= OnInputDataElementAdded; + _subscribedInputDataCollection.ElementRemoved -= OnInputDataElementRemoved; + _subscribedInputDataCollection = null; + } + } + + /// + /// Handles the SelectionChanged event of the InputDataComboBox. Updates visual validation indicators + /// based on whether valid input data is selected. + /// + /// The source of the event. + /// Selection changed event arguments. + private void InputDataComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (Element == null) return; + if (Element.InputData == null || Element.InputData.Name == null) + { + ((Border)InputDataComboBox.InnerContent).BorderThickness = new Thickness(1); + InputDataComboBox.ToolTip = "Please select a valid input data."; + } + else + { + ((Border)InputDataComboBox.InnerContent).BorderThickness = new Thickness(0); + InputDataComboBox.ToolTip = null; + } + } + + /// + /// Handles the SelectionChanged event of the DistributionComboBox. + /// + /// The source of the event. + /// Selection changed event arguments. + private void DistributionComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + } + + /// + /// Captures a reference to the Distribution ComboBox for programmatic selection during undo. + /// + private void DistributionComboBox_Loaded(object sender, RoutedEventArgs e) + { + _distributionComboBox = sender as ComboBox; + } + + /// + /// Handles the Loaded event of the ComboBox. Sets up sorted input data as the item source. + /// + /// The source of the event. + /// Event arguments. + private void ComboBox_Loaded(object sender, RoutedEventArgs e) + { + if (Element == null) return; + ComboBox cmbo = (ComboBox)sender; + CollectionViewSource csv = new CollectionViewSource() { Source = InputDataList, IsLiveSortingRequested = true }; + csv.SortDescriptions.Add(new SortDescription(nameof(IElement.Name), ListSortDirection.Ascending)); + var view = csv.View; + view.MoveCurrentToPosition(-1); + cmbo.ItemsSource = view; + } + + /// + /// Handles the Click event of the EstimateButton. Validates inputs, configures the UI for simulation, + /// and executes the B17C frequency analysis asynchronously with progress reporting. + /// + /// The source of the event. + /// Event arguments. + private async void EstimateButton_Click(object sender, RoutedEventArgs e) + { + // Check if the analysis is valid + if (Element.IsValid == false) + { + GenericControls.MessageBox.Show("Cannot perform the analysis because the inputs are invalid.", "Error!", MessageBoxButton.OK, MessageBoxImage.Error); + return; + } + + Mouse.OverrideCursor = Cursors.Wait; + + // Disable all open windows + FrameworkUI.ShellPublicVariables.SimulationInProgress = true; + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableMenuStrip(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableProjectExplorer(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableMessageWindow(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableOpenWindows(); + // Disable Properties + PropertiesExpander.IsEnabled = false; + ParameterPriorsExpander.IsEnabled = false; + QuantilePriorsExpander.IsEnabled = false; + Options_TabItem.IsEnabled = false; + + // Set up progress bar + AnalysisProgressDisplayHelper.ShowInitial(ProgressBar, ProgressTextBlock, "Fitting with GMM..."); + + // Show cancel button + EstimateButton.Visibility = Visibility.Hidden; + CancelButton.Visibility = Visibility.Visible; + + // Set up progress reporter + var progressReporter = new SafeProgressReporter(nameof(B17CAnalysis)); + progressReporter.ProgressReported += (SafeProgressReporter reporter, double progress, double progressDelta) => + { + AnalysisProgressDisplayHelper.PostProgress(Dispatcher, ProgressBar, ProgressTextBlock, progress, "Fitting with GMM..."); + }; + bool progressCleanupEnabled = false; + progressReporter.TaskEnded += () => + { + if (!progressCleanupEnabled) return; + // Close out progress bar + ProgressBar.Value = 100; + ProgressTextBlock.Text = "Simulation Complete"; + Mouse.OverrideCursor = null; + EstimateButton.IsEnabled = true; + ProgressBar.Visibility = Visibility.Hidden; + ProgressTextBlock.Visibility = Visibility.Hidden; + + // Close cancel button + EstimateButton.Visibility = Visibility.Visible; + CancelButton.Visibility = Visibility.Hidden; + + // Enable all windows + FrameworkUI.ShellPublicVariables.SimulationInProgress = false; + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableMenuStrip(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableProjectExplorer(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableMessageWindow(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableOpenWindows(); + // Enable Properties + PropertiesExpander.IsEnabled = true; + ParameterPriorsExpander.IsEnabled = true; + QuantilePriorsExpander.IsEnabled = true; + Options_TabItem.IsEnabled = true; + }; + + + // Perform Bayesian estimation + try + { + await WaitCursorHelper.RunWithVisibleWaitCursorAsync(Dispatcher, async () => + { + await Element.RunAsync(progressReporter); + }); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"B17CAnalysisPropertiesControl.EstimateButton_Click: {ex}"); + GenericControls.MessageBox.Show("An unexpected error occurred. Please verify your analysis inputs and settings." + Environment.NewLine + "If the issue persists, contact the Risk Management Center.", "Error!", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + progressCleanupEnabled = true; + progressReporter.IndicateTaskEnded(); + } + + + } + + /// + /// Handles the Click event of the CancelButton. Cancels the currently running analysis. + /// + /// The source of the event. + /// Event arguments. + private void CancelButton_Click(object sender, RoutedEventArgs e) + { + Element.CancelAnalysis(); + } + + /// + /// Handles the KeyDown event for the analysis properties control. Cancels the analysis when Escape key is pressed. + /// + /// The source of the event. + /// Key event arguments. + private void AnalysisProperties_KeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + Element.CancelAnalysis(); + } + + + } +} diff --git a/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Composite/CompositeAnalysisControl.xaml b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Composite/CompositeAnalysisControl.xaml new file mode 100644 index 0000000..4308e65 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Composite/CompositeAnalysisControl.xaml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Composite/CompositeAnalysisControl.xaml.cs b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Composite/CompositeAnalysisControl.xaml.cs new file mode 100644 index 0000000..09b1ead --- /dev/null +++ b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Composite/CompositeAnalysisControl.xaml.cs @@ -0,0 +1,826 @@ +using OxyPlot; +using OxyPlot.Wpf; +using OxyPlotControls; +using FrameworkInterfaces; +using FrameworkUI; +using RMC.BestFit.Models; +using ModelAnalyses = RMC.BestFit.Analyses; +using RMC.BestFit.Estimation; +using RMC.BestFit.UI; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Media3D; +using System.Windows.Threading; +using System.Xml.Linq; + +namespace RMC_BestFit +{ + /// + /// User control for displaying and managing composite distribution analysis results, including frequency plots, + /// data visualization, and statistical summaries. + /// + public partial class CompositeAnalysisControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// Sets up the control and initializes color codes for plot series. + /// + public CompositeAnalysisControl() + { + InitializeComponent(); + DataContext = this; + _colorHexCodes = GenericControls.GeneralMethods.RandomColorsLongList; + // DataGrid Binding.StringFormat must be set before first render. + SetColumnStringFormats(); + } + + /// + /// Dependency property for the property. + /// Enables data binding for the composite analysis element. + /// + public static DependencyProperty ElementProperty = DependencyProperty.Register(nameof(Element), typeof(CompositeAnalysis), typeof(CompositeAnalysisControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the composite analysis element displayed in this control. + /// + public CompositeAnalysis Element + { + get { return (CompositeAnalysis)GetValue(ElementProperty); } + set { SetValue(ElementProperty, value); } + } + + /// + /// Callback method invoked when the dependency property changes. + /// Manages event handler subscriptions and unsubscriptions for the old and new element values. + /// + /// The dependency object on which the property changed. + /// Event arguments containing the old and new values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as CompositeAnalysisControl == null) return; + var thisControl = (CompositeAnalysisControl)d; + + // Remove handlers + if (e.OldValue != null) + { + CompositeAnalysis oldElement = e.OldValue as CompositeAnalysis; + if (oldElement != null) + { + oldElement.PropertyChanged -= thisControl.Element_PropertyChanged; + thisControl.FrequencyPlotHost.Content = null; + thisControl.FrequencyPlotToolbar.Plot = null; + // NOTE: PropertiesCalled is wired in XAML no programmatic -= needed. + } + } + + if (e.NewValue == null) return; + var newElement = e.NewValue as CompositeAnalysis; + if (newElement == null) return; + + // Subscribe Element-scoped handler + newElement.PropertyChanged += thisControl.Element_PropertyChanged; + + // Attach plot, wire toolbar, and bind axis titles inside a bridge-suspension + // block. PropertiesCalled is wired in XAML no programmatic += needed. + using (newElement.SuspendPlotBridges()) + { + thisControl.FrequencyPlotHost.Content = newElement.FrequencyPlot; + thisControl.FrequencyPlotToolbar.Plot = newElement.FrequencyPlot; + thisControl.BindAxisTitles(); + } + } + + /// + /// Convenience accessor for the Element-owned frequency plot. + /// + private Plot FrequencyPlot => Element?.FrequencyPlot; + + /// + /// Gets a value indicating whether a plot was clicked. + /// + public bool PlotClicked { get; private set; } = false; + + /// + /// Event raised when plot properties are requested. + /// + public event PlotPropertiesCalledEventHandler PlotPropertiesCalled; + + /// + /// Handles the PropertiesCalled event from the plot toolbar. Forwards to the control's PlotPropertiesCalled event. + /// + private void PlotToolbar_PropertiesCalled(Plot targetPlot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject) + { + PlotPropertiesCalled?.Invoke(targetPlot, openProperties, propertyExpander, selectedObject); + } + + /// + /// Delegate for the event. + /// + /// The plot for which properties are requested. + /// Indicates whether to open the properties dialog. + /// The property expander control. + /// The selected object in the plot. + public delegate void PlotPropertiesCalledEventHandler(Plot plot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject); + + /// + /// Event raised when the preview control is clicked. + /// + public event PreviewControlClickedEventHandler PreviewControlClicked; + + /// + /// Delegate for the event. + /// + /// Indicates whether the plot was clicked. + /// Indicates whether the toolbar was clicked. + /// The plot that was clicked. + public delegate void PreviewControlClickedEventHandler(bool plotClicked, bool toolbarClicked, Plot plot); + + /// + /// Array of hex color codes used for plot series styling. + /// + private string[] _colorHexCodes; + + + /// + /// Handles the Loaded event of the user control. Initializes plot settings and data bindings. + /// + /// The source of the event. + /// The instance containing the event data. + private void UserControl_Loaded(object sender, RoutedEventArgs e) + { + if (Element == null) return; + + // Data refresh on every load. Plot host, toolbar, axis-title bindings, and + // Element.PropertyChanged are wired once per Element in ElementCallback. + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try + { + UpdateFrequencyPlot(); + BindFrequencyCurveDataGrid(); + SetFrequencyCurveTableColumnHeaders(); + BindSummaryStatisticsDataGrid(); + } + finally + { + Element.IsUndoEnabled = wasUndoEnabled; + } + } + + /// + /// Handles the Unloaded event of the user control. Unsubscribes all element event handlers + /// to prevent memory leaks and stale event callbacks. + /// + /// The source of the event. + /// Event arguments. + private void UserControl_Unloaded(object sender, RoutedEventArgs e) + { + // Pattern B (canonical): Element-scoped lifecycle (PropertyChanged, plot + // hosts, toolbars) is owned by ElementCallback and survives unload/reload + // cycles. This control has no control-scoped subscriptions to tear down, + // so Unloaded is intentionally a no-op. + } + + /// + /// Handles property change events from the Element. Updates the UI when analysis results or input data change. + /// + /// The source of the event. + /// The instance containing the event data. + private void Element_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + // Marshal to UI thread if called from a background thread. + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke(new Action(() => Element_PropertyChanged(sender, e))); + return; + } + + if (e.PropertyName == nameof(Element.AnalysisResults)) + { + BindFrequencyCurveDataGrid(); + BindSummaryStatisticsDataGrid(); + UpdateFrequencyPlot(); + ResetWaitCursor(); + } + if (e.PropertyName == nameof(Element.InputData)) + { + using (Element?.SuspendPlotBridges()) + { + BindAxisTitles(); + } + UpdateFrequencyPlot(); + } + if (e.PropertyName == nameof(Element.BayesianAnalysis.CredibleIntervalWidth)) + { + SetFrequencyCurveTableColumnHeaders(); + UpdateFrequencyPlot(); + BindFrequencyCurveDataGrid(); + BindSummaryStatisticsDataGrid(); + } + if (e.PropertyName == nameof(Element.BayesianAnalysis.PointEstimator)) + { + UpdateFrequencyPlot(); + BindFrequencyCurveDataGrid(); + BindSummaryStatisticsDataGrid(); + } + // CompositeAnalysis exception: BayesianAnalysis.CredibleIntervalWidth calls ClearResults + // (no per-realisation distribution cache), so no wait cursor for that property. + // BayesianAnalysis.PointEstimator and ProbabilityOrdinates DO reprocess via + // UpdatePointEstimateResultsAsync / CreateFrequencyAnalysisResultsAsync. + if ((e.PropertyName == nameof(Element.BayesianAnalysis.PointEstimator) + || e.PropertyName == nameof(Element.ProbabilityOrdinates)) + && Element.IsEstimated) + { + // Cursor wait while the model layer's ReprocessIfEstimated runs the recompute + // on TaskScheduler.Default. Reset is wired to the AnalysisResults notification above. + Mouse.OverrideCursor = Cursors.Wait; + } + } + + /// + /// Clears when it is currently set to . + /// Marshals onto the UI thread when called from a background-thread PropertyChanged + /// notification (the model layer's ReprocessIfEstimated path uses + /// TaskScheduler.Default). + /// + private void ResetWaitCursor() + { + // Reset at Background priority so the Render-priority cursor frame from the + // earlier `Mouse.OverrideCursor = Cursors.Wait` is guaranteed to flush before + // the reset runs. Without this, fast reprocesses (UpdatePointEstimateResultsAsync) + // can reset the cursor before the OS visually picks up the change the user + // sees no wait cursor at all when the mouse is stationary. + Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + { + if (Mouse.OverrideCursor == Cursors.Wait) + Mouse.OverrideCursor = null; + })); + } + + /// + /// Binds the frequency plot Y-axis title to the InputData.UnitLabel property. + /// Called after element attachment and when InputData changes. + /// + private void BindAxisTitles() + { + if (Element == null) return; + + // Frequency plot: Y-axis title = UnitLabel + var freqYAxis = FrequencyPlot?.Axes.FirstOrDefault(a => a.Key == "Yaxis"); + if (freqYAxis != null) + { + if (Element.InputData != null) + PlotAxisTitleDefaults.BindTitleIfDefault(freqYAxis, Element.InputData, nameof(InputData.UnitLabel), Element.InputData.UnitLabel); + else + PlotAxisTitleDefaults.SetTitleIfDefault(freqYAxis, string.Empty); + } + } + + /// + /// Handles the LostFocus event of the user control. Resets the PlotClicked flag. + /// + /// The source of the event. + /// The instance containing the event data. + private void UserControl_LostFocus(object sender, RoutedEventArgs e) + { + PlotClicked = false; + } + + /// + /// Handles the PreviewMouseDown event of the user control. Determines if the plot or toolbar was clicked and raises the PreviewControlClicked event. + /// + /// The source of the event. + /// The instance containing the event data. + private void UserControl_PreviewMouseDown(object sender, MouseButtonEventArgs e) + { + Plot plot = FrequencyPlot; + OxyPlotToolbar toolbar = FrequencyPlotToolbar; + if (plot == null || toolbar == null) return; + var plotHitResult = VisualTreeHelper.HitTest(plot, e.GetPosition(plot)); + var toolbarHitResult = VisualTreeHelper.HitTest(toolbar, e.GetPosition(toolbar)); + PreviewControlClicked?.Invoke(plotHitResult != null, toolbarHitResult != null, plot); + PlotClicked = plotHitResult != null; + } + + /// + /// Handles the Checked event of the SubDistCheckbox. Updates the frequency plot to show sub-distributions. + /// + /// The source of the event. + /// The instance containing the event data. + private void SubDistCheckbox_Checked(object sender, RoutedEventArgs e) + { + UpdateFrequencyPlot(); + } + + /// + /// Handles the Unchecked event of the SubDistCheckbox. Updates the frequency plot to hide sub-distributions. + /// + /// The source of the event. + /// The instance containing the event data. + private void SubDistCheckbox_Unchecked(object sender, RoutedEventArgs e) + { + UpdateFrequencyPlot(); + } + + /// + /// Updates the frequency plot with the current analysis results and input data. + /// Adds or removes series based on the available data and user selections. + /// + private void UpdateFrequencyPlot() + { + if (FrequencyPlot == null) return; + + // Lookup-or-create named series so user-customized styling survives Save/Open. + var frequencyExactData = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "ExactData") + ?? new ScatterPointSeries + { + Name = "ExactData", + Title = "Exact Data", + MarkerFill = Colors.Black, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle, + }; + var frequencyLowOutlierData = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "LowOutlierData") + ?? new ScatterPointSeries + { + Name = "LowOutlierData", + Title = "Low Outlier Data", + MarkerStroke = Colors.Red, + MarkerStrokeThickness = 2, + MarkerSize = 3, + MarkerType = MarkerType.Cross, + }; + var frequencyUncertainData = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "UncertainData") + ?? new ScatterErrorSeries + { + Name = "UncertainData", + Title = "Uncertain Data", + MarkerFill = Colors.Green, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Diamond, + }; + var frequencyIntervalData = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "IntervalData") + ?? new ScatterErrorSeries + { + Name = "IntervalData", + Title = "Interval Data", + MarkerFill = Colors.Cyan, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle, + }; + var credibleIntervals = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "CredibleIntervals") + ?? new AreaSeries + { + Name = "CredibleIntervals", + Fill = Color.FromArgb(75, 104, 140, 175), + Color = Color.FromArgb(255, 53, 59, 122), + LineStyle = LineStyle.Solid, + BrokenLineThickness = 1, + StrokeThickness = 1, + Decimator = OxyPlot.Decimator.Decimate, + }; + var posteriorPredictive = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "PosteriorPredictive") + ?? new LineSeries + { + Name = "PosteriorPredictive", + Title = "Posterior Predictive", + Color = Colors.Blue, + StrokeThickness = 2, + LineStyle = LineStyle.Dash, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0, + }; + var posteriorMode = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "PosteriorMode") + ?? new LineSeries + { + Name = "PosteriorMode", + Title = "Posterior Mode", + Color = Colors.Black, + StrokeThickness = 2, + LineStyle = LineStyle.Solid, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0, + }; + + using (Element?.SuspendPlotBridges()) + { + FrequencyPlot.Series.Clear(); + + if (Element == null) + { + FrequencyPlot.InvalidatePlot(true); + } + else + { + Mouse.OverrideCursor = Cursors.Wait; + try + { + // Add Curves + if (Element.IsEstimated == true && Element.AnalysisResults != null) + { + // Add sub-distributions (dynamic count per filtered distribution; + // documented exception to the lookup-or-create rule series count depends + // on runtime state). + if (Element.AnalysesValid == true && SubDistCheckbox.IsChecked == true) + { + var colorHexCodes = GenericControls.GeneralMethods.RandomColorsShortList; + for (int i = 0; i < Element.Analyses.Count; i++) + { + var points = new List(); + for (int j = 0; j < Element.Analyses[i].UnivariateAnalysis.ProbabilityOrdinates.Count; j++) + { + var x = Element.Analyses[i].UnivariateAnalysis.ProbabilityOrdinates[j]; + var y = Element.Analyses[i].UnivariateAnalysis.AnalysisResults.ModeCurve[j]; + points.Add(new OxyPlot.DataPoint(x, y)); + } + + var color = (Color)ColorConverter.ConvertFromString(colorHexCodes[i + 1]); + var newLineSeries = new LineSeries() + { + Name = "series_" + i.ToString(), + Title = (Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode") + " - " + Element.Analyses[i].UnivariateAnalysis.Name, + LineStyle = OxyPlot.LineStyle.Solid, + Color = Color.FromArgb(200, color.R, color.G, color.B), + StrokeThickness = 1.5, + MarkerType = MarkerType.Triangle, + MarkerStroke = Color.FromArgb(200, color.R, color.G, color.B), + MarkerFill = Colors.Transparent, + MarkerSize = 3, + MarkerStrokeThickness = 1.5, + ItemsSource = points, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0, + TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}" + }; + + FrequencyPlot.Series.Add(newLineSeries); + } + } + + var ciPoints = new List(); + var prdPoints = new List(); + var mdPoints = new List(); + for (int i = 0; i < Element.ProbabilityOrdinates.Count; i++) + { + var p = Element.ProbabilityOrdinates[i]; + var up = Element.AnalysisResults.ConfidenceIntervals[i, 1]; + var lo = Element.AnalysisResults.ConfidenceIntervals[i, 0]; + var prd = Element.AnalysisResults.MeanCurve[i]; + var md = Element.AnalysisResults.ModeCurve[i]; + ciPoints.Add(new Point3D(p, lo, up)); + prdPoints.Add(new OxyPlot.DataPoint(p, prd)); + mdPoints.Add(new OxyPlot.DataPoint(p, md)); + } + + // Credible Intervals + credibleIntervals.ItemsSource = ciPoints; + credibleIntervals.DataFieldX = "X"; + credibleIntervals.DataFieldY = "Y"; + credibleIntervals.DataFieldX2 = "X"; + credibleIntervals.DataFieldY2 = "Z"; + credibleIntervals.Title = (Element.BayesianAnalysis.CredibleIntervalWidth * 100).ToString("F0") + "% Credible Intervals"; + credibleIntervals.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + FrequencyPlot.Series.Add(credibleIntervals); + + // Predictive + posteriorPredictive.ItemsSource = prdPoints; + posteriorPredictive.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + FrequencyPlot.Series.Add(posteriorPredictive); + + // Point Estimator + posteriorMode.Title = Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"; + posteriorMode.ItemsSource = mdPoints; + posteriorMode.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + FrequencyPlot.Series.Add(posteriorMode); + } + + // See if Y-axis is logarithmic + bool logAxis = false; + foreach (var axis in FrequencyPlot.Axes) + { + if (axis.Key == "Yaxis" && axis as LogarithmicAxis != null) + { + logAxis = true; + break; + } + } + + if (Element.InputData != null) + { + // Exact data + frequencyExactData.ItemsSource = logAxis == true ? Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == false && x.Value > 1E-16) : Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == false); + frequencyExactData.Mapping = item => { var d = (ExactData)item; return new OxyPlot.Series.ScatterPoint(d.PlottingPosition, d.Value); }; + frequencyExactData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.ExactSeries.Count > 0) FrequencyPlot.Series.Add(frequencyExactData); + + // low outliers data + frequencyLowOutlierData.ItemsSource = logAxis == true ? Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == true && x.Value > 1E-16) : Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == true); + frequencyLowOutlierData.Mapping = item => { var d = (ExactData)item; return new OxyPlot.Series.ScatterPoint(d.PlottingPosition, d.Value); }; + frequencyLowOutlierData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.NumberOfLowOutliers > 0) FrequencyPlot.Series.Add(frequencyLowOutlierData); + + // Uncertain data + frequencyUncertainData.ItemsSource = logAxis == true ? Element.InputData.DataFrame.UncertainSeries : Element.InputData.DataFrame.UncertainSeries.Where(x => x.Value > 1E-16); + frequencyUncertainData.DataFieldX = nameof(UncertainData.PlottingPosition); + frequencyUncertainData.DataFieldY = nameof(UncertainData.Value); + frequencyUncertainData.DataFieldLowerErrorY = nameof(UncertainData.LowerValue); + frequencyUncertainData.DataFieldUpperErrorY = nameof(UncertainData.UpperValue); + frequencyUncertainData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.UncertainSeries.Count > 0) FrequencyPlot.Series.Add(frequencyUncertainData); + + // interval data + frequencyIntervalData.ItemsSource = logAxis == true ? Element.InputData.DataFrame.IntervalSeries : Element.InputData.DataFrame.IntervalSeries.Where(x => x.Value > 1E-16); + frequencyIntervalData.DataFieldX = nameof(IntervalData.PlottingPosition); + frequencyIntervalData.DataFieldY = nameof(IntervalData.Value); + frequencyIntervalData.DataFieldLowerErrorY = nameof(IntervalData.LowerValue); + frequencyIntervalData.DataFieldUpperErrorY = nameof(IntervalData.UpperValue); + frequencyIntervalData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.IntervalSeries.Count > 0) FrequencyPlot.Series.Add(frequencyIntervalData); + } + + // Add Alternatives + if (Element.IsEstimated == true && Element.AnalysisResults != null) + AlternativeSelector.AddAllChecked(); + + FrequencyPlot.InvalidatePlot(true); + } + finally + { + Mouse.OverrideCursor = null; + } + } + } + Element?.RebuildSeriesAndAnnotationBridges(FrequencyPlot); + } + + /// + /// Handles the AnalysisAdded event from the AlternativeSelector. Adds series for the alternative analysis to the frequency plot. + /// + /// The analysis alternative item containing the alternative distribution to add. + private void AlternativeSelector_AnalysisAdded(AnalysisAlternativeItem analysisItem) + { + // Remove series + FrequencyPlot.Series.Remove(analysisItem.CredibleIntervals); + FrequencyPlot.Series.Remove(analysisItem.PosteriorPredictive); + FrequencyPlot.Series.Remove(analysisItem.PosteriorMode); + + var alternative = analysisItem.Alternative; + if (alternative.IsEstimated == true && alternative.AnalysisResults != null) + { + // Get unique color + var index = FrequencyPlot.Series.Count; + Color lineColor = (Color)ColorConverter.ConvertFromString(_colorHexCodes[index]); + Color fillColor = Color.FromArgb(100, lineColor.R, lineColor.G, lineColor.B); + + for (int i = 4; i < _colorHexCodes.Length; i++) + { + var templineColor = (Color)ColorConverter.ConvertFromString(_colorHexCodes[i]); + var tempfillColor = Color.FromArgb(100, templineColor.R, templineColor.G, templineColor.B); + bool colorExists = false; + for (int j = 0; j < FrequencyPlot.Series.Count; j++) + { + if (FrequencyPlot.Series[j].Name.Contains("Mode") && FrequencyPlot.Series[j].Color == templineColor) + { + colorExists = true; + break; + } + } + if (colorExists == false || i == _colorHexCodes.Length - 1) + { + lineColor = templineColor; + fillColor = tempfillColor; + break; + } + } + + var ciPoints = new List(); + var prdPoints = new List(); + var mdPoints = new List(); + for (int i = 0; i < ((ModelAnalyses.IProbabilityOrdinates)alternative).ProbabilityOrdinates.Count; i++) + { + var p = ((ModelAnalyses.IProbabilityOrdinates)alternative).ProbabilityOrdinates[i]; + var up = alternative.AnalysisResults.ConfidenceIntervals[i, 1]; + var lo = alternative.AnalysisResults.ConfidenceIntervals[i, 0]; + var prd = alternative.AnalysisResults.MeanCurve[i]; + var md = alternative.AnalysisResults.ModeCurve[i]; + ciPoints.Add(new Point3D(p, lo, up)); + prdPoints.Add(new OxyPlot.DataPoint(p, prd)); + mdPoints.Add(new OxyPlot.DataPoint(p, md)); + } + + // Series labels + double ciWidth = alternative.BayesianAnalysis.CredibleIntervalWidth; + string ciLabel = "% Credible Intervals"; + string predLabel = " - Posterior Predictive"; + string pointLabel = " - " + (alternative.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"); + if (alternative.GetType() == typeof(B17CAnalysis)) + { + ciLabel = "% Confidence Intervals"; + predLabel = " - Expected Probability"; + pointLabel = " - " + (alternative.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Mean Parameters" : "Computed"); + } + + // Credible Intervals + analysisItem.CredibleIntervals.Name = "CredibleIntervals_" + index; + analysisItem.CredibleIntervals.Title = alternative.Name + " - " + (ciWidth * 100).ToString("F0") + ciLabel; + analysisItem.CredibleIntervals.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + analysisItem.CredibleIntervals.ItemsSource = ciPoints; + analysisItem.CredibleIntervals.DataFieldX = "X"; + analysisItem.CredibleIntervals.DataFieldY = "Y"; + analysisItem.CredibleIntervals.DataFieldX2 = "X"; + analysisItem.CredibleIntervals.DataFieldY2 = "Z"; + analysisItem.CredibleIntervals.Decimator = OxyPlot.Decimator.Decimate; + analysisItem.CredibleIntervals.Fill = fillColor; + analysisItem.CredibleIntervals.Color = Colors.Transparent; + FrequencyPlot.Series.Add(analysisItem.CredibleIntervals); + + // Predictive + analysisItem.PosteriorPredictive.Name = "PosteriorPredictive_" + index; + analysisItem.PosteriorPredictive.Title = alternative.Name + predLabel; + analysisItem.PosteriorPredictive.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + analysisItem.PosteriorPredictive.ItemsSource = prdPoints; + analysisItem.PosteriorPredictive.Decimator = OxyPlot.Decimator.Decimate; + analysisItem.PosteriorPredictive.MinimumSegmentLength = 4.0; + analysisItem.PosteriorPredictive.Color = lineColor; + FrequencyPlot.Series.Add(analysisItem.PosteriorPredictive); + + // Point Estimator + analysisItem.PosteriorMode.Name = "PosteriorMode_" + index; + analysisItem.PosteriorMode.Title = alternative.Name + pointLabel; + analysisItem.PosteriorMode.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + analysisItem.PosteriorMode.ItemsSource = mdPoints; + analysisItem.PosteriorMode.Decimator = OxyPlot.Decimator.Decimate; + analysisItem.PosteriorMode.MinimumSegmentLength = 4.0; + analysisItem.PosteriorMode.Color = lineColor; + FrequencyPlot.Series.Add(analysisItem.PosteriorMode); + + FrequencyPlot.InvalidatePlot(true); + + } + + } + + /// + /// Handles the AnalysisRemoved event from the AlternativeSelector. Removes series for the alternative analysis from the frequency plot. + /// + /// The analysis alternative item containing the alternative distribution to remove. + private void AlternativeSelector_AnalysisRemoved(AnalysisAlternativeItem analysisItem) + { + FrequencyPlot.Series.Remove(analysisItem.CredibleIntervals); + FrequencyPlot.Series.Remove(analysisItem.PosteriorPredictive); + FrequencyPlot.Series.Remove(analysisItem.PosteriorMode); + FrequencyPlot.InvalidatePlot(true); + } + + /// + /// Handles the MouseLeftButtonUp event of a TextBlock. Toggles the visibility of the filter options. + /// + /// The source of the event. + /// The instance containing the event data. + private void TextBlock_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + ShowFilterToggleButton.IsChecked = !ShowFilterToggleButton.IsChecked; + } + + /// + /// Binds the frequency curve data to the data grid, populating it with probability ordinates, + /// confidence intervals, predictive values, and mode/mean estimates. + /// + private void BindFrequencyCurveDataGrid() + { + FrequencyCurveTable.ItemsSource = null; + + if (Element?.BayesianAnalysis == null) + return; + + ModeColumn.Header = Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"; + + if (Element.IsEstimated != true || Element.AnalysisResults == null) + return; + + var curvePoints = new List(); + for (int i = 0; i < Element.ProbabilityOrdinates.Count; i++) + { + var p = Element.ProbabilityOrdinates[i]; + var up = Element.AnalysisResults.ConfidenceIntervals[i, 1]; + var lo = Element.AnalysisResults.ConfidenceIntervals[i, 0]; + var prd = Element.AnalysisResults.MeanCurve[i]; + var md = Element.AnalysisResults.ModeCurve[i]; + curvePoints.Add(new FrequencyCurvePoint(p, up, lo, prd, md)); + } + FrequencyCurveTable.ItemsSource = curvePoints; + FrequencyCurveTable.Items.Refresh(); + } + + /// + /// Sets the frequency curve table column headers based on the credible interval width. + /// + private void SetFrequencyCurveTableColumnHeaders() + { + if (Element?.BayesianAnalysis == null) return; + double alpha = (1 - Element.BayesianAnalysis.CredibleIntervalWidth) / 2; + UpperColumn.Header = ((1 - alpha) * 100).ToString("F1") + "% CI"; + LowerColumn.Header = (alpha * 100).ToString("F1") + "% CI"; + } + + /// + /// Sets the string format for the frequency curve table columns based on user settings. + /// + private void SetColumnStringFormats() + { + // Update the column binding string format + UpperColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + LowerColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + PredictiveColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + ModeColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + } + + /// + /// Binds the summary statistics data to the data grid, populating it with distribution properties + /// such as minimum, maximum, mean, standard deviation, skewness, and kurtosis. + /// + private void BindSummaryStatisticsDataGrid() + { + SummaryStatisticsTable.ItemsSource = null; + + if (Element == null) return; + + StatValueColumn.Header = Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"; + + var summaryStats = new List(); + if (Element.IsEstimated == false || Element.AnalysisResults == null) + { + summaryStats.Add(new SummaryStatistic("Minimum", double.NaN)); + summaryStats.Add(new SummaryStatistic("Maximum", double.NaN)); + summaryStats.Add(new SummaryStatistic("Mean", double.NaN)); + summaryStats.Add(new SummaryStatistic("Std Dev", double.NaN)); + summaryStats.Add(new SummaryStatistic("Skewness", double.NaN)); + summaryStats.Add(new SummaryStatistic("Kurtosis", double.NaN)); + } + else + { + // Use GetPointEstimateDistribution() instead of AnalysisResults.ParentDistribution + // because the ParentDistribution is an empirical-CDF Mixture/CompetingRisks whose + // moment properties (StandardDeviation, Skewness, Kurtosis) return NaN. + var pointDist = Element.GetPointEstimateDistribution(); + if (pointDist != null) + { + var min = pointDist.Minimum; + var max = pointDist.Maximum; + summaryStats.Add(new SummaryStatistic("Minimum", min < -1E12 ? double.NegativeInfinity : min)); + summaryStats.Add(new SummaryStatistic("Maximum", max > 1E12 ? double.PositiveInfinity : max)); + summaryStats.Add(new SummaryStatistic("Mean", pointDist.Mean)); + summaryStats.Add(new SummaryStatistic("Std Dev", pointDist.StandardDeviation)); + summaryStats.Add(new SummaryStatistic("Skewness", pointDist.Skewness)); + summaryStats.Add(new SummaryStatistic("Kurtosis", pointDist.Kurtosis)); + } + else + { + summaryStats.Add(new SummaryStatistic("Minimum", double.NaN)); + summaryStats.Add(new SummaryStatistic("Maximum", double.NaN)); + summaryStats.Add(new SummaryStatistic("Mean", double.NaN)); + summaryStats.Add(new SummaryStatistic("Std Dev", double.NaN)); + summaryStats.Add(new SummaryStatistic("Skewness", double.NaN)); + summaryStats.Add(new SummaryStatistic("Kurtosis", double.NaN)); + } + } + + SummaryStatisticsTable.ItemsSource = summaryStats; + SummaryStatisticsTable.Items.Refresh(); + } + + /// + /// Handles the LoadingRow event of the SummaryStatisticsTable. Currently not performing any row customization. + /// + /// The source of the event. + /// The instance containing the event data. + private void SummaryStatisticsTable_LoadingRow(object sender, DataGridRowEventArgs e) + { + //if (((SummaryStatistic)e.Row.DataContext).Name == "Minimum") + //{ + // e.Row.BorderThickness = new Thickness(0, 2, 0, 0); + // e.Row.BorderBrush = new SolidColorBrush(Colors.Black); + //} + //else + //{ + //e.Row.BorderThickness = new Thickness(0, 0, 0, 0); + //} + + } + + } +} diff --git a/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Composite/CompositeAnalysisPropertiesControl.xaml b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Composite/CompositeAnalysisPropertiesControl.xaml new file mode 100644 index 0000000..644cb51 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Composite/CompositeAnalysisPropertiesControl.xaml @@ -0,0 +1,309 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Composite/CompositeAnalysisPropertiesControl.xaml.cs b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Composite/CompositeAnalysisPropertiesControl.xaml.cs new file mode 100644 index 0000000..83f6554 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Composite/CompositeAnalysisPropertiesControl.xaml.cs @@ -0,0 +1,845 @@ +using Numerics.Data.Statistics; +using Numerics.Utilities; +using FrameworkInterfaces; +using RMC.BestFit.Models; +using RMC.BestFit.Estimation; +using RMC.BestFit.UI; +using ModelAnalyses = RMC.BestFit.Analyses; +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Input; +using System.Windows.Media; + +namespace RMC_BestFit +{ + /// + /// User control for displaying and editing the properties of a composite distribution analysis, + /// including input data selection, composite type configuration, distribution weighting, and Bayesian analysis settings. + /// + public partial class CompositeAnalysisPropertiesControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// Sets up the control and configures the composite function data grid. + /// + public CompositeAnalysisPropertiesControl() + { + InitializeComponent(); + DataContext = this; + CompositeFunctionDataGrid.RowType = typeof(WeightedUnivariateAnalysis); + this.Unloaded += UserControl_Unloaded; + } + + /// + /// Stores the previous name of the element for validation and rollback purposes. + /// + private string _previousName; + + /// The currently subscribed (named handlers). + private IElementCollection _subscribedInputDataCollection; + + /// The inner ComboBox used for optional input-data overlay selection. + private ComboBox _inputDataInnerComboBox; + + /// The currently subscribed (named handlers). + private IElementCollection _subscribedUnivariateCollection; + + /// + /// Dependency property for the property. + /// Enables data binding for the composite analysis element. + /// + public static DependencyProperty ElementProperty = DependencyProperty.Register(nameof(Element), typeof(CompositeAnalysis), typeof(CompositeAnalysisPropertiesControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the composite analysis element whose properties are displayed in this control. + /// + public CompositeAnalysis Element + { + get { return (CompositeAnalysis)GetValue(ElementProperty); } + set { SetValue(ElementProperty, value); } + } + + /// + /// Callback method invoked when the dependency property changes. + /// Initializes event handlers and loads related data collections. + /// + /// The dependency object on which the property changed. + /// Event arguments containing the old and new values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as CompositeAnalysisPropertiesControl == null) return; + var thisControl = (CompositeAnalysisPropertiesControl)d; + + // Unsubscribe from old element + if (e.OldValue is CompositeAnalysis oldElement) + oldElement.PropertyChanged -= thisControl.Element_PropertyChanged; + + thisControl.UnsubscribeInputDataCollection(); + thisControl.ClearInputDataSelectionItems(); + + if (e.NewValue == null) return; + var newElement = e.NewValue as CompositeAnalysis; + if (newElement == null) return; + + newElement.PropertyChanged += thisControl.Element_PropertyChanged; + thisControl._previousName = newElement.Name; + thisControl.PropertyAttributes.GetClassAttributes(newElement); + thisControl.LoadInputData(); + thisControl.LoadUnivariateAnalyses(); + thisControl.UpdateInputDataValidationBorder(); + thisControl.SelectCurrentInputDataItem(thisControl._inputDataInnerComboBox); + } + + /// + /// Dependency property for the existing names property. + /// + public static DependencyProperty ExistingNamesProperty = DependencyProperty.Register(nameof(ExistingNames), typeof(string[]), typeof(CompositeAnalysisPropertiesControl), new FrameworkPropertyMetadata(new string[] { })); + + /// + /// Array of existing names for this element type. + /// + public string[] ExistingNames + { + get { return (string[])GetValue(ExistingNamesProperty); } + private set { SetValue(ExistingNamesProperty, value); } + } + + /// + /// Gets the observable collection of available optional input-data overlay choices. + /// + public ObservableCollection InputDataList { get; private set; } = new ObservableCollection(); + + /// + /// Gets the observable collection of available univariate-flavored analysis elements + /// in the project that are eligible to serve as composite children. + /// + /// + /// Holds any sibling that implements EXCEPT another + /// � composite-of-composite is rejected at the + /// model layer (would risk circular references) so it is filtered out here so + /// the user cannot pick it from the data grid in the first place. Currently + /// this includes and ; + /// other siblings (Mixture, PointProcess) are also + /// surfaced if they ever land in the same parent collection. + /// + public ObservableCollection UnivariateAnalysisList { get; private set; } = new ObservableCollection(); + + /// + /// Gets the observable collection of composite distribution type options. + /// + public ObservableCollection CompositeTypeList { get; private set; } = new ObservableCollection() + { + new CompositeTypeItem("Competing Risks", ModelAnalyses.CompositeType.CompetingRisks, "The distributions are competing to be the maximum (or minimum) event."), + new CompositeTypeItem("Mixture Distribution", ModelAnalyses.CompositeType.Mixture, "The distributions are treated as mixture distribution with user-defined weights."), + new CompositeTypeItem("Model Average", ModelAnalyses.CompositeType.ModelAverage, "The distributions are combined as a model average.") + }; + + /// + /// Gets the observable collection of model averaging method options. + /// + public ObservableCollection AverageMethodList { get; private set; } = new ObservableCollection() + { + new AverageMethodItem("AIC", ModelAnalyses.AverageMethod.AIC, "The distributions are weighted based on the Akaike Information Criteria (AIC)."), + new AverageMethodItem("BIC", ModelAnalyses.AverageMethod.BIC, "The distributions are weighted based on the Bayesian Information Criteria (BIC)."), + new AverageMethodItem("DIC", ModelAnalyses.AverageMethod.DIC, "The distributions are weighted based on the Deviance Information Criteria (DIC)."), + new AverageMethodItem("WAIC", ModelAnalyses.AverageMethod.WAIC, "The distributions are weighted based on the Watanabe-Akaike Information Criteria (WAIC)."), + new AverageMethodItem("LOO-CV", ModelAnalyses.AverageMethod.LOOIC, "The distributions are weighted based on Leave-One-Out Cross-Validation via Pareto Smoothed Importance Sampling (PSIS-LOO)."), + new AverageMethodItem("RMSE", ModelAnalyses.AverageMethod.RMSE, "The distributions are weighted based on the Root Mean Square Error (RMSE)."), + new AverageMethodItem("Equal", ModelAnalyses.AverageMethod.Equal, "Each distribution is given equal weight.") + }; + + /// + /// Gets the observable collection of dependency type options for competing risks. + /// + public ObservableCollection DependencyList { get; private set; } = new ObservableCollection() + { + new DependencyItem("Independent", Probability.DependencyType.Independent, "The distributions are treated as independent."), + new DependencyItem("Perfectly Negative", Probability.DependencyType.PerfectlyNegative, "The distributions are treated as perfectly negatively dependent."), + new DependencyItem("Perfectly Positive", Probability.DependencyType.PerfectlyPositive, "The distributions are treated as perfectly positively dependent.") + }; + + /// + /// Gets the observable collection of credible interval width options for Bayesian analysis. + /// + public ObservableCollection CredibleIntervalItems { get; private set; } = new ObservableCollection() + { + new CredibleIntervalItem("90%", 0.9), + new CredibleIntervalItem("95%", 0.95), + new CredibleIntervalItem("98%", 0.98), + new CredibleIntervalItem("99%", 0.99) + }; + + /// + /// Gets the observable collection of point estimator options for Bayesian analysis. + /// + public ObservableCollection PointEstimatorItems { get; private set; } = new ObservableCollection() + { + new PointEstimatorItem("Posterior Mean", BayesianAnalysis.PointEstimateType.PosteriorMean, "Calculates the average of all posterior samples, providing a balanced summary of the parameter estimates."), + new PointEstimatorItem("Posterior Mode", BayesianAnalysis.PointEstimateType.PosteriorMode, "Selects the most likely parameter values for the posterior distribution (the Maximum A Posteriori estimate)."), + }; + + #region Property Attributes + + /// + /// Handles the PreviewMouseLeftButtonDown event for the Name control. Displays property attributes for the element name. + /// + /// The source of the event. + /// The instance containing the event data. + private void Name_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.Name), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the Description control. Displays property attributes for the element description. + /// + /// The source of the event. + /// The instance containing the event data. + private void Description_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.Description), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the CreationDate control. Displays property attributes for the creation date. + /// + /// The source of the event. + /// The instance containing the event data. + private void CreationDate_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.CreationDate), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the LastModified control. Displays property attributes for the last modified date. + /// + /// The source of the event. + /// The instance containing the event data. + private void LastModified_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.LastModified), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the InputDataComboBox control. Displays property attributes for the input data. + /// + /// The source of the event. + /// The instance containing the event data. + private void InputDataComboBox_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.InputData), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the CompositeType control. Displays property attributes for the composite distribution type. + /// + /// The source of the event. + /// The instance containing the event data. + private void CompositeType_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.CompositeDistributionType), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the AverageMethod control. Displays property attributes for the model averaging method. + /// + /// The source of the event. + /// The instance containing the event data. + private void AverageMethod_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.ModelAverageMethod), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the Dependency control. Displays property attributes for the dependency type. + /// + /// The source of the event. + /// The instance containing the event data. + private void Dependency_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.Dependency), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the IsMaximum control. Displays property attributes for the IsMaximum property. + /// + /// The source of the event. + /// The instance containing the event data. + private void IsMaximum_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.IsMaximum), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the CompositeFunctionDataGrid control. Displays property attributes for the analyses collection. + /// + /// The source of the event. + /// The instance containing the event data. + private void CompositeFunctionDataGrid_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.Analyses), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the CredibleInterval control. Displays property attributes for the credible interval width. + /// + /// The source of the event. + /// The instance containing the event data. + private void CredibleInterval_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.BayesianAnalysis.CredibleIntervalWidth), Element.BayesianAnalysis); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the PointEstimator control. Displays property attributes for the point estimator type. + /// + /// The source of the event. + /// The instance containing the event data. + private void PointEstimator_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.BayesianAnalysis.PointEstimator), Element.BayesianAnalysis); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the ProbabilityOrdinatesControl. Displays property attributes for the probability ordinates. + /// + /// The source of the event. + /// The instance containing the event data. + private void ProbabilityOrdinatesControl_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.SetDefaultAttributes("Probability Ordinates", "The exceedance probabilities used for plotting the probability distribution."); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the TabItem. Displays class-level attributes for the element. + /// + /// The source of the event. + /// The instance containing the event data. + private void TabItem_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetClassAttributes(Element); + } + + #endregion + + /// + /// Handles the GotFocus event for the Name control. Saves the previous name and loads existing names for validation. + /// + /// The source of the event. + /// The instance containing the event data. + private void Name_GotFocus(object sender, RoutedEventArgs e) + { + _previousName = Element.Name; + ExistingNames = Element.ParentCollection.GetElementNames(Element).ToArray(); + } + + /// + /// Handles the LostFocus event for the Name control. Reverts to the previous name if validation fails. + /// + /// The source of the event. + /// The instance containing the event data. + private void Name_LostFocus(object sender, RoutedEventArgs e) + { + if (ElementName.NameTextBox.IsValid) + return; + if (Element != null) + Element.Name = _previousName; + } + + /// + /// Handles the Unloaded event for the user control. Unsubscribes element event handlers + /// to prevent memory leaks and stale event callbacks. + /// + /// The source of the event. + /// Event arguments. + private void UserControl_Unloaded(object sender, RoutedEventArgs e) + { + UnsubscribeInputDataCollection(); + UnsubscribeUnivariateAnalysisCollection(); + ClearInputDataSelectionItems(); + } + + /// + /// Handles property change events from the Element. Updates the data grid styling when the analyses collection changes. + /// + /// The source of the event. + /// The instance containing the event data. + private void Element_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + // Marshal to UI thread if called from a background thread (e.g. during MCMC). + // CompositeAnalysis re-raises "Analyses" for every child-analysis property change, + // so this handler is invoked on an MCMC pool thread while a batch run is active. + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke(new Action(() => Element_PropertyChanged(sender, e))); + return; + } + + // Analyses collection replaced (e.g., during undo) � refresh data grid + if (e.PropertyName == nameof(Element.Analyses)) + { + SetDataGridStyle(); + } + if (e.PropertyName == nameof(Element.InputData)) + { + UpdateInputDataValidationBorder(); + SelectCurrentInputDataItem(_inputDataInnerComboBox); + } + // BayesianAnalysis replaced � push to sub-controls + if (e.PropertyName == nameof(Element.BayesianAnalysis)) + { + // Composite doesn't have direct BA sub-controls but notify for consistency + } + } + + /// + /// Loads the available input data elements from the project and subscribes to collection change events. + /// + private void LoadInputData() + { + UnsubscribeInputDataCollection(); + ClearInputDataSelectionItems(); + InputDataList.Add(InputDataSelectionItem.CreateNone()); + if (Element == null) return; + foreach (IElementCollection collection in Element.ParentCollection.ParentProject.ElementCollections) + { + if (collection.GetType() == typeof(InputDataCollection)) + { + _subscribedInputDataCollection = collection; + collection.ElementAdded += OnInputDataAdded; + collection.ElementRemoved += OnInputDataRemoved; + foreach (IElement element in collection) + if (element is InputData id) AddInputDataSelectionItem(id); + break; + } + } + } + + /// Handles a new InputData element being added to the project. + private void OnInputDataAdded(IElement x) + { + if (x is InputData id) AddInputDataSelectionItem(id); + } + + /// Handles an InputData element being removed from the project. + private void OnInputDataRemoved(IElement x) + { + if (x is InputData id) RemoveInputDataSelectionItem(id); + } + + /// Unsubscribes named InputData handlers and clears the tracker field. + private void UnsubscribeInputDataCollection() + { + if (_subscribedInputDataCollection != null) + { + _subscribedInputDataCollection.ElementAdded -= OnInputDataAdded; + _subscribedInputDataCollection.ElementRemoved -= OnInputDataRemoved; + _subscribedInputDataCollection = null; + } + } + + /// + /// Adds a real input-data selection item if it is not already represented. + /// + /// The input data element to add. + /// + /// The no-overlay item is created only by ; this helper + /// is for project collection additions. + /// + private void AddInputDataSelectionItem(InputData inputData) + { + if (FindInputDataSelectionItem(inputData) != null) return; + InputDataList.Add(new InputDataSelectionItem(inputData)); + } + + /// + /// Removes and disposes a real input-data selection item. + /// + /// The input data element to remove. + /// + /// Disposing the wrapper releases the rename subscription held for ComboBox display. + /// + private void RemoveInputDataSelectionItem(InputData inputData) + { + var item = FindInputDataSelectionItem(inputData); + if (item == null) return; + InputDataList.Remove(item); + item.Dispose(); + } + + /// + /// Finds the selection item wrapping the supplied input data reference. + /// + /// The input data reference to find. + /// The matching selection item, or null when absent. + /// + /// Reference matching preserves identity semantics used by the analysis wrapper. + /// + private InputDataSelectionItem FindInputDataSelectionItem(InputData inputData) + { + foreach (InputDataSelectionItem item in InputDataList) + { + if (ReferenceEquals(item.Value, inputData)) + { + return item; + } + } + + return null; + } + + /// + /// Disposes all current input-data selection items and clears the list. + /// + /// + /// This is called before rebuilding the list and when the control unloads to avoid + /// retaining stale wrappers through input-data rename subscriptions. + /// + private void ClearInputDataSelectionItems() + { + foreach (InputDataSelectionItem item in InputDataList) + { + item.Dispose(); + } + + InputDataList.Clear(); + } + + /// + /// Handles the SelectionChanged event for the InputDataComboBox. Validates the selection and updates visual feedback. + /// + /// The source of the event. + /// The instance containing the event data. + private void InputDataComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + // First-load NRE guard (the WPF DataContext pattern). + if (Element == null) return; + UpdateInputDataValidationBorder(); + } + + /// + /// Toggles the input-data selection border based on real invalid selections. + /// + /// + /// A null input data value is the explicit no-overlay selection and is valid. + /// Only a real input-data reference with a missing name should show the invalid state. + /// + private void UpdateInputDataValidationBorder() + { + if (Element == null) return; + if (Element.InputData != null && Element.InputData.Name == null) + { + ((Border)InputDataComboBox.InnerContent).BorderThickness = new Thickness(1); + InputDataComboBox.ToolTip = "Please select a valid input data."; + } + else + { + ((Border)InputDataComboBox.InnerContent).BorderThickness = new Thickness(0); + InputDataComboBox.ToolTip = null; + } + } + + /// + /// Handles the Loaded event for the InputDataComboBox. Sets up the sorted data source for the combo box. + /// + /// The source of the event. + /// The instance containing the event data. + private void InputDataComboBox_Loaded(object sender, RoutedEventArgs e) + { + ComboBox cmbo = (ComboBox)sender; + _inputDataInnerComboBox = cmbo; + CollectionViewSource csv = new CollectionViewSource() { Source = InputDataList, IsLiveSortingRequested = true }; + csv.LiveSortingProperties.Add(nameof(InputDataSelectionItem.SortKey)); + csv.SortDescriptions.Add(new SortDescription(nameof(InputDataSelectionItem.SortKey), ListSortDirection.Ascending)); + var view = csv.View; + view.MoveCurrentToPosition(-1); + cmbo.ItemsSource = view; + SelectCurrentInputDataItem(cmbo); + } + + /// + /// Selects the ComboBox item that matches the element's current input-data reference. + /// + /// The ComboBox to synchronize. + /// + /// WPF can treat a null SelectedValue as no selection, so this method explicitly + /// selects the UI-only <None> item when the analysis overlay is null. + /// + private void SelectCurrentInputDataItem(ComboBox comboBox) + { + if (comboBox == null || Element == null) return; + foreach (object comboBoxItem in comboBox.Items) + { + if (comboBoxItem is InputDataSelectionItem item && ReferenceEquals(item.Value, Element.InputData)) + { + comboBox.SelectedItem = item; + return; + } + } + } + + /// + /// Handles the SelectionChanged event for the CompositeType control. Adjusts the UI based on the selected composite distribution type. + /// + /// The source of the event. + /// The instance containing the event data. + private void CompositeType_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (Element == null) return; + if (Element.CompositeDistributionType == ModelAnalyses.CompositeType.CompetingRisks) + { + AverageMethod.Visibility = Visibility.Collapsed; + Dependency.Visibility = Visibility.Visible; + IsMaximum.Visibility = Visibility.Visible; + CompositeFunctionDataGrid.Columns[1].Visibility = Visibility.Collapsed; + CompositeFunctionDataGrid.Columns[1].IsReadOnly = true; + ((DataGridTextColumn)CompositeFunctionDataGrid.Columns[1]).ClearValue(DataGridTextColumn.ForegroundProperty); + ((DataGridTextColumn)CompositeFunctionDataGrid.Columns[1]).FontStyle = FontStyles.Normal; + } + else if (Element.CompositeDistributionType == ModelAnalyses.CompositeType.Mixture) + { + AverageMethod.Visibility = Visibility.Collapsed; + Dependency.Visibility = Visibility.Collapsed; + IsMaximum.Visibility = Visibility.Collapsed; + CompositeFunctionDataGrid.Columns[1].Visibility = Visibility.Visible; + CompositeFunctionDataGrid.Columns[1].IsReadOnly = false; + ((DataGridTextColumn)CompositeFunctionDataGrid.Columns[1]).ClearValue(DataGridTextColumn.ForegroundProperty); + ((DataGridTextColumn)CompositeFunctionDataGrid.Columns[1]).FontStyle = FontStyles.Normal; + } + else if (Element.CompositeDistributionType == ModelAnalyses.CompositeType.ModelAverage) + { + AverageMethod.Visibility = Visibility.Visible; + Dependency.Visibility = Visibility.Collapsed; + IsMaximum.Visibility = Visibility.Collapsed; + CompositeFunctionDataGrid.Columns[1].Visibility = Visibility.Visible; + CompositeFunctionDataGrid.Columns[1].IsReadOnly = true; + ((DataGridTextColumn)CompositeFunctionDataGrid.Columns[1]).Foreground = Brushes.Gray; + ((DataGridTextColumn)CompositeFunctionDataGrid.Columns[1]).FontStyle = FontStyles.Italic; + } + } + + /// + /// Loads the available univariate-flavored analysis elements from the project and + /// subscribes to collection change events via NAMED handlers (Convention 8). + /// Accepts any sibling EXCEPT + /// (composite-of-composite is rejected at the model layer; filtered here so it never + /// reaches the picker). + /// + private void LoadUnivariateAnalyses() + { + UnivariateAnalysisList.Clear(); + if (Element == null) return; + foreach (IElementCollection collection in Element.ParentCollection.ParentProject.ElementCollections) + { + if (collection.GetType() == typeof(UnivariateAnalysisCollection)) + { + UnsubscribeUnivariateAnalysisCollection(); + _subscribedUnivariateCollection = collection; + collection.ElementAdded += OnUnivariateAnalysisAdded; + collection.ElementRemoved += OnUnivariateAnalysisRemoved; + foreach (IElement element in collection) + { + if (IsEligibleCompositeChild(element, out IUnivariate iu)) + UnivariateAnalysisList.Add(iu); + } + break; + } + } + } + + /// Handles an analysis being added to the project. + private void OnUnivariateAnalysisAdded(IElement x) + { + if (IsEligibleCompositeChild(x, out IUnivariate iu) && !UnivariateAnalysisList.Contains(iu)) + UnivariateAnalysisList.Add(iu); + } + + /// Handles an analysis being removed from the project. + private void OnUnivariateAnalysisRemoved(IElement x) + { + if (x is IUnivariate iu) UnivariateAnalysisList.Remove(iu); + } + + /// + /// Returns whether the given element can serve as a child of a CompositeAnalysis. + /// Eligible: any IUnivariate sibling except another CompositeAnalysis. + /// + private static bool IsEligibleCompositeChild(IElement element, out IUnivariate iu) + { + if (element is IUnivariate u && element is not CompositeAnalysis) + { + iu = u; + return true; + } + iu = null!; + return false; + } + + /// Unsubscribes named UnivariateAnalysis handlers and clears the tracker field. + private void UnsubscribeUnivariateAnalysisCollection() + { + if (_subscribedUnivariateCollection != null) + { + _subscribedUnivariateCollection.ElementAdded -= OnUnivariateAnalysisAdded; + _subscribedUnivariateCollection.ElementRemoved -= OnUnivariateAnalysisRemoved; + _subscribedUnivariateCollection = null; + } + } + + /// + /// Handles the Loaded event for combo boxes in the data grid. Sets up the sorted data source for univariate analyses. + /// + /// The source of the event. + /// The instance containing the event data. + private void DataGridComboBox_Loaded(object sender, RoutedEventArgs e) + { + ComboBox cmbo = (ComboBox)sender; + CollectionViewSource csv = new CollectionViewSource() { Source = UnivariateAnalysisList, IsLiveSortingRequested = true }; + csv.SortDescriptions.Add(new SortDescription(nameof(IUnivariate.Name), ListSortDirection.Ascending)); + var view = csv.View; + view.MoveCurrentToPosition(-1); + cmbo.ItemsSource = view; + } + + /// + /// Sets the data grid cell style for the weight column, including validation triggers for invalid weights. + /// + private void SetDataGridStyle() + { + var weightStyle = new Style(typeof(DataGridCell), (Style)FindResource("Right_CellStyle")); + DataTrigger dt1 = new DataTrigger(); + dt1.Binding = new Binding("IsWeightValid"); + dt1.Value = false; + dt1.Setters.Add(new Setter(DataGridCell.BackgroundProperty, new SolidColorBrush((Color)ColorConverter.ConvertFromString("#FFF7B6AF")))); + dt1.Setters.Add(new Setter(DataGridCell.BorderBrushProperty, new SolidColorBrush(Colors.Red))); + dt1.Setters.Add(new Setter(DataGridCell.ToolTipProperty, new ToolTip() { Content = "The weight must be between 0 and 1" })); + weightStyle.Triggers.Add(dt1); + WeightColumn.CellStyle = weightStyle; + } + + /// + /// Handles the Click event for the EstimateButton. Validates the analysis configuration and runs the composite distribution estimation asynchronously. + /// + /// The source of the event. + /// The instance containing the event data. + private async void EstimateButton_Click(object sender, RoutedEventArgs e) + { + // Check if the fitting analysis is valid + if (Element.IsValid == false) + { + GenericControls.MessageBox.Show("Cannot perform the analysis because the inputs are invalid.", "Error!", MessageBoxButton.OK, MessageBoxImage.Error); + return; + } + + Mouse.OverrideCursor = Cursors.Wait; + + // Disable all open windows and main-window controls so the user cannot navigate + // away or kick off a second analysis on the same control while this one runs. + // Mirrors UnivariateAnalysisPropertiesControl.EstimateButton_Click. + FrameworkUI.ShellPublicVariables.SimulationInProgress = true; + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableMenuStrip(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableProjectExplorer(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableMessageWindow(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableOpenWindows(); + // Disable Properties + PropertiesExpander.IsEnabled = false; + CompositeOptionsExpander.IsEnabled = false; + Options_TabItem.IsEnabled = false; + + // Set up progress bar + AnalysisProgressDisplayHelper.ShowInitial(ProgressBar, ProgressTextBlock); + + // Toggle Estimate ? Cancel so a second click cancels rather than starting a + // concurrent run (the previous behavior crashed the project: re-clicking + // Estimate started a second RunAsync against the same composite while the + // first was still active). + EstimateButton.Visibility = Visibility.Hidden; + CancelButton.Visibility = Visibility.Visible; + + // Set up progress reporter + var progressReporter = new SafeProgressReporter(nameof(UnivariateAnalysis)); + progressReporter.ProgressReported += (SafeProgressReporter reporter, double progress, double progressDelta) => + { + AnalysisProgressDisplayHelper.PostProgress(Dispatcher, ProgressBar, ProgressTextBlock, progress); + }; + bool progressCleanupEnabled = false; + progressReporter.TaskEnded += () => + { + if (!progressCleanupEnabled) return; + Dispatcher.BeginInvoke(new Action(() => + { + // Close out progress bar + ProgressBar.Value = 100; + ProgressTextBlock.Text = "Analysis Complete"; + Mouse.OverrideCursor = null; + EstimateButton.IsEnabled = true; + ProgressBar.Visibility = Visibility.Hidden; + ProgressTextBlock.Visibility = Visibility.Hidden; + + // Restore Estimate button. + EstimateButton.Visibility = Visibility.Visible; + CancelButton.Visibility = Visibility.Hidden; + + // Re-enable the main window. + FrameworkUI.ShellPublicVariables.SimulationInProgress = false; + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableMenuStrip(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableProjectExplorer(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableMessageWindow(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableOpenWindows(); + // Enable Properties + PropertiesExpander.IsEnabled = true; + CompositeOptionsExpander.IsEnabled = true; + Options_TabItem.IsEnabled = true; + })); + }; + + + // Perform Bayesian estimation + try + { + await WaitCursorHelper.RunWithVisibleWaitCursorAsync(Dispatcher, async () => + { + await Element.RunAsync(progressReporter); + }); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"CompositeAnalysisPropertiesControl.EstimateButton_Click: {ex}"); + GenericControls.MessageBox.Show("An unexpected error occurred. Please verify your analysis inputs and settings." + Environment.NewLine + "If the issue persists, contact the Risk Management Center.", "Error!", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + progressCleanupEnabled = true; + progressReporter.IndicateTaskEnded(); + } + + } + + /// + /// Handles the Cancel button click event. Cancels the running analysis. + /// + /// The event sender. + /// The event arguments. + private void CancelButton_Click(object sender, RoutedEventArgs e) + { + Element?.CancelAnalysis(); + } + + /// + /// Handles key-down events for the control. Cancels the running analysis if Escape is pressed. + /// + /// The event sender. + /// The key event arguments. + private void CompositeAnalysisProperties_KeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + Element?.CancelAnalysis(); + } + + } +} diff --git a/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Mixture/MixtureAnalysisControl.xaml b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Mixture/MixtureAnalysisControl.xaml new file mode 100644 index 0000000..7ba3780 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Mixture/MixtureAnalysisControl.xaml @@ -0,0 +1,199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Mixture/MixtureAnalysisControl.xaml.cs b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Mixture/MixtureAnalysisControl.xaml.cs new file mode 100644 index 0000000..e7a6e68 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Mixture/MixtureAnalysisControl.xaml.cs @@ -0,0 +1,895 @@ +using OxyPlot.Wpf; +using OxyPlot; +using OxyPlotControls; +using FrameworkInterfaces; +using FrameworkUI; +using RMC.BestFit.Models; +using ModelAnalyses = RMC.BestFit.Analyses; +using RMC.BestFit.Estimation; +using RMC.BestFit.UI; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Media3D; +using System.Windows.Threading; +using System.Xml.Linq; + +namespace RMC_BestFit +{ + /// + /// User control for displaying and managing mixture distribution analysis results and visualizations. + /// Provides multiple tabs for frequency plots, kernel density estimation, histograms, bivariate analysis, + /// and Bayesian diagnostic plots including mean likelihood, autocorrelation, and Markov chain traces. + /// + public partial class MixtureAnalysisControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// Sets up the component and initializes the color scheme for plot series. + /// + public MixtureAnalysisControl() + { + InitializeComponent(); + DataContext = this; + _colorHexCodes = GenericControls.GeneralMethods.RandomColorsLongList; + // DataGrid Binding.StringFormat must be set before first render. + SetColumnStringFormats(); + } + + /// + /// Identifies the dependency property. + /// + public static DependencyProperty ElementProperty = DependencyProperty.Register(nameof(Element), typeof(MixtureAnalysis), typeof(MixtureAnalysisControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the mixture analysis element that this control visualizes. + /// + public MixtureAnalysis Element + { + get { return (MixtureAnalysis)GetValue(ElementProperty); } + set { SetValue(ElementProperty, value); } + } + + /// + /// Callback method invoked when the dependency property changes. + /// Manages event handler subscriptions and unsubscriptions for the old and new elements. + /// + /// The dependency object on which the property changed. + /// Event arguments containing the old and new property values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as MixtureAnalysisControl == null) return; + var thisControl = (MixtureAnalysisControl)d; + + // Remove handlers and detach old element's plots + if (e.OldValue != null) + { + MixtureAnalysis oldElement = e.OldValue as MixtureAnalysis; + if (oldElement != null) + { + oldElement.PropertyChanged -= thisControl.Element_PropertyChanged; + thisControl.FrequencyPlotHost.Content = null; + thisControl.FrequencyPlotToolbar.Plot = null; + // NOTE: PropertiesCalled is wired in XAML no programmatic -= needed. + } + } + + if (e.NewValue == null) return; + var newElement = e.NewValue as MixtureAnalysis; + if (newElement == null) return; + + // Subscribe Element-scoped handler + newElement.PropertyChanged += thisControl.Element_PropertyChanged; + + // Attach plots, wire toolbars, bind axis titles, and wire Bayesian sub-control + // plots inside a bridge-suspension block. PropertiesCalled / PlotPropertiesCalled + // are wired in XAML no programmatic += needed. + using (newElement.SuspendPlotBridges()) + { + thisControl.FrequencyPlotHost.Content = newElement.FrequencyPlot; + thisControl.FrequencyPlotToolbar.Plot = newElement.FrequencyPlot; + + thisControl.BindAxisTitles(); + + thisControl.HistogramControl.SetPlot(newElement.BayesianPlots.HistogramPlot); + thisControl.KernelDensityControl.SetPlot(newElement.BayesianPlots.KernelDensityPlot); + thisControl.AutocorrelationControl.SetPlot(newElement.BayesianPlots.AutocorrelationPlot); + thisControl.MarkovChainTraceControl.SetPlot(newElement.BayesianPlots.MarkovChainTracePlot); + thisControl.MeanLikelihoodControl.SetPlot(newElement.BayesianPlots.MeanLikelihoodPlot); + thisControl.BivariateHeatMapControl.SetPlot(newElement.BayesianPlots.BivariateHeatMapPlot); + thisControl.InfluenceDiagnosticsControl.SetPlot(newElement.BayesianPlots.InfluenceDiagnosticsPlot); + } + } + + /// + /// Convenience accessor for the Element-owned frequency plot. + /// + private Plot FrequencyPlot => Element?.FrequencyPlot; + + /// + /// Gets a value indicating whether a plot area was clicked by the user. + /// + public bool PlotClicked { get; private set; } = false; + + /// + /// Raised when the plot properties dialog is requested. + /// + public event PlotPropertiesCalledEventHandler PlotPropertiesCalled; + + /// + /// Represents the method that handles the event. + /// + /// The plot for which properties were requested. + /// Indicates whether to open the properties dialog. + /// The property expander section to display. + /// The selected object in the properties dialog. + public delegate void PlotPropertiesCalledEventHandler(Plot plot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject); + + /// + /// Raised when the preview control is clicked. + /// + public event PreviewControlClickedEventHandler PreviewControlClicked; + + /// + /// Represents the method that handles the event. + /// + /// Indicates whether the plot area was clicked. + /// Indicates whether the toolbar area was clicked. + /// The plot that was clicked. + public delegate void PreviewControlClickedEventHandler(bool plotClicked, bool toolbarClicked, Plot plot); + + /// + /// Array of hexadecimal color codes used for rendering multiple plot series with distinct colors. + /// + private string[] _colorHexCodes; + + + + /// + /// Handles the Loaded event of the UserControl. Initializes plots, data grids, and column formats on first load. + /// + /// The source of the event. + /// The event data. + private void UserControl_Loaded(object sender, RoutedEventArgs e) + { + if (Element == null) return; + + // Data refresh on every load. Plot hosts, toolbars, axis-title bindings, and + // Element.PropertyChanged are wired once per Element in ElementCallback. + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try + { + UpdateFrequencyPlot(); + BindFrequencyCurveDataGrid(); + SetFrequencyCurveTableColumnHeaders(); + BindSummaryStatisticsDataGrid(); + } + finally + { + Element.IsUndoEnabled = wasUndoEnabled; + } + } + + /// + /// Handles the Unloaded event of the user control. Unsubscribes all element event handlers + /// to prevent memory leaks and stale event callbacks. + /// + /// The source of the event. + /// Event arguments. + private void UserControl_Unloaded(object sender, RoutedEventArgs e) + { + // Pattern B (canonical): Element-scoped lifecycle (PropertyChanged, plot + // hosts, toolbars) is owned by ElementCallback and survives unload/reload + // cycles. This control has no control-scoped subscriptions to tear down, + // so Unloaded is intentionally a no-op. + } + + /// + /// Handles property changes on the associated Element. Updates the UI when analysis results or credible interval width changes. + /// + /// The source of the event. + /// The event data containing the name of the changed property. + private void Element_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + // Marshal to UI thread if called from a background thread. + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke(new Action(() => Element_PropertyChanged(sender, e))); + return; + } + + if (e.PropertyName == nameof(Element.AnalysisResults)) + { + BindFrequencyCurveDataGrid(); + BindSummaryStatisticsDataGrid(); + UpdateFrequencyPlot(); + ResetWaitCursor(); + } + if (e.PropertyName == nameof(Element.BayesianAnalysis.CredibleIntervalWidth)) + { + SetFrequencyCurveTableColumnHeaders(); + } + if (e.PropertyName == nameof(Element.InputData)) + { + using (Element.SuspendPlotBridges()) + { + BindAxisTitles(); + } + // Refresh the frequency plot so data points reflect the new InputData (S1). + // Mirror PointProcessAnalysisControl:217. + UpdateFrequencyPlot(); + } + if ((e.PropertyName == nameof(Element.BayesianAnalysis.PointEstimator) + || e.PropertyName == nameof(Element.BayesianAnalysis.CredibleIntervalWidth) + || e.PropertyName == nameof(Element.ProbabilityOrdinates)) + && Element.IsEstimated) + { + // Cursor wait while the model layer's ReprocessIfEstimated runs the recompute + // on TaskScheduler.Default. Reset is wired to the AnalysisResults notification above. + Mouse.OverrideCursor = Cursors.Wait; + } + } + + /// + /// Clears when it is currently set to . + /// Marshals onto the UI thread when called from a background-thread PropertyChanged + /// notification (the model layer's ReprocessIfEstimated path uses + /// TaskScheduler.Default). + /// + private void ResetWaitCursor() + { + // Reset at Background priority so the Render-priority cursor frame from the + // earlier `Mouse.OverrideCursor = Cursors.Wait` is guaranteed to flush before + // the reset runs. Without this, fast reprocesses (UpdatePointEstimateResultsAsync) + // can reset the cursor before the OS visually picks up the change the user + // sees no wait cursor at all when the mouse is stationary. + Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + { + if (Mouse.OverrideCursor == Cursors.Wait) + Mouse.OverrideCursor = null; + })); + } + + #region Plots + + + /// + /// Binds the frequency plot Y-axis title to the InputData.UnitLabel property. + /// Called after element attachment and when InputData changes. + /// + private void BindAxisTitles() + { + if (Element == null) return; + + // Frequency plot: Y-axis title = UnitLabel + var freqYAxis = FrequencyPlot?.Axes.FirstOrDefault(a => a.Key == "Yaxis"); + if (freqYAxis != null) + { + if (Element.InputData != null) + PlotAxisTitleDefaults.BindTitleIfDefault(freqYAxis, Element.InputData, nameof(InputData.UnitLabel), Element.InputData.UnitLabel); + else + PlotAxisTitleDefaults.SetTitleIfDefault(freqYAxis, string.Empty); + } + } + + /// + /// Handles the LostFocus event of the UserControl. Resets the PlotClicked property to false. + /// + /// The source of the event. + /// The event data. + private void UserControl_LostFocus(object sender, RoutedEventArgs e) + { + PlotClicked = false; + } + + /// + /// Handles the PreviewMouseDown event of the UserControl. Detects clicks on plots and toolbars and raises appropriate events. + /// + /// The source of the event. + /// The mouse button event data. + private void UserControl_PreviewMouseDown(object sender, MouseButtonEventArgs e) + { + Plot plot = GetCurrentPlot(); + OxyPlotToolbar toolbar = GetCurrentPlotToolbar(); + System.Windows.Media.HitTestResult plotHitResult = null; + System.Windows.Media.HitTestResult toolbarHitResult = null; + if (plot != null) + { + plotHitResult = VisualTreeHelper.HitTest(plot, e.GetPosition(plot)); + } + if (toolbar != null) + { + toolbarHitResult = VisualTreeHelper.HitTest(toolbar, e.GetPosition(toolbar)); + } + PreviewControlClicked?.Invoke(plotHitResult != null, toolbarHitResult != null, plot); + PlotClicked = plotHitResult != null; + } + + /// + /// Handles the PropertiesCalled event from the plot toolbar. Forwards the event to subscribers. + /// + /// The plot for which properties were requested. + /// Indicates whether to open the properties dialog. + /// The property expander section to display. + /// The selected object in the properties dialog. + private void PlotToolbar_PropertiesCalled(Plot targetPlot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject) + { + PlotPropertiesCalled?.Invoke(targetPlot, openProperties, propertyExpander, selectedObject); + } + + /// + /// Gets the plot control that is currently displayed in the active tab. + /// + /// The currently active Plot control, or null if no plot tab is selected. + public Plot GetCurrentPlot() + { + if (FrequencyResultsTabItem.IsSelected == true) + { + return FrequencyPlot; + } + else if (KernelDensityTabItem.IsSelected == true) + { + return KernelDensityControl.Plot; + } + else if (HistogramTabItem.IsSelected == true) + { + return HistogramControl.Plot; + } + else if (BivariateTabItem.IsSelected == true) + { + return BivariateHeatMapControl.Plot; + } + else if (MeanLikelihoodTabItem.IsSelected == true) + { + return MeanLikelihoodControl.Plot; + } + else if (AutocorrelationTabItem.IsSelected == true) + { + return AutocorrelationControl.Plot; + } + else if (MarkovChainTabItem.IsSelected == true) + { + return MarkovChainTraceControl.Plot; + } + else if (InfluenceDiagnosticsTabItem.IsSelected == true) + { + return InfluenceDiagnosticsControl.Plot; + } + return null; + } + + /// + /// Gets the plot toolbar that corresponds to the currently displayed plot in the active tab. + /// + /// The currently active OxyPlotToolbar control, or null if no plot tab is selected. + public OxyPlotToolbar GetCurrentPlotToolbar() + { + if (FrequencyResultsTabItem.IsSelected == true) + { + return FrequencyPlotToolbar; + } + else if (KernelDensityTabItem.IsSelected == true) + { + return KernelDensityControl.PlotToolbar; + } + else if (HistogramTabItem.IsSelected == true) + { + return HistogramControl.PlotToolbar; + } + else if (BivariateTabItem.IsSelected == true) + { + return BivariateHeatMapControl.PlotToolbar; + } + else if (MeanLikelihoodTabItem.IsSelected == true) + { + return MeanLikelihoodControl.PlotToolbar; + } + else if (AutocorrelationTabItem.IsSelected == true) + { + return AutocorrelationControl.PlotToolbar; + } + else if (MarkovChainTabItem.IsSelected == true) + { + return MarkovChainTraceControl.PlotToolbar; + } + else if (InfluenceDiagnosticsTabItem.IsSelected == true) + { + return InfluenceDiagnosticsControl.PlotToolbar; + } + return null; + } + + /// + /// Updates the frequency plot with the current analysis results including data points, curves, + /// credible intervals, and alternative analyses. Clears and rebuilds all plot series. + /// + private void UpdateFrequencyPlot() + { + if (FrequencyPlot == null) return; + + // Lookup-or-create named series so user-customized styling survives Save/Open. + var credibleIntervals = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "CredibleIntervals") + ?? new AreaSeries + { + Name = "CredibleIntervals", + Fill = Color.FromArgb(75, 104, 140, 175), + Color = Color.FromArgb(255, 53, 59, 122), + LineStyle = LineStyle.Solid, + BrokenLineThickness = 1, + StrokeThickness = 1, + Decimator = OxyPlot.Decimator.Decimate, + }; + var posteriorPredictive = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "PosteriorPredictive") + ?? new LineSeries + { + Name = "PosteriorPredictive", + Title = "Posterior Predictive", + Color = Colors.Blue, + StrokeThickness = 1, + LineStyle = LineStyle.Dash, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0, + }; + var posteriorMode = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "PosteriorMode") + ?? new LineSeries + { + Name = "PosteriorMode", + Title = "Posterior Mode", + Color = Colors.Black, + StrokeThickness = 1, + LineStyle = LineStyle.Solid, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0, + }; + var frequencyExactData = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "ExactData") + ?? new ScatterPointSeries + { + Name = "ExactData", + Title = "Exact Data", + MarkerFill = Colors.Black, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle, + }; + var frequencyLowOutlierData = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "LowOutlierData") + ?? new ScatterPointSeries + { + Name = "LowOutlierData", + Title = "Low Outlier Data", + MarkerStroke = Colors.Red, + MarkerStrokeThickness = 2, + MarkerSize = 3, + MarkerType = MarkerType.Cross, + }; + var frequencyUncertainData = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "UncertainData") + ?? new ScatterErrorSeries + { + Name = "UncertainData", + Title = "Uncertain Data", + MarkerFill = Colors.Green, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Diamond, + }; + var frequencyIntervalData = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "IntervalData") + ?? new ScatterErrorSeries + { + Name = "IntervalData", + Title = "Interval Data", + MarkerFill = Colors.Cyan, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle, + }; + var quantilePriors = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "QuantilePrior") + ?? new ScatterErrorSeries + { + Name = "QuantilePrior", + Title = "Quantile Prior", + MarkerFill = Colors.Red, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Square, + }; + + using (Element?.SuspendPlotBridges()) + { + FrequencyPlot.Series.Clear(); + + if (Element != null && Element.InputData != null && Element.BayesianAnalysis != null) + { + // Add Curves + if (Element.BayesianAnalysis.IsEstimated == true && Element.AnalysisResults != null) + { + var ciPoints = new List(); + var prdPoints = new List(); + var mdPoints = new List(); + for (int i = 0; i < Element.ProbabilityOrdinates.Count; i++) + { + var p = Element.ProbabilityOrdinates[i]; + var up = Element.AnalysisResults.ConfidenceIntervals[i, 1]; + var lo = Element.AnalysisResults.ConfidenceIntervals[i, 0]; + var prd = Element.AnalysisResults.MeanCurve[i]; + var md = Element.AnalysisResults.ModeCurve[i]; + ciPoints.Add(new Point3D(p, lo, up)); + prdPoints.Add(new OxyPlot.DataPoint(p, prd)); + mdPoints.Add(new OxyPlot.DataPoint(p, md)); + } + + // Credible Intervals + credibleIntervals.ItemsSource = ciPoints; + credibleIntervals.DataFieldX = "X"; + credibleIntervals.DataFieldY = "Y"; + credibleIntervals.DataFieldX2 = "X"; + credibleIntervals.DataFieldY2 = "Z"; + credibleIntervals.Title = (Element.BayesianAnalysis.CredibleIntervalWidth * 100).ToString("F0") + "% Credible Intervals"; + credibleIntervals.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + FrequencyPlot.Series.Add(credibleIntervals); + + // Predictive + posteriorPredictive.ItemsSource = prdPoints; + posteriorPredictive.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + FrequencyPlot.Series.Add(posteriorPredictive); + + // Point Estimator + posteriorMode.Title = Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"; + posteriorMode.ItemsSource = mdPoints; + posteriorMode.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + FrequencyPlot.Series.Add(posteriorMode); + } + + // See if Y-axis is logarithmic + bool logAxis = false; + foreach (var axis in FrequencyPlot.Axes) + { + if (axis.Key == "Yaxis" && axis as LogarithmicAxis != null) + { + logAxis = true; + break; + } + } + + // Exact data + frequencyExactData.ItemsSource = logAxis == true ? Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == false && x.Value > 1E-16) : Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == false); + frequencyExactData.Mapping = item => { var d = (ExactData)item; return new OxyPlot.Series.ScatterPoint(d.PlottingPosition, d.Value); }; + frequencyExactData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.ExactSeries.Count > 0) FrequencyPlot.Series.Add(frequencyExactData); + + // low outliers data + frequencyLowOutlierData.ItemsSource = logAxis == true ? Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == true && x.Value > 1E-16) : Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == true); + frequencyLowOutlierData.Mapping = item => { var d = (ExactData)item; return new OxyPlot.Series.ScatterPoint(d.PlottingPosition, d.Value); }; + frequencyLowOutlierData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.NumberOfLowOutliers > 0) FrequencyPlot.Series.Add(frequencyLowOutlierData); + + // uncertain data + frequencyUncertainData.ItemsSource = logAxis == true ? Element.InputData.DataFrame.UncertainSeries : Element.InputData.DataFrame.UncertainSeries.Where(x => x.Value > 1E-16); + frequencyUncertainData.DataFieldX = nameof(UncertainData.PlottingPosition); + frequencyUncertainData.DataFieldY = nameof(UncertainData.Value); + frequencyUncertainData.DataFieldLowerErrorY = nameof(UncertainData.LowerValue); + frequencyUncertainData.DataFieldUpperErrorY = nameof(UncertainData.UpperValue); + frequencyUncertainData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.UncertainSeries.Count > 0) FrequencyPlot.Series.Add(frequencyUncertainData); + + // interval data + frequencyIntervalData.ItemsSource = logAxis == true ? Element.InputData.DataFrame.IntervalSeries : Element.InputData.DataFrame.IntervalSeries.Where(x => x.Value > 1E-16); + frequencyIntervalData.DataFieldX = nameof(IntervalData.PlottingPosition); + frequencyIntervalData.DataFieldY = nameof(IntervalData.Value); + frequencyIntervalData.DataFieldLowerErrorY = nameof(IntervalData.LowerValue); + frequencyIntervalData.DataFieldUpperErrorY = nameof(IntervalData.UpperValue); + frequencyIntervalData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.IntervalSeries.Count > 0) FrequencyPlot.Series.Add(frequencyIntervalData); + + // quantile priors + quantilePriors.ItemsSource = Element.MixtureDistribution.QuantilePriors; + quantilePriors.DataFieldX = nameof(QuantilePrior.Alpha); + quantilePriors.DataFieldY = nameof(QuantilePrior.MeanValue); + quantilePriors.DataFieldLowerErrorY = nameof(QuantilePrior.LowerValue); + quantilePriors.DataFieldUpperErrorY = nameof(QuantilePrior.UpperValue); + quantilePriors.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.MixtureDistribution.EnableQuantilePriors && Element.MixtureDistribution.QuantilePriors.Count > 0) FrequencyPlot.Series.Add(quantilePriors); + + // Add Alternatives + if (Element.BayesianAnalysis.IsEstimated == true && Element.AnalysisResults != null) + AlternativeSelector.AddAllChecked(); + } + + FrequencyPlot.InvalidatePlot(true); + } + Element?.RebuildSeriesAndAnnotationBridges(FrequencyPlot); + } + + /// + /// Handles the event when an alternative analysis is added to the frequency plot. + /// Creates and adds series for the alternative's credible intervals, posterior predictive, and posterior mode/mean. + /// + /// The analysis alternative item containing the alternative analysis and associated plot series. + private void AlternativeSelector_AnalysisAdded(AnalysisAlternativeItem analysisItem) + { + if (Element == null || FrequencyPlot == null) return; + + // Remove series + FrequencyPlot.Series.Remove(analysisItem.CredibleIntervals); + FrequencyPlot.Series.Remove(analysisItem.PosteriorPredictive); + FrequencyPlot.Series.Remove(analysisItem.PosteriorMode); + + var alternative = analysisItem.Alternative; + if (alternative.IsEstimated == true && alternative.AnalysisResults != null) + { + // Get unique color + var index = FrequencyPlot.Series.Count; + Color lineColor = (Color)ColorConverter.ConvertFromString(_colorHexCodes[index]); + Color fillColor = Color.FromArgb(100, lineColor.R, lineColor.G, lineColor.B); + + for (int i = 4; i < _colorHexCodes.Length; i++) + { + var templineColor = (Color)ColorConverter.ConvertFromString(_colorHexCodes[i]); + var tempfillColor = Color.FromArgb(100, templineColor.R, templineColor.G, templineColor.B); + bool colorExists = false; + for (int j = 0; j < FrequencyPlot.Series.Count; j++) + { + if (FrequencyPlot.Series[j].Name.Contains("Mode") && FrequencyPlot.Series[j].Color == templineColor) + { + colorExists = true; + break; + } + } + if (colorExists == false || i == _colorHexCodes.Length - 1) + { + lineColor = templineColor; + fillColor = tempfillColor; + break; + } + } + + var ciPoints = new List(); + var prdPoints = new List(); + var mdPoints = new List(); + for (int i = 0; i < ((ModelAnalyses.IProbabilityOrdinates)alternative).ProbabilityOrdinates.Count; i++) + { + var p = ((ModelAnalyses.IProbabilityOrdinates)alternative).ProbabilityOrdinates[i]; + var up = alternative.AnalysisResults.ConfidenceIntervals[i, 1]; + var lo = alternative.AnalysisResults.ConfidenceIntervals[i, 0]; + var prd = alternative.AnalysisResults.MeanCurve[i]; + var md = alternative.AnalysisResults.ModeCurve[i]; + ciPoints.Add(new Point3D(p, lo, up)); + prdPoints.Add(new OxyPlot.DataPoint(p, prd)); + mdPoints.Add(new OxyPlot.DataPoint(p, md)); + } + + // Series labels + double ciWidth = alternative.BayesianAnalysis.CredibleIntervalWidth; + string ciLabel = "% Credible Intervals"; + string predLabel = " - Posterior Predictive"; + string pointLabel = " - " + (alternative.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"); + if (alternative.GetType() == typeof(B17CAnalysis)) + { + ciLabel = "% Confidence Intervals"; + predLabel = " - Expected Probability"; + pointLabel = " - " + (alternative.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Mean Parameters" : "Computed"); + } + + // Credible Intervals + analysisItem.CredibleIntervals.Name = "CredibleIntervals_" + index; + analysisItem.CredibleIntervals.Title = alternative.Name + " - " + (ciWidth * 100).ToString("F0") + ciLabel; + analysisItem.CredibleIntervals.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + analysisItem.CredibleIntervals.ItemsSource = ciPoints; + analysisItem.CredibleIntervals.DataFieldX = "X"; + analysisItem.CredibleIntervals.DataFieldY = "Y"; + analysisItem.CredibleIntervals.DataFieldX2 = "X"; + analysisItem.CredibleIntervals.DataFieldY2 = "Z"; + analysisItem.CredibleIntervals.Fill = fillColor; + analysisItem.CredibleIntervals.Color = Colors.Transparent; + FrequencyPlot.Series.Add(analysisItem.CredibleIntervals); + + // Predictive + analysisItem.PosteriorPredictive.Name = "PosteriorPredictive_" + index; + analysisItem.PosteriorPredictive.Title = alternative.Name + predLabel; + analysisItem.PosteriorPredictive.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + analysisItem.PosteriorPredictive.ItemsSource = prdPoints; + analysisItem.PosteriorPredictive.Color = lineColor; + FrequencyPlot.Series.Add(analysisItem.PosteriorPredictive); + + // Point Estimator + analysisItem.PosteriorMode.Name = "PosteriorMode_" + index; + analysisItem.PosteriorMode.Title = alternative.Name + pointLabel; + analysisItem.PosteriorMode.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + analysisItem.PosteriorMode.ItemsSource = mdPoints; + analysisItem.PosteriorMode.Color = lineColor; + FrequencyPlot.Series.Add(analysisItem.PosteriorMode); + + FrequencyPlot.InvalidatePlot(true); + + } + + } + + /// + /// Handles the event when an alternative analysis is removed from the frequency plot. + /// Removes all series associated with the alternative analysis and refreshes the plot. + /// + /// The analysis alternative item to be removed. + private void AlternativeSelector_AnalysisRemoved(AnalysisAlternativeItem analysisItem) + { + if (Element == null || FrequencyPlot == null) return; + + FrequencyPlot.Series.Remove(analysisItem.CredibleIntervals); + FrequencyPlot.Series.Remove(analysisItem.PosteriorPredictive); + FrequencyPlot.Series.Remove(analysisItem.PosteriorMode); + FrequencyPlot.InvalidatePlot(true); + } + + /// + /// Handles the MouseLeftButtonUp event on a TextBlock. Toggles the filter visibility. + /// + /// The source of the event. + /// The mouse button event data. + private void TextBlock_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + ShowFilterToggleButton.IsChecked = !ShowFilterToggleButton.IsChecked; + } + + #endregion + + /// + /// Binds the frequency curve data grid to the analysis results, displaying probability ordinates + /// with corresponding credible intervals, posterior predictive, and mode/mean values. + /// + private void BindFrequencyCurveDataGrid() + { + FrequencyCurveTable.ItemsSource = null; + + if (Element == null || Element.BayesianAnalysis == null || Element.BayesianAnalysis.IsEstimated != true || Element.AnalysisResults == null) + return; + + ModeColumn.Header = Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"; + + var curvePoints = new List(); + for (int i = 0; i < Element.ProbabilityOrdinates.Count; i++) + { + var p = Element.ProbabilityOrdinates[i]; + var up = Element.AnalysisResults.ConfidenceIntervals[i, 1]; + var lo = Element.AnalysisResults.ConfidenceIntervals[i, 0]; + var prd = Element.AnalysisResults.MeanCurve[i]; + var md = Element.AnalysisResults.ModeCurve[i]; + curvePoints.Add(new FrequencyCurvePoint(p, up, lo, prd, md)); + } + FrequencyCurveTable.ItemsSource = curvePoints; + FrequencyCurveTable.Items.Refresh(); + } + + /// + /// Sets the column headers for the frequency curve table based on the credible interval width. + /// + private void SetFrequencyCurveTableColumnHeaders() + { + double alpha = (1 - Element.BayesianAnalysis.CredibleIntervalWidth) / 2; + UpperColumn.Header = ((1 - alpha) * 100).ToString("F1") + "% CI"; + LowerColumn.Header = (alpha * 100).ToString("F1") + "% CI"; + } + + /// + /// Sets the string format for all data grid columns based on user settings. + /// + private void SetColumnStringFormats() + { + // Update the column binding string format + UpperColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + LowerColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + PredictiveColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + ModeColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + } + + /// + /// Binds the summary statistics data grid to display distribution parameters, moments, + /// and goodness-of-fit statistics from the analysis results. + /// + private void BindSummaryStatisticsDataGrid() + { + SummaryStatisticsTable.ItemsSource = null; + + if (Element == null || Element.BayesianAnalysis == null) + return; + + StatValueColumn.Header = Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"; + + var summaryStats = new List(); + if (Element.BayesianAnalysis.IsEstimated == false || Element.AnalysisResults == null) + { + if (Element.MixtureDistribution.IsZeroInflated) + { + summaryStats.Add(new SummaryStatistic("Weight 0", double.NaN)); + } + if (Element.MixtureDistribution.Mixture.Distributions.Count() == 1) + { + summaryStats.Add(new SummaryStatistic("Weight 1", double.NaN)); + } + + for (int i = 0; i < Element.MixtureDistribution.Parameters.Count; i++) + { + summaryStats.Add(new SummaryStatistic(Element.MixtureDistribution.Parameters[i].DisplayName, double.NaN)); + } + summaryStats.Add(new SummaryStatistic("Minimum", double.NaN)); + summaryStats.Add(new SummaryStatistic("Maximum", double.NaN)); + summaryStats.Add(new SummaryStatistic("Mean", double.NaN)); + summaryStats.Add(new SummaryStatistic("Std Dev", double.NaN)); + summaryStats.Add(new SummaryStatistic("Skewness", double.NaN)); + summaryStats.Add(new SummaryStatistic("Kurtosis", double.NaN)); + summaryStats.Add(new SummaryStatistic("AIC", double.NaN)); + summaryStats.Add(new SummaryStatistic("BIC", double.NaN)); + summaryStats.Add(new SummaryStatistic("DIC", double.NaN)); + summaryStats.Add(new SummaryStatistic("WAIC", double.NaN)); + summaryStats.Add(new SummaryStatistic("LOO-CV", double.NaN)); + summaryStats.Add(new SummaryStatistic("RMSE", double.NaN)); + } + else + { + if (Element.MixtureDistribution.IsZeroInflated) + { + summaryStats.Add(new SummaryStatistic("Weight 0", Element.MixtureDistribution.Mixture.ZeroWeight)); + } + if (Element.MixtureDistribution.Mixture.Distributions.Count() == 1) + { + summaryStats.Add(new SummaryStatistic("Weight 1", Element.MixtureDistribution.Mixture.Weights[0])); + } + + for (int i = 0; i < Element.MixtureDistribution.Parameters.Count; i++) + { + summaryStats.Add(new SummaryStatistic(Element.MixtureDistribution.Parameters[i].DisplayName, Element.MixtureDistribution.Parameters[i].Value)); + } + + var min = Element.MixtureDistribution.Mixture.Minimum; + var max = Element.MixtureDistribution.Mixture.Maximum; + + summaryStats.Add(new SummaryStatistic("Minimum", min < -1E12 ? double.NegativeInfinity : min)); + summaryStats.Add(new SummaryStatistic("Maximum", max > 1E12 ? double.PositiveInfinity : max)); + summaryStats.Add(new SummaryStatistic("Mean", Element.MixtureDistribution.Mixture.Mean)); + summaryStats.Add(new SummaryStatistic("Std Dev", Element.MixtureDistribution.Mixture.StandardDeviation)); + summaryStats.Add(new SummaryStatistic("Skewness", Element.MixtureDistribution.Mixture.Skewness)); + summaryStats.Add(new SummaryStatistic("Kurtosis", Element.MixtureDistribution.Mixture.Kurtosis)); + summaryStats.Add(new SummaryStatistic("AIC", Element.AnalysisResults.AIC)); + summaryStats.Add(new SummaryStatistic("BIC", Element.AnalysisResults.BIC)); + summaryStats.Add(new SummaryStatistic("DIC", Element.AnalysisResults.DIC)); + summaryStats.Add(new SummaryStatistic("WAIC", Element.BayesianAnalysis.WAIC)); + summaryStats.Add(new SummaryStatistic("LOO-CV", Element.BayesianAnalysis.LOOIC)); + summaryStats.Add(new SummaryStatistic("RMSE", Element.AnalysisResults.RMSE)); + } + + SummaryStatisticsTable.ItemsSource = summaryStats; + SummaryStatisticsTable.Items.Refresh(); + } + + /// + /// Handles the LoadingRow event of the SummaryStatisticsTable. Applies visual separators + /// between different groups of statistics (parameters, moments, and goodness-of-fit measures). + /// + /// The source of the event. + /// The event data containing the row being loaded. + private void SummaryStatisticsTable_LoadingRow(object sender, DataGridRowEventArgs e) + { + if (((SummaryStatistic)e.Row.DataContext).Name == "Minimum" || + ((SummaryStatistic)e.Row.DataContext).Name == "AIC" || + ((SummaryStatistic)e.Row.DataContext).Name == "ERL") + { + e.Row.BorderThickness = new Thickness(0, 2, 0, 0); + e.Row.SetResourceReference(Border.BorderBrushProperty, "EnvironmentWindowText"); + } + else + { + e.Row.BorderThickness = new Thickness(0, 0, 0, 0); + } + } + } +} + diff --git a/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Mixture/MixtureAnalysisPropertiesControl.xaml b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Mixture/MixtureAnalysisPropertiesControl.xaml new file mode 100644 index 0000000..9279ddd --- /dev/null +++ b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Mixture/MixtureAnalysisPropertiesControl.xaml @@ -0,0 +1,261 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Mixture/MixtureAnalysisPropertiesControl.xaml.cs b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Mixture/MixtureAnalysisPropertiesControl.xaml.cs new file mode 100644 index 0000000..d445d9f --- /dev/null +++ b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Mixture/MixtureAnalysisPropertiesControl.xaml.cs @@ -0,0 +1,647 @@ +using Numerics.Distributions; +using Numerics.Utilities; +using FrameworkInterfaces; +using RMC.BestFit.Models; +using RMC.BestFit.Estimation; +using RMC.BestFit.UI; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Navigation; +using System.Windows.Shapes; + +namespace RMC_BestFit +{ + /// + /// User control for configuring and managing mixture distribution analysis properties. + /// Provides settings for distribution selection, parameter priors, quantile priors, + /// Bayesian analysis options, and execution controls for running the analysis. + /// + public partial class MixtureAnalysisPropertiesControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// Sets up the component and configures the distribution data grid. + /// + public MixtureAnalysisPropertiesControl() + { + InitializeComponent(); + DataContext = this; + // RowType set to null enum types cannot be default-constructed by DataGrid toolbar + // (Activator.CreateInstance on an enum yields the 0-value, which is not a supported mixture type). + // Add/remove is handled via PreviewAddRows/PreviewDeleteRows to inject Normal as the default. + DistributionDataGrid.RowType = null; + DistributionDataGrid.PreviewAddRows += DistributionDataGrid_PreviewAddRows; + DistributionDataGrid.PreviewDeleteRows += DistributionDataGrid_PreviewDeleteRows; + this.Unloaded += UserControl_Unloaded; + } + + /// + /// Stores the previous name value for validation and rollback purposes. + /// + private string _previousName; + + /// + /// Tracks the InputDataCollection currently subscribed to, so it can be unsubscribed when the control unloads or the element changes. + /// + private IElementCollection _subscribedInputDataCollection; + + /// + /// Identifies the dependency property. + /// + public static DependencyProperty ElementProperty = DependencyProperty.Register(nameof(Element), typeof(MixtureAnalysis), typeof(MixtureAnalysisPropertiesControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the mixture analysis element whose properties are being configured. + /// + public MixtureAnalysis Element + { + get { return (MixtureAnalysis)GetValue(ElementProperty); } + set { SetValue(ElementProperty, value); } + } + + /// + /// Callback method invoked when the dependency property changes. + /// Subscribes to property change events, loads input data, and refreshes the distribution data grid. + /// + /// The dependency object on which the property changed. + /// Event arguments containing the old and new property values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as MixtureAnalysisPropertiesControl == null) return; + var thisControl = (MixtureAnalysisPropertiesControl)d; + + // Unsubscribe from old element both PropertyChanged (element-scoped) and + // InputDataCollection subscriptions so the new element's LoadInputData starts clean. + if (e.OldValue is MixtureAnalysis oldElement) + { + oldElement.PropertyChanged -= thisControl.Element_PropertyChanged; + thisControl.UnsubscribeInputDataCollection(); + } + + if (e.NewValue == null) return; + var newElement = e.NewValue as MixtureAnalysis; + if (newElement == null) return; + + newElement.PropertyChanged += thisControl.Element_PropertyChanged; + thisControl._previousName = newElement.Name; + thisControl.PropertyAttributes.GetClassAttributes(newElement); + thisControl.LoadInputData(); + thisControl.DistributionDataGrid.ItemsSource = newElement.Distributions; + thisControl.DistributionDataGrid.Items.Refresh(); + } + + /// + /// Identifies the dependency property. + /// + public static DependencyProperty ExistingNamesProperty = DependencyProperty.Register(nameof(ExistingNames), typeof(string[]), typeof(MixtureAnalysisPropertiesControl), new FrameworkPropertyMetadata(new string[] { })); + + /// + /// Gets or sets the array of existing analysis names used for name uniqueness validation. + /// + public string[] ExistingNames + { + get { return (string[])GetValue(ExistingNamesProperty); } + private set { SetValue(ExistingNamesProperty, value); } + } + + /// + /// Gets the observable collection of available input data sets from the project. + /// + public ObservableCollection InputDataList { get; private set; } = new ObservableCollection(); + + /// + /// Gets the observable collection of available univariate distribution types for mixture modeling. + /// + public ObservableCollection DistributionList { get; private set; } = new ObservableCollection() + { new UnivariateDistributionItem("Exponential", UnivariateDistributionType.Exponential), + new UnivariateDistributionItem("Gamma", UnivariateDistributionType.GammaDistribution), + new UnivariateDistributionItem("Generalized Extreme Value", UnivariateDistributionType.GeneralizedExtremeValue), + new UnivariateDistributionItem("Generalized Logistic", UnivariateDistributionType.GeneralizedLogistic), + new UnivariateDistributionItem("Generalized Normal", UnivariateDistributionType.GeneralizedNormal), + new UnivariateDistributionItem("Generalized Pareto", UnivariateDistributionType.GeneralizedPareto), + new UnivariateDistributionItem("Gumbel (EVI)", UnivariateDistributionType.Gumbel), + new UnivariateDistributionItem("Kappa-4", UnivariateDistributionType.KappaFour), + new UnivariateDistributionItem("Ln-Normal", UnivariateDistributionType.LnNormal), + new UnivariateDistributionItem("Logistic", UnivariateDistributionType.Logistic), + new UnivariateDistributionItem("Log-Normal", UnivariateDistributionType.LogNormal), + new UnivariateDistributionItem("Log-Pearson Type III", UnivariateDistributionType.LogPearsonTypeIII), + new UnivariateDistributionItem("Normal", UnivariateDistributionType.Normal), + new UnivariateDistributionItem("Pearson Type III", UnivariateDistributionType.PearsonTypeIII), + new UnivariateDistributionItem("Weibull", UnivariateDistributionType.Weibull), + }; + + /// + /// Gets the observable collection of available credible interval width options for Bayesian analysis. + /// + public ObservableCollection CredibleIntervalItems { get; private set; } = new ObservableCollection() + { + new CredibleIntervalItem("90%", 0.9), + new CredibleIntervalItem("95%", 0.95), + new CredibleIntervalItem("98%", 0.98), + new CredibleIntervalItem("99%", 0.99) + }; + + #region Property Attributes + + /// + /// Handles the PreviewMouseLeftButtonDown event on the Name control. + /// Displays property attributes for the Name property. + /// + /// The source of the event. + /// The mouse button event data. + private void Name_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.Name), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the Description control. + /// Displays property attributes for the Description property. + /// + /// The source of the event. + /// The mouse button event data. + private void Description_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.Description), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the CreationDate control. + /// Displays property attributes for the CreationDate property. + /// + /// The source of the event. + /// The mouse button event data. + private void CreationDate_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.CreationDate), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the LastModified control. + /// Displays property attributes for the LastModified property. + /// + /// The source of the event. + /// The mouse button event data. + private void LastModified_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.LastModified), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the InputData control. + /// Displays property attributes for the InputData property. + /// + /// The source of the event. + /// The mouse button event data. + private void InputData_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.InputData), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the IsZeroInflated control. + /// Displays property attributes for the IsZeroInflated property of the mixture distribution. + /// + /// The source of the event. + /// The mouse button event data. + private void IsZeroInflated_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.MixtureDistribution.IsZeroInflated), Element.MixtureDistribution); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the DistributionDataGrid control. + /// Displays property attributes for the Distributions property. + /// + /// The source of the event. + /// The mouse button event data. + private void DistributionDataGrid_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.Distributions), Element); + } + + /// + /// Handles the SelectionChanged event on distribution type combo boxes in the DataGrid. + /// Updates the corresponding entry in the Element's Distributions collection. + /// + private void DistributionComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (Element == null) return; + var comboBox = sender as System.Windows.Controls.ComboBox; + if (comboBox == null) return; + var selectedItem = comboBox.SelectedValue; + if (selectedItem == null || !(selectedItem is UnivariateDistributionType newType)) return; + + // Find the row index for this combo box + var row = DistributionDataGrid.ItemContainerGenerator.ContainerFromItem(comboBox.DataContext); + if (row == null) return; + int index = DistributionDataGrid.ItemContainerGenerator.IndexFromContainer(row); + if (index < 0 || index >= Element.Distributions.Count) return; + + if (Element.Distributions[index] != newType) + { + Element.Distributions[index] = newType; + } + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the UseJeffreysRule control. + /// Displays property attributes for the UseJeffreysRuleForScale property of the mixture distribution. + /// + /// The source of the event. + /// The mouse button event data. + private void UseJeffreysRule_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.MixtureDistribution.UseJeffreysRuleForScale), Element.MixtureDistribution); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on the ProbabilityOrdinatesControl. + /// Displays default attributes for the Probability Ordinates property. + /// + /// The source of the event. + /// The mouse button event data. + private void ProbabilityOrdinatesControl_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.SetDefaultAttributes("Probability Ordinates", "The exceedance probabilities used for plotting the probability distribution."); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event on a TabItem. + /// Displays class-level attributes for the Element. + /// + /// The source of the event. + /// The mouse button event data. + private void TabItem_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetClassAttributes(Element); + } + + /// + /// Handles the ShowPropertyAttributes event from the ParameterPriorsControl. + /// Displays property attributes for the specified property. + /// + /// The name of the property to display attributes for. + /// The object containing the property. + private void ParameterPriorsControl_ShowPropertyAttributes(string propertyName, object classObject) + { + PropertyAttributes.GetPropertyAttributes(propertyName, classObject); + } + + /// + /// Handles the ShowPropertyAttributes event from the QuantilePriorsControl. + /// Displays property attributes for the specified property. + /// + /// The name of the property to display attributes for. + /// The object containing the property. + private void QuantilePriorsControl_ShowPropertyAttributes(string propertyName, object classObject) + { + PropertyAttributes.GetPropertyAttributes(propertyName, classObject); + } + + /// + /// Handles the ShowPropertyAttributes event from the BayesianOptionsControl. + /// Displays property attributes for the specified property. + /// + /// The name of the property to display attributes for. + /// The object containing the property. + private void BayesianOptionsControl_ShowPropertyAttributes(string propertyName, object classObject) + { + PropertyAttributes.GetPropertyAttributes(propertyName, classObject); + } + + /// + /// Handles the ShowPropertyAttributes event from the BayesianOutputControl. + /// Displays property attributes for the specified property. + /// + /// The name of the property to display attributes for. + /// The object containing the property. + private void BayesianOutputControl_ShowPropertyAttributes(string propertyName, object classObject) + { + PropertyAttributes.GetPropertyAttributes(propertyName, classObject); + } + + #endregion + + /// + /// Handles the GotFocus event on the Name control. Stores the current name and retrieves existing names for validation. + /// + /// The source of the event. + /// The event data. + private void Name_GotFocus(object sender, RoutedEventArgs e) + { + _previousName = Element.Name; + ExistingNames = Element.ParentCollection.GetElementNames(Element).ToArray(); + } + + /// + /// Handles the LostFocus event on the Name control. Restores the previous name if validation fails. + /// + /// The source of the event. + /// The event data. + private void Name_LostFocus(object sender, RoutedEventArgs e) + { + if (ElementName.NameTextBox.IsValid) + return; + if (Element != null) + Element.Name = _previousName; + } + + /// + /// Intercepts the DataGrid add-row operation. Cancels the default add (which would create the + /// wrong enum default) and manually adds instead. + /// Enforces a maximum of 3 distributions. + /// + /// The starting row index for the new rows. + /// The number of rows to add. + /// Set to to cancel the default add operation. + private void DistributionDataGrid_PreviewAddRows(int startRowIndex, int nRows, ref bool cancelAddRows) + { + cancelAddRows = true; + if (Element == null) return; + for (int i = 0; i < nRows && Element.Distributions.Count < 3; i++) + Element.Distributions.Add(UnivariateDistributionType.Normal); + } + + /// + /// Intercepts the DataGrid delete-row operation. Enforces a minimum of 1 distribution. + /// + private void DistributionDataGrid_PreviewDeleteRows(List rowIndices, ref bool cancel) + { + if (Element == null) { cancel = true; return; } + // Prevent deleting all distributions at least 1 must remain + if (Element.Distributions.Count - rowIndices.Count < 1) + { + cancel = true; + return; + } + } + + /// + /// Handles the Unloaded event for the user control. Unsubscribes from the InputDataCollection + /// to prevent memory leaks during visual-tree cycles. + /// + /// + /// Element.PropertyChanged is element-scoped and is managed exclusively in + /// so it survives Unload/Reload cycles. + /// + /// The source of the event. + /// Event arguments. + private void UserControl_Unloaded(object sender, RoutedEventArgs e) + { + UnsubscribeInputDataCollection(); + } + + /// + /// Handles property changes on the associated Element. + /// + /// The source of the event. + /// The event data containing the name of the changed property. + private void Element_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + // Model object replaced (e.g., during undo) push to sub-controls + if (e.PropertyName == nameof(Element.MixtureDistribution)) + { + ParameterPriorsControl.Model = Element.MixtureDistribution; + QuantilePriorsControl.Model = Element.MixtureDistribution; + } + // BayesianAnalysis replaced push to sub-controls + if (e.PropertyName == nameof(Element.BayesianAnalysis)) + { + BayesianOptionsControl.Analysis = Element.BayesianAnalysis; + BayesianOutputControl.Analysis = Element.BayesianAnalysis; + } + // Distributions collection changed refresh the data grid + if (e.PropertyName == nameof(Element.Distributions)) + { + DistributionDataGrid.ItemsSource = null; + DistributionDataGrid.ItemsSource = Element.Distributions; + } + } + + /// + /// Loads the available input data sets from the project's InputDataCollection. + /// Unsubscribes from any previously subscribed collection, then subscribes to the new one + /// using named handlers to allow clean unsubscription. + /// + private void LoadInputData() + { + UnsubscribeInputDataCollection(); + InputDataList.Clear(); + foreach (IElementCollection collection in Element.ParentCollection.ParentProject.ElementCollections) + { + if (collection.GetType() == typeof(InputDataCollection)) + { + _subscribedInputDataCollection = collection; + collection.ElementAdded += OnInputDataElementAdded; + collection.ElementRemoved += OnInputDataElementRemoved; + foreach (IElement element in collection) + InputDataList.Add((InputData)element); + break; + } + } + } + + /// + /// Handles the addition of an input data element to the subscribed collection. + /// + /// The element that was added. + private void OnInputDataElementAdded(IElement element) + { + InputDataList.Add((InputData)element); + } + + /// + /// Handles the removal of an input data element from the subscribed collection. + /// + /// The element that was removed. + private void OnInputDataElementRemoved(IElement element) + { + InputDataList.Remove((InputData)element); + } + + /// + /// Unsubscribes from the currently tracked InputDataCollection and clears the reference. + /// + private void UnsubscribeInputDataCollection() + { + if (_subscribedInputDataCollection != null) + { + _subscribedInputDataCollection.ElementAdded -= OnInputDataElementAdded; + _subscribedInputDataCollection.ElementRemoved -= OnInputDataElementRemoved; + _subscribedInputDataCollection = null; + } + } + + /// + /// Handles the SelectionChanged event on the InputDataComboBox. Provides visual feedback for invalid selection. + /// + /// The source of the event. + /// The selection changed event data. + private void InputDataComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (Element == null) return; + if (Element.InputData == null || Element.InputData.Name == null) + { + ((Border)InputDataComboBox.InnerContent).BorderThickness = new Thickness(1); + InputDataComboBox.ToolTip = "Please select a valid input data."; + } + else + { + ((Border)InputDataComboBox.InnerContent).BorderThickness = new Thickness(0); + InputDataComboBox.ToolTip = null; + } + } + + /// + /// Handles the Loaded event on a ComboBox. Sets up sorting for the input data list and binds it to the combo box. + /// + /// The source of the event. + /// The event data. + private void ComboBox_Loaded(object sender, RoutedEventArgs e) + { + ComboBox cmbo = (ComboBox)sender; + CollectionViewSource csv = new CollectionViewSource() { Source = InputDataList, IsLiveSortingRequested = true }; + csv.SortDescriptions.Add(new SortDescription(nameof(IElement.Name), ListSortDirection.Ascending)); + var view = csv.View; + view.MoveCurrentToPosition(-1); + cmbo.ItemsSource = view; + } + + /// + /// Handles the Click event on the Estimate button. Validates inputs, disables UI elements, + /// sets up progress reporting, and executes the Bayesian mixture analysis asynchronously. + /// + /// The source of the event. + /// The event data. + private async void EstimateButton_Click(object sender, RoutedEventArgs e) + { + // Check if the analysis is valid + if (Element.IsValid == false) + { + GenericControls.MessageBox.Show("Cannot perform the analysis because the inputs are invalid.", "Error!", MessageBoxButton.OK, MessageBoxImage.Error); + return; + } + + Mouse.OverrideCursor = Cursors.Wait; + + // Disable all open windows + FrameworkUI.ShellPublicVariables.SimulationInProgress = true; + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableMenuStrip(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableProjectExplorer(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableMessageWindow(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableOpenWindows(); + // Disable Properties + PropertiesExpander.IsEnabled = false; + MixtureOptionsExpander.IsEnabled = false; + ParameterPriorsExpander.IsEnabled = false; + QuantilePriorsExpander.IsEnabled = false; + Options_TabItem.IsEnabled = false; + Output_TabItem.IsEnabled = false; + + // Set up progress bar + AnalysisProgressDisplayHelper.ShowInitial(ProgressBar, ProgressTextBlock); + + // Show cancel button + EstimateButton.Visibility = Visibility.Hidden; + CancelButton.Visibility = Visibility.Visible; + + // Set up progress reporter + var progressReporter = new SafeProgressReporter(nameof(UnivariateAnalysis)); + progressReporter.ProgressReported += (SafeProgressReporter reporter, double progress, double progressDelta) => + { + AnalysisProgressDisplayHelper.PostProgress(Dispatcher, ProgressBar, ProgressTextBlock, progress); + }; + bool progressCleanupEnabled = false; + progressReporter.TaskEnded += () => + { + if (!progressCleanupEnabled) return; + // Close out progress bar + ProgressBar.Value = 100; + ProgressTextBlock.Text = "Simulation Complete"; + Mouse.OverrideCursor = null; + EstimateButton.IsEnabled = true; + ProgressBar.Visibility = Visibility.Hidden; + ProgressTextBlock.Visibility = Visibility.Hidden; + + // Close cancel button + EstimateButton.Visibility = Visibility.Visible; + CancelButton.Visibility = Visibility.Hidden; + + // Enable all windows + FrameworkUI.ShellPublicVariables.SimulationInProgress = false; + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableMenuStrip(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableProjectExplorer(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableMessageWindow(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableOpenWindows(); + // Enable Properties + PropertiesExpander.IsEnabled = true; + MixtureOptionsExpander.IsEnabled = true; + ParameterPriorsExpander.IsEnabled = true; + QuantilePriorsExpander.IsEnabled = true; + Options_TabItem.IsEnabled = true; + Output_TabItem.IsEnabled = true; + }; + + + // Perform Bayesian estimation + try + { + await WaitCursorHelper.RunWithVisibleWaitCursorAsync(Dispatcher, async () => + { + await Element.RunAsync(progressReporter); + }); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"MixtureAnalysisPropertiesControl.EstimateButton_Click: {ex}"); + GenericControls.MessageBox.Show("An unexpected error occurred. Please verify your analysis inputs and settings." + Environment.NewLine + "If the issue persists, contact the Risk Management Center.", "Error!", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + progressCleanupEnabled = true; + progressReporter.IndicateTaskEnded(); + } + + + } + + /// + /// Handles the Click event on the Cancel button. Cancels the currently running analysis. + /// + /// The source of the event. + /// The event data. + private void CancelButton_Click(object sender, RoutedEventArgs e) + { + Element.CancelAnalysis(); + } + + /// + /// Handles the KeyDown event on the MixtureAnalysisProperties control. + /// Cancels the analysis if the Escape key is pressed. + /// + /// The source of the event. + /// The key event data. + private void MixtureAnalysisProperties_KeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + Element.CancelAnalysis(); + } + + } +} + diff --git a/src/RMC.BestFit.App/GUI/UnivariateAnalysis/PointProcess/PointProcessAnalysisControl.xaml b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/PointProcess/PointProcessAnalysisControl.xaml new file mode 100644 index 0000000..d9de1a1 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/PointProcess/PointProcessAnalysisControl.xaml @@ -0,0 +1,212 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/UnivariateAnalysis/PointProcess/PointProcessAnalysisControl.xaml.cs b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/PointProcess/PointProcessAnalysisControl.xaml.cs new file mode 100644 index 0000000..1195e97 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/PointProcess/PointProcessAnalysisControl.xaml.cs @@ -0,0 +1,1052 @@ +using System; +using System.Collections.Generic; +using OxyPlot; +using OxyPlot.Wpf; +using OxyPlotControls; +using RMC.BestFit.Models; +using ModelAnalyses = RMC.BestFit.Analyses; +using RMC.BestFit.Estimation; +using RMC.BestFit.UI; +using System.ComponentModel; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Media3D; +using System.Windows.Data; +using System.Windows.Threading; +using FrameworkUI; +using Numerics.Distributions; + +namespace RMC_BestFit +{ + /// + /// User control for displaying and managing Point Process Analysis results and visualizations. + /// Provides frequency plots, statistical tables, and various diagnostic plots for analyzing + /// point process data including POT (Peaks Over Threshold) and AMS (Annual Maximum Series) approaches. + /// + public partial class PointProcessAnalysisControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public PointProcessAnalysisControl() + { + InitializeComponent(); + DataContext = this; + _colorHexCodes = GenericControls.GeneralMethods.RandomColorsLongList; + // DataGrid Binding.StringFormat must be set before first render. + SetColumnStringFormats(); + } + + /// + /// Dependency property for the property. + /// + public static DependencyProperty ElementProperty = DependencyProperty.Register(nameof(Element), typeof(PointProcessAnalysis), typeof(PointProcessAnalysisControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the Point Process Analysis element being displayed in this control. + /// + public PointProcessAnalysis Element + { + get { return (PointProcessAnalysis)GetValue(ElementProperty); } + set { SetValue(ElementProperty, value); } + } + + /// + /// Callback method invoked when the property changes. + /// Handles event subscription management and initializes the control with the new element. + /// + /// The dependency object whose property changed. + /// Event arguments containing the old and new values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as PointProcessAnalysisControl == null) return; + var thisControl = (PointProcessAnalysisControl)d; + + // Remove handlers and detach old element's plots + if (e.OldValue != null) + { + PointProcessAnalysis oldElement = e.OldValue as PointProcessAnalysis; + if (oldElement != null) + { + oldElement.PropertyChanged -= thisControl.Element_PropertyChanged; + thisControl.FrequencyPlotHost.Content = null; + thisControl.FrequencyPlotToolbar.Plot = null; + // NOTE: PropertiesCalled is wired in XAML — no programmatic -= needed. + } + } + + if (e.NewValue == null) return; + var newElement = e.NewValue as PointProcessAnalysis; + if (newElement == null) return; + + // Subscribe Element-scoped handler + newElement.PropertyChanged += thisControl.Element_PropertyChanged; + thisControl.SetAMSData(); + + // Attach plots, wire toolbars, bind axis titles, and wire Bayesian sub-control + // plots inside a bridge-suspension block. PropertiesCalled / PlotPropertiesCalled + // are wired in XAML — no programmatic += needed. + using (newElement.SuspendPlotBridges()) + { + thisControl.FrequencyPlotHost.Content = newElement.FrequencyPlot; + thisControl.FrequencyPlotToolbar.Plot = newElement.FrequencyPlot; + + thisControl.BindAxisTitles(); + + thisControl.HistogramControl.SetPlot(newElement.BayesianPlots.HistogramPlot); + thisControl.KernelDensityControl.SetPlot(newElement.BayesianPlots.KernelDensityPlot); + thisControl.AutocorrelationControl.SetPlot(newElement.BayesianPlots.AutocorrelationPlot); + thisControl.MarkovChainTraceControl.SetPlot(newElement.BayesianPlots.MarkovChainTracePlot); + thisControl.MeanLikelihoodControl.SetPlot(newElement.BayesianPlots.MeanLikelihoodPlot); + thisControl.BivariateHeatMapControl.SetPlot(newElement.BayesianPlots.BivariateHeatMapPlot); + thisControl.InfluenceDiagnosticsControl.SetPlot(newElement.BayesianPlots.InfluenceDiagnosticsPlot); + } + } + + /// + /// Convenience accessor for the Element-owned frequency plot. + /// + private Plot FrequencyPlot => Element?.FrequencyPlot; + + /// + /// Gets a value indicating whether a plot was clicked. + /// + public bool PlotClicked { get; private set; } = false; + + /// + /// Raised when plot properties are requested to be displayed or modified. + /// + public event PlotPropertiesCalledEventHandler PlotPropertiesCalled; + + /// + /// Delegate for handling plot properties called events. + /// + /// The plot for which properties are being accessed. + /// Indicates whether to open the properties panel. + /// The property expander control. + /// The selected object for property display. + public delegate void PlotPropertiesCalledEventHandler(Plot plot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject); + + /// + /// Raised when the preview control is clicked. + /// + public event PreviewControlClickedEventHandler PreviewControlClicked; + + /// + /// Delegate for handling preview control clicked events. + /// + /// Indicates whether the plot area was clicked. + /// Indicates whether the toolbar was clicked. + /// The plot that was interacted with. + public delegate void PreviewControlClickedEventHandler(bool plotClicked, bool toolbarClicked, Plot plot); + + /// + /// Array of color hex codes used for rendering plot series. + /// + private string[] _colorHexCodes; + + /// + /// Data frame containing Annual Maximum Series data derived from the POT data. + /// + private DataFrame amsDataFrame = new DataFrame(); + + + + /// + /// Handles the Loaded event for the user control. Initializes plots and data grids on first load. + /// + /// The source of the event. + /// Event arguments. + private void UserControl_Loaded(object sender, RoutedEventArgs e) + { + if (Element == null) return; + + // Data refresh on every load. Plot hosts, toolbars, axis-title bindings, and + // Element.PropertyChanged are wired once per Element in ElementCallback. + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try + { + UpdateFrequencyPlot(); + BindFrequencyCurveDataGrid(); + SetFrequencyCurveTableColumnHeaders(); + BindSummaryStatisticsDataGrid(); + } + finally + { + Element.IsUndoEnabled = wasUndoEnabled; + } + } + + /// + /// Handles the Unloaded event of the user control. Unsubscribes all element event handlers + /// to prevent memory leaks and stale event callbacks. + /// + /// The source of the event. + /// Event arguments. + private void UserControl_Unloaded(object sender, RoutedEventArgs e) + { + // Pattern B (canonical): Element-scoped lifecycle (PropertyChanged, plot + // hosts, toolbars) is owned by ElementCallback and survives unload/reload + // cycles. This control has no control-scoped subscriptions to tear down, + // so Unloaded is intentionally a no-op. + } + + /// + /// Handles property changed events from the Element. Updates plots and data displays accordingly. + /// + /// The source of the event. + /// Event arguments containing the name of the changed property. + private void Element_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + // Marshal to UI thread if called from a background thread. + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke(new Action(() => Element_PropertyChanged(sender, e))); + return; + } + + if (e.PropertyName == nameof(Element.AnalysisResults)) + { + BindFrequencyCurveDataGrid(); + BindSummaryStatisticsDataGrid(); + UpdateFrequencyPlot(); + ResetWaitCursor(); + } + if (e.PropertyName == nameof(Element.BayesianAnalysis.CredibleIntervalWidth)) + { + SetFrequencyCurveTableColumnHeaders(); + } + if (e.PropertyName == nameof(Element.InputData) || e.PropertyName == nameof(Element.InputData.DataFrame) || e.PropertyName == nameof(Element.PointProcess.TotalYears)) + { + SetAMSData(); + UpdateFrequencyPlot(); + } + if (e.PropertyName == nameof(Element.InputData)) + { + using (Element.SuspendPlotBridges()) + { + BindAxisTitles(); + } + } + if ((e.PropertyName == nameof(Element.BayesianAnalysis.PointEstimator) + || e.PropertyName == nameof(Element.BayesianAnalysis.CredibleIntervalWidth) + || e.PropertyName == nameof(Element.ProbabilityOrdinates)) + && Element.IsEstimated) + { + // Cursor wait while the model layer's ReprocessIfEstimated runs the recompute + // on TaskScheduler.Default. Reset is wired to the AnalysisResults notification above. + Mouse.OverrideCursor = Cursors.Wait; + } + + } + + /// + /// Clears when it is currently set to . + /// Marshals onto the UI thread when called from a background-thread PropertyChanged + /// notification (the model layer's ReprocessIfEstimated path uses + /// TaskScheduler.Default). + /// + private void ResetWaitCursor() + { + // Reset at Background priority so the Render-priority cursor frame from the + // earlier `Mouse.OverrideCursor = Cursors.Wait` is guaranteed to flush before + // the reset runs. Without this, fast reprocesses (UpdatePointEstimateResultsAsync) + // can reset the cursor before the OS visually picks up the change — the user + // sees no wait cursor at all when the mouse is stationary. + Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + { + if (Mouse.OverrideCursor == Cursors.Wait) + Mouse.OverrideCursor = null; + })); + } + + /// + /// Initializes or updates the Annual Maximum Series (AMS) data frame from the input POT data. + /// + private void SetAMSData() + { + if (Element == null || Element.InputData == null || Element.InputData.DataFrame == null) return; + amsDataFrame = Element.InputData.DataFrame.Clone(); + amsDataFrame.ApplyLangbeinConversion(Element.PointProcess.Lambda); + } + + #region Plots + + /// + /// Binds the frequency plot Y-axis title to the InputData.UnitLabel property. + /// Called after element attachment and when InputData changes. + /// + private void BindAxisTitles() + { + if (Element == null) return; + + // Frequency plot: Y-axis title = UnitLabel + var freqYAxis = FrequencyPlot?.Axes.FirstOrDefault(a => a.Key == "Yaxis"); + if (freqYAxis != null) + { + if (Element.InputData != null) + PlotAxisTitleDefaults.BindTitleIfDefault(freqYAxis, Element.InputData, nameof(InputData.UnitLabel), Element.InputData.UnitLabel); + else + PlotAxisTitleDefaults.SetTitleIfDefault(freqYAxis, string.Empty); + } + } + + /// + /// Handles the LostFocus event for the user control. Resets the PlotClicked flag. + /// + /// The source of the event. + /// Event arguments. + private void UserControl_LostFocus(object sender, RoutedEventArgs e) + { + PlotClicked = false; + } + + /// + /// Handles the PreviewMouseDown event for the user control. Detects clicks on plots and toolbars. + /// + /// The source of the event. + /// Mouse button event arguments. + private void UserControl_PreviewMouseDown(object sender, MouseButtonEventArgs e) + { + Plot plot = GetCurrentPlot(); + OxyPlotToolbar toolbar = GetCurrentPlotToolbar(); + System.Windows.Media.HitTestResult plotHitResult = null; + System.Windows.Media.HitTestResult toolbarHitResult = null; + if (plot != null) + { + plotHitResult = VisualTreeHelper.HitTest(plot, e.GetPosition(plot)); + } + if (toolbar != null) + { + toolbarHitResult = VisualTreeHelper.HitTest(toolbar, e.GetPosition(toolbar)); + } + PreviewControlClicked?.Invoke(plotHitResult != null, toolbarHitResult != null, plot); + PlotClicked = plotHitResult != null; + } + + /// + /// Handles the PropertiesCalled event from plot toolbars. Raises the PlotPropertiesCalled event. + /// + /// The plot whose properties are being accessed. + /// Indicates whether to open the properties panel. + /// The property expander control. + /// The selected object for property display. + private void PlotToolbar_PropertiesCalled(Plot targetPlot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject) + { + PlotPropertiesCalled?.Invoke(targetPlot, openProperties, propertyExpander, selectedObject); + } + + /// + /// Gets the currently selected plot based on the active tab. + /// + /// The current plot, or null if no tab is selected. + public Plot GetCurrentPlot() + { + if (FrequencyResultsTabItem.IsSelected == true) + { + return FrequencyPlot; + } + else if (KernelDensityTabItem.IsSelected == true) + { + return KernelDensityControl.Plot; + } + else if (HistogramTabItem.IsSelected == true) + { + return HistogramControl.Plot; + } + else if (BivariateTabItem.IsSelected == true) + { + return BivariateHeatMapControl.Plot; + } + else if (MeanLikelihoodTabItem.IsSelected == true) + { + return MeanLikelihoodControl.Plot; + } + else if (AutocorrelationTabItem.IsSelected == true) + { + return AutocorrelationControl.Plot; + } + else if (MarkovChainTabItem.IsSelected == true) + { + return MarkovChainTraceControl.Plot; + } + else if (InfluenceDiagnosticsTabItem.IsSelected == true) + { + return InfluenceDiagnosticsControl.Plot; + } + return null; + } + + /// + /// Gets the toolbar for the currently selected plot based on the active tab. + /// + /// The current plot toolbar, or null if no tab is selected. + public OxyPlotToolbar GetCurrentPlotToolbar() + { + if (FrequencyResultsTabItem.IsSelected == true) + { + return FrequencyPlotToolbar; + } + else if (KernelDensityTabItem.IsSelected == true) + { + return KernelDensityControl.PlotToolbar; + } + else if (HistogramTabItem.IsSelected == true) + { + return HistogramControl.PlotToolbar; + } + else if (BivariateTabItem.IsSelected == true) + { + return BivariateHeatMapControl.PlotToolbar; + } + else if (MeanLikelihoodTabItem.IsSelected == true) + { + return MeanLikelihoodControl.PlotToolbar; + } + else if (AutocorrelationTabItem.IsSelected == true) + { + return AutocorrelationControl.PlotToolbar; + } + else if (MarkovChainTabItem.IsSelected == true) + { + return MarkovChainTraceControl.PlotToolbar; + } + else if (InfluenceDiagnosticsTabItem.IsSelected == true) + { + return InfluenceDiagnosticsControl.PlotToolbar; + } + return null; + } + + + /// + /// Handles the Checked event for the POT checkbox. Updates the frequency plot. + /// + /// The source of the event. + /// Event arguments. + private void POTCheckbox_Checked(object sender, RoutedEventArgs e) + { + UpdateFrequencyPlot(); + } + + /// + /// Handles the Unchecked event for the POT checkbox. Updates the frequency plot. + /// + /// The source of the event. + /// Event arguments. + private void POTCheckbox_Unchecked(object sender, RoutedEventArgs e) + { + UpdateFrequencyPlot(); + } + + /// + /// Updates the frequency plot with the current analysis results, data series, and fitted curves. + /// Clears existing series and rebuilds the plot based on current settings and available data. + /// + private void UpdateFrequencyPlot() + { + if (FrequencyPlot == null) return; + + // Seasonal series default colors — used only if series doesn't already exist in plot. + var seasonalColors = GenericControls.GeneralMethods.RandomColorsShortList; + var s1Color = (Color)ColorConverter.ConvertFromString(seasonalColors[1]); + var s1Default = Color.FromArgb(200, s1Color.R, s1Color.G, s1Color.B); + var s2Color = (Color)ColorConverter.ConvertFromString(seasonalColors[2]); + var s2Default = Color.FromArgb(200, s2Color.R, s2Color.G, s2Color.B); + + // Lookup-or-create named series so user-customized styling survives Save/Open. + var frequencyExactData = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "ExactData") + ?? new ScatterPointSeries + { + Name = "ExactData", + Title = "POT Exact Data", + MarkerFill = Color.FromArgb(64, Colors.Black.R, Colors.Black.G, Colors.Black.B), + MarkerStroke = Color.FromArgb(128, Colors.Black.R, Colors.Black.G, Colors.Black.B), + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle, + }; + var frequencyLowOutlierData = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "LowOutlierData") + ?? new ScatterPointSeries + { + Name = "LowOutlierData", + Title = "POT Low Outlier Data", + MarkerStroke = Color.FromArgb(64, Colors.Red.R, Colors.Red.G, Colors.Red.B), + MarkerStrokeThickness = 2, + MarkerSize = 3, + MarkerType = MarkerType.Cross, + }; + var frequencyUncertainData = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "UncertainData") + ?? new ScatterErrorSeries + { + Name = "UncertainData", + Title = "POT Uncertain Data", + MarkerFill = Color.FromArgb(64, Colors.Green.R, Colors.Green.G, Colors.Green.B), + MarkerStroke = Color.FromArgb(128, Colors.Black.R, Colors.Black.G, Colors.Black.B), + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Diamond, + ErrorBarColor = Color.FromArgb(128, Colors.Black.R, Colors.Black.G, Colors.Black.B), + }; + var frequencyIntervalData = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "IntervalData") + ?? new ScatterErrorSeries + { + Name = "IntervalData", + Title = "POT Interval Data", + MarkerFill = Color.FromArgb(64, Colors.Cyan.R, Colors.Cyan.G, Colors.Cyan.B), + MarkerStroke = Color.FromArgb(128, Colors.Black.R, Colors.Black.G, Colors.Black.B), + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle, + ErrorBarColor = Color.FromArgb(128, Colors.Black.R, Colors.Black.G, Colors.Black.B), + }; + var amsFrequencyExactData = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "AMSExactData") + ?? new ScatterPointSeries + { + Name = "AMSExactData", + Title = "AMS Exact Data", + MarkerFill = Colors.Black, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle, + }; + var amsFrequencyLowOutlierData = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "AMSLowOutlierData") + ?? new ScatterPointSeries + { + Name = "AMSLowOutlierData", + Title = "AMS Low Outlier Data", + MarkerStroke = Colors.Red, + MarkerStrokeThickness = 2, + MarkerSize = 3, + MarkerType = MarkerType.Cross, + }; + // Note: prior code used Name="UncertainData" for AMS (same as POT) — pre-existing name + // collision bug. Renamed to "AMSUncertainData" here so lookup-or-create resolves correctly. + var amsFrequencyUncertainData = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "AMSUncertainData") + ?? new ScatterErrorSeries + { + Name = "AMSUncertainData", + Title = "AMS Uncertain Data", + MarkerFill = Colors.Green, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Diamond, + }; + var amsFrequencyIntervalData = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "AMSIntervalData") + ?? new ScatterErrorSeries + { + Name = "AMSIntervalData", + Title = "AMS Interval Data", + MarkerFill = Colors.Cyan, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle, + }; + var quantilePriors = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "QuantilePrior") + ?? new ScatterErrorSeries + { + Name = "QuantilePrior", + Title = "Quantile Prior", + MarkerFill = Colors.Red, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Square, + }; + var credibleIntervals = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "CredibleIntervals") + ?? new AreaSeries + { + Name = "CredibleIntervals", + Fill = Color.FromArgb(75, 104, 140, 175), + Color = Color.FromArgb(255, 53, 59, 122), + LineStyle = LineStyle.Solid, + BrokenLineThickness = 1, + StrokeThickness = 1, + Decimator = OxyPlot.Decimator.Decimate, + }; + var posteriorPredictive = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "PosteriorPredictive") + ?? new LineSeries + { + Name = "PosteriorPredictive", + Title = "Posterior Predictive", + Color = Colors.Blue, + StrokeThickness = 1, + LineStyle = LineStyle.Dash, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0, + }; + var posteriorMode = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "PosteriorMode") + ?? new LineSeries + { + Name = "PosteriorMode", + Title = "Posterior Mode", + Color = Colors.Black, + StrokeThickness = 1, + LineStyle = LineStyle.Solid, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0, + }; + var posteriorModeS1 = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "PosteriorModeS1") + ?? new LineSeries + { + Name = "PosteriorModeS1", + Title = "Posterior Mode", + Color = s1Default, + MarkerStroke = s1Default, + LineStyle = LineStyle.Solid, + StrokeThickness = 1.5, + MarkerType = MarkerType.Triangle, + MarkerFill = Colors.Transparent, + MarkerSize = 3, + MarkerStrokeThickness = 1.5, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0, + }; + var posteriorModeS2 = FrequencyPlot.Series.OfType().FirstOrDefault(s => s.Name == "PosteriorModeS2") + ?? new LineSeries + { + Name = "PosteriorModeS2", + Title = "Posterior Mode", + Color = s2Default, + MarkerStroke = s2Default, + LineStyle = LineStyle.Solid, + StrokeThickness = 1.5, + MarkerType = MarkerType.Triangle, + MarkerFill = Colors.Transparent, + MarkerSize = 3, + MarkerStrokeThickness = 1.5, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0, + }; + + using (Element?.SuspendPlotBridges()) + { + FrequencyPlot.Series.Clear(); + + if (Element != null && Element.InputData != null && Element.BayesianAnalysis != null) + { + // Add Curves + if (Element.BayesianAnalysis.IsEstimated == true && Element.AnalysisResults != null) + { + // Add sub-distributions + if (Element.PointProcess.IsSeasonal == true && SeasonalCheckbox.IsChecked == true) + { + var gev1 = ((CompetingRisks)Element.AnalysisResults.ParentDistribution).Distributions[0]; + var gev2 = ((CompetingRisks)Element.AnalysisResults.ParentDistribution).Distributions[1]; + var points1 = new List(); + var points2 = new List(); + for (int j = 0; j < Element.ProbabilityOrdinates.Count; j++) + { + var x = Element.ProbabilityOrdinates[j]; + points1.Add(new OxyPlot.DataPoint(x, gev1.InverseCDF(1 - x))); + points2.Add(new OxyPlot.DataPoint(x, gev2.InverseCDF(1 - x))); + } + + // Season 1 + posteriorModeS1.Title = (Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode") + " - Season 1"; + posteriorModeS1.ItemsSource = points1; + posteriorModeS1.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + FrequencyPlot.Series.Add(posteriorModeS1); + + // Season 2 + posteriorModeS2.Title = (Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode") + " - Season 2"; + posteriorModeS2.ItemsSource = points2; + posteriorModeS2.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + FrequencyPlot.Series.Add(posteriorModeS2); + } + + var ciPoints = new List(); + var prdPoints = new List(); + var mdPoints = new List(); + for (int i = 0; i < Element.ProbabilityOrdinates.Count; i++) + { + var p = Element.ProbabilityOrdinates[i]; + var up = Element.AnalysisResults.ConfidenceIntervals[i, 1]; + var lo = Element.AnalysisResults.ConfidenceIntervals[i, 0]; + var prd = Element.AnalysisResults.MeanCurve[i]; + var md = Element.AnalysisResults.ModeCurve[i]; + ciPoints.Add(new Point3D(p, lo, up)); + prdPoints.Add(new OxyPlot.DataPoint(p, prd)); + mdPoints.Add(new OxyPlot.DataPoint(p, md)); + } + + // Credible Intervals + credibleIntervals.ItemsSource = ciPoints; + credibleIntervals.DataFieldX = "X"; + credibleIntervals.DataFieldY = "Y"; + credibleIntervals.DataFieldX2 = "X"; + credibleIntervals.DataFieldY2 = "Z"; + credibleIntervals.Title = (Element.BayesianAnalysis.CredibleIntervalWidth * 100).ToString("F0") + "% Credible Intervals"; + credibleIntervals.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + FrequencyPlot.Series.Add(credibleIntervals); + + // Predictive + posteriorPredictive.ItemsSource = prdPoints; + posteriorPredictive.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + FrequencyPlot.Series.Add(posteriorPredictive); + + // Point Estimator + posteriorMode.Title = Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"; + posteriorMode.ItemsSource = mdPoints; + posteriorMode.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + FrequencyPlot.Series.Add(posteriorMode); + } + + // See if Y-axis is logarithmic + bool logAxis = false; + foreach (var axis in FrequencyPlot.Axes) + { + if (axis.Key == "Yaxis" && axis as LogarithmicAxis != null) + { + logAxis = true; + break; + } + } + + // POT Data + if (POTCheckbox.IsChecked == true) + { + frequencyExactData.ItemsSource = logAxis == true ? Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == false && x.Value > 1E-16) : Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == false); + frequencyExactData.Mapping = item => { var d = (ExactData)item; return new OxyPlot.Series.ScatterPoint(d.PlottingPosition, d.Value); }; + frequencyExactData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.ExactSeries.Count > 0) FrequencyPlot.Series.Add(frequencyExactData); + + frequencyLowOutlierData.ItemsSource = logAxis == true ? Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == true && x.Value > 1E-16) : Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == true); + frequencyLowOutlierData.Mapping = item => { var d = (ExactData)item; return new OxyPlot.Series.ScatterPoint(d.PlottingPosition, d.Value); }; + frequencyLowOutlierData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.NumberOfLowOutliers > 0) FrequencyPlot.Series.Add(frequencyLowOutlierData); + + frequencyUncertainData.ItemsSource = logAxis == true ? Element.InputData.DataFrame.UncertainSeries : Element.InputData.DataFrame.UncertainSeries.Where(x => x.Value > 1E-16); + frequencyUncertainData.DataFieldX = nameof(UncertainData.PlottingPosition); + frequencyUncertainData.DataFieldY = nameof(UncertainData.Value); + frequencyUncertainData.DataFieldLowerErrorY = nameof(UncertainData.LowerValue); + frequencyUncertainData.DataFieldUpperErrorY = nameof(UncertainData.UpperValue); + frequencyUncertainData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.UncertainSeries.Count > 0) FrequencyPlot.Series.Add(frequencyUncertainData); + + frequencyIntervalData.ItemsSource = logAxis == true ? Element.InputData.DataFrame.IntervalSeries : Element.InputData.DataFrame.IntervalSeries.Where(x => x.Value > 1E-16); + frequencyIntervalData.DataFieldX = nameof(IntervalData.PlottingPosition); + frequencyIntervalData.DataFieldY = nameof(IntervalData.Value); + frequencyIntervalData.DataFieldLowerErrorY = nameof(IntervalData.LowerValue); + frequencyIntervalData.DataFieldUpperErrorY = nameof(IntervalData.UpperValue); + frequencyIntervalData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.IntervalSeries.Count > 0) FrequencyPlot.Series.Add(frequencyIntervalData); + } + + // AMS Data + amsFrequencyExactData.ItemsSource = logAxis == true ? amsDataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == false && x.Value > 1E-16) : amsDataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == false); + amsFrequencyExactData.Mapping = item => { var d = (ExactData)item; return new OxyPlot.Series.ScatterPoint(d.PlottingPosition, d.Value); }; + amsFrequencyExactData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (amsDataFrame.ExactSeries.Count > 0) FrequencyPlot.Series.Add(amsFrequencyExactData); + + amsFrequencyLowOutlierData.ItemsSource = logAxis == true ? amsDataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == true && x.Value > 1E-16) : amsDataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == true); + amsFrequencyLowOutlierData.Mapping = item => { var d = (ExactData)item; return new OxyPlot.Series.ScatterPoint(d.PlottingPosition, d.Value); }; + amsFrequencyLowOutlierData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (amsDataFrame.NumberOfLowOutliers > 0) FrequencyPlot.Series.Add(amsFrequencyLowOutlierData); + + amsFrequencyUncertainData.ItemsSource = logAxis == true ? amsDataFrame.UncertainSeries : amsDataFrame.UncertainSeries.Where(x => x.Value > 1E-16); + amsFrequencyUncertainData.DataFieldX = nameof(UncertainData.PlottingPosition); + amsFrequencyUncertainData.DataFieldY = nameof(UncertainData.Value); + amsFrequencyUncertainData.DataFieldLowerErrorY = nameof(UncertainData.LowerValue); + amsFrequencyUncertainData.DataFieldUpperErrorY = nameof(UncertainData.UpperValue); + amsFrequencyUncertainData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (amsDataFrame.UncertainSeries.Count > 0) FrequencyPlot.Series.Add(amsFrequencyUncertainData); + + amsFrequencyIntervalData.ItemsSource = logAxis == true ? amsDataFrame.IntervalSeries : amsDataFrame.IntervalSeries.Where(x => x.Value > 1E-16); + amsFrequencyIntervalData.DataFieldX = nameof(IntervalData.PlottingPosition); + amsFrequencyIntervalData.DataFieldY = nameof(IntervalData.Value); + amsFrequencyIntervalData.DataFieldLowerErrorY = nameof(IntervalData.LowerValue); + amsFrequencyIntervalData.DataFieldUpperErrorY = nameof(IntervalData.UpperValue); + amsFrequencyIntervalData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (amsDataFrame.IntervalSeries.Count > 0) FrequencyPlot.Series.Add(amsFrequencyIntervalData); + + // quantile priors + quantilePriors.ItemsSource = Element.PointProcess.QuantilePriors; + quantilePriors.DataFieldX = nameof(QuantilePrior.Alpha); + quantilePriors.DataFieldY = nameof(QuantilePrior.MeanValue); + quantilePriors.DataFieldLowerErrorY = nameof(QuantilePrior.LowerValue); + quantilePriors.DataFieldUpperErrorY = nameof(QuantilePrior.UpperValue); + quantilePriors.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.PointProcess.EnableQuantilePriors && Element.PointProcess.QuantilePriors.Count > 0) FrequencyPlot.Series.Add(quantilePriors); + + // Add Alternatives + if (Element.BayesianAnalysis.IsEstimated == true && Element.AnalysisResults != null) + AlternativeSelector.AddAllChecked(); + } + + FrequencyPlot.InvalidatePlot(true); + } + Element?.RebuildSeriesAndAnnotationBridges(FrequencyPlot); + } + + /// + /// Handles the AnalysisAdded event from the alternative selector. Adds the alternative analysis curves to the frequency plot. + /// + /// The analysis item being added. + private void AlternativeSelector_AnalysisAdded(AnalysisAlternativeItem analysisItem) + { + // Remove series + FrequencyPlot.Series.Remove(analysisItem.CredibleIntervals); + FrequencyPlot.Series.Remove(analysisItem.PosteriorPredictive); + FrequencyPlot.Series.Remove(analysisItem.PosteriorMode); + + var alternative = analysisItem.Alternative; + if (alternative.IsEstimated == true && alternative.AnalysisResults != null) + { + // Get unique color + var index = FrequencyPlot.Series.Count; + Color lineColor = (Color)ColorConverter.ConvertFromString(_colorHexCodes[index]); + Color fillColor = Color.FromArgb(100, lineColor.R, lineColor.G, lineColor.B); + + for (int i = 4; i < _colorHexCodes.Length; i++) + { + var templineColor = (Color)ColorConverter.ConvertFromString(_colorHexCodes[i]); + var tempfillColor = Color.FromArgb(100, templineColor.R, templineColor.G, templineColor.B); + bool colorExists = false; + for (int j = 0; j < FrequencyPlot.Series.Count; j++) + { + if (FrequencyPlot.Series[j].Name.Contains("Mode") && FrequencyPlot.Series[j].Color == templineColor) + { + colorExists = true; + break; + } + } + if (colorExists == false || i == _colorHexCodes.Length - 1) + { + lineColor = templineColor; + fillColor = tempfillColor; + break; + } + } + + var ciPoints = new List(); + var prdPoints = new List(); + var mdPoints = new List(); + for (int i = 0; i < ((ModelAnalyses.IProbabilityOrdinates)alternative).ProbabilityOrdinates.Count; i++) + { + var p = ((ModelAnalyses.IProbabilityOrdinates)alternative).ProbabilityOrdinates[i]; + var up = alternative.AnalysisResults.ConfidenceIntervals[i, 1]; + var lo = alternative.AnalysisResults.ConfidenceIntervals[i, 0]; + var prd = alternative.AnalysisResults.MeanCurve[i]; + var md = alternative.AnalysisResults.ModeCurve[i]; + ciPoints.Add(new Point3D(p, lo, up)); + prdPoints.Add(new OxyPlot.DataPoint(p, prd)); + mdPoints.Add(new OxyPlot.DataPoint(p, md)); + } + + // Series labels + double ciWidth = alternative.BayesianAnalysis.CredibleIntervalWidth; + string ciLabel = "% Credible Intervals"; + string predLabel = " - Posterior Predictive"; + string pointLabel = " - " + (alternative.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"); + if (alternative.GetType() == typeof(B17CAnalysis)) + { + ciLabel = "% Confidence Intervals"; + predLabel = " - Expected Probability"; + pointLabel = " - " + (alternative.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Mean Parameters" : "Computed"); + } + + // Credible Intervals + analysisItem.CredibleIntervals.Name = "CredibleIntervals_" + index; + analysisItem.CredibleIntervals.Title = alternative.Name + " - " + (ciWidth * 100).ToString("F0") + ciLabel; + analysisItem.CredibleIntervals.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + analysisItem.CredibleIntervals.ItemsSource = ciPoints; + analysisItem.CredibleIntervals.DataFieldX = "X"; + analysisItem.CredibleIntervals.DataFieldY = "Y"; + analysisItem.CredibleIntervals.DataFieldX2 = "X"; + analysisItem.CredibleIntervals.DataFieldY2 = "Z"; + analysisItem.CredibleIntervals.Fill = fillColor; + analysisItem.CredibleIntervals.Color = Colors.Transparent; + FrequencyPlot.Series.Add(analysisItem.CredibleIntervals); + + // Predictive + analysisItem.PosteriorPredictive.Name = "PosteriorPredictive_" + index; + analysisItem.PosteriorPredictive.Title = alternative.Name + predLabel; + analysisItem.PosteriorPredictive.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + analysisItem.PosteriorPredictive.ItemsSource = prdPoints; + analysisItem.PosteriorPredictive.Color = lineColor; + FrequencyPlot.Series.Add(analysisItem.PosteriorPredictive); + + // Point Estimator + analysisItem.PosteriorMode.Name = "PosteriorMode_" + index; + analysisItem.PosteriorMode.Title = alternative.Name + pointLabel; + analysisItem.PosteriorMode.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + analysisItem.PosteriorMode.ItemsSource = mdPoints; + analysisItem.PosteriorMode.Color = lineColor; + FrequencyPlot.Series.Add(analysisItem.PosteriorMode); + + FrequencyPlot.InvalidatePlot(true); + + } + + } + + /// + /// Handles the AnalysisRemoved event from the alternative selector. Removes the alternative analysis curves from the frequency plot. + /// + /// The analysis item being removed. + private void AlternativeSelector_AnalysisRemoved(AnalysisAlternativeItem analysisItem) + { + FrequencyPlot.Series.Remove(analysisItem.CredibleIntervals); + FrequencyPlot.Series.Remove(analysisItem.PosteriorPredictive); + FrequencyPlot.Series.Remove(analysisItem.PosteriorMode); + FrequencyPlot.InvalidatePlot(true); + } + + /// + /// Handles the MouseLeftButtonUp event for the text block. Toggles the show filter toggle button. + /// + /// The source of the event. + /// Mouse button event arguments. + private void TextBlock_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + ShowFilterToggleButton.IsChecked = !ShowFilterToggleButton.IsChecked; + } + + #endregion + + /// + /// Binds the frequency curve data grid to the analysis results. + /// Populates the grid with probability ordinates and their corresponding quantile estimates. + /// + private void BindFrequencyCurveDataGrid() + { + FrequencyCurveTable.ItemsSource = null; + + if (Element == null || Element.BayesianAnalysis == null) + return; + + ModeColumn.Header = Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"; + + if (Element.BayesianAnalysis.IsEstimated != true || Element.AnalysisResults == null) + return; + + var curvePoints = new List(); + for (int i = 0; i < Element.ProbabilityOrdinates.Count; i++) + { + var p = Element.ProbabilityOrdinates[i]; + var up = Element.AnalysisResults.ConfidenceIntervals[i, 1]; + var lo = Element.AnalysisResults.ConfidenceIntervals[i, 0]; + var prd = Element.AnalysisResults.MeanCurve[i]; + var md = Element.AnalysisResults.ModeCurve[i]; + curvePoints.Add(new FrequencyCurvePoint(p, up, lo, prd, md)); + } + FrequencyCurveTable.ItemsSource = curvePoints; + FrequencyCurveTable.Items.Refresh(); + } + + /// + /// Sets the column headers for the frequency curve table based on the credible interval width. + /// + private void SetFrequencyCurveTableColumnHeaders() + { + double alpha = (1 - Element.BayesianAnalysis.CredibleIntervalWidth) / 2; + UpperColumn.Header = ((1 - alpha) * 100).ToString("F1") + "% CI"; + LowerColumn.Header = (alpha * 100).ToString("F1") + "% CI"; + } + + /// + /// Sets the string formats for data grid columns based on user settings. + /// + private void SetColumnStringFormats() + { + // Update the column binding string format + UpperColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + LowerColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + PredictiveColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + ModeColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + } + + /// + /// Binds the summary statistics data grid to the analysis results. + /// Displays distribution parameters, moments, and goodness-of-fit statistics. + /// + private void BindSummaryStatisticsDataGrid() + { + SummaryStatisticsTable.ItemsSource = null; + + if (Element == null || Element.BayesianAnalysis == null) + return; + + StatValueColumn.Header = Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"; + + var summaryStats = new List(); + if (Element.BayesianAnalysis.IsEstimated == false || Element.AnalysisResults == null) + { + + for (int i = 0; i < Element.PointProcess.Parameters.Count; i++) + { + summaryStats.Add(new SummaryStatistic(Element.PointProcess.Parameters[i].DisplayName, double.NaN)); + } + summaryStats.Add(new SummaryStatistic("Minimum", double.NaN)); + summaryStats.Add(new SummaryStatistic("Maximum", double.NaN)); + summaryStats.Add(new SummaryStatistic("Mean", double.NaN)); + summaryStats.Add(new SummaryStatistic("Std Dev", double.NaN)); + summaryStats.Add(new SummaryStatistic("Skewness", double.NaN)); + summaryStats.Add(new SummaryStatistic("Kurtosis", double.NaN)); + summaryStats.Add(new SummaryStatistic("AIC", double.NaN)); + summaryStats.Add(new SummaryStatistic("BIC", double.NaN)); + summaryStats.Add(new SummaryStatistic("DIC", double.NaN)); + summaryStats.Add(new SummaryStatistic("WAIC", double.NaN)); + summaryStats.Add(new SummaryStatistic("LOO-CV", double.NaN)); + summaryStats.Add(new SummaryStatistic("RMSE", double.NaN)); + //summaryStats.Add(new SummaryStatistic("ERL", double.NaN)); + } + else + { + for (int i = 0; i < Element.PointProcess.Parameters.Count; i++) + { + summaryStats.Add(new SummaryStatistic(Element.PointProcess.Parameters[i].DisplayName, Element.PointProcess.Parameters[i].Value)); + } + var min = Element.PointProcess.Distribution.Minimum; + var max = Element.PointProcess.Distribution.Maximum; + + summaryStats.Add(new SummaryStatistic("Minimum", min < -1E12 ? double.NegativeInfinity : min)); + summaryStats.Add(new SummaryStatistic("Maximum", max > 1E12 ? double.PositiveInfinity : max)); + summaryStats.Add(new SummaryStatistic("Mean", Element.PointProcess.Distribution.Mean)); + summaryStats.Add(new SummaryStatistic("Std Dev", Element.PointProcess.Distribution.StandardDeviation)); + summaryStats.Add(new SummaryStatistic("Skewness", Element.PointProcess.Distribution.Skewness)); + summaryStats.Add(new SummaryStatistic("Kurtosis", Element.PointProcess.Distribution.Kurtosis)); + summaryStats.Add(new SummaryStatistic("AIC", Element.AnalysisResults.AIC)); + summaryStats.Add(new SummaryStatistic("BIC", Element.AnalysisResults.BIC)); + summaryStats.Add(new SummaryStatistic("DIC", Element.AnalysisResults.DIC)); + summaryStats.Add(new SummaryStatistic("WAIC", Element.BayesianAnalysis.WAIC)); + summaryStats.Add(new SummaryStatistic("LOO-CV", Element.BayesianAnalysis.LOOIC)); + summaryStats.Add(new SummaryStatistic("RMSE", Element.AnalysisResults.RMSE)); + //summaryStats.Add(new SummaryStatistic("ERL", Element.FrequencyAnalysisResults.ERL)); + } + + SummaryStatisticsTable.ItemsSource = summaryStats; + SummaryStatisticsTable.Items.Refresh(); + } + + /// + /// Handles the LoadingRow event for the summary statistics table. + /// Applies custom border styling to separate sections of statistics. + /// + /// The source of the event. + /// DataGrid row event arguments. + private void SummaryStatisticsTable_LoadingRow(object sender, DataGridRowEventArgs e) + { + if (((SummaryStatistic)e.Row.DataContext).Name == "Minimum" || + ((SummaryStatistic)e.Row.DataContext).Name == "AIC" || + ((SummaryStatistic)e.Row.DataContext).Name == "ERL") + { + e.Row.BorderThickness = new Thickness(0, 2, 0, 0); + e.Row.SetResourceReference(Border.BorderBrushProperty, "EnvironmentWindowText"); + } + else + { + e.Row.BorderThickness = new Thickness(0, 0, 0, 0); + } + } + } +} diff --git a/src/RMC.BestFit.App/GUI/UnivariateAnalysis/PointProcess/PointProcessAnalysisPropertiesControl.xaml b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/PointProcess/PointProcessAnalysisPropertiesControl.xaml new file mode 100644 index 0000000..3590c15 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/PointProcess/PointProcessAnalysisPropertiesControl.xaml @@ -0,0 +1,249 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/RMC.BestFit.App/GUI/UnivariateAnalysis/PointProcess/PointProcessAnalysisPropertiesControl.xaml.cs b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/PointProcess/PointProcessAnalysisPropertiesControl.xaml.cs new file mode 100644 index 0000000..cf79ca5 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/PointProcess/PointProcessAnalysisPropertiesControl.xaml.cs @@ -0,0 +1,694 @@ +using FrameworkInterfaces; +using GenericControls; +using RMC.BestFit.Models; +using RMC.BestFit.UI; +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Input; +using Numerics.Distributions; +using Numerics.Utilities; +using System.Collections.Generic; +using System.Windows.Controls.Primitives; +using System.Linq; +using Numerics.Data; + +namespace RMC_BestFit +{ + /// + /// User control for managing and editing properties of a Point Process Analysis element. + /// Provides an interface for configuring analysis parameters, input data, priors, Bayesian options, + /// and executing the analysis estimation process. + /// + public partial class PointProcessAnalysisPropertiesControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public PointProcessAnalysisPropertiesControl() + { + InitializeComponent(); + DataContext = this; + this.Unloaded += UserControl_Unloaded; + } + + /// + /// Stores the previous name of the element for validation and rollback purposes. + /// + private string _previousName; + + /// + /// Tracks the InputDataCollection currently subscribed to, so it can be unsubscribed when the control unloads or the element changes. + /// + private IElementCollection _subscribedInputDataCollection; + + /// + /// Dependency property for the property. + /// + public static DependencyProperty ElementProperty = DependencyProperty.Register(nameof(Element), typeof(PointProcessAnalysis), typeof(PointProcessAnalysisPropertiesControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the Point Process Analysis element whose properties are being edited. + /// + public PointProcessAnalysis Element + { + get { return (PointProcessAnalysis)GetValue(ElementProperty); } + set { SetValue(ElementProperty, value); } + } + + /// + /// Callback method invoked when the property changes. + /// Handles event subscription and initializes property attributes and input data. + /// + /// The dependency object whose property changed. + /// Event arguments containing the old and new values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as PointProcessAnalysisPropertiesControl == null) return; + var thisControl = (PointProcessAnalysisPropertiesControl)d; + + // Unsubscribe from old element — both PropertyChanged (element-scoped) and + // InputDataCollection subscriptions so the new element's LoadInputData starts clean. + if (e.OldValue is PointProcessAnalysis oldElement) + { + oldElement.PropertyChanged -= thisControl.Element_PropertyChanged; + thisControl.UnsubscribeInputDataCollection(); + } + + if (e.NewValue == null) return; + var newElement = e.NewValue as PointProcessAnalysis; + if (newElement == null) return; + + newElement.PropertyChanged += thisControl.Element_PropertyChanged; + thisControl._previousName = newElement.Name; + thisControl.PropertyAttributes.GetClassAttributes(newElement); + thisControl.LoadInputData(); + thisControl.UpdateComboBoxes(); + + } + + /// + /// Dependency property for the existing names property. + /// + public static DependencyProperty ExistingNamesProperty = DependencyProperty.Register(nameof(ExistingNames), typeof(string[]), typeof(PointProcessAnalysisPropertiesControl), new FrameworkPropertyMetadata(new string[] { })); + + /// + /// Gets or sets the array of existing names for this element type. + /// Used for validating that the element name is unique. + /// + public string[] ExistingNames + { + get { return (string[])GetValue(ExistingNamesProperty); } + private set { SetValue(ExistingNamesProperty, value); } + } + + /// + /// Gets the observable collection of available input data elements that can be selected for analysis. + /// + public ObservableCollection InputDataList { get; private set; } = new ObservableCollection(); + + /// + /// Backing field for the property. Allocated once to avoid creating a new list on every XAML binding call. + /// + private static readonly List _monthOptions = new List() + { + new MonthItem("January", 1), + new MonthItem("February", 2), + new MonthItem("March", 3), + new MonthItem("April", 4), + new MonthItem("May", 5), + new MonthItem("June", 6), + new MonthItem("July", 7), + new MonthItem("August", 8), + new MonthItem("September", 9), + new MonthItem("October", 10), + new MonthItem("November", 11), + new MonthItem("December", 12), + }; + + /// + /// Gets the list of available month options for configuring seasonal analysis. + /// + public List MonthOptions => _monthOptions; + + /// + /// Backing field for the property. Allocated once to avoid creating a new list on every XAML binding call. + /// + private static readonly List _timeBlockOptions = new List() + { + new TimeBlockItem("Calendar Year", TimeBlockWindow.CalendarYear), + new TimeBlockItem("Water Year", TimeBlockWindow.WaterYear), + }; + + /// + /// Gets the list of available time block window options (Calendar Year or Water Year). + /// + public List TimeBlockOptions => _timeBlockOptions; + + #region Property Attributes + + /// + /// Handles the PreviewMouseLeftButtonDown event for the Name control. + /// Displays property attributes for the Name property. + /// + /// The source of the event. + /// Mouse button event arguments. + private void Name_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.Name), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the Description control. + /// Displays property attributes for the Description property. + /// + /// The source of the event. + /// Mouse button event arguments. + private void Description_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.Description), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the CreationDate control. + /// Displays property attributes for the CreationDate property. + /// + /// The source of the event. + /// Mouse button event arguments. + private void CreationDate_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.CreationDate), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the LastModified control. + /// Displays property attributes for the LastModified property. + /// + /// The source of the event. + /// Mouse button event arguments. + private void LastModified_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.LastModified), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the InputData control. + /// Displays property attributes for the InputData property. + /// + /// The source of the event. + /// Mouse button event arguments. + private void InputData_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.InputData), Element); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the Threshold control. + /// Displays property attributes for the Threshold property. + /// + /// The source of the event. + /// Mouse button event arguments. + private void Threshold_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.PointProcess.Threshold), Element.PointProcess); + } + + + /// + /// Handles the PreviewMouseLeftButtonDown event for the TotalYears control. + /// Displays property attributes for the TotalYears property. + /// + /// The source of the event. + /// Mouse button event arguments. + private void TotalYears_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.PointProcess.TotalYears), Element.PointProcess); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the UseDefaults control. + /// Displays property attributes for the UseDefaults property. + /// + /// The source of the event. + /// Mouse button event arguments. + private void UseDefaults_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.PointProcess.UseDefaults), Element.PointProcess); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the IsSeasonal control. + /// Displays property attributes for the IsSeasonal property. + /// + /// The source of the event. + /// Mouse button event arguments. + private void IsSeasonal_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.PointProcess.IsSeasonal), Element.PointProcess); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the TimeBlock control. + /// Displays property attributes for the TimeBlock property. + /// + /// The source of the event. + /// Mouse button event arguments. + private void TimeBlock_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.PointProcess.TimeBlock), Element.PointProcess); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the StartMonth control. + /// Displays property attributes for the StartMonth property. + /// + /// The source of the event. + /// Mouse button event arguments. + private void StartMonth_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.PointProcess.StartMonth), Element.PointProcess); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the UseJeffreysRule control. + /// Displays property attributes for the UseJeffreysRuleForScale property. + /// + /// The source of the event. + /// Mouse button event arguments. + private void UseJeffreysRule_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.PointProcess.UseJeffreysRuleForScale), Element.PointProcess); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the ProbabilityOrdinatesControl. + /// Displays default attributes for the Probability Ordinates feature. + /// + /// The source of the event. + /// Mouse button event arguments. + private void ProbabilityOrdinatesControl_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.SetDefaultAttributes("Probability Ordinates", "The exceedance probabilities used for plotting the probability distribution."); + } + + /// + /// Handles the PreviewMouseLeftButtonDown event for the TabItem control. + /// Displays class-level attributes for the Element. + /// + /// The source of the event. + /// Mouse button event arguments. + private void TabItem_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetClassAttributes(Element); + } + + /// + /// Handles the ShowPropertyAttributes event from the ParameterPriorsControl. + /// Displays property attributes for the specified property. + /// + /// The name of the property to display attributes for. + /// The object containing the property. + private void ParameterPriorsControl_ShowPropertyAttributes(string propertyName, object classObject) + { + PropertyAttributes.GetPropertyAttributes(propertyName, classObject); + } + + /// + /// Handles the ShowPropertyAttributes event from the QuantilePriorsControl. + /// Displays property attributes for the specified property. + /// + /// The name of the property to display attributes for. + /// The object containing the property. + private void QuantilePriorsControl_ShowPropertyAttributes(string propertyName, object classObject) + { + PropertyAttributes.GetPropertyAttributes(propertyName, classObject); + } + + /// + /// Handles the ShowPropertyAttributes event from the BayesianOptionsControl. + /// Displays property attributes for the specified property. + /// + /// The name of the property to display attributes for. + /// The object containing the property. + private void BayesianOptionsControl_ShowPropertyAttributes(string propertyName, object classObject) + { + PropertyAttributes.GetPropertyAttributes(propertyName, classObject); + } + + /// + /// Handles the ShowPropertyAttributes event from the BayesianOutputControl. + /// Displays property attributes for the specified property. + /// + /// The name of the property to display attributes for. + /// The object containing the property. + private void BayesianOutputControl_ShowPropertyAttributes(string propertyName, object classObject) + { + PropertyAttributes.GetPropertyAttributes(propertyName, classObject); + } + + #endregion + + /// + /// Handles the GotFocus event for the Name control. + /// Stores the current name and populates the existing names list for validation. + /// + /// The source of the event. + /// Event arguments. + private void Name_GotFocus(object sender, RoutedEventArgs e) + { + _previousName = Element.Name; + ExistingNames = Element.ParentCollection.GetElementNames(Element).ToArray(); + } + + /// + /// Handles the LostFocus event for the Name control. + /// Reverts to the previous name if validation fails. + /// + /// The source of the event. + /// Event arguments. + private void Name_LostFocus(object sender, RoutedEventArgs e) + { + if (ElementName.NameTextBox.IsValid) + return; + if (Element != null) + Element.Name = _previousName; + } + + /// + /// Handles the Unloaded event for the user control. Unsubscribes from the InputDataCollection + /// to prevent memory leaks during visual-tree cycles. + /// + /// + /// Element.PropertyChanged is element-scoped and is managed exclusively in + /// so it survives Unload/Reload cycles. + /// + /// The source of the event. + /// Event arguments. + private void UserControl_Unloaded(object sender, RoutedEventArgs e) + { + UnsubscribeInputDataCollection(); + } + + /// + /// Handles property changed events from the Element. + /// + /// The source of the event. + /// Event arguments containing the name of the changed property. + private void Element_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + // Model object replaced (e.g., during undo) — push to sub-controls + if (e.PropertyName == nameof(Element.PointProcess)) + { + ParameterPriorsControl.Model = Element.PointProcess; + QuantilePriorsControl.Model = Element.PointProcess; + UpdateComboBoxes(); + } + // BayesianAnalysis replaced — push to sub-controls + if (e.PropertyName == nameof(Element.BayesianAnalysis)) + { + BayesianOptionsControl.Analysis = Element.BayesianAnalysis; + BayesianOutputControl.Analysis = Element.BayesianAnalysis; + } + } + + /// + /// Loads the available input data elements from the project into the InputDataList collection. + /// Unsubscribes from any previously subscribed collection, then subscribes to the new one + /// using named handlers to allow clean unsubscription. + /// + private void LoadInputData() + { + UnsubscribeInputDataCollection(); + InputDataList.Clear(); + foreach (IElementCollection collection in Element.ParentCollection.ParentProject.ElementCollections) + { + if (collection.GetType() == typeof(InputDataCollection)) + { + _subscribedInputDataCollection = collection; + collection.ElementAdded += OnInputDataElementAdded; + collection.ElementRemoved += OnInputDataElementRemoved; + foreach (IElement element in collection) + InputDataList.Add((InputData)element); + break; + } + } + } + + /// + /// Handles the addition of an input data element to the subscribed collection. + /// + /// The element that was added. + private void OnInputDataElementAdded(IElement element) + { + InputDataList.Add((InputData)element); + } + + /// + /// Handles the removal of an input data element from the subscribed collection. + /// + /// The element that was removed. + private void OnInputDataElementRemoved(IElement element) + { + InputDataList.Remove((InputData)element); + } + + /// + /// Unsubscribes from the currently tracked InputDataCollection and clears the reference. + /// + private void UnsubscribeInputDataCollection() + { + if (_subscribedInputDataCollection != null) + { + _subscribedInputDataCollection.ElementAdded -= OnInputDataElementAdded; + _subscribedInputDataCollection.ElementRemoved -= OnInputDataElementRemoved; + _subscribedInputDataCollection = null; + } + } + + /// + /// Handles the SelectionChanged event for the InputData combo box. + /// Updates visual validation indicators based on whether valid input data is selected. + /// + /// The source of the event. + /// Selection changed event arguments. + private void InputDataComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (Element == null) return; + if (Element.InputData == null || Element.InputData.Name == null) + { + ((Border)InputDataComboBox.InnerContent).BorderThickness = new Thickness(1); + InputDataComboBox.ToolTip = "Please select a valid input data."; + } + else + { + ((Border)InputDataComboBox.InnerContent).BorderThickness = new Thickness(0); + InputDataComboBox.ToolTip = null; + } + } + + /// + /// Handles the SelectionChanged event for the Distribution combo box. + /// + /// The source of the event. + /// Selection changed event arguments. + private void DistributionComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + + } + + /// + /// Handles the Loaded event for combo box controls. + /// Sets up sorted data binding for the combo box with live sorting enabled. + /// + /// The source of the event. + /// Event arguments. + private void ComboBox_Loaded(object sender, RoutedEventArgs e) + { + ComboBox cmbo = (ComboBox)sender; + CollectionViewSource csv = new CollectionViewSource() { Source = InputDataList, IsLiveSortingRequested = true }; + csv.SortDescriptions.Add(new SortDescription(nameof(IElement.Name), ListSortDirection.Ascending)); + var view = csv.View; + view.MoveCurrentToPosition(-1); + cmbo.ItemsSource = view; + } + + /// + /// Handles the Click event for the Estimate button. + /// Validates inputs and initiates the Bayesian analysis estimation process asynchronously, + /// displaying progress feedback and managing UI state during execution. + /// + /// The source of the event. + /// Event arguments. + private async void EstimateButton_Click(object sender, RoutedEventArgs e) + { + // Check if the analysis is valid + if (Element.IsValid == false) + { + GenericControls.MessageBox.Show("Cannot perform the analysis because the inputs are invalid.", "Error!", MessageBoxButton.OK, MessageBoxImage.Error); + return; + } + + Mouse.OverrideCursor = Cursors.Wait; + + // Disable all open windows + FrameworkUI.ShellPublicVariables.SimulationInProgress = true; + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableMenuStrip(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableProjectExplorer(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableMessageWindow(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableOpenWindows(); + // Disable Properties + PropertiesExpander.IsEnabled = false; + ParameterPriorsExpander.IsEnabled = false; + QuantilePriorsExpander.IsEnabled = false; + Options_TabItem.IsEnabled = false; + Output_TabItem.IsEnabled = false; + + // Set up progress bar + AnalysisProgressDisplayHelper.ShowInitial(ProgressBar, ProgressTextBlock); + + // Show cancel button + EstimateButton.Visibility = Visibility.Hidden; + CancelButton.Visibility = Visibility.Visible; + + // Set up progress reporter + var progressReporter = new SafeProgressReporter(nameof(UnivariateAnalysis)); + progressReporter.ProgressReported += (SafeProgressReporter reporter, double progress, double progressDelta) => + { + AnalysisProgressDisplayHelper.PostProgress(Dispatcher, ProgressBar, ProgressTextBlock, progress); + }; + bool progressCleanupEnabled = false; + progressReporter.TaskEnded += () => + { + if (!progressCleanupEnabled) return; + // Close out progress bar + ProgressBar.Value = 100; + ProgressTextBlock.Text = "Simulation Complete"; + Mouse.OverrideCursor = null; + EstimateButton.IsEnabled = true; + ProgressBar.Visibility = Visibility.Hidden; + ProgressTextBlock.Visibility = Visibility.Hidden; + + // Close cancel button + EstimateButton.Visibility = Visibility.Visible; + CancelButton.Visibility = Visibility.Hidden; + + // Enable all windows + FrameworkUI.ShellPublicVariables.SimulationInProgress = false; + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableMenuStrip(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableProjectExplorer(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableMessageWindow(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableOpenWindows(); + // Enable Properties + PropertiesExpander.IsEnabled = true; + ParameterPriorsExpander.IsEnabled = true; + QuantilePriorsExpander.IsEnabled = true; + Options_TabItem.IsEnabled = true; + Output_TabItem.IsEnabled = true; + }; + + + // Perform Bayesian estimation + try + { + await WaitCursorHelper.RunWithVisibleWaitCursorAsync(Dispatcher, async () => + { + await Element.RunAsync(progressReporter); + }); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"PointProcessAnalysisPropertiesControl.EstimateButton_Click: {ex}"); + GenericControls.MessageBox.Show("An unexpected error occurred. Please verify your analysis inputs and settings." + Environment.NewLine + "If the issue persists, contact the Risk Management Center.", "Error!", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + progressCleanupEnabled = true; + progressReporter.IndicateTaskEnded(); + } + + + } + + /// + /// Handles the Click event for the Cancel button. + /// Requests cancellation of the currently running analysis. + /// + /// The source of the event. + /// Event arguments. + private void CancelButton_Click(object sender, RoutedEventArgs e) + { + Element.CancelAnalysis(); + } + + /// + /// Handles the KeyDown event for the properties control. + /// Cancels the analysis when the Escape key is pressed. + /// + /// The source of the event. + /// Key event arguments. + private void UnivariateAnalysisProperties_KeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + Element.CancelAnalysis(); + } + + /// + /// Handles the Checked event for the IsSeasonal checkbox. + /// Updates the visibility of seasonal-related combo boxes. + /// + /// The source of the event. + /// Event arguments. + private void IsSeasonal_Checked(object sender, RoutedEventArgs e) + { + UpdateComboBoxes(); + } + + /// + /// Handles the Unchecked event for the IsSeasonal checkbox. + /// Updates the visibility of seasonal-related combo boxes. + /// + /// The source of the event. + /// Event arguments. + private void IsSeasonal_Unchecked(object sender, RoutedEventArgs e) + { + UpdateComboBoxes(); + } + + /// + /// Handles the SelectionChanged event for the TimeBlock combo box. + /// Updates the visibility of dependent controls based on the selected time block. + /// + /// The source of the event. + /// Selection changed event arguments. + private void TimeBlockComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + UpdateComboBoxes(); + } + + /// + /// Updates the visibility of time block and start month controls based on + /// the seasonal analysis setting and selected time block window type. + /// + private void UpdateComboBoxes() + { + if (Element == null || Element.PointProcess == null) return; + if (Element.PointProcess.IsSeasonal == true) + { + TimeBlock.Visibility = Visibility.Visible; + StartMonth.Visibility = Element.PointProcess.TimeBlock == TimeBlockWindow.CalendarYear ? Visibility.Collapsed : Visibility.Visible; + } + else + { + TimeBlock.Visibility = Visibility.Collapsed; + StartMonth.Visibility = Visibility.Collapsed; + } + + } + + + } +} diff --git a/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Univariate/UnivariateAnalysisControl.xaml b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Univariate/UnivariateAnalysisControl.xaml new file mode 100644 index 0000000..03cc7d6 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Univariate/UnivariateAnalysisControl.xaml @@ -0,0 +1,211 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Univariate/UnivariateAnalysisControl.xaml.cs b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Univariate/UnivariateAnalysisControl.xaml.cs new file mode 100644 index 0000000..24e7e75 --- /dev/null +++ b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Univariate/UnivariateAnalysisControl.xaml.cs @@ -0,0 +1,1201 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using OxyPlot; +using OxyPlot.Wpf; +using OxyPlotControls; +using RMC.BestFit.Models; +using ModelAnalyses = RMC.BestFit.Analyses; +using RMC.BestFit.Estimation; +using RMC.BestFit.UI; +using System.ComponentModel; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Media3D; +using System.Windows.Data; +using FrameworkUI; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Threading; + +namespace RMC_BestFit +{ + /// + /// User control for displaying and interacting with univariate analysis results, including frequency plots, + /// chronology plots, and various diagnostic plots for Bayesian statistical analysis. + /// + public partial class UnivariateAnalysisControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public UnivariateAnalysisControl() + { + InitializeComponent(); + DataContext = this; + _colorHexCodes = GenericControls.GeneralMethods.RandomColorsLongList; + // DataGrid Binding.StringFormat must be set before first render see + // RatingCurveAnalysisControl constructor for the rationale. + SetColumnStringFormats(); + } + + /// + /// Dependency property for the property. + /// + public static DependencyProperty ElementProperty = DependencyProperty.Register(nameof(Element), typeof(UnivariateAnalysis), typeof(UnivariateAnalysisControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the univariate analysis element being displayed and managed by this control. + /// + public UnivariateAnalysis Element + { + get { return (UnivariateAnalysis)GetValue(ElementProperty); } + set { SetValue(ElementProperty, value); } + } + + /// + /// Callback method invoked when the Element dependency property changes. + /// Manages event handler subscriptions and unsubscriptions for the element. + /// + /// The dependency object whose property changed. + /// Event arguments containing the old and new values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as UnivariateAnalysisControl == null) return; + var thisControl = (UnivariateAnalysisControl)d; + + // Remove handlers and detach old element's plots + if (e.OldValue != null) + { + UnivariateAnalysis oldElement = e.OldValue as UnivariateAnalysis; + if (oldElement != null) + { + oldElement.PropertyChanged -= thisControl.Element_PropertyChanged; + thisControl.FrequencyPlotHost.Content = null; + thisControl.ChronologyPlotHost.Content = null; + thisControl.FrequencyPlotToolbar.Plot = null; + thisControl.ChronologyPlotToolbar.Plot = null; + // NOTE: PropertiesCalled is wired in XAML (PropertiesCalled="PlotToolbar_PropertiesCalled"), + // so no programmatic += / -= is needed here. Adding one would cause a duplicate + // handler invocation that toggles the properties panel closed immediately after it opens. + } + } + + if (e.NewValue == null) return; + var newElement = e.NewValue as UnivariateAnalysis; + if (newElement == null) return; + + // Reset _isLoaded so the next Loaded event re-runs one-time setup steps + // for the new element (U1). + thisControl._isLoaded = false; + + // Subscribe Element-scoped handler + newElement.PropertyChanged += thisControl.Element_PropertyChanged; + + // Attach plots, wire toolbars, and bind axis titles inside a bridge-suspension + // block so WPF visual-tree attachment doesn't record spurious undo entries + // (theme-deferred Background / PlotAreaBackground PropertyChanged on Bayesian + // plots that live in non-selected TabItems). PropertiesCalled / + // PlotPropertiesCalled are wired in XAML on every toolbar and every Bayesian + // sub-control no programmatic += needed. + using (newElement.SuspendPlotBridges()) + { + thisControl.FrequencyPlotHost.Content = newElement.FrequencyPlot; + thisControl.FrequencyPlotToolbar.Plot = newElement.FrequencyPlot; + thisControl.ChronologyPlotHost.Content = newElement.ChronologyPlot; + thisControl.ChronologyPlotToolbar.Plot = newElement.ChronologyPlot; + + thisControl.BindAxisTitles(); + + thisControl.HistogramControl.SetPlot(newElement.BayesianPlots.HistogramPlot); + thisControl.KernelDensityControl.SetPlot(newElement.BayesianPlots.KernelDensityPlot); + thisControl.AutocorrelationControl.SetPlot(newElement.BayesianPlots.AutocorrelationPlot); + thisControl.MarkovChainTraceControl.SetPlot(newElement.BayesianPlots.MarkovChainTracePlot); + thisControl.MeanLikelihoodControl.SetPlot(newElement.BayesianPlots.MeanLikelihoodPlot); + thisControl.BivariateHeatMapControl.SetPlot(newElement.BayesianPlots.BivariateHeatMapPlot); + thisControl.InfluenceDiagnosticsControl.SetPlot(newElement.BayesianPlots.InfluenceDiagnosticsPlot); + } + } + + /// + /// Gets a value indicating whether a plot was clicked by the user. + /// + public bool PlotClicked { get; private set; } = false; + + /// + /// Raised when plot properties are requested to be displayed or modified. + /// + public event PlotPropertiesCalledEventHandler PlotPropertiesCalled; + + /// + /// Delegate for the event. + /// + /// The plot whose properties are being accessed. + /// Indicates whether to open the properties panel. + /// The property expander to use for displaying properties. + /// The currently selected object in the plot. + public delegate void PlotPropertiesCalledEventHandler(Plot plot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject); + + /// + /// Raised when a plot or toolbar control within the preview area is clicked. + /// + public event PreviewControlClickedEventHandler PreviewControlClicked; + + /// + /// Delegate for the event. + /// + /// Indicates whether the plot was clicked. + /// Indicates whether the toolbar was clicked. + /// The plot that was clicked. + public delegate void PreviewControlClickedEventHandler(bool plotClicked, bool toolbarClicked, Plot plot); + + /// + /// Array of hexadecimal color codes used for rendering multiple plot series with unique colors. + /// + private string[] _colorHexCodes; + + /// + /// Indicates whether the control has completed its initial load operations. + /// Set to false by ElementCallback when a new element is assigned so the next + /// Loaded event re-runs one-time setup steps (U1). + /// + private bool _isLoaded = false; + + + + /// + /// Handles the Loaded event of the user control. Initializes plot settings, updates visualizations, + /// and binds data grids when the control is first loaded. + /// + /// The event sender. + /// The event arguments. + private void UserControl_Loaded(object sender, RoutedEventArgs e) + { + if (Element == null) return; + + // Data refresh on every load. Plot hosts, toolbars, axis-title bindings, and + // Element.PropertyChanged are wired once per Element in ElementCallback; this + // method just refreshes tabular / bound content. Undo is suspended because + // Bayesian plots live in non-selected TabItems and WPF's deferred theme + // application produces Background/PlotAreaBackground PropertyChanged events + // that would otherwise dirty the element. + bool wasUndoEnabled = Element.IsUndoEnabled; + Element.IsUndoEnabled = false; + try + { + UpdateFrequencyPlot(); + UpdateChronologyPlot(); + BindFrequencyCurveDataGrid(); + + // One-time setup: column headers and summary grid layout do not need to + // re-run on every transient visual-tree cycle only on the first load + // after a new Element is assigned (U1). + if (!_isLoaded) + { + SetFrequencyCurveTableColumnHeaders(); + BindSummaryStatisticsDataGrid(); + } + } + finally + { + Element.IsUndoEnabled = wasUndoEnabled; + } + _isLoaded = true; + } + + /// + /// Handles the Unloaded event of the user control. Resets so the + /// next Loaded event re-runs one-time setup steps (U1). + /// Unsubscribes event handlers to prevent memory leaks and spurious dirty state + /// from WPF property change notifications during control teardown. + /// + /// The source of the event. + /// Event data. + private void UserControl_Unloaded(object sender, RoutedEventArgs e) + { + // Reset _isLoaded so the next Loaded event re-runs one-time setup steps. + // Element-scoped lifecycle (PropertyChanged, plot hosts, toolbars) is owned by + // ElementCallback and survives unload/reload cycles no additional teardown needed. + _isLoaded = false; + } + + /// + /// Handles property changed events from the Element. Updates plots, data grids, and table headers + /// in response to changes in analysis results, credible interval width, and parameter values. + /// + /// The event sender. + /// The property changed event arguments. + /// + /// The model layer's Model_PropertyChanged handler owns the re-process decision for + /// ParameterTimeIndex (re-runs ) + /// and Alpha (re-runs ); + /// this control only flips the cursor to Wait when a re-process is about to run and resets it + /// when the corresponding AnalysisResults / ChronologyAnalysisResults notification + /// arrives, marshaling onto the UI thread via since the model + /// layer's reprocess fires on a background TaskScheduler.Default. + /// + private void Element_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + // Marshal to UI thread if called from a background thread. The model layer's + // ReprocessIfEstimated path uses TaskScheduler.Default and AnalysisBase.RaisePropertyChange + // does NOT marshal so AnalysisResults / ChronologyAnalysisResults notifications can + // arrive here on a worker thread. WPF DataGrid and OxyPlot mutations from a worker + // thread throw InvalidOperationException ("calling thread cannot access this object"). + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke(new Action(() => Element_PropertyChanged(sender, e))); + return; + } + + if (e.PropertyName == nameof(Element.AnalysisResults)) + { + BindFrequencyCurveDataGrid(); + BindSummaryStatisticsDataGrid(); + UpdateFrequencyPlot(); + ResetWaitCursor(); + } + if (e.PropertyName == nameof(Element.BayesianAnalysis.CredibleIntervalWidth)) + { + SetFrequencyCurveTableColumnHeaders(); + } + if (e.PropertyName == nameof(Element.ChronologyAnalysisResults)) + { + UpdateChronologyPlot(); + ResetWaitCursor(); + } + if ((e.PropertyName == nameof(Element.UnivariateDistribution.ParameterTimeIndex) || + e.PropertyName == nameof(Element.UnivariateDistribution.Alpha)) + && Element.UnivariateDistribution.IsNonstationary == true + && Element.IsEstimated) + { + // Cursor wait while the model layer's ReprocessIfEstimated runs the recompute. + // Reset is wired to the AnalysisResults / ChronologyAnalysisResults notification above. + Mouse.OverrideCursor = Cursors.Wait; + } + if ((e.PropertyName == nameof(Element.BayesianAnalysis.PointEstimator) + || e.PropertyName == nameof(Element.BayesianAnalysis.CredibleIntervalWidth) + || e.PropertyName == nameof(Element.ProbabilityOrdinates)) + && Element.IsEstimated) + { + // Cursor wait while the model layer's ReprocessIfEstimated runs the recompute. + // Reset is wired to the AnalysisResults notification above. + Mouse.OverrideCursor = Cursors.Wait; + } + if (e.PropertyName == nameof(Element.InputData)) + { + using (Element.SuspendPlotBridges()) + { + BindAxisTitles(); + } + UpdateFrequencyPlot(); + UpdateChronologyPlot(); + } + + } + + /// + /// Clears when it is currently set to . + /// Marshals onto the UI thread when called from a background-thread PropertyChanged + /// notification (the model layer's ReprocessIfEstimated path uses + /// TaskScheduler.Default). + /// + private void ResetWaitCursor() + { + // Reset at Background priority so the Render-priority cursor frame from the + // earlier `Mouse.OverrideCursor = Cursors.Wait` is guaranteed to flush before + // the reset runs. Without this, fast reprocesses (UpdatePointEstimateResultsAsync) + // can reset the cursor before the OS visually picks up the change the user + // sees no wait cursor at all when the mouse is stationary. + Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + { + if (Mouse.OverrideCursor == Cursors.Wait) + Mouse.OverrideCursor = null; + })); + } + + #region Plots + + // Note: TimeIndexChanged and AlphaChanged methods were removed; re-process for + // ParameterTimeIndex and Alpha is now owned by the model layer's + // Model_PropertyChanged handler in src/RMC.BestFit/Analyses/Univariate/UnivariateAnalysis.cs. + // Only cursor management remains in this file; see Element_PropertyChanged above. + + /// + /// Handles the LostFocus event by resetting the PlotClicked flag. + /// + /// The event sender. + /// The event arguments. + private void UserControl_LostFocus(object sender, RoutedEventArgs e) + { + PlotClicked = false; + } + + /// + /// Handles mouse down events on the user control. Performs hit testing to determine if a plot or + /// toolbar was clicked and raises the appropriate event. + /// + /// The event sender. + /// The mouse button event arguments. + private void UserControl_PreviewMouseDown(object sender, MouseButtonEventArgs e) + { + Plot plot = GetCurrentPlot(); + OxyPlotToolbar toolbar = GetCurrentPlotToolbar(); + System.Windows.Media.HitTestResult plotHitResult = null; + System.Windows.Media.HitTestResult toolbarHitResult = null; + if (plot != null) + { + plotHitResult = VisualTreeHelper.HitTest(plot, e.GetPosition(plot)); + } + if (toolbar != null) + { + toolbarHitResult = VisualTreeHelper.HitTest(toolbar, e.GetPosition(toolbar)); + } + PreviewControlClicked?.Invoke(plotHitResult != null, toolbarHitResult != null, plot); + PlotClicked = plotHitResult != null; + } + + /// + /// Handles the properties called event from the plot toolbar by forwarding it to subscribers. + /// + /// The plot whose properties are being accessed. + /// Indicates whether to open the properties panel. + /// The property expander to use. + /// The selected object in the plot. + private void PlotToolbar_PropertiesCalled(Plot targetPlot, bool openProperties, OxyPlotPropertiesControl.PropertyEXP? propertyExpander, object selectedObject) + { + PlotPropertiesCalled?.Invoke(targetPlot, openProperties, propertyExpander, selectedObject); + } + + /// + /// Gets the currently selected plot based on which tab item is active. + /// + /// The currently active plot, or null if no plot tab is selected. + public Plot GetCurrentPlot() + { + if (ChronologyResultsTabItem.IsSelected == true) + { + return Element?.ChronologyPlot; + } + else if (FrequencyResultsTabItem.IsSelected == true) + { + return Element?.FrequencyPlot; + } + else if (KernelDensityTabItem.IsSelected == true) + { + return KernelDensityControl.Plot; + } + else if (HistogramTabItem.IsSelected == true) + { + return HistogramControl.Plot; + } + else if (BivariateTabItem.IsSelected == true) + { + return BivariateHeatMapControl.Plot; + } + else if (MeanLikelihoodTabItem.IsSelected == true) + { + return MeanLikelihoodControl.Plot; + } + else if (AutocorrelationTabItem.IsSelected == true) + { + return AutocorrelationControl.Plot; + } + else if (MarkovChainTabItem.IsSelected == true) + { + return MarkovChainTraceControl.Plot; + } + else if (InfluenceDiagnosticsTabItem.IsSelected == true) + { + return InfluenceDiagnosticsControl.Plot; + } + return null; + } + + /// + /// Gets the toolbar for the currently selected plot based on which tab item is active. + /// + /// The toolbar for the currently active plot, or null if no plot tab is selected. + public OxyPlotToolbar GetCurrentPlotToolbar() + { + if (ChronologyResultsTabItem.IsSelected == true) + { + return ChronologyPlotToolbar; + } + else if (FrequencyResultsTabItem.IsSelected == true) + { + return FrequencyPlotToolbar; + } + else if (KernelDensityTabItem.IsSelected == true) + { + return KernelDensityControl.PlotToolbar; + } + else if (HistogramTabItem.IsSelected == true) + { + return HistogramControl.PlotToolbar; + } + else if (BivariateTabItem.IsSelected == true) + { + return BivariateHeatMapControl.PlotToolbar; + } + else if (MeanLikelihoodTabItem.IsSelected == true) + { + return MeanLikelihoodControl.PlotToolbar; + } + else if (AutocorrelationTabItem.IsSelected == true) + { + return AutocorrelationControl.PlotToolbar; + } + else if (MarkovChainTabItem.IsSelected == true) + { + return MarkovChainTraceControl.PlotToolbar; + } + else if (InfluenceDiagnosticsTabItem.IsSelected == true) + { + return InfluenceDiagnosticsControl.PlotToolbar; + } + return null; + } + + /// + /// Binds axis titles on Element-owned plots to the InputData.UnitLabel and IndexLabel properties. + /// Called after plot attachment and when InputData changes. + /// + private void BindAxisTitles() + { + if (Element == null) return; + + // Frequency plot: Y-axis title = UnitLabel + var freqYAxis = Element.FrequencyPlot?.Axes.FirstOrDefault(a => a.Key == "Yaxis"); + if (freqYAxis != null) + { + if (Element.InputData != null) + PlotAxisTitleDefaults.BindTitleIfDefault(freqYAxis, Element.InputData, nameof(InputData.UnitLabel), Element.InputData.UnitLabel); + else + PlotAxisTitleDefaults.SetTitleIfDefault(freqYAxis, string.Empty); + } + + // Chronology plot: Y-axis title = UnitLabel + var chronYAxis = Element.ChronologyPlot?.Axes.FirstOrDefault(a => a.Key == "Yaxis"); + if (chronYAxis != null) + { + if (Element.InputData != null) + PlotAxisTitleDefaults.BindTitleIfDefault(chronYAxis, Element.InputData, nameof(InputData.UnitLabel), Element.InputData.UnitLabel); + else + PlotAxisTitleDefaults.SetTitleIfDefault(chronYAxis, string.Empty); + } + + // Chronology plot: X-axis title = IndexLabel + var chronXAxis = Element.ChronologyPlot?.Axes.FirstOrDefault(a => a.Key == "Xaxis"); + if (chronXAxis != null) + { + if (Element.InputData != null) + PlotAxisTitleDefaults.BindTitleIfDefault(chronXAxis, Element.InputData, nameof(InputData.IndexLabel), Element.InputData.IndexLabel); + else + PlotAxisTitleDefaults.SetTitleIfDefault(chronXAxis, string.Empty); + } + } + + /// + /// Updates the frequency plot with current analysis results, data points, and statistical curves. + /// Handles both estimated results (curves and intervals) and raw data visualization with appropriate + /// filtering for logarithmic axes. + /// + private void UpdateFrequencyPlot() + { + var plot = Element?.FrequencyPlot; + if (plot == null) return; + + // Lookup-or-create named series so user-customized styling survives Save/Open. + var credibleIntervals = plot.Series.OfType().FirstOrDefault(s => s.Name == "CredibleIntervals") + ?? new AreaSeries + { + Name = "CredibleIntervals", + Fill = Color.FromArgb(75, 104, 140, 175), + Color = Color.FromArgb(255, 53, 59, 122), + LineStyle = LineStyle.Solid, + BrokenLineThickness = 1, + StrokeThickness = 1, + Decimator = OxyPlot.Decimator.Decimate, + }; + var posteriorPredictive = plot.Series.OfType().FirstOrDefault(s => s.Name == "PosteriorPredictive") + ?? new LineSeries + { + Name = "PosteriorPredictive", + Title = "Posterior Predictive", + Color = Colors.Blue, + StrokeThickness = 1, + LineStyle = LineStyle.Dash, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0, + }; + var posteriorMode = plot.Series.OfType().FirstOrDefault(s => s.Name == "PosteriorMode") + ?? new LineSeries + { + Name = "PosteriorMode", + Title = "Posterior Mode", + Color = Colors.Black, + StrokeThickness = 1, + LineStyle = LineStyle.Solid, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0, + }; + var frequencyExactData = plot.Series.OfType().FirstOrDefault(s => s.Name == "ExactData") + ?? new ScatterPointSeries + { + Name = "ExactData", + Title = "Exact Data", + MarkerFill = Colors.Black, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle, + }; + var frequencyLowOutlierData = plot.Series.OfType().FirstOrDefault(s => s.Name == "LowOutlierData") + ?? new ScatterPointSeries + { + Name = "LowOutlierData", + Title = "Low Outlier Data", + MarkerStroke = Colors.Red, + MarkerStrokeThickness = 2, + MarkerSize = 3, + MarkerType = MarkerType.Cross, + }; + var frequencyUncertainData = plot.Series.OfType().FirstOrDefault(s => s.Name == "UncertainData") + ?? new ScatterErrorSeries + { + Name = "UncertainData", + Title = "Uncertain Data", + MarkerFill = Colors.Green, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Diamond, + }; + var frequencyIntervalData = plot.Series.OfType().FirstOrDefault(s => s.Name == "IntervalData") + ?? new ScatterErrorSeries + { + Name = "IntervalData", + Title = "Interval Data", + MarkerFill = Colors.Cyan, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Circle, + }; + var quantilePriors = plot.Series.OfType().FirstOrDefault(s => s.Name == "QuantilePrior") + ?? new ScatterErrorSeries + { + Name = "QuantilePrior", + Title = "Quantile Prior", + MarkerFill = Colors.Red, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 3, + MarkerType = MarkerType.Square, + }; + + using (Element.SuspendPlotBridges()) + { + plot.Series.Clear(); + + if (Element.InputData != null && Element.BayesianAnalysis != null) + { + // Add Curves + if (Element.BayesianAnalysis.IsEstimated == true && Element.AnalysisResults != null) + { + var ciPoints = new List(); + var prdPoints = new List(); + var mdPoints = new List(); + for (int i = 0; i < Element.ProbabilityOrdinates.Count; i++) + { + var p = Element.ProbabilityOrdinates[i]; + var up = Element.AnalysisResults.ConfidenceIntervals[i, 1]; + var lo = Element.AnalysisResults.ConfidenceIntervals[i, 0]; + var prd = Element.AnalysisResults.MeanCurve[i]; + var md = Element.AnalysisResults.ModeCurve[i]; + ciPoints.Add(new Point3D(p, lo, up)); + prdPoints.Add(new OxyPlot.DataPoint(p, prd)); + mdPoints.Add(new OxyPlot.DataPoint(p, md)); + } + + // Credible Intervals + credibleIntervals.ItemsSource = ciPoints; + credibleIntervals.DataFieldX = "X"; + credibleIntervals.DataFieldY = "Y"; + credibleIntervals.DataFieldX2 = "X"; + credibleIntervals.DataFieldY2 = "Z"; + credibleIntervals.Title = (Element.BayesianAnalysis.CredibleIntervalWidth * 100).ToString("F0") + "% Credible Intervals"; + credibleIntervals.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + plot.Series.Add(credibleIntervals); + + // Predictive + posteriorPredictive.ItemsSource = prdPoints; + posteriorPredictive.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + plot.Series.Add(posteriorPredictive); + + // Point Estimator + posteriorMode.Title = Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"; + posteriorMode.ItemsSource = mdPoints; + posteriorMode.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + plot.Series.Add(posteriorMode); + } + + // See if Y-axis is logarithmic + bool logAxis = false; + foreach (var axis in plot.Axes) + { + if (axis.Key == "Yaxis" && axis as LogarithmicAxis != null) + { + logAxis = true; + break; + } + } + + // Exact data + frequencyExactData.ItemsSource = logAxis == true ? Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == false && x.Value > 1E-16) : Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == false); + frequencyExactData.Mapping = item => { var d = (ExactData)item; return new OxyPlot.Series.ScatterPoint(d.PlottingPosition, d.Value); }; + frequencyExactData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.ExactSeries.Count > 0) plot.Series.Add(frequencyExactData); + + // low outliers data + frequencyLowOutlierData.ItemsSource = logAxis == true ? Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == true && x.Value > 1E-16) : Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == true); + frequencyLowOutlierData.Mapping = item => { var d = (ExactData)item; return new OxyPlot.Series.ScatterPoint(d.PlottingPosition, d.Value); }; + frequencyLowOutlierData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.NumberOfLowOutliers > 0) plot.Series.Add(frequencyLowOutlierData); + + // uncertain data + frequencyUncertainData.ItemsSource = logAxis == true ? Element.InputData.DataFrame.UncertainSeries : Element.InputData.DataFrame.UncertainSeries.Where(x => x.Value > 1E-16); + frequencyUncertainData.DataFieldX = nameof(UncertainData.PlottingPosition); + frequencyUncertainData.DataFieldY = nameof(UncertainData.Value); + frequencyUncertainData.DataFieldLowerErrorY = nameof(UncertainData.LowerValue); + frequencyUncertainData.DataFieldUpperErrorY = nameof(UncertainData.UpperValue); + frequencyUncertainData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.UncertainSeries.Count > 0) plot.Series.Add(frequencyUncertainData); + + // interval data + frequencyIntervalData.ItemsSource = logAxis == true ? Element.InputData.DataFrame.IntervalSeries : Element.InputData.DataFrame.IntervalSeries.Where(x => x.Value > 1E-16); + frequencyIntervalData.DataFieldX = nameof(IntervalData.PlottingPosition); + frequencyIntervalData.DataFieldY = nameof(IntervalData.Value); + frequencyIntervalData.DataFieldLowerErrorY = nameof(IntervalData.LowerValue); + frequencyIntervalData.DataFieldUpperErrorY = nameof(IntervalData.UpperValue); + frequencyIntervalData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.IntervalSeries.Count > 0) plot.Series.Add(frequencyIntervalData); + + // quantile priors + quantilePriors.ItemsSource = Element.UnivariateDistribution.QuantilePriors; + quantilePriors.DataFieldX = nameof(QuantilePrior.Alpha); + quantilePriors.DataFieldY = nameof(QuantilePrior.MeanValue); + quantilePriors.DataFieldLowerErrorY = nameof(QuantilePrior.LowerValue); + quantilePriors.DataFieldUpperErrorY = nameof(QuantilePrior.UpperValue); + quantilePriors.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.000000000}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.UnivariateDistribution.EnableQuantilePriors && Element.UnivariateDistribution.QuantilePriors.Count > 0) plot.Series.Add(quantilePriors); + + // Add Alternatives + if (Element.BayesianAnalysis.IsEstimated == true && Element.AnalysisResults != null) + AlternativeSelector.AddAllChecked(); + } + + plot.InvalidatePlot(true); + } + Element.RebuildSeriesAndAnnotationBridges(plot); + } + + /// + /// Handles the event when an alternative analysis is added to the selector. Adds the alternative's + /// frequency curves (credible intervals, predictive, and mode) to the frequency plot with unique colors. + /// + /// The analysis alternative item to add to the plot. + private void AlternativeSelector_AnalysisAdded(AnalysisAlternativeItem analysisItem) + { + var plot = Element?.FrequencyPlot; + if (plot == null) return; + + // Remove series + plot.Series.Remove(analysisItem.CredibleIntervals); + plot.Series.Remove(analysisItem.PosteriorPredictive); + plot.Series.Remove(analysisItem.PosteriorMode); + + var alternative = analysisItem.Alternative; + if (alternative.IsEstimated == true && alternative.AnalysisResults != null) + { + // Get unique color + var index = plot.Series.Count; + Color lineColor = (Color)ColorConverter.ConvertFromString(_colorHexCodes[index]); + Color fillColor = Color.FromArgb(100, lineColor.R, lineColor.G, lineColor.B); + + for (int i = 4; i < _colorHexCodes.Length; i++) + { + var templineColor = (Color)ColorConverter.ConvertFromString(_colorHexCodes[i]); + var tempfillColor = Color.FromArgb(100, templineColor.R, templineColor.G, templineColor.B); + bool colorExists = false; + for (int j = 0; j < plot.Series.Count; j++) + { + if (plot.Series[j].Name.Contains("Mode") && plot.Series[j].Color == templineColor) + { + colorExists = true; + break; + } + } + if (colorExists == false || i == _colorHexCodes.Length - 1) + { + lineColor = templineColor; + fillColor = tempfillColor; + break; + } + } + + var ciPoints = new List(); + var prdPoints = new List(); + var mdPoints = new List(); + for (int i = 0; i < ((ModelAnalyses.IProbabilityOrdinates)alternative).ProbabilityOrdinates.Count; i++) + { + var p = ((ModelAnalyses.IProbabilityOrdinates)alternative).ProbabilityOrdinates[i]; + var up = alternative.AnalysisResults.ConfidenceIntervals[i, 1]; + var lo = alternative.AnalysisResults.ConfidenceIntervals[i, 0]; + var prd = alternative.AnalysisResults.MeanCurve[i]; + var md = alternative.AnalysisResults.ModeCurve[i]; + ciPoints.Add(new Point3D(p, lo, up)); + prdPoints.Add(new OxyPlot.DataPoint(p, prd)); + mdPoints.Add(new OxyPlot.DataPoint(p, md)); + } + + // Series labels + double ciWidth = alternative.BayesianAnalysis.CredibleIntervalWidth; + string ciLabel = "% Credible Intervals"; + string predLabel = " - Posterior Predictive"; + string pointLabel = " - " + (alternative.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"); + if (alternative.GetType() == typeof(B17CAnalysis)) + { + ciLabel = "% Confidence Intervals"; + predLabel = " - Expected Probability"; + pointLabel = " - " + (alternative.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Mean Parameters" : "Computed"); + } + + // Credible Intervals + analysisItem.CredibleIntervals.Name = "CredibleIntervals_" + index; + analysisItem.CredibleIntervals.Title = alternative.Name + " - " + (ciWidth * 100).ToString("F0") + ciLabel; + analysisItem.CredibleIntervals.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + analysisItem.CredibleIntervals.ItemsSource = ciPoints; + analysisItem.CredibleIntervals.DataFieldX = "X"; + analysisItem.CredibleIntervals.DataFieldY = "Y"; + analysisItem.CredibleIntervals.DataFieldX2 = "X"; + analysisItem.CredibleIntervals.DataFieldY2 = "Z"; + analysisItem.CredibleIntervals.Fill = fillColor; + analysisItem.CredibleIntervals.Color = Colors.Transparent; + plot.Series.Add(analysisItem.CredibleIntervals); + + // Predictive + analysisItem.PosteriorPredictive.Name = "PosteriorPredictive_" + index; + analysisItem.PosteriorPredictive.Title = alternative.Name + predLabel; + analysisItem.PosteriorPredictive.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + analysisItem.PosteriorPredictive.ItemsSource = prdPoints; + analysisItem.PosteriorPredictive.Color = lineColor; + plot.Series.Add(analysisItem.PosteriorPredictive); + + // Point Estimator + analysisItem.PosteriorMode.Name = "PosteriorMode_" + index; + analysisItem.PosteriorMode.Title = alternative.Name + pointLabel; + analysisItem.PosteriorMode.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + analysisItem.PosteriorMode.ItemsSource = mdPoints; + analysisItem.PosteriorMode.Color = lineColor; + plot.Series.Add(analysisItem.PosteriorMode); + + plot.InvalidatePlot(true); + + } + + } + + /// + /// Handles the event when an alternative analysis is removed from the selector. Removes the alternative's + /// series from the frequency plot and refreshes the display. + /// + /// The analysis alternative item to remove from the plot. + private void AlternativeSelector_AnalysisRemoved(AnalysisAlternativeItem analysisItem) + { + var plot = Element?.FrequencyPlot; + if (plot == null) return; + plot.Series.Remove(analysisItem.CredibleIntervals); + plot.Series.Remove(analysisItem.PosteriorPredictive); + plot.Series.Remove(analysisItem.PosteriorMode); + plot.InvalidatePlot(true); + } + + /// + /// Handles mouse click on text block to toggle the visibility of the filter section. + /// + /// The event sender. + /// The mouse button event arguments. + private void TextBlock_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + ShowFilterToggleButton.IsChecked = !ShowFilterToggleButton.IsChecked; + } + + /// + /// Updates the chronology plot with time series data, threshold information, and nonstationary analysis results. + /// Displays data points, credible intervals, and mean trends over time for nonstationary distributions. + /// + private void UpdateChronologyPlot() + { + var plot = Element?.ChronologyPlot; + if (plot == null) return; + + var chronologyExactData = plot.Series.OfType().FirstOrDefault(s => s.Name == "ExactData") + ?? new ScatterPointSeries + { + Name = "ExactData", + Title = "Exact Data", + MarkerFill = Colors.Black, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 4, + MarkerType = MarkerType.Circle, + }; + var chronologyLowOutlierData = plot.Series.OfType().FirstOrDefault(s => s.Name == "LowOutlierData") + ?? new ScatterPointSeries + { + Name = "LowOutlierData", + Title = "Low Outlier Data", + MarkerStroke = Colors.Red, + MarkerStrokeThickness = 2, + MarkerSize = 4, + MarkerType = MarkerType.Cross, + }; + var chronologyUncertainData = plot.Series.OfType().FirstOrDefault(s => s.Name == "UncertainData") + ?? new ScatterErrorSeries + { + Name = "UncertainData", + Title = "Uncertain Data", + MarkerFill = Colors.Green, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 4, + MarkerType = MarkerType.Diamond, + }; + var chronologyIntervalData = plot.Series.OfType().FirstOrDefault(s => s.Name == "IntervalData") + ?? new ScatterErrorSeries + { + Name = "IntervalData", + Title = "Interval Data", + MarkerFill = Colors.Cyan, + MarkerStroke = Colors.Black, + MarkerStrokeThickness = 1, + MarkerSize = 4, + MarkerType = MarkerType.Circle, + }; + var credibleIntervalsChrono = plot.Series.OfType().FirstOrDefault(s => s.Name == "CredibleIntervals") + ?? new AreaSeries + { + Name = "CredibleIntervals", + Fill = Color.FromArgb(75, 104, 140, 175), + Color = Color.FromArgb(255, 53, 59, 122), + LineStyle = LineStyle.Solid, + BrokenLineThickness = 1, + StrokeThickness = 1, + Decimator = OxyPlot.Decimator.Decimate, + }; + var meanChrono = plot.Series.OfType().FirstOrDefault(s => s.Name == "PosteriorPredictive") + ?? new LineSeries + { + Name = "PosteriorPredictive", + Title = "Mean", + Color = Colors.Blue, + StrokeThickness = 1, + LineStyle = LineStyle.Dash, + Decimator = OxyPlot.Decimator.Decimate, + MinimumSegmentLength = 4.0, + }; + + using (Element.SuspendPlotBridges()) + { + plot.Series.Clear(); + + if (Element.InputData != null && Element.UnivariateDistribution != null && Element.UnivariateDistribution.IsNonstationary != false) + { + // Exact data + chronologyExactData.ItemsSource = Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == false); + chronologyExactData.Mapping = item => { var d = (ExactData)item; return new OxyPlot.Series.ScatterPoint(d.Index, d.Value); }; + chronologyExactData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.ExactSeries.Count > 0) plot.Series.Add(chronologyExactData); + + // low outliers data + chronologyLowOutlierData.ItemsSource = Element.InputData.DataFrame.ExactSeries.Where(x => ((ExactData)x).IsLowOutlier == true); + chronologyLowOutlierData.Mapping = item => { var d = (ExactData)item; return new OxyPlot.Series.ScatterPoint(d.Index, d.Value); }; + chronologyLowOutlierData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.NumberOfLowOutliers > 0) plot.Series.Add(chronologyLowOutlierData); + + // uncertain data + chronologyUncertainData.ItemsSource = Element.InputData.DataFrame.UncertainSeries; + chronologyUncertainData.DataFieldX = nameof(UncertainData.Index); + chronologyUncertainData.DataFieldY = nameof(UncertainData.Value); + chronologyUncertainData.DataFieldLowerErrorY = nameof(UncertainData.LowerValue); + chronologyUncertainData.DataFieldUpperErrorY = nameof(UncertainData.UpperValue); + chronologyUncertainData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.UncertainSeries.Count > 0) plot.Series.Add(chronologyUncertainData); + + // interval data + chronologyIntervalData.ItemsSource = Element.InputData.DataFrame.IntervalSeries; + chronologyIntervalData.DataFieldX = nameof(IntervalData.Index); + chronologyIntervalData.DataFieldY = nameof(IntervalData.Value); + chronologyIntervalData.DataFieldLowerErrorY = nameof(IntervalData.LowerValue); + chronologyIntervalData.DataFieldUpperErrorY = nameof(IntervalData.UpperValue); + chronologyIntervalData.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + if (Element.InputData.DataFrame.IntervalSeries.Count > 0) plot.Series.Add(chronologyIntervalData); + + // threshold data (dynamic count created inline) + UpdateThresholdPlotSeries(); + + // frequency results + if (Element.BayesianAnalysis != null && + Element.BayesianAnalysis.IsEstimated == true && + Element.ChronologyAnalysisResults != null && + Element.UnivariateDistribution != null && + Element.UnivariateDistribution.IsNonstationary == true && + Element.InputData.DataFrame.FullTimeSeries.Count > 0) + { + var ciPoints = new List(); + var prdPoints = new List(); + var mdPoints = new List(); + int t = Element.InputData.DataFrame.FullTimeSeries.First().Index; + for (int i = 0; i < Element.ChronologyAnalysisResults.ModeCurve.Length; i++) + { + var up = Element.ChronologyAnalysisResults.ConfidenceIntervals[i, 1]; + var lo = Element.ChronologyAnalysisResults.ConfidenceIntervals[i, 0]; + var prd = Element.ChronologyAnalysisResults.MeanCurve[i]; + var md = Element.ChronologyAnalysisResults.ModeCurve[i]; + ciPoints.Add(new Point3D(t, lo, up)); + prdPoints.Add(new OxyPlot.DataPoint(t, prd)); + mdPoints.Add(new OxyPlot.DataPoint(t, md)); + t += 1; + } + + // Credible Intervals + credibleIntervalsChrono.ItemsSource = ciPoints; + credibleIntervalsChrono.DataFieldX = "X"; + credibleIntervalsChrono.DataFieldY = "Y"; + credibleIntervalsChrono.DataFieldX2 = "X"; + credibleIntervalsChrono.DataFieldY2 = "Z"; + credibleIntervalsChrono.Title = (Element.BayesianAnalysis.CredibleIntervalWidth * 100).ToString("F0") + "% Credible Intervals"; + credibleIntervalsChrono.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + plot.Series.Add(credibleIntervalsChrono); + + // mean + meanChrono.ItemsSource = prdPoints; + meanChrono.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + plot.Series.Add(meanChrono); + } + } + + if (plot.Visibility == Visibility.Visible) + plot.InvalidatePlot(true); + } + Element.RebuildSeriesAndAnnotationBridges(plot); + } + + /// + /// Updates the threshold data series on the chronology plot by clearing existing threshold series + /// and adding new ones from the current input data. + /// + private void UpdateThresholdPlotSeries() + { + var plot = Element?.ChronologyPlot; + if (plot == null) return; + // Clear the threshold data series from plot + for (int i = plot.Series.Count - 1; i >= 0; i -= 1) + { + if (plot.Series[i].GetType() == typeof(AreaSeries)) + plot.Series.RemoveAt(i); + } + // Add back in the threshold data + for (int i = 0; i <= Element.InputData.DataFrame.ThresholdSeries.Count - 1; i++) + AddThresholdDataPoint((ThresholdData)Element.InputData.DataFrame.ThresholdSeries[i], i + 1, i == 0 ? true : false); + } + + /// + /// Adds a threshold data point to the chronology plot as an area series. + /// + /// The threshold data to add. + /// The index of this threshold in the series. + /// Indicates whether to show this threshold in the legend. + private void AddThresholdDataPoint(ThresholdData data, int index, bool showInLegend = false) + { + // Get start and end indices in case they are the same + double startIndex = data.StartIndex; + double endIndex = data.EndIndex; + if (endIndex == startIndex) + { + startIndex -= 0.5; + endIndex += 0.5; + } + // Create a new area series for the threshold and add to the chronology plot + AreaSeries areaSeries = new AreaSeries() { Name = "ThresholdData_" + index + "_" + data.StartIndex.ToString().Replace("-", "_") + "_" + data.Index.ToString().Replace("-", "_") }; + if (showInLegend == true) + { + areaSeries.Title = "Threshold Data"; + areaSeries.TrackerFormatString = "Threshold " + data.StartIndex.ToString() + "-" + data.EndIndex.ToString() + Environment.NewLine + "{1}: {2:0}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + areaSeries.RenderInLegend = true; + } + else + { + areaSeries.Title = "Threshold " + data.StartIndex.ToString() + "-" + data.EndIndex.ToString(); + areaSeries.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}"; + areaSeries.RenderInLegend = false; + } + areaSeries.Fill = Color.FromArgb(100, 250, 128, 114); + areaSeries.Color = Color.FromArgb(255, 250, 128, 114); + areaSeries.LineStyle = LineStyle.Solid; + areaSeries.BrokenLineThickness = 1; + areaSeries.StrokeThickness = 1; + ((OxyPlot.Series.AreaSeries)areaSeries.InternalSeries).Points.Clear(); + ((OxyPlot.Series.AreaSeries)areaSeries.InternalSeries).Points2.Clear(); + ((OxyPlot.Series.AreaSeries)areaSeries.InternalSeries).Points.Add(new DataPoint(startIndex, 0)); + ((OxyPlot.Series.AreaSeries)areaSeries.InternalSeries).Points2.Add(new DataPoint(startIndex, data.Value)); + ((OxyPlot.Series.AreaSeries)areaSeries.InternalSeries).Points.Add(new DataPoint(endIndex, 0)); + ((OxyPlot.Series.AreaSeries)areaSeries.InternalSeries).Points2.Add(new DataPoint(endIndex, data.Value)); + Element?.ChronologyPlot?.Series.Add(areaSeries); + } + + #endregion + + /// + /// Binds the frequency curve data grid with analysis results including probability ordinates, + /// credible intervals, predictive values, and mode values. + /// + private void BindFrequencyCurveDataGrid() + { + FrequencyCurveTable.ItemsSource = null; + + if (Element == null || Element.BayesianAnalysis == null) + return; + + ModeColumn.Header = Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"; + + if (Element.BayesianAnalysis.IsEstimated != true || Element.AnalysisResults == null) + return; + + var curvePoints = new List(); + for (int i = 0; i < Element.ProbabilityOrdinates.Count; i++) + { + var p = Element.ProbabilityOrdinates[i]; + var up = Element.AnalysisResults.ConfidenceIntervals[i, 1]; + var lo = Element.AnalysisResults.ConfidenceIntervals[i, 0]; + var prd = Element.AnalysisResults.MeanCurve[i]; + var md = Element.AnalysisResults.ModeCurve[i]; + curvePoints.Add(new FrequencyCurvePoint(p, up, lo, prd, md)); + } + FrequencyCurveTable.ItemsSource = curvePoints; + FrequencyCurveTable.Items.Refresh(); + } + + /// + /// Sets the column headers for the frequency curve table based on the current credible interval width. + /// + private void SetFrequencyCurveTableColumnHeaders() + { + double alpha = (1 - Element.BayesianAnalysis.CredibleIntervalWidth) / 2; + UpperColumn.Header = ((1 - alpha) * 100).ToString("F1") + "% CI"; + LowerColumn.Header = (alpha * 100).ToString("F1") + "% CI"; + } + + /// + /// Sets the string format for data grid columns based on user settings. + /// + private void SetColumnStringFormats() + { + // Update the column binding string format + UpperColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + LowerColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + PredictiveColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + ModeColumn.Binding.StringFormat = "{0:" + FrameworkUI.UserSettings.ValueStringFormat + "}"; + } + + /// + /// Binds the summary statistics data grid with parameter values and goodness-of-fit statistics. + /// Displays distribution parameters, moments (mean, std dev, skewness, kurtosis), and model comparison criteria (AIC, BIC, DIC, WAIC, LOO-CV, RMSE). + /// + private void BindSummaryStatisticsDataGrid() + { + SummaryStatisticsTable.ItemsSource = null; + + if (Element == null || Element.BayesianAnalysis == null) + return; + + StatValueColumn.Header = Element.BayesianAnalysis.PointEstimator == BayesianAnalysis.PointEstimateType.PosteriorMean ? "Posterior Mean" : "Posterior Mode"; + + var summaryStats = new List(); + if (Element.BayesianAnalysis.IsEstimated == false || Element.AnalysisResults == null) + { + + for (int i = 0; i < Element.UnivariateDistribution.Parameters.Count; i++) + { + summaryStats.Add(new SummaryStatistic(Element.UnivariateDistribution.Parameters[i].DisplayName, double.NaN)); + } + summaryStats.Add(new SummaryStatistic("Minimum", double.NaN)); + summaryStats.Add(new SummaryStatistic("Maximum", double.NaN)); + summaryStats.Add(new SummaryStatistic("Mean", double.NaN)); + summaryStats.Add(new SummaryStatistic("Std Dev", double.NaN)); + summaryStats.Add(new SummaryStatistic("Skewness", double.NaN)); + summaryStats.Add(new SummaryStatistic("Kurtosis", double.NaN)); + summaryStats.Add(new SummaryStatistic("AIC", double.NaN)); + summaryStats.Add(new SummaryStatistic("BIC", double.NaN)); + summaryStats.Add(new SummaryStatistic("DIC", double.NaN)); + summaryStats.Add(new SummaryStatistic("WAIC", double.NaN)); + summaryStats.Add(new SummaryStatistic("LOO-CV", double.NaN)); + summaryStats.Add(new SummaryStatistic("RMSE", double.NaN)); + //summaryStats.Add(new SummaryStatistic("ERL", double.NaN)); + } + else + { + for (int i = 0; i < Element.UnivariateDistribution.Parameters.Count; i++) + { + summaryStats.Add(new SummaryStatistic(Element.UnivariateDistribution.Parameters[i].DisplayName, Element.UnivariateDistribution.Parameters[i].Value)); + } + var min = Element.UnivariateDistribution.Distribution.Minimum; + var max = Element.UnivariateDistribution.Distribution.Maximum; + + summaryStats.Add(new SummaryStatistic("Minimum", min < -1E12 ? double.NegativeInfinity : min)); + summaryStats.Add(new SummaryStatistic("Maximum", max > 1E12 ? double.PositiveInfinity : max)); + summaryStats.Add(new SummaryStatistic("Mean", Element.UnivariateDistribution.Distribution.Mean)); + summaryStats.Add(new SummaryStatistic("Std Dev", Element.UnivariateDistribution.Distribution.StandardDeviation)); + summaryStats.Add(new SummaryStatistic("Skewness", Element.UnivariateDistribution.Distribution.Skewness)); + summaryStats.Add(new SummaryStatistic("Kurtosis", Element.UnivariateDistribution.Distribution.Kurtosis)); + summaryStats.Add(new SummaryStatistic("AIC", Element.AnalysisResults.AIC)); + summaryStats.Add(new SummaryStatistic("BIC", Element.AnalysisResults.BIC)); + summaryStats.Add(new SummaryStatistic("DIC", Element.AnalysisResults.DIC)); + summaryStats.Add(new SummaryStatistic("WAIC", Element.BayesianAnalysis.WAIC)); + summaryStats.Add(new SummaryStatistic("LOO-CV", Element.BayesianAnalysis.LOOIC)); + summaryStats.Add(new SummaryStatistic("RMSE", Element.AnalysisResults.RMSE)); + //summaryStats.Add(new SummaryStatistic("ERL", Element.FrequencyAnalysisResults.ERL)); + } + + SummaryStatisticsTable.ItemsSource = summaryStats; + SummaryStatisticsTable.Items.Refresh(); + } + + /// + /// Handles the loading of each row in the summary statistics table. Adds visual separators + /// (borders) before certain rows to group related statistics. + /// + /// The event sender. + /// The data grid row event arguments. + private void SummaryStatisticsTable_LoadingRow(object sender, DataGridRowEventArgs e) + { + if (((SummaryStatistic)e.Row.DataContext).Name == "Minimum" || + ((SummaryStatistic)e.Row.DataContext).Name == "AIC" || + ((SummaryStatistic)e.Row.DataContext).Name == "ERL") + { + e.Row.BorderThickness = new Thickness(0, 2, 0, 0); + e.Row.SetResourceReference(Border.BorderBrushProperty, "EnvironmentWindowText"); + } + else + { + e.Row.BorderThickness = new Thickness(0, 0, 0, 0); + } + } + + } +} diff --git a/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Univariate/UnivariateAnalysisPropertiesControl.xaml b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Univariate/UnivariateAnalysisPropertiesControl.xaml new file mode 100644 index 0000000..61a800a --- /dev/null +++ b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Univariate/UnivariateAnalysisPropertiesControl.xaml @@ -0,0 +1,266 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Univariate/UnivariateAnalysisPropertiesControl.xaml.cs b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Univariate/UnivariateAnalysisPropertiesControl.xaml.cs new file mode 100644 index 0000000..c16d85e --- /dev/null +++ b/src/RMC.BestFit.App/GUI/UnivariateAnalysis/Univariate/UnivariateAnalysisPropertiesControl.xaml.cs @@ -0,0 +1,731 @@ +using FrameworkInterfaces; +using GenericControls; +using RMC.BestFit.Models; +using RMC.BestFit.UI; +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Input; +using Numerics.Distributions; +using Numerics.Utilities; +using System.Collections.Generic; +using System.Windows.Controls.Primitives; +using System.Linq; +using RMC.BestFit.Models.TrendFunctions.Support; + +namespace RMC_BestFit +{ + /// + /// User control for displaying and editing properties of a univariate analysis, including + /// distribution selection, trend models, parameter priors, quantile priors, and Bayesian estimation options. + /// + public partial class UnivariateAnalysisPropertiesControl : UserControl + { + /// + /// Initializes a new instance of the class. + /// + public UnivariateAnalysisPropertiesControl() + { + InitializeComponent(); + DataContext = this; + this.Unloaded += UserControl_Unloaded; + } + + /// + /// Stores the previous name of the element for validation and rollback purposes. + /// + private string _previousName; + + /// + /// Tracks the InputDataCollection currently subscribed to, so it can be unsubscribed when the control unloads or the element changes. + /// + private IElementCollection _subscribedInputDataCollection; + + /// + /// Dependency property for the property. + /// + public static DependencyProperty ElementProperty = DependencyProperty.Register(nameof(Element), typeof(UnivariateAnalysis), typeof(UnivariateAnalysisPropertiesControl), new PropertyMetadata(null, ElementCallback)); + + /// + /// Gets or sets the univariate analysis element whose properties are being displayed and edited. + /// + public UnivariateAnalysis Element + { + get { return (UnivariateAnalysis)GetValue(ElementProperty); } + set { SetValue(ElementProperty, value); } + } + + /// + /// Callback method invoked when the Element dependency property changes. + /// Subscribes to property changed events and initializes the control with the new element's data. + /// + /// The dependency object whose property changed. + /// Event arguments containing the old and new values. + private static void ElementCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d == null) return; + if (d as UnivariateAnalysisPropertiesControl == null) return; + var thisControl = (UnivariateAnalysisPropertiesControl)d; + + // Unsubscribe from old element + if (e.OldValue is UnivariateAnalysis oldElement) + { + oldElement.PropertyChanged -= thisControl.Element_PropertyChanged; + } + + if (e.NewValue == null) return; + var newElement = e.NewValue as UnivariateAnalysis; + if (newElement == null) return; + + // Initialize previous name for rollback safety + thisControl._previousName = newElement.Name; + + newElement.PropertyChanged += thisControl.Element_PropertyChanged; + thisControl.PropertyAttributes.GetClassAttributes(newElement); + thisControl.LoadInputData(); + thisControl.BindTrendModelDataGrid(); + + } + + /// + /// Dependency property for the existing names property. + /// + public static DependencyProperty ExistingNamesProperty = DependencyProperty.Register(nameof(ExistingNames), typeof(string[]), typeof(UnivariateAnalysisPropertiesControl), new FrameworkPropertyMetadata(new string[] { })); + + /// + /// Gets or sets an array of existing names for this element type, used for name uniqueness validation. + /// + public string[] ExistingNames + { + get { return (string[])GetValue(ExistingNamesProperty); } + private set { SetValue(ExistingNamesProperty, value); } + } + + /// + /// Cached reference to the Distribution combo box for programmatic binding updates during undo. + /// Cannot use x:Name due to ContentPropertyControl XAML scoping; captured via Loaded event. + /// + private System.Windows.Controls.ComboBox _distributionComboBox; + + /// + /// Gets the observable collection of available input data elements that can be selected for analysis. + /// + public ObservableCollection InputDataList { get; private set; } = new ObservableCollection(); + + /// + /// Stores the trend model row items for display in the data grid. + /// + private List _trendModelRowItems = new List(); + + /// + /// When true, suppresses to prevent + /// re-entrant SetTrendModel calls during . + /// Clearing DataGrid.ItemsSource can fire ComboBox SelectionChanged events that would + /// overwrite a freshly restored distribution during undo. + /// + private bool _suppressTrendModelSelection = false; + + /// + /// Gets the observable collection of available univariate distribution types that can be selected. + /// + public ObservableCollection DistributionList { get; private set; } = new ObservableCollection() + { new UnivariateDistributionItem("Exponential", UnivariateDistributionType.Exponential), + new UnivariateDistributionItem("Gamma", UnivariateDistributionType.GammaDistribution), + new UnivariateDistributionItem("Generalized Extreme Value", UnivariateDistributionType.GeneralizedExtremeValue), + new UnivariateDistributionItem("Generalized Logistic", UnivariateDistributionType.GeneralizedLogistic), + new UnivariateDistributionItem("Generalized Normal", UnivariateDistributionType.GeneralizedNormal), + new UnivariateDistributionItem("Generalized Pareto", UnivariateDistributionType.GeneralizedPareto), + new UnivariateDistributionItem("Gumbel (EVI)", UnivariateDistributionType.Gumbel), + new UnivariateDistributionItem("Kappa-4", UnivariateDistributionType.KappaFour), + new UnivariateDistributionItem("Ln-Normal", UnivariateDistributionType.LnNormal), + new UnivariateDistributionItem("Logistic", UnivariateDistributionType.Logistic), + new UnivariateDistributionItem("Log-Normal", UnivariateDistributionType.LogNormal), + new UnivariateDistributionItem("Log-Pearson Type III", UnivariateDistributionType.LogPearsonTypeIII), + new UnivariateDistributionItem("Normal", UnivariateDistributionType.Normal), + new UnivariateDistributionItem("Pearson Type III", UnivariateDistributionType.PearsonTypeIII), + new UnivariateDistributionItem("Weibull", UnivariateDistributionType.Weibull), + }; + + /// + /// Gets the observable collection of available trend model types for nonstationary distributions. + /// + public ObservableCollection TrendFunctionItems { get; private set; } = new ObservableCollection() + { + new TrendModelItem("Constant", TrendModelType.Constant), + new TrendModelItem("Cubic", TrendModelType.Cubic), + new TrendModelItem("Exponential", TrendModelType.Exponential), + new TrendModelItem("Linear", TrendModelType.Linear), + new TrendModelItem("Logistic", TrendModelType.Logistic), + new TrendModelItem("Power", TrendModelType.Power), + new TrendModelItem("Quadratic", TrendModelType.Quadratic), + new TrendModelItem("Sinusoidal", TrendModelType.Sinusoidal), + new TrendModelItem("Step Function", TrendModelType.StepFunction) + }; + + + #region Property Attributes + + /// + /// Handles mouse button down event on the Name field to display property attributes. + /// + /// The event sender. + /// The mouse button event arguments. + private void Name_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.Name), Element); + } + + /// + /// Handles mouse button down event on the Description field to display property attributes. + /// + /// The event sender. + /// The mouse button event arguments. + private void Description_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.Description), Element); + } + + /// + /// Handles mouse button down event on the CreationDate field to display property attributes. + /// + /// The event sender. + /// The mouse button event arguments. + private void CreationDate_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.CreationDate), Element); + } + + /// + /// Handles mouse button down event on the LastModified field to display property attributes. + /// + /// The event sender. + /// The mouse button event arguments. + private void LastModified_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.LastModified), Element); + } + + /// + /// Handles mouse button down event on the InputData field to display property attributes. + /// + /// The event sender. + /// The mouse button event arguments. + private void InputData_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.InputData), Element); + } + + /// + /// Handles mouse button down event on the Distribution field to display property attributes. + /// + /// The event sender. + /// The mouse button event arguments. + private void Distribution_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.UnivariateDistribution.Distribution), Element.UnivariateDistribution); + } + + /// + /// Handles mouse button down event on the UseJeffreysRule field to display property attributes. + /// + /// The event sender. + /// The mouse button event arguments. + private void UseJeffreysRule_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.UnivariateDistribution.UseJeffreysRuleForScale), Element.UnivariateDistribution); + } + + /// + /// Handles mouse button down event on the IsNonstationary field to display property attributes. + /// + /// The event sender. + /// The mouse button event arguments. + private void IsNonstationary_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.UnivariateDistribution.IsNonstationary), Element.UnivariateDistribution); + } + + /// + /// Handles mouse button down event on the ParameterTimeStep field to display property attributes. + /// + /// The event sender. + /// The mouse button event arguments. + private void ParameterTimeStep_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.UnivariateDistribution.ParameterTimeIndex), Element.UnivariateDistribution); + } + + /// + /// Handles mouse button down event on the Alpha field to display property attributes. + /// + /// The event sender. + /// The mouse button event arguments. + private void Alpha_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.UnivariateDistribution.Alpha), Element.UnivariateDistribution); + } + + /// + /// Handles mouse button down event on the TrendModelDataGrid to display property attributes. + /// + /// The event sender. + /// The mouse button event arguments. + private void TrendModelDataGrid_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetPropertyAttributes(nameof(Element.UnivariateDistribution.TrendModels), Element.UnivariateDistribution); + } + + /// + /// Handles mouse button down event on the ProbabilityOrdinatesControl to display default property attributes. + /// + /// The event sender. + /// The mouse button event arguments. + private void ProbabilityOrdinatesControl_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.SetDefaultAttributes("Probability Ordinates", "The exceedance probabilities used for plotting the probability distribution."); + } + + /// + /// Handles mouse button down event on a TabItem to display class-level property attributes. + /// + /// The event sender. + /// The mouse button event arguments. + private void TabItem_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + PropertyAttributes.GetClassAttributes(Element); + } + + /// + /// Handles requests from the ParameterPriorsControl to show property attributes. + /// + /// The name of the property. + /// The object containing the property. + private void ParameterPriorsControl_ShowPropertyAttributes(string propertyName, object classObject) + { + PropertyAttributes.GetPropertyAttributes(propertyName, classObject); + } + + /// + /// Handles requests from the QuantilePriorsControl to show property attributes. + /// + /// The name of the property. + /// The object containing the property. + private void QuantilePriorsControl_ShowPropertyAttributes(string propertyName, object classObject) + { + PropertyAttributes.GetPropertyAttributes(propertyName, classObject); + } + + /// + /// Handles requests from the BayesianOptionsControl to show property attributes. + /// + /// The name of the property. + /// The object containing the property. + private void BayesianOptionsControl_ShowPropertyAttributes(string propertyName, object classObject) + { + PropertyAttributes.GetPropertyAttributes(propertyName, classObject); + } + + /// + /// Handles requests from the BayesianOutputControl to show property attributes. + /// + /// The name of the property. + /// The object containing the property. + private void BayesianOutputControl_ShowPropertyAttributes(string propertyName, object classObject) + { + PropertyAttributes.GetPropertyAttributes(propertyName, classObject); + } + + #endregion + + /// + /// Handles the GotFocus event for the Name field. Stores the current name and retrieves existing names for validation. + /// + /// The event sender. + /// The event arguments. + private void Name_GotFocus(object sender, RoutedEventArgs e) + { + _previousName = Element.Name; + ExistingNames = Element.ParentCollection.GetElementNames(Element).ToArray(); + } + + /// + /// Handles the LostFocus event for the Name field. Restores the previous name if validation fails. + /// + /// The event sender. + /// The event arguments. + private void Name_LostFocus(object sender, RoutedEventArgs e) + { + if (ElementName.NameTextBox.IsValid) + return; + if (Element != null) + Element.Name = _previousName; + } + + /// + /// Handles property changed events from the Element. Responds to changes in trend models and data frame properties. + /// + /// The event sender. + /// The property changed event arguments. + private void Element_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Element.UnivariateDistribution.TrendModels) || e.PropertyName == nameof(Element.UnivariateDistribution.Distribution)) + { + BindTrendModelDataGrid(); + } + if (e.PropertyName == nameof(Element.UnivariateDistribution.DataFrame) || e.PropertyName == nameof(Element.UnivariateDistribution.IsNonstationary)) + { + UpdateTimeIndexConstraints(); + } + // Distribution object replaced (e.g., during undo) explicitly push the new object + // to sub-controls and combo box. Do NOT rely on WPF multi-level binding path refresh. + if (e.PropertyName == nameof(Element.UnivariateDistribution)) + { + ParameterPriorsControl.Model = Element.UnivariateDistribution; + QuantilePriorsControl.Model = Element.UnivariateDistribution; + if (_distributionComboBox != null) + _distributionComboBox.SelectedValue = Element.UnivariateDistribution?.DistributionType; + BindTrendModelDataGrid(); + UpdateTimeIndexConstraints(); + } + // BayesianAnalysis object replaced (during distribution undo, RestoreDistributionFromSnapshot + // creates a new inner analysis) explicitly push to sub-controls. + if (e.PropertyName == nameof(Element.BayesianAnalysis)) + { + BayesianOptionsControl.Analysis = Element.BayesianAnalysis; + BayesianOutputControl.Analysis = Element.BayesianAnalysis; + } + } + + /// + /// Loads the available input data elements from the project and subscribes to collection change events. + /// Unsubscribes from any previously subscribed collection before subscribing to the new one. + /// + private void LoadInputData() + { + UnsubscribeInputDataCollection(); + InputDataList.Clear(); + foreach (IElementCollection collection in Element.ParentCollection.ParentProject.ElementCollections) + { + if (collection.GetType() == typeof(InputDataCollection)) + { + _subscribedInputDataCollection = collection; + collection.ElementAdded += OnInputDataElementAdded; + collection.ElementRemoved += OnInputDataElementRemoved; + foreach (IElement element in collection) + InputDataList.Add((InputData)element); + break; + } + } + } + + /// + /// Handles the addition of an input data element to the subscribed collection. + /// + /// The element that was added. + private void OnInputDataElementAdded(IElement element) + { + InputDataList.Add((InputData)element); + } + + /// + /// Handles the removal of an input data element from the subscribed collection. + /// + /// The element that was removed. + private void OnInputDataElementRemoved(IElement element) + { + InputDataList.Remove((InputData)element); + } + + /// + /// Unsubscribes from the currently tracked InputDataCollection and clears the reference. + /// + private void UnsubscribeInputDataCollection() + { + if (_subscribedInputDataCollection != null) + { + _subscribedInputDataCollection.ElementAdded -= OnInputDataElementAdded; + _subscribedInputDataCollection.ElementRemoved -= OnInputDataElementRemoved; + _subscribedInputDataCollection = null; + } + } + + /// + /// Handles the Unloaded event of the user control. Unsubscribes from InputDataCollection events to prevent memory leaks. + /// + /// The source of the event. + /// Event data. + private void UserControl_Unloaded(object sender, RoutedEventArgs e) + { + UnsubscribeInputDataCollection(); + } + + /// + /// Handles selection changes in the InputData combo box. Validates the selection and updates time index constraints. + /// + /// The event sender. + /// The selection changed event arguments. + private void InputDataComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (Element == null) return; + if (Element.InputData == null || Element.InputData.Name == null) + { + ((Border)InputDataComboBox.InnerContent).BorderThickness = new Thickness(1); + InputDataComboBox.ToolTip = "Please select a valid input data."; + } + else + { + ((Border)InputDataComboBox.InnerContent).BorderThickness = new Thickness(0); + InputDataComboBox.ToolTip = null; + + UpdateTimeIndexConstraints(); + } + } + + /// + /// Handles selection changes in the Distribution combo box. + /// + /// The event sender. + /// The selection changed event arguments. + private void DistributionComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + } + + /// + /// Captures the Distribution combo box reference when it is loaded. + /// Required because the ComboBox is inside a ContentPropertyControl and cannot have x:Name in XAML. + /// + /// The combo box. + /// Routed event arguments. + private void DistributionComboBox_Loaded(object sender, RoutedEventArgs e) + { + _distributionComboBox = sender as System.Windows.Controls.ComboBox; + } + + /// + /// Handles the Loaded event for the combo box. Sets up a sorted view of the input data list. + /// + /// The event sender. + /// The event arguments. + private void ComboBox_Loaded(object sender, RoutedEventArgs e) + { + if (Element == null) return; + ComboBox cmbo = (ComboBox)sender; + CollectionViewSource csv = new CollectionViewSource() { Source = InputDataList, IsLiveSortingRequested = true }; + csv.SortDescriptions.Add(new SortDescription(nameof(IElement.Name), ListSortDirection.Ascending)); + var view = csv.View; + view.MoveCurrentToPosition(-1); + cmbo.ItemsSource = view; + } + + /// + /// Handles the Estimate button click event. Validates inputs, disables the UI, and runs the Bayesian analysis asynchronously. + /// + /// The event sender. + /// The event arguments. + private async void EstimateButton_Click(object sender, RoutedEventArgs e) + { + // Check if the analysis is valid + if (Element.IsValid == false) + { + GenericControls.MessageBox.Show("Cannot perform the analysis because the inputs are invalid.", "Error!", MessageBoxButton.OK, MessageBoxImage.Error); + return; + } + + Mouse.OverrideCursor = Cursors.Wait; + + // Disable all open windows + FrameworkUI.ShellPublicVariables.SimulationInProgress = true; + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableMenuStrip(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableProjectExplorer(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableMessageWindow(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).DisableOpenWindows(); + // Disable Properties + PropertiesExpander.IsEnabled = false; + ParameterPriorsExpander.IsEnabled = false; + QuantilePriorsExpander.IsEnabled = false; + Options_TabItem.IsEnabled = false; + Output_TabItem.IsEnabled = false; + + // Set up progress bar + AnalysisProgressDisplayHelper.ShowInitial(ProgressBar, ProgressTextBlock); + + // Show cancel button + EstimateButton.Visibility = Visibility.Hidden; + CancelButton.Visibility = Visibility.Visible; + + // Set up progress reporter + var progressReporter = new SafeProgressReporter(nameof(UnivariateAnalysis)); + progressReporter.ProgressReported += (SafeProgressReporter reporter, double progress, double progressDelta) => + { + AnalysisProgressDisplayHelper.PostProgress(Dispatcher, ProgressBar, ProgressTextBlock, progress); + }; + bool progressCleanupEnabled = false; + progressReporter.TaskEnded += () => + { + if (!progressCleanupEnabled) return; + // Close out progress bar + ProgressBar.Value = 100; + ProgressTextBlock.Text = "Simulation Complete"; + Mouse.OverrideCursor = null; + EstimateButton.IsEnabled = true; + ProgressBar.Visibility = Visibility.Hidden; + ProgressTextBlock.Visibility = Visibility.Hidden; + + // Close cancel button + EstimateButton.Visibility = Visibility.Visible; + CancelButton.Visibility = Visibility.Hidden; + + // Enable all windows + FrameworkUI.ShellPublicVariables.SimulationInProgress = false; + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableMenuStrip(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableProjectExplorer(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableMessageWindow(); + ((FrameworkUI.MainWindow)Application.Current.MainWindow).EnableOpenWindows(); + // Enable Properties + PropertiesExpander.IsEnabled = true; + ParameterPriorsExpander.IsEnabled = true; + QuantilePriorsExpander.IsEnabled = true; + Options_TabItem.IsEnabled = true; + Output_TabItem.IsEnabled = true; + }; + + + // Perform Bayesian estimation + try + { + await WaitCursorHelper.RunWithVisibleWaitCursorAsync(Dispatcher, async () => + { + await Element.RunAsync(progressReporter); + }); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"UnivariateAnalysisPropertiesControl.EstimateButton_Click: {ex}"); + GenericControls.MessageBox.Show("An unexpected error occurred. Please verify your analysis inputs and settings." + Environment.NewLine + "If the issue persists, contact the Risk Management Center.", "Error!", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + progressCleanupEnabled = true; + progressReporter.IndicateTaskEnded(); + } + + + } + + /// + /// Handles the Cancel button click event. Cancels the running analysis. + /// + /// The event sender. + /// The event arguments. + private void CancelButton_Click(object sender, RoutedEventArgs e) + { + Element.CancelAnalysis(); + } + + /// + /// Handles key down events for the control. Cancels the analysis if Escape is pressed. + /// + /// The event sender. + /// The key event arguments. + private void UnivariateAnalysisProperties_KeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + Element.CancelAnalysis(); + } + + /// + /// Handles the Checked event for the IsNonstationary checkbox. Updates time index constraints when nonstationary analysis is enabled. + /// + /// The event sender. + /// The event arguments. + private void IsNonstationary_Checked(object sender, RoutedEventArgs e) + { + UpdateTimeIndexConstraints(); + } + + /// + /// Handles the Unchecked event for the IsNonstationary checkbox. + /// + /// The event sender. + /// The event arguments. + private void IsNonstationary_Unchecked(object sender, RoutedEventArgs e) + { + //TrendModelDataGrid.ItemsSource = null; + //TrendModelDataGrid.UpdateLayout(); + } + + /// + /// Updates the minimum and maximum constraints for the parameter time index based on the input data time series range. + /// + private void UpdateTimeIndexConstraints() + { + if (Element != null && Element.UnivariateDistribution.IsNonstationary == true && + Element.InputData != null && + Element.InputData.DataFrame != null && + Element.InputData.DataFrame.FullTimeSeries.Count > 0) + { + ParameterTimeStep.MinValue = Element.InputData.DataFrame.FullTimeSeries.First().Index; + ParameterTimeStep.MaxValue = Element.InputData.DataFrame.FullTimeSeries.Last().Index + 100; + } + } + + /// + /// Binds the trend model data grid with the current distribution's parameter trend models. + /// Sets up column headers with tooltips and populates the grid with trend model row items. + /// + private void BindTrendModelDataGrid() + { + _suppressTrendModelSelection = true; + try + { + TrendModelDataGrid.ItemsSource = null; + if (Element == null || Element.UnivariateDistribution == null) return; + + _trendModelRowItems = new List(); + for (int i = 0; i < Element.UnivariateDistribution.TrendModels.Count; i++) + { + _trendModelRowItems.Add(new TrendModelRowItem(Element.UnivariateDistribution.TrendModels[i].OwnerName, Element.UnivariateDistribution.TrendModels[i].Type)); + } + + // Parameter Column Header + var style = new Style(typeof(DataGridColumnHeader), ParameterNameColumn.HeaderStyle); + style.Setters.Add(new Setter(ToolTipProperty, new TextBlock() { Text = "The model parameter names.", FontWeight = System.Windows.FontWeights.Normal, TextAlignment = TextAlignment.Left, TextWrapping = TextWrapping.Wrap })); + ParameterNameColumn.HeaderStyle = style; + + // Distribution Column Header + style = new Style(typeof(DataGridColumnHeader), TrendModelColumn.HeaderStyle); + style.Setters.Add(new Setter(ToolTipProperty, new TextBlock() { Text = "Set the parameter trend model type.", FontWeight = System.Windows.FontWeights.Normal, TextAlignment = TextAlignment.Left, TextWrapping = TextWrapping.Wrap })); + TrendModelColumn.HeaderStyle = style; + + TrendModelDataGrid.ItemsSource = _trendModelRowItems; + TrendModelDataGrid.Items.Refresh(); + } + finally + { + _suppressTrendModelSelection = false; + } + } + + /// + /// Handles selection changes in the trend model combo box. Updates the distribution's trend models when changes are detected. + /// + /// The event sender. + /// The selection changed event arguments. + private void TrendModelComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_suppressTrendModelSelection) return; + if (Element == null || Element.UnivariateDistribution == null || _trendModelRowItems == null) return; + for (int i = 0; i < _trendModelRowItems.Count; i++) + { + if (_trendModelRowItems[i].Value != Element.UnivariateDistribution.TrendModels[i].Type) + { + Element.UnivariateDistribution.SetTrendModel(i, _trendModelRowItems[i].Value); + } + } + } + + } +} diff --git a/src/RMC.BestFit.App/Properties/AssemblyInfo.cs b/src/RMC.BestFit.App/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..10c6a83 --- /dev/null +++ b/src/RMC.BestFit.App/Properties/AssemblyInfo.cs @@ -0,0 +1,58 @@ +using System.Reflection; +using System.Resources; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Windows; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("RMC-BestFit")] +[assembly: AssemblyDescription("The Bayesian estimation and fitting software (RMC-BestFit) was developed to enhance and expedite flood hazard assessments within the Flood Risk Management, Planning, and Dam and Levee Safety communities of practice.")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("U.S. Army Corps of Engineers")] +[assembly: AssemblyProduct("RMC-BestFit")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +//In order to begin building localizable applications, set +//CultureYouAreCodingWith in your .csproj file +//inside a . For example, if you are using US english +//in your source files, set the to en-US. Then uncomment +//the NeutralResourceLanguage attribute below. Update the "en-US" in +//the line below to match the UICulture setting in the project file. + +//[assembly: NeutralResourcesLanguage("en-US", UltimateResourceFallbackLocation.Satellite)] + + +[assembly: ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] + + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("2.0.0.0")] +[assembly: AssemblyFileVersion("2.0.0.0")] +[assembly: AssemblyInformationalVersion("2.0.0")] +[assembly: SupportedOSPlatform("windows")] +[assembly: InternalsVisibleTo("RMC.BestFit.App.Tests")] diff --git a/src/RMC.BestFit.App/Properties/Resources.Designer.cs b/src/RMC.BestFit.App/Properties/Resources.Designer.cs new file mode 100644 index 0000000..b21a888 --- /dev/null +++ b/src/RMC.BestFit.App/Properties/Resources.Designer.cs @@ -0,0 +1,64 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace RMC_BestFit.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("RMC_BestFit.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + } +} diff --git a/src/RMC.BestFit.App/Properties/Resources.resx b/src/RMC.BestFit.App/Properties/Resources.resx new file mode 100644 index 0000000..1af7de1 --- /dev/null +++ b/src/RMC.BestFit.App/Properties/Resources.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/src/RMC.BestFit.App/Properties/Settings.Designer.cs b/src/RMC.BestFit.App/Properties/Settings.Designer.cs new file mode 100644 index 0000000..3ea37c2 --- /dev/null +++ b/src/RMC.BestFit.App/Properties/Settings.Designer.cs @@ -0,0 +1,26 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace RMC_BestFit.Properties { + + + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "17.9.0.0")] + internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { + + private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); + + public static Settings Default { + get { + return defaultInstance; + } + } + } +} diff --git a/src/RMC.BestFit.App/Properties/Settings.settings b/src/RMC.BestFit.App/Properties/Settings.settings new file mode 100644 index 0000000..033d7a5 --- /dev/null +++ b/src/RMC.BestFit.App/Properties/Settings.settings @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/RMC.BestFit.App/RMC.BestFit.App.csproj b/src/RMC.BestFit.App/RMC.BestFit.App.csproj new file mode 100644 index 0000000..5b8ac9b --- /dev/null +++ b/src/RMC.BestFit.App/RMC.BestFit.App.csproj @@ -0,0 +1,71 @@ + + + + net10.0-windows + WinExe + RMC_BestFit + RMC-BestFit + true + BestFit_Icon.ico + RMC_BestFit.App + disable + disable + false + en + + $(NoWarn);CS0618 + + + + + + $(HecDssAssemblyPath) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RMC.BestFit.App/Resources/BestFitStyles.xaml b/src/RMC.BestFit.App/Resources/BestFitStyles.xaml new file mode 100644 index 0000000..128d25d --- /dev/null +++ b/src/RMC.BestFit.App/Resources/BestFitStyles.xaml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + +