diff --git a/images/res1d_network_mapping.png b/images/res1d_network_mapping.png new file mode 100644 index 000000000..2930e9914 Binary files /dev/null and b/images/res1d_network_mapping.png differ diff --git a/notebooks/Collection_systems_network.ipynb b/notebooks/Collection_systems_network.ipynb new file mode 100644 index 000000000..864f1211d --- /dev/null +++ b/notebooks/Collection_systems_network.ipynb @@ -0,0 +1,1539 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 2, + "id": "fdb0d0b9", + "metadata": {}, + "outputs": [], + "source": [ + "import modelskill as ms\n", + "import pandas as pd\n", + "import numpy as np\n", + "\n", + "import networkx as nx\n", + "import matplotlib.pyplot as plt\n", + "\n", + "from modelskill.model.network import Network" + ] + }, + { + "cell_type": "markdown", + "id": "b643e568", + "metadata": {}, + "source": [ + "# 1D network workflow\n", + "\n", + "This notebook shows how to use `modelskill` to evaluate model results from 1D network simulations, such as collection systems or river networks. The workflow follows the same four-step pattern used elsewhere in `modelskill`: define model results → define observations → match → compare.\n", + "\n", + "## Loading network results\n", + "\n", + "The `Network` class organises data from a network simulation (e.g. a sewer system or a river) into a form that `modelskill` can work with. It stores time-series data for every node and break point in the network, and exposes the topology via a `networkx` graph.\n", + "\n", + "### Loading from a supported format\n", + "\n", + "The easiest way to create a `Network` is to load it directly from a supported file format. Currently **Res1D** (MIKE 1D) is supported:\n", + "\n", + "```python\n", + "from modelskill.model.network import Network\n", + "\n", + "network = Network.from_res1d(\"path/to/results.res1d\")\n", + "``` \n", + "\n", + "### Custom network format\n", + "\n", + "For other simulation tools you can build a `Network` from your own data by subclassing the abstract base classes `NetworkNode` and `NetworkEdge`. Notice that this approach requires that you build the logic to generate a list of `NetworkEdge` to pass it to the network.\n", + "\n", + "```python\n", + "from modelskill.model.network import Network, NetworkNode, NetworkEdge\n", + "\n", + "class MyNode(NetworkNode): ...\n", + "\n", + "class MyEdge(NetworkEdge): ...\n", + "\n", + "\n", + "def generate_list_of_edges(a_network: CustomNetwork) -> list[MyEdge]: ...\n", + "\n", + "\n", + "edges = generate_list_of_edges(custom_network)\n", + "\n", + "network = Network(edges)\n", + "``` \n", + "\n", + "#### Break points\n", + "\n", + "Edges can optionally contain **break points** — intermediate locations along a reach (e.g. cross-section chainages) that carry their own time-series data. You can include them with subclass `EdgeBreakPoint`.\n", + "\n", + "### Example" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "1d61af88", + "metadata": {}, + "outputs": [], + "source": [ + "network = Network.from_res1d(\"../tests/testdata/network.res1d\")" + ] + }, + { + "cell_type": "markdown", + "id": "33a451d3", + "metadata": {}, + "source": [ + "All time-series data stored in the network can be accessed as an `xarray.Dataset` with `to_dataset()`. The dataset has one variable per physical quantity and uses the network's integer node IDs as the `node` coordinate:" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "2a2d7414", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
<xarray.Dataset> Size: 231kB\n",
+              "Dimensions:     (time: 110, node: 259)\n",
+              "Coordinates:\n",
+              "  * time        (time) datetime64[ns] 880B 1994-08-07T16:35:00 ... 1994-08-07...\n",
+              "  * node        (node) int64 2kB 0 1 2 3 4 5 6 7 ... 252 253 254 255 256 257 258\n",
+              "Data variables:\n",
+              "    WaterLevel  (time, node) float32 114kB 195.4 194.7 nan ... 188.5 nan nan\n",
+              "    Discharge   (time, node) float32 114kB nan nan 5.72e-06 ... nan 0.01692 0.0
" + ], + "text/plain": [ + " Size: 231kB\n", + "Dimensions: (time: 110, node: 259)\n", + "Coordinates:\n", + " * time (time) datetime64[ns] 880B 1994-08-07T16:35:00 ... 1994-08-07...\n", + " * node (node) int64 2kB 0 1 2 3 4 5 6 7 ... 252 253 254 255 256 257 258\n", + "Data variables:\n", + " WaterLevel (time, node) float32 114kB 195.4 194.7 nan ... 188.5 nan nan\n", + " Discharge (time, node) float32 114kB nan nan 5.72e-06 ... nan 0.01692 0.0" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "network.to_dataset()" + ] + }, + { + "cell_type": "markdown", + "id": "a972271a", + "metadata": {}, + "source": [ + "`Network` also exposes the underlying `networkx.Graph` via the `graph` property. This graph contains the full network topology — each graph node stores the node's data and boundary metadata — making it straightforward to run graph-based analyses (shortest path, connectivity checks, etc.):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "06e8c2cb", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "network.graph" + ] + }, + { + "cell_type": "markdown", + "id": "885523e1", + "metadata": {}, + "source": [ + "Querying the underlying `networkx` graph lets you run topology-based analyses (e.g. shortest path, connected components) directly on the network. The visualisation below plots the graph, highlighting boundary/junction nodes with a thicker outline:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "1d068e44", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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nnvucQUKFHBDhgxxx44dc4cOHXJLly5169atcz/++KMbOXKk+/XXX12FChXc8OHDE+Q5CCGEEEIIkVRQpVuIFMa///7rypcvb9Vu2snz5s1r148ZM8Z16tTJde3a1YR16tSpo93HE0884QYOHOiGDh3qevbseRGfgRBCCCGEEEkHiW4hUiCHDx+21nBcyOfOnWvZ3eXKlXMPPfSQGz169Dlt51HRr18/98orr7hly5a5KlWqxPtxCyGEEEIIkdSQ6BYihUKr+F133WWt5jly5HDp0qUz07ToKtyRVbxvu+02lydPHhPvQgghhBBCiIhopluIFErOnDmtvfzVV191P/30k7WVhwvuKVOmuCxZstjlRo0aucqVK7uyZcsG58FxPOd+8+fPd/v27UuQ5yGEEEIIIURiRqJbiBRM2rRpLRosTZo0rk2bNhFu++eff9yMGTNc7ty5gwJ8xYoVbtq0aW7AgAHB7Zo3b+4yZMjgxo0bd9GPXwghhBBCiMSORLcQKRxayosVK+auueaaCNcjsps0aWLVbLj88svtO5ndN998c3C79OnTW9TYt99+e5GPXAghhBBCiMSPRLcQKRxENJXq8Cr39OnTXdOmTSNcX6lSJVejRg1Xt27dCNdnzJjR/fbbbxfleIUQQgghhEhKSHQLkcK58sor3enTpyNcN2nSJHffffcFq9weZrnJ6+7Tp0+E60+dOmX7EUIIIYQQQkREoluIFE6BAgXcli1bIlSqt23b5iZOnOhq167tdu3a5R555BGLFwPEdajAPnPmjNu0aZPtRwghhBBCCBERRYYJkcL54YcfXN68ed2oUaNcly5dzrmdee3333/f1axZ00zXEN/MdHN927Zt3ddff+1at25tM92FChVKkOcghBBCCCFEYkWiWwjhGjdu7Hbs2GEV70suuSR4RnArHzJkiFuwYIHNbZcrV86+045OdfvQoUPuqquuMtFO3nfofYUQQgghhBBqLxdCOOceffRRt3XrVvfUU08FzweV76pVq7qDBw+6MWPGWEV88eLFZrCGCN+/f7+bOXOmK1GihNu8ebPtAwM2IYQQQgghxP+hSrcQwhg8eLB7/PHHXf/+/V22bNlsjrtXr15u0KBB5xiqhfPmm2+6rl27Wns6Yl0VbyGEEEIIIf7Hpf//uxAihdO7d28XCATMmRzR3L17d2stjw2dO3e2+/D9jjvucPfff3+8H68QQgghhBBJAVW6hRARqFWrltu+fbvbu3evS506dZzv++uvv7o1a9borAohhBBCCKGZbiFEKMePHzfzNFrFwwX3lClTXJYsWYI/M9OdJk0a98033wSvo7187dq1buPGjTqxQgghhBBCSHQLIUKZPHmytZg/+OCDEa7HIG3GjBkud+7cweuY9aaVPJS77rrLtnnnnXd0YoUQQgghhJDoFkKEsmfPHlewYMEIFW1f5W7SpEnQUI3Wc2a48+TJE2G7Sy+91JUuXdr2I4QQQgghhFBkmBAihFOnTrn06dOfU+UmJqxp06bB6wYOHGjGa5Fx5ZVX2n6EEEIIIYQQEt1CiBAyZMjgTp48GeGcTJo0yd13333BKvfu3bvte758+SI9d7/99pvLmDGjzqsQQgghhBAS3UIkT3AQp8Ubgfzzzz/bnHZsKFq0qNu1a5c7ePBg8Lpt27a5iRMnutq1a9tt9957r9u6dav9/PHHH1tM2JkzZ2zbP//8033xxReuSJEi8fbchBBCCCGESEooMkyIZMLp06fNCO311193X331VYTbEMEPPfSQa926tbv66quj3AdV6ly5crmePXu655577pzbS5Uq5TZs2BD8uU2bNtZmfvPNNwdnv5s3b26i/MYbb7ygz08IIYQQQoikiES3EEmcf//917300ktu8ODBJpqj44orrjDx/corr7jLLrss0m1atWrlFi9e7L7//nuLBIstVNMrVKhg91m6dGmcn4cQQgghhBDJkUsT+gCEEOfP2bNnXcuWLd20adMiXH/rrbdadRuH8X379rnVq1fb9X/88YcbNmyY27Jli5s1a5aZnrGNh+sR3MeOHTPjtHLlypn4/v33321Ou1ixYu7++++3+4XzwgsvWGv5ggUL9JIKIYQQQgjx/1GlW4gkis/Tfvfdd+1njM5oH+/SpYu1gYfCXDZt52PGjDGhTrQXruSpU6d2mTJlcvfcc49r0KCB7e/w4cNBIZ42bVpXoEABczSnir5z504T3DxOt27dLF6M43j22WfdgAEDrCX96aefTpDzIYQQQgghRGJEoluIJMr777/vHnjgAbtMS/fMmTPd3XffHem2GJ4xp/3NN9+4/PnzWwt59uzZrTX9wIED7r333nM//vijiW1ENFVyRDX7D3Ui379/vwn3t99+26rfVNk//fRTM1ij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Le7MM890v/zyi8ubN2/MafkvZfz48e6VV15xn332mcuePbvNmyFDBrdjxw63evVqly1bNvfAAw+4xx57LNHlCAH//fefO+OMM+wRD5s3b3YjRoxw33//vfvtt99cxowZXf78+d19993nihQpcsj0//77rxszZowbMmSIW7BggcuRI4ddr2eddZbbtm2b+/nnn91FF13kHnroIXtkzZrV5mPZ999/v/voo4/sem7atKmrXLmyO++889z+/fvdokWL3LBhw9ymTZtclSpV3DvvvOPSpUvnatSo4b744osE23D55Ze7XLlyub/++su2e/fu3ZHP0qRJ4wYNGuRatmx5yHeybdu2bujQofbduvfee92dd95p2/Lnn3+6VatW2fq/+uor2/93333XlS9fPsEyWN+DDz7oRo0aFdexTZs2rfvnn3/c+eefb99bf4w2btxox4317tu3L8llFC5c2LVq1cp+C95++223YcMG9/fff7tMmTK5K664wj388MN2jGbMmGHnZPr06Ycso0CBAjYdx5/fFw/bxrHbu3evnYcsWbLYNsf6jfrpp5/c9u3bbZvZn0suucRlzpzZpVbeeOMNO9d9+/Z1PXr0sGvixx9/dHny5LHjzrng87vvvtvdcccddnyXL19ux+f333+388R3heserr76arvGhRDxI9EthBAiScaNG+fq1atnz++66y73wQcfmPAuW7asK1asmL3PjXujRo3sOTdpiILRo0eboEFkzJkzJ3KzhhhHKAhxMrF06VIbMEJIIuAQbZUqVXK33XabDTbFAuHy6quvujfffNPt2bMn8v65555rQhixXLJkycj7f/zxh+vSpYt7/fXXkxWopUqVcr169XLVq1dPdBoEL8tCYDPg4GGQgvlY/y233JLo9gODDYj39957zwRwNLlz53YtWrRwzZo1M1HLMUKw+mPEYEPVqlVNlE2YMMGWhSDjdwAQszfeeKNtCwMQ4QEUBDADK4lt36+//mrHmGN7zjnnuJw5c7orr7zSBiNE/HAuLrzwQhPTL774ol0rDDacffbZ7sknn3RXXXWVq1OnjnvkkUfcypUr3cCBA13x4sVtAKRdu3Z23hiYYKDlu+++s2V+8803rkSJEjoNQsSJRLcQQgi7YZ4yZYoJCG6qiBZxk3vxxRfbTfL8+fPtKBF5uummm0x0czOGAI/mrbfespvl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0XgGEXCWxoma+tx169a5QoUKmWhv1qyZbUs0q1evthpetpv9jgUu1iyDQYNYyxCpC9LGv/32W+u/zfUaixIlSriWLVta3++zzz77uG+jEEIIIYQ4dZDoPoYQMa1Xr15c4hWhSISWlGqiz0RZueFHNA8dOtStXbvWpkGkkn5NdDxz5swWoSXllXRzD8Lwo48+sunvvPNOe41AQDQiTMMsXbrUlo97M+KfVkgI5nB0+4orrjBjqZtuusmizaSDf/zxxyZ2f/jhB5uGaDIDBF27djWBG6tfMfMhbrt06WLb6wU3yyRVnWg9UXD2m+0iqv/ZZ59F5s+ZM6ebP3++CW+WhdCnj3L16tVt/4hKs8+A8GdatnHcuHEmnBHtRNw9DALcf//9Jt7jgd7O9G9GyMeC/WEdRPk5Lj79n/k4JjVr1kwyzV4cf7hOwlkMXG8MTNEmjL8M6nz//feWHYK45vtDG7EKFSoo+0EIIYQQQsRHII4JO3bsCIoXLx5wiP0jV65cQZcuXYJx48YFEyZMCAYPHhzkzJnTPnvssceCtWvXxlzWv//+G3z66afBFVdcEaRPn97mD7N79+5gwIABwaWXXhpZF9NlyJAhqFq1arBnz55kt/err76ybcmSJUtkGVmzZg2mT58e/PfffzHn4f0xY8YEZ511VnDuuecG8+fPj+vYfPPNN0G2bNmCM888M3jkkUeCvXv3Jjrt8uXLg8svvzyyTYULFw62bt0alCtXLjj//POD2bNnJ7u+lStXBvnz5w8uvvjiYNOmTfbea6+9luDcnH322UGzZs2Czz77LPjxxx9tnvfeey+44YYbEkyXPXv2YNWqVYccB45/vnz5bJqyZcsGTzzxRNCtW7egffv2QYUKFSLn/4UXXgj++eefJLeX5c2aNSuoW7dukCdPHtu2c845J7jooouCe++9145zYudEJM327duD3r17B0WLFrVr9owzzrDr6LrrrgtGjx4dXH311cFVV12l4yuEEEIIIY4aEt3HgL/++isitLxQGzt2bPD3338nmA4BzudvvPFGXMvdv39/UK1atSBjxozBsmXLDvn8wIEDwR133GHLRNBec801we+//x73diOGESLMnzlz5mDFihXJzsO2I1xmzJgRpIQvv/wySJcuXdCzZ8+4BjDCwpv9QoQuXrw47vWtW7fOBGz58uWDadOmBWnSpIks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Om3GWrdu7caPH+9Klizp3n777WO+b0IIIYQQQiSGIt1CiJMG35cbEQ3UZderV8+cyq+55ho3adIkczGvUaOGGz16tEW0E4O2YtSKk3rOg1R2IYQQQgghjjcS3UKIkwoi19Rl//zzz4c4o5OiTkT8/ffft37iyUENOcKbmnLczxH0QgghhBBCHE8kuoUQJx30Au/Vq1ekZtuTI0cO9+uvv7qzzz477mX9888/1sLssssus5RzIYQQQgghjieq6RZCnHRgoNanTx9r/YX7+Y033ujSpk1raeakjV999dVWH/7tt99aCzKcz2lJxl9EOdB2jPd4MP/EiRNteUIIIYQQQhxPJLqFECctRLSbNGni6tSpY6niDz74oLmfT5kyxd7zaefUd8+dO9d17NjRxDp07tzZzNY++eQTt3jxYlsWr4UQQgghhDiepD2uaxNCiMNgxYoVrlixYi5PnjyRNHNPhgwZXO7cue15unTpIu7oOJkXKlQoMn2ZMmVsOUIIIYQQQhxPJLqFECc9v/32m8ucOXOS05B23q1bN3MqB0zXPMxLbfeePXuO+bYKIYQQQggRRunlQoiTHlLKf//99ySnad68udV8++h2uB84oh3RnVSLMSGEEEIIIY4FinQLcRqydu1a99lnn5lLOBHhrFmzWjutokWLupORiy++2I0dO9bt37/fDNSiefbZZ13BggVd/fr1I+/lypXLrVmzxl1wwQW2n+vXr3d33nnncd5yIYQQQghxuqOWYUKcJvz7779mKjZkyBD3v//9z4zJosHpm2hxrVq1rD76ZAFHckT10KFDLaJ96623umXLlpkY5zmiu2LFijYtgwc9e/Z033//vWvRooXtd7Vq1Sz1/Ouvv3alS5c+0bsjhBBCCCFOIyS6hTgN2Lx5s0V5cfGOB1K0J0+e7IoUKeJOFmrWrOl++eUXE9tnnHFGiuZFdJNivnDhwmO2fUIIIYQQQsRCNd1CpHLWrVtn0d+w4M6XL5/r3r27e++999z777/vevfu7S677LLI5z/++KO79tpr3fLly93JQqtWrdw333zj+vbtm6L5Ro4c6aZPn+7q1at3zLZNCCGEEEKIxFCkW4hUzN69e01wk2rtxfagQYPcbbfd5s4888zIdKRgM83MmTPdwIEDreYbaMWFWPetuk40Xbp0cT169HAvv/yya9OmTbLTjxo1yt1///22f5kyZXITJkxwVapUOS7bKoQQQgghBEh0C5GKoY6ZemefMo55GgZjnm3btrk33njDvfrqqxYRD5M2bVpz/L777rvdO++8404GqEPv2LGj69Onj7vxxhtd69atraY7PIDANAweULv+0UcfWW067cSA5zNmzHDXXXfdCdwLIYQQQghxOiHRLUQq5eDBg2Y0Rj037bNWrlzpChcuHBGmL774onvmmWdMsDZs2NA1atTIItp8tmHDBkvLxjGcKDHC/amnnkpxLfWRsm/fPvfuu++6r776ymqy06dPb9s2Z84c22627aKLLjIDuPPOO8/czRctWuRWr17tihUr5h5//HFXt25dd88997iPP/7YlpklSxaL6l944YXHdV+EEEIIIcTpiUS3EKmUDz74wAQn4Eb+4Ycf2vM9e/aYQRpR7qZNm1ptN6IU6IWNWF+6dKm93rFjhzmBv/TSS65du3YWYT4SWC5Rdd+uDGhXRuT54YcfdldddZW9R6svUsjffvttd+DAAXfllVfadH/++af77rvv3K5du0x00yKMHt4//PCDpdKTQo7L+QMPPGDL9IME7NMdd9zhpk2bZq+ffvpp228hhBBCCCGONRLdQqRS7rrrrojQ/vTTT13VqlXtea9evdyTTz7pypUr515//XVXvHjxyDxvvfWWtefq2rVrgmVR500q94ABA8zQzEP6+aRJk9zo0aPdxo0bTRRnzpzZxPNDDz0UcT+fMmWKe/7555N1Dy9btqzVmyPyzz77bPfggw9aizCi2R4E9MSJE602fe7cue6+++5zr732WrItztg+Iv9Ex4lys58nU1s0IYQQQgiROpHoFuIk5eeffzaRunv3bksPz5Ytm6VRJ5UWTbSXVl+bNm0yoUwqNlDTfNZZZ9mySCFHEBMtJnodFt0IXtzBL7/88kOW/cgjj1jKOenqiFWi3kStEbMIeNK5EcosF5G/fft2q7vOnz+/1Y2HIU3c15Zv2bLFxLqHfb3pppvcuHHjLHKdGKSb03Ob43TLLbdY+jgCnB7kDBo8+uijNt3s2bNd586drUadZSPUAed2OZoLIYQQQohjTiCEOGk4ePBgMGHChODmm28O+HpGP9KmTRvUq1cvmDNnTvDff/9F5luxYkXw8MMPBxkzZrTpMmfOHGTLli0466yz7HWpUqWC1157LejVq5e9t3nz5qBJkyY2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hBBCCCFONEovFyKV4x3IIWPGjAk+I2KdIUMGM0hDYFOrvXr1avf777+bkRqGaoMHD3ZXX321PagH91DzjTkb7cVwASdanhy07qIunGUXL17c+oCT2k46++zZsy16nRSktl9yySXW5iuW4Pau5TxwX2cwgYi8EEIIIYQQJwpFuoVI5eBW7okWoCNHjrTPiRxnz57dRDBu32effbalmBPBJqI8d+5cN2XKFEtBX79+vcuSJYu5l5Ou/t5777lNmza5KlWquAULFrhChQodsg3//vuv69u3r6Wvb9y40ZUrV86mp13Z3r17TXDzmhZfHTp0sCh1tPkZ/bapR//rr78i71WoUMF6bBNlZ4Bg+fLlbvjw4bY9MGPGDFexYkX32WefuWzZsh2DoyuEEEIIIUTSSHQLkcpBSHsmT57sSpcubc8RqQhoosZFihSJRK8RtT5ajEAmUk4a+R9//GFC+KmnnrJIOM9xRffs3LnTItgrVqyw+moP8zVo0MBE+/333+9atmzprrzyygTbSIQbYYwoZxrqtF9++eVIqviyZcusPtsLbtLk+/Xr50qWLJlgObiyd+nSxU2YMMH2izZoRMQR6zNnzjQzOCGEEEIIIY4nahkmxDEAEUm9NEIUqJfOmzdvRETGAhFMJBrBSvQ5qWlTAsI6f/78JqKJCFO3zTqIcuNAXrhwYavvJmqNuO7YsaPVTTPfrl273Pnnn2/zkob+5ptvWu/rAwcOWISb1HNS0omMI65h3LhxEQMzItx169Z106ZNs/ep/06OV1991YQ5Ee9evXrZezfccIObM2eOPSelfcyYMQmEfSx++uknd91117ktW7bY69dff91aoQkhhBBCCHE8kegW4ihCbTS1xAjHn3/+OcFn+fLlcw899JB74IEH3AUXXGDv/fnnn+7999+3FG76SXvSp09vwhXxSUuvI+0zXatWLYv+wmuvveaaN29uzzNlymStwdq1axeZll7dl112mYnVFi1aWOr5FVdcYVFw9okBAbaHaDcDBb1793b16tUzoY7IRiD7FmU4oT/66KPWWuz222+Pe3uJcrdt29bSwxkQKFasWKTlGZF0BgDigRpwhDewDwwQqGe3EEIIIYQ4nkh0C3EUQHwSIUY8h2uOY0GE9sEHH7QI83PPPWfR8JtuuslENmKcZRGlfeONN9yaNWvMaIwIs08LPxxIrfbO5awfMzMi1pUqVTIR6qPIGJ2VKFHCenszeEA99N13320DAp06dbIIM5FyovBvvfWW+/XXX0200xebwQGEOFHxlStXWso6YpkHUe6UZgqw35imkSGAUzr079/ftW7dOjId29OqVStLIyezgEEKxD/15s8++6xNc80111jvcaDmnHpyIYQQQgghjhcS3UIcIQhNIsnTp09P8D4RXyKziFrEM9HfaHdu0p3bt29vAjwaxOunn37qOnfu7H744QeLVIdbfqUE1kuUHbdyQDST9k1tNWIa0Yw4RugzIEB0GUM0Us4R1hiqkW5OtJpIOSDaMUfD/AwQ8tRUA8spWLCgHYNJkybZ4ML333/vFi5caEL6lltuiRw7Bhmo4SayTs9wUtdZL7Xk1GWT3k6a+znnnGM15rwOp64zCEAEGwd0tie6x7hPowcEO8JdCCGEEEKI40YghDhs/vnnn6BmzZooaXukT58+ePzxx4PVq1cfMu2aNWuCDh06BGnTprVpBw4cGNc69u/fH1SvXj3ImDFj8M477wQPPPBAULRo0eDCCy8McufOHZQoUSJo1apVsHLlypjz79y5M+jbt29wySWXRLbTP9KkSROceeaZwZ133mnTPvvss/b+119/HZQtWzY499xzgxUrVgSrVq0KMmXKFFx//fX2PussXbp08P777weFCxcOrrrqquDff/8NLrroIpuf9d1///322V9//RVs27YtaNKkiS0rzIgRI4Ju3brZ8zZt2gSffvppcPDgwaBcuXI2D+v323rttdcmmHfUqFHBu+++a+v++++/bZ769esHN9xwQ/DFF19Eplu7dm1kGXXr1o3rmAshhBBCCHG0kOgW4ggYPHhwRNAhSj///PMkp//1119N6D755JMpWg/Cu3jx4iaQo4Vz+FGlSpVg1qxZCYTp2WefHaRLly645557gjlz5gSbNm0KduzYYUK6R48eQa5cuWxeRDTC/sYbbzQR64XyggULguuuu86m90IZkY5AZzmIaoQvvPjii7Z/3bt3D2666abgrrvuimxLLNF96623Bt9//709R1Qj3OHRRx8Nvvzyy6BIkSKRfatatWqCwY477rjDpmfd7FOGDBns+G7evDkoU6ZMZFr2wy/jtttuS9FxF0IIIYQQ4kg5OvbIQpyGMGg1cODAyOvx48dbDXRSkN6dMWNGSxlPCfSzpkaZlGqg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Nnd7njvHEZlt1tGx/o694QTtrK7j5BKvAffLcVq1atXV+GwlSVJJYf2lpGUi+CXrDAJdguoddtghgmHKbZkoTjabEnIGT/E7grkrr7wyMtB8cbslS3O5XzKKZCnJPBIE0h/+xBNPRGaS3xM8McyrZs2ayd133x0BHgFSFnDzd7icQDILuMl8cpmKVq9evdzr369fv8j88l4RiPI6c4KF0mwGp40bNy7p3LlzXMa0eaoXpk2blsuWV6tWrdi9zJwUYlc7peL77bdflJoTcOc/z2XJStKZig9aGjgR0bRp07hfjnOuw0kL2iEowef4lyRJ+i1muiUtE8OlGDrFYCm+KAknACMwvvDCC5O6desmderUiZJjAhwGm1HyTa/1BhtsEEPWCIo/+OCD3H0SLHN7SskpB6Z0OR8B4fnnn5/cfvvti13esGHDCMwJ8Al2yDxyX5lNN900yqR5TCoaFQW8jqAHmtePEyVZST4nPR566KE4qUIfNGXWvFcMvSPYJnvMMXDHHXfEyjGqG+i5L47I0HMigZNGrETLnicnhzheea4E1VRJcBwyxZ3qCo5bsvpUWPA6sGZsSawqo22CAXSUnzs/QJIkLZdLyyVhwYIF6cSJE9OxY8em48ePT2+++WZaT9LbbrttqRfopJNOSt9///34vnnz5um///3vdPr06ekuu+ySlitXLq1UqVL64IMPpg888EDavn37dOHChem7776btm7dOl1//fXTddddN73vvvuW+cL/+uuvadeuXdMNN9wwHsNvfe2xxx7p5MmTfSNXQMOGDXOvG+9R5tprr43Lrrzyynj9l2fRokXxXnL9e+65p9i+7gcccEBap06ddO7cuSt1u+HDh8dzGzRo0G9e59lnn10Fj1SSJK3JDLqlUo4Au23btukGG2ywVDBbpkyZCGhvvPHGdObMmUUG3d27d08333zztGrVqumwYcPSRx99dLH7WHvttXPfly1bNr388svTKVOmrNBjmzNnTgR1O+6441KPjeD+jDPOSMeNG1ew12ZN1L9//9xr+Kc//SkdMGBABI78fNVVV63w/RCYt2rVKl1nnXXSt956Ky2OODY4Tlq0aBEnlZZl9OjRaePGjdMmTZqk+++/f7rRRhulVapUSTfeeOM4+ZPp0qVLWqNGjbRly5bx/OvVqxf3LUmStDyWl0ul1FdffRWrtfLLZymTZcAZZbYMyqKUm7VMlIlTVszaMHZgP/jgg1G6y5AqJjn37t07hpwxRZpSXkqVuT0/n3nmmbGj+/cMOeMEIeXMDE1jwBWPj9Jeyn+18q8l71V+7zsDwejRpi1gRQfhgYngtBOwfoxjojhi4Bn92PRgMwiNtWdLoledoWlMzGc/N68H3/N65A9bY0o+Jfm0XdDz3bNnzyhXp9y8evXqq+HZSZKkksBBalIp9OGHHyaNGzdeql+1UaNGSf/+/WNQFL2qzzzzTAQU9GqzLoqe6VtvvTUmiROI3HTTTdH/ytTx++67L9Z5MRiNQOTZZ59NDjnkkBhClcn6alcWgSD3zURtgif6yA24k//6tWQiNydcMrzfrHtjJRZD1NhpzSTwgQMHxmV77713DLXLVoUxUI8BYy1btowTLI888khMNS+OONHDADSOeY5bngvPiwFxHNv0d3PscpKIgWscZ506dcpN7M/HkDam9WeYiM5JKl5P7k+SJKlIy82DSyoxZs2ald5+++1RHktvdaNGjdJ//vOf6d13353Onj07d70vv/wyrVatWq7EuHz58lECzvWWJSsnf+WVV6Jcl9ttu+22UVbeuXPnuA96hSk1p8f7zTffjOtQmrvlllvm/tZnn332B70a+i2UR3O88N5VqFAh+vnnzZsXv+vQoUM6cODAKLmmLHvkyJHpmWeeGb+76aab0iFDhuTu59tvv402hFtvvbVYv+jz589P+/btm+62225LtSr8+c9/Tk8//fR06NCh8ZyZQYBevXotVl6OSZMmRXk56BXPvx/K0x966KHllrJLkqTSx0y3VMJNmTIlJkizLouJ4mQyySiTDWbiNKXEm222WUyiJqNJljLLWpLVmzt3buwv5vLfQqbwb3/7W2SwyRJSXnzRRRdFVpBSXLKeG264YW6aM1nESZMmxff7779/TIhW8cBx0q5du2SfffaJSdys2aJqAeutt16sCOP95nuqC2gVANUPI0eOjHVwTKDfeOON4/2fMGFCUpyVKVMmOe6446KCg8nmL7/8cmSt+cxQXn7LLbfEijT2d+dXZyzPTz/9tNjPr776alQJUHLPsS9JkgSDbqkEYz81K7noW2XvNX3P9KLee++9UTJLOThB7znnnBOroFi5xXWzFVvlypWLAPrUU09dob9HgEXpOL3VWSCD/JVJrFrifnH//ffnLifAU/HD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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "widthmap = [2 if 'boundary' in network.graph.nodes[node] else 1 for node in network.graph.nodes()]\n", + "plot_kwargs = {\n", + " \"font_size\": 6,\n", + " \"node_size\": 150,\n", + " \"node_color\": \"white\",\n", + " \"edgecolors\": \"black\",\n", + " \"linewidths\": widthmap,\n", + " \"with_labels\": True,\n", + "}\n", + "fig, ax = plt.subplots(1, 1, sharey=True, layout=\"tight\", figsize=(10, 9))\n", + "pos = nx.kamada_kawai_layout(network.graph, weight=\"length\")\n", + "nx.draw(network.graph, ax=ax, pos=pos, **plot_kwargs)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "5d3030f5", + "metadata": {}, + "source": [ + "Notice that in the visualisation above, some graph nodes have a thicker outline — these are the *nodes* (junctions and boundaries), while the others are *break points* along each reach.\n", + "\n", + "#### Mapping original IDs to integer IDs\n", + "\n", + "Internally, the `Network` relabels all nodes and break points with consecutive integers for efficient indexing. The original IDs used by the simulation tool are preserved and can be looked up with `find()`:\n", + "\n", + "In this Res1D example the original node IDs are strings. `find(node=...)` returns the corresponding integer ID:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "d9d23a8b", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "252" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "network.find(node=\"98\")" + ] + }, + { + "cell_type": "markdown", + "id": "ae495c5d", + "metadata": {}, + "source": [ + "Break points are identified in the original network by the edge (reach) they belong to and their distance from the start node.\n", + "\n", + "> **Note:** The current `Network` implementation assumes a directed edge, so distance is always measured from the start node." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "30c88717", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "131" + ] + }, + "execution_count": 40, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "network.find(edge=\"44l1\", distance=44.841)" + ] + }, + { + "cell_type": "markdown", + "id": "a7e41a31", + "metadata": {}, + "source": [ + "Multiple IDs can be looked up in a single call. For break points, each distance value corresponds to the edge at the same position in the `edge` list (one-to-one pairing):" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "e25a4ba8", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "([241, 3, 40], [131, 133])" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "network.find(node=[\"92\", \"101\", \"113\"]), network.find(edge=[\"44l1\", \"45l1\"], distance=[44.841, 37.206])" + ] + }, + { + "cell_type": "markdown", + "id": "c4f36cfd", + "metadata": {}, + "source": [ + "Use `recall()` to translate integer IDs back to the original identifiers. This is useful when you want to know which original node or break point corresponds to a given integer ID:" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "cb1ae550", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "({'node': '98'},\n", + " {'edge': '45l1', 'distance': 37.20599457458005},\n", + " [{'node': '98'}, {'edge': '45l1', 'distance': 37.20599457458005}])" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "network.recall(252), network.recall(133), network.recall([252, 133]) " + ] + }, + { + "cell_type": "markdown", + "id": "41ca197f", + "metadata": {}, + "source": [ + "## Integration with `modelskill`\n", + "\n", + "Wrap a `Network` in a `NetworkModelResult` to make it compatible with the standard `modelskill` comparison workflow. The `item` argument selects which quantity to use when more than one is available in the network:" + ] + }, + { + "cell_type": "code", + "execution_count": 46, + "id": "edec2e5a", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + ": WaterLevel" + ] + }, + "execution_count": 46, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "network_model = ms.NetworkModelResult(network, item=\"WaterLevel\")\n", + "network_model" + ] + }, + { + "cell_type": "markdown", + "id": "5905e267", + "metadata": {}, + "source": [ + "To evaluate the model we need observations at network nodes. These are represented by `NodeObservation`, which requires an integer node ID obtained via `Network.find()`.\n", + "\n", + "In this example we create synthetic sensor observations by extracting data from the network dataset and adding random noise to simulate real-world measurement error and timing jitter:" + ] + }, + { + "cell_type": "code", + "execution_count": 47, + "id": "817e1800", + "metadata": {}, + "outputs": [], + "source": [ + "ds = network.to_dataset()\n", + "\n", + "# Script to generate dummy sensor data\n", + "sensor_1 = ds[\"WaterLevel\"].sel(node=30).to_pandas().rename(\"water_level@sens1\")\n", + "sensor_2 = ds[\"WaterLevel\"].sel(node=54).to_pandas().rename(\"water_level@sens2\")\n", + "sensor_3 = ds[\"WaterLevel\"].sel(node=71).to_pandas().rename(\"water_level@sens3\")\n", + "\n", + "perfect_sensors = [sensor_1, sensor_2, sensor_3]\n", + "real_sensors = []\n", + "\n", + "for n, sensor in enumerate(perfect_sensors, start=1):\n", + " sensor += np.random.normal(0, sensor.mean() / 2, len(sensor))\n", + " sensor.index = [sensor.index[i] + pd.Timedelta(s, unit=\"s\") for i, s in enumerate(np.random.uniform(-10, 10, len(sensor)))]\n", + " sensor.sort_index(inplace=True)\n", + " if n == 2:\n", + " sensor = sensor.iloc[30:]\n", + " if n == 3:\n", + " sensor = pd.concat([sensor.iloc[:50], sensor.iloc[70:]])\n", + "\n", + " real_sensors.append(sensor)\n", + " # sensor.to_csv(f\"../tests/testdata/network_sensor_{n}.csv\")\n", + "\n", + "sensor_1 = real_sensors[0]\n", + "sensor_2 = real_sensors[1]\n", + "sensor_3 = real_sensors[2]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "66d1b420", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
nbiasrmseurmsemaeccsir2
observation
water_level@sens28010.24860590.80570290.22550472.579862-0.0352330.488015-0.013033
\n", + "
" + ], + "text/plain": [ + " n bias rmse urmse mae cc \\\n", + "observation \n", + "water_level@sens2 80 10.248605 90.805702 90.225504 72.579862 -0.035233 \n", + "\n", + " si r2 \n", + "observation \n", + "water_level@sens2 0.488015 -0.013033 " + ] + }, + "execution_count": 49, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# The name is taken from the name of the series\n", + "node_id = network.find(edge=\"117l1\", distance=48.7)\n", + "single_obs = ms.NodeObservation(sensor_2, node=node_id)\n", + "ms.match(single_obs, network_model).skill()" + ] + }, + { + "cell_type": "code", + "execution_count": 50, + "id": "ed1f9094", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
nbiasrmseurmsemaeccsir2
observation
network_sensor_279-12.621078108.474917107.73818282.401059-0.0370010.508059-0.013838
\n", + "
" + ], + "text/plain": [ + " n bias rmse urmse mae cc \\\n", + "observation \n", + "network_sensor_2 79 -12.621078 108.474917 107.738182 82.401059 -0.037001 \n", + "\n", + " si r2 \n", + "observation \n", + "network_sensor_2 0.508059 -0.013838 " + ] + }, + "execution_count": 50, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# The name is taken from the name of the file\n", + "# - The numbers change because the csv contains a subset of the series\n", + "path_to_sensor2 = \"../tests/testdata/network_sensor_2.csv\"\n", + "single_obs = ms.NodeObservation(path_to_sensor2, node=node_id)\n", + "ms.match(single_obs, network_model).skill()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "de621fec", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
nbiasrmseurmsemaeccsir2
observation
Sensor 279-12.621078108.474917107.73818282.401059-0.0370010.508059-0.013838
\n", + "
" + ], + "text/plain": [ + " n bias rmse urmse mae cc \\\n", + "observation \n", + "Sensor 2 79 -12.621078 108.474917 107.738182 82.401059 -0.037001 \n", + "\n", + " si r2 \n", + "observation \n", + "Sensor 2 0.508059 -0.013838 " + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# The name is passed\n", + "single_obs = ms.NodeObservation(path_to_sensor2, node=node_id, name=\"Sensor 2\")\n", + "ms.match(single_obs, network_model).skill()" + ] + }, + { + "cell_type": "markdown", + "id": "02e77cf0", + "metadata": {}, + "source": [ + "## Multiple sensors\n", + "\n", + "When you have observations at several nodes you can use `NodeObservation.from_multiple()` to create a list of `NodeObservation` objects. Pass `nodes` as a `dict` mapping each node ID to either a column name/index within a shared `data` source, or to a separate data source entirely:" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "752261bf", + "metadata": {}, + "outputs": [], + "source": [ + "sensor_df = pd.concat(real_sensors, axis=1)" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "9acfaf6f", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
nbiasrmseurmsemaeccsir2
observation
water_level@sens1109-5.80684294.45126494.27259473.8674380.2768790.468854-0.001276
water_level@sens280-4.368469105.044680104.95380683.1471490.2506620.526098-0.000966
water_level@sens389-5.99201498.28348898.10066172.4349600.1544690.490824-0.002882
\n", + "
" + ], + "text/plain": [ + " n bias rmse urmse mae cc \\\n", + "observation \n", + "water_level@sens1 109 -5.806842 94.451264 94.272594 73.867438 0.276879 \n", + "water_level@sens2 80 -4.368469 105.044680 104.953806 83.147149 0.250662 \n", + "water_level@sens3 89 -5.992014 98.283488 98.100661 72.434960 0.154469 \n", + "\n", + " si r2 \n", + "observation \n", + "water_level@sens1 0.468854 -0.001276 \n", + "water_level@sens2 0.526098 -0.000966 \n", + "water_level@sens3 0.490824 -0.002882 " + ] + }, + "execution_count": 25, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "multi_obs = ms.NodeObservation.from_multiple(data=sensor_df, nodes={30: \"water_level@sens1\", 54: \"water_level@sens2\", 71: \"water_level@sens3\"})\n", + "ms.match(multi_obs, network_model).skill()" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "id": "d7a0acf1", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
nbiasrmseurmsemaeccsir2
observation
water_level@sens1109-5.80684294.45126494.27259473.8674380.2768790.468854-0.001276
network_sensor_279-12.621078108.474917107.73818282.401059-0.0370010.508059-0.013838
water_level@sens389-5.99201498.28348898.10066172.4349600.1544690.490824-0.002882
\n", + "
" + ], + "text/plain": [ + " n bias rmse urmse mae \\\n", + "observation \n", + "water_level@sens1 109 -5.806842 94.451264 94.272594 73.867438 \n", + "network_sensor_2 79 -12.621078 108.474917 107.738182 82.401059 \n", + "water_level@sens3 89 -5.992014 98.283488 98.100661 72.434960 \n", + "\n", + " cc si r2 \n", + "observation \n", + "water_level@sens1 0.276879 0.468854 -0.001276 \n", + "network_sensor_2 -0.037001 0.508059 -0.013838 \n", + "water_level@sens3 0.154469 0.490824 -0.002882 " + ] + }, + "execution_count": 26, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "multi_obs = ms.NodeObservation.from_multiple(nodes={30: sensor_1, 54: path_to_sensor2, 71: sensor_3})\n", + "ms.match(multi_obs, network_model).skill()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "modelskill", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4ba46aacd..94e42d12a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [tool.uv.build-backend] source-exclude = ["**/.mypy_cache**", "**/.mypy_cache/**"] -wheel-exclude = ["**/.mypy_cache**","**/.mypy_cache/**"] +wheel-exclude = ["**/.mypy_cache**", "**/.mypy_cache/**"] [project] name = "modelskill" @@ -17,7 +17,7 @@ dependencies = [ "xarray", "netCDF4", "scipy", - "jinja2", # used for skill.style + "jinja2", # used for skill.style ] authors = [ @@ -42,19 +42,9 @@ classifiers = [ ] [dependency-groups] -dev = [ - "pytest", - "plotly >= 4.5", - "ruff==0.6.2", - "netCDF4", - "dask", -] +dev = ["pytest", "plotly >= 4.5", "ruff==0.6.2", "netCDF4", "dask"] -docs = [ - "quarto-cli==1.5.57", - "quartodoc==0.11.1", - "griffe<2", -] +docs = ["quarto-cli==1.5.57", "quartodoc==0.11.1", "griffe<2"] test = [ "pytest", @@ -68,6 +58,8 @@ test = [ notebooks = ["nbformat", "nbconvert", "jupyter", "plotly", "shapely", "seaborn"] +networks = ["mikeio1d", "networkx"] + [project.urls] "Homepage" = "https://github.com/DHI/modelskill" "Bug Tracker" = "https://github.com/DHI/modelskill/issues" diff --git a/src/modelskill/__init__.py b/src/modelskill/__init__.py index 0b3ce52f2..a6fa90a5f 100644 --- a/src/modelskill/__init__.py +++ b/src/modelskill/__init__.py @@ -38,13 +38,10 @@ TrackModelResult, GridModelResult, DfsuModelResult, + NetworkModelResult, DummyModelResult, ) -from .obs import ( - observation, - PointObservation, - TrackObservation, -) +from .obs import observation, PointObservation, TrackObservation, NodeObservation from .matching import from_matched, match from .configuration import from_config from .settings import options, get_option, set_option, reset_option, load_style @@ -94,9 +91,11 @@ def load(filename: Union[str, Path]) -> Comparer | ComparerCollection: "GridModelResult", "DfsuModelResult", "DummyModelResult", + "NetworkModelResult", "observation", "PointObservation", "TrackObservation", + "NodeObservation", "TimeSeries", "match", "from_matched", diff --git a/src/modelskill/comparison/_collection.py b/src/modelskill/comparison/_collection.py index 884abe81c..7b56ad9f2 100644 --- a/src/modelskill/comparison/_collection.py +++ b/src/modelskill/comparison/_collection.py @@ -10,7 +10,6 @@ Iterator, List, Union, - Optional, Mapping, Iterable, overload, @@ -286,13 +285,13 @@ def merge( def sel( self, - model: Optional[IdxOrNameTypes] = None, - observation: Optional[IdxOrNameTypes] = None, - quantity: Optional[IdxOrNameTypes] = None, - start: Optional[TimeTypes] = None, - end: Optional[TimeTypes] = None, - time: Optional[TimeTypes] = None, - area: Optional[List[float]] = None, + model: IdxOrNameTypes | None = None, + observation: IdxOrNameTypes | None = None, + quantity: IdxOrNameTypes | None = None, + start: TimeTypes | None = None, + end: TimeTypes | None = None, + time: TimeTypes | None = None, + area: List[float] | None = None, **kwargs: Any, ) -> "ComparerCollection": """Select data based on model, time and/or area. @@ -572,7 +571,7 @@ def gridded_skill( binsize: float | None = None, by: str | Iterable[str] | None = None, metrics: Iterable[str] | Iterable[Callable] | str | Callable | None = None, - n_min: Optional[int] = None, + n_min: int | None = None, **kwargs: Any, ) -> SkillGrid: """Skill assessment of model(s) on a regular spatial grid. diff --git a/src/modelskill/comparison/_collection_plotter.py b/src/modelskill/comparison/_collection_plotter.py index 2122b8488..64297ad03 100644 --- a/src/modelskill/comparison/_collection_plotter.py +++ b/src/modelskill/comparison/_collection_plotter.py @@ -6,7 +6,6 @@ List, Literal, Mapping, - Optional, Sequence, Tuple, Union, @@ -60,19 +59,19 @@ def scatter( quantiles: int | Sequence[float] | None = None, fit_to_quantiles: bool = False, show_points: bool | int | float | None = None, - show_hist: Optional[bool] = None, - show_density: Optional[bool] = None, - norm: Optional[colors.Normalize] = None, + show_hist: bool | None = None, + show_density: bool | None = None, + norm: colors.Normalize | None = None, backend: Literal["matplotlib", "plotly"] = "matplotlib", figsize: Tuple[float, float] = (8, 8), - xlim: Optional[Tuple[float, float]] = None, - ylim: Optional[Tuple[float, float]] = None, + xlim: Tuple[float, float] | None = None, + ylim: Tuple[float, float] | None = None, reg_method: str | bool = "ols", - title: Optional[str] = None, - xlabel: Optional[str] = None, - ylabel: Optional[str] = None, - skill_table: Optional[Union[str, List[str], Mapping[str, str], bool]] = None, - ax: Optional[Axes] = None, + title: str | None = None, + xlabel: str | None = None, + ylabel: str | None = None, + skill_table: Union[str, List[str], Mapping[str, str], bool] | None = None, + ax: Axes | None = None, **kwargs, ) -> Axes | list[Axes]: """Scatter plot tailored for comparing model output with observations. @@ -190,17 +189,17 @@ def _scatter_one_model( quantiles: int | Sequence[float] | None, fit_to_quantiles: bool, show_points: bool | int | float | None, - show_hist: Optional[bool], - show_density: Optional[bool], + show_hist: bool | None, + show_density: bool | None, backend: Literal["matplotlib", "plotly"], figsize: Tuple[float, float], - xlim: Optional[Tuple[float, float]], - ylim: Optional[Tuple[float, float]], + xlim: Tuple[float, float] | None, + ylim: Tuple[float, float] | None, reg_method: str | bool, - title: Optional[str], - xlabel: Optional[str], - ylabel: Optional[str], - skill_table: Optional[Union[str, List[str], Mapping[str, str], bool]], + title: str | None, + xlabel: str | None, + ylabel: str | None, + skill_table: Union[str, List[str], Mapping[str, str], bool] | None, ax, **kwargs, ): @@ -344,11 +343,11 @@ def hist( bins: int | Sequence = 100, *, model: str | int | None = None, - title: Optional[str] = None, + title: str | None = None, density: bool = True, alpha: float = 0.5, ax=None, - figsize: Optional[Tuple[float, float]] = None, + figsize: Tuple[float, float] | None = None, **kwargs, ): """Plot histogram of specific model and all observations. @@ -409,11 +408,11 @@ def _hist_one_model( *, mod_name: str, bins: int | Sequence, - title: Optional[str], + title: str | None, density: bool, alpha: float, ax, - figsize: Optional[Tuple[float, float]], + figsize: Tuple[float, float] | None, **kwargs, ): from ._comparison import MOD_COLORS @@ -464,7 +463,7 @@ def taylor( marker: str = "o", marker_size: float = 6.0, title: str = "Taylor diagram", - ) -> Optional[Figure]: + ) -> Figure | None: """Taylor diagram for model skill comparison. Taylor diagram showing model std and correlation to observation @@ -783,8 +782,8 @@ def _residual_hist_one_model( def spatial_overview( self, ax=None, - figsize: Optional[Tuple] = None, - title: Optional[str] = None, + figsize: Tuple | None = None, + title: str | None = None, ) -> Axes: """Plot observation points on a map showing the model domain diff --git a/src/modelskill/comparison/_comparer_plotter.py b/src/modelskill/comparison/_comparer_plotter.py index 5467eafc2..820814235 100644 --- a/src/modelskill/comparison/_comparer_plotter.py +++ b/src/modelskill/comparison/_comparer_plotter.py @@ -3,7 +3,6 @@ Literal, Union, List, - Optional, Tuple, Sequence, TYPE_CHECKING, @@ -464,19 +463,19 @@ def scatter( quantiles: int | Sequence[float] | None = None, fit_to_quantiles: bool = False, show_points: bool | int | float | None = None, - show_hist: Optional[bool] = None, - show_density: Optional[bool] = None, - norm: Optional[colors.Normalize] = None, + show_hist: bool | None = None, + show_density: bool | None = None, + norm: colors.Normalize | None = None, backend: Literal["matplotlib", "plotly"] = "matplotlib", figsize: Tuple[float, float] = (8, 8), - xlim: Optional[Tuple[float, float]] = None, - ylim: Optional[Tuple[float, float]] = None, + xlim: Tuple[float, float] | None = None, + ylim: Tuple[float, float] | None = None, reg_method: str | bool = "ols", - title: Optional[str] = None, - xlabel: Optional[str] = None, - ylabel: Optional[str] = None, - skill_table: Optional[Union[str, List[str], Mapping[str, str], bool]] = None, - ax: Optional[matplotlib.axes.Axes] = None, + title: str | None = None, + xlabel: str | None = None, + ylabel: str | None = None, + skill_table: Union[str, List[str], Mapping[str, str], bool] | None = None, + ax: matplotlib.axes.Axes | None = None, **kwargs, ) -> matplotlib.axes.Axes | list[matplotlib.axes.Axes]: """Scatter plot tailored for model-observation comparison. @@ -594,18 +593,18 @@ def _scatter_one_model( quantiles: int | Sequence[float] | None, fit_to_quantiles: bool, show_points: bool | int | float | None, - show_hist: Optional[bool], - show_density: Optional[bool], - norm: Optional[colors.Normalize], + show_hist: bool | None, + show_density: bool | None, + norm: colors.Normalize | None, backend: Literal["matplotlib", "plotly"], figsize: Tuple[float, float], - xlim: Optional[Tuple[float, float]], - ylim: Optional[Tuple[float, float]], + xlim: Tuple[float, float] | None, + ylim: Tuple[float, float] | None, reg_method: str | bool, - title: Optional[str], - xlabel: Optional[str], - ylabel: Optional[str], - skill_table: Optional[Union[str, List[str], Mapping[str, str], bool]], + title: str | None, + xlabel: str | None, + ylabel: str | None, + skill_table: Union[str, List[str], Mapping[str, str], bool] | None, **kwargs, ): """Scatter plot for one model only""" @@ -746,7 +745,14 @@ def taylor( df = df.rename(columns={"_std_obs": "obs_std", "_std_mod": "std"}) pts = [ - TaylorPoint(name=r.model, obs_std=r.obs_std, std=r.std, cc=r.cc, marker=marker, marker_size=marker_size) + TaylorPoint( + name=r.model, + obs_std=r.obs_std, + std=r.std, + cc=r.cc, + marker=marker, + marker_size=marker_size, + ) for r in df.itertuples() ] diff --git a/src/modelskill/comparison/_comparison.py b/src/modelskill/comparison/_comparison.py index f8fedef75..2e68491dc 100644 --- a/src/modelskill/comparison/_comparison.py +++ b/src/modelskill/comparison/_comparison.py @@ -8,7 +8,6 @@ List, Literal, Mapping, - Optional, Union, Iterable, Sequence, @@ -20,11 +19,13 @@ import xarray as xr from copy import deepcopy +from ..model.network import NodeModelResult + from .. import metrics as mtr from .. import Quantity from ..types import GeometryType -from ..obs import PointObservation, TrackObservation +from ..obs import PointObservation, TrackObservation, NodeObservation from ..model import PointModelResult, TrackModelResult from ..timeseries._timeseries import _validate_data_var_name from ._comparer_plotter import ComparerPlotter @@ -49,6 +50,12 @@ Serializable = Union[str, int, float] +def _drop_scalar_coords(data: xr.Dataset) -> xr.Dataset: + """Drop scalar coordinate variables that shouldn't appear as columns in dataframes""" + coords_to_drop = ["x", "y", "z", "node"] + return data.drop_vars(coords_to_drop, errors="ignore") + + def _parse_dataset(data: xr.Dataset) -> xr.Dataset: if not isinstance(data, xr.Dataset): raise ValueError("matched_data must be an xarray.Dataset") @@ -60,12 +67,15 @@ def _parse_dataset(data: xr.Dataset) -> xr.Dataset: raise ValueError("Observation data must not contain missing values.") # coordinates - if "x" not in data.coords: - data.coords["x"] = np.nan - if "y" not in data.coords: - data.coords["y"] = np.nan - if "z" not in data.coords: - data.coords["z"] = np.nan + # Only add x, y, z coordinates if they don't exist and we don't have node coordinates + has_node_coords = "node" in data.coords + if not has_node_coords: + if "x" not in data.coords: + data.coords["x"] = np.nan + if "y" not in data.coords: + data.coords["y"] = np.nan + if "z" not in data.coords: + data.coords["z"] = np.nan # Validate data vars = [v for v in data.data_vars] @@ -97,7 +107,11 @@ def _parse_dataset(data: xr.Dataset) -> xr.Dataset: # Validate attrs if "gtype" not in data.attrs: - data.attrs["gtype"] = str(GeometryType.POINT) + # Determine gtype based on available coordinates + if "node" in data.coords: + data.attrs["gtype"] = str(GeometryType.NODE) + else: + data.attrs["gtype"] = str(GeometryType.POINT) # assert "gtype" in data.attrs, "data must have a gtype attribute" # assert data.attrs["gtype"] in [ # str(GeometryType.POINT), @@ -173,8 +187,8 @@ class ItemSelection: obs: str model: Sequence[str] aux: Sequence[str] - x: Optional[str] = None - y: Optional[str] = None + x: str | None = None + y: str | None = None def __post_init__(self) -> None: # check that obs, model and aux are unique, and that they are not overlapping @@ -195,8 +209,8 @@ def all(self) -> Sequence[str]: def parse( items: Sequence[str], obs_item: str | int | None = None, - mod_items: Optional[Iterable[str | int]] = None, - aux_items: Optional[Iterable[str | int]] = None, + mod_items: Iterable[str | int] | None = None, + aux_items: Iterable[str | int] | None = None, x_item: str | int | None = None, y_item: str | int | None = None, ) -> ItemSelection: @@ -294,15 +308,15 @@ def _inside_polygon(polygon: Any, xy: np.ndarray) -> np.ndarray: def _matched_data_to_xarray( df: pd.DataFrame, obs_item: int | str | None = None, - mod_items: Optional[Iterable[str | int]] = None, - aux_items: Optional[Iterable[str | int]] = None, - name: Optional[str] = None, - x: Optional[float] = None, - y: Optional[float] = None, - z: Optional[float] = None, + mod_items: Iterable[str | int] | None = None, + aux_items: Iterable[str | int] | None = None, + name: str | None = None, + x: float | None = None, + y: float | None = None, + z: float | None = None, x_item: str | int | None = None, y_item: str | int | None = None, - quantity: Optional[Quantity] = None, + quantity: Quantity | None = None, ) -> xr.Dataset: """Convert matched data to accepted xarray.Dataset format""" assert isinstance(df, pd.DataFrame) @@ -444,7 +458,11 @@ class Comparer: def __init__( self, matched_data: xr.Dataset, - raw_mod_data: dict[str, PointModelResult | TrackModelResult] | None = None, + raw_mod_data: dict[ + str, + PointModelResult | TrackModelResult | NodeModelResult, + ] + | None = None, ) -> None: self.data = _parse_dataset(matched_data) self.raw_mod_data = ( @@ -464,18 +482,22 @@ def __init__( @staticmethod def from_matched_data( data: xr.Dataset | pd.DataFrame, - raw_mod_data: Optional[Dict[str, PointModelResult | TrackModelResult]] = None, + raw_mod_data: Dict[ + str, + PointModelResult | TrackModelResult | NodeModelResult, + ] + | None = None, obs_item: str | int | None = None, - mod_items: Optional[Iterable[str | int]] = None, - aux_items: Optional[Iterable[str | int]] = None, - name: Optional[str] = None, + mod_items: Iterable[str | int] | None = None, + aux_items: Iterable[str | int] | None = None, + name: str | None = None, weight: float = 1.0, - x: Optional[float] = None, - y: Optional[float] = None, - z: Optional[float] = None, + x: float | None = None, + y: float | None = None, + z: float | None = None, x_item: str | int | None = None, y_item: str | int | None = None, - quantity: Optional[Quantity] = None, + quantity: Quantity | None = None, ) -> "Comparer": """Initialize from compared data""" if not isinstance(data, xr.Dataset): @@ -582,7 +604,15 @@ def z(self) -> Any: """z-coordinate""" return self._coordinate_values("z") - def _coordinate_values(self, coord: str) -> Any: + @property + def node(self) -> Any: + """node-coordinate""" + return self._coordinate_values("node") + + def _coordinate_values(self, coord: str) -> None | Any: + """Get coordinate values if they exist, otherwise return None""" + if coord not in self.data.coords: + return None vals = self.data[coord].values return np.atleast_1d(vals)[0] if vals.ndim == 0 else vals @@ -707,10 +737,10 @@ def rename( return Comparer(matched_data=data, raw_mod_data=raw_mod_data) - def _to_observation(self) -> PointObservation | TrackObservation: + def _to_observation(self) -> PointObservation | TrackObservation | NodeObservation: """Convert to Observation""" if self.gtype == "point": - df = self.data.drop_vars(["x", "y", "z"])[self._obs_str].to_dataframe() + df = _drop_scalar_coords(self.data)[self._obs_str].to_dataframe() return PointObservation( data=df, name=self.name, @@ -721,7 +751,9 @@ def _to_observation(self) -> PointObservation | TrackObservation: # TODO: add attrs ) elif self.gtype == "track": - df = self.data.drop_vars(["z"])[[self._obs_str]].to_dataframe() + df = self.data.drop_vars(["z"], errors="ignore")[ + [self._obs_str] + ].to_dataframe() return TrackObservation( data=df, item=0, @@ -731,10 +763,21 @@ def _to_observation(self) -> PointObservation | TrackObservation: quantity=self.quantity, # TODO: add attrs ) + elif self.gtype == "node": + df = _drop_scalar_coords(self.data)[self._obs_str].to_dataframe() + return NodeObservation( + data=df, + name=self.name, + node=self.node, + quantity=self.quantity, + # TODO: add attrs + ) else: raise NotImplementedError(f"Unknown gtype: {self.gtype}") - def _to_model(self) -> list[PointModelResult | TrackModelResult]: + def _to_model( + self, + ) -> list[PointModelResult | TrackModelResult | NodeModelResult]: mods = list(self.raw_mod_data.values()) return mods @@ -776,11 +819,11 @@ def merge( def sel( self, - model: Optional[IdxOrNameTypes] = None, - start: Optional[TimeTypes] = None, - end: Optional[TimeTypes] = None, - time: Optional[TimeTypes] = None, - area: Optional[List[float]] = None, + model: IdxOrNameTypes | None = None, + start: TimeTypes | None = None, + end: TimeTypes | None = None, + time: TimeTypes | None = None, + area: List[float] | None = None, ) -> "Comparer": """Select data based on model, time and/or area. @@ -896,9 +939,9 @@ def _to_long_dataframe( ) -> pd.DataFrame: """Return a copy of the data as a long-format pandas DataFrame (for groupby operations)""" - data = self.data.drop_vars("z", errors="ignore") + data = self.data.drop_vars(["z", "node"], errors="ignore") - # this step is necessary since we keep arbitrary derived data in the dataset, but not z + # this step is necessary since we keep arbitrary derived data in the dataset, but not z/node # i.e. using a hardcoded whitelist of variables to keep is less flexible id_vars = [v for v in data.variables if v not in self.mod_names] @@ -981,8 +1024,8 @@ def skill( df = cmp._to_long_dataframe() res = _groupby_df(df, by=by, metrics=metrics) - res["x"] = np.nan if self.gtype == "track" else cmp.x - res["y"] = np.nan if self.gtype == "track" else cmp.y + res["x"] = np.nan if self.gtype == "track" or cmp.x is None else cmp.x + res["y"] = np.nan if self.gtype == "track" or cmp.y is None else cmp.y res = self._add_as_col_if_not_in_index(df, skilldf=res) return SkillTable(res) @@ -1138,7 +1181,7 @@ def gridded_skill( @property def _residual(self) -> np.ndarray: - df = self.data.drop_vars(["x", "y", "z"]).to_dataframe() + df = _drop_scalar_coords(self.data).to_dataframe() obs = df[self._obs_str].values mod = df[self.mod_names].values return mod - np.vstack(obs) @@ -1202,12 +1245,17 @@ def to_dataframe(self) -> pd.DataFrame: if self.gtype == str(GeometryType.POINT): # we remove the scalar coordinate variables as they # will otherwise be columns in the dataframe - return self.data.drop_vars(["x", "y", "z"]).to_dataframe() + return _drop_scalar_coords(self.data).to_dataframe() elif self.gtype == str(GeometryType.TRACK): - df = self.data.drop_vars(["z"]).to_dataframe() - # make sure that x, y cols are first - cols = ["x", "y"] + [c for c in df.columns if c not in ["x", "y"]] + df = self.data.drop_vars(["z"], errors="ignore").to_dataframe() + # make sure that x, y cols are first if they exist + coord_cols = [c for c in ["x", "y"] if c in df.columns] + other_cols = [c for c in df.columns if c not in ["x", "y"]] + cols = coord_cols + other_cols return df[cols] + elif self.gtype == str(GeometryType.NODE): + # For network data, drop node coordinate like other geometries drop their coordinates + return _drop_scalar_coords(self.data).to_dataframe() else: raise NotImplementedError(f"Unknown gtype: {self.gtype}") @@ -1258,7 +1306,10 @@ def load(filename: Union[str, Path]) -> "Comparer": return Comparer(matched_data=data) if data.gtype == "point": - raw_mod_data: Dict[str, PointModelResult | TrackModelResult] = {} + raw_mod_data: Dict[ + str, + PointModelResult | TrackModelResult | NodeModelResult, + ] = {} for var in data.data_vars: var_name = str(var) diff --git a/src/modelskill/comparison/_utils.py b/src/modelskill/comparison/_utils.py index f8d399d1a..2df4fa8d1 100644 --- a/src/modelskill/comparison/_utils.py +++ b/src/modelskill/comparison/_utils.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import Callable, Optional, Iterable, List, Tuple, Union +from typing import Callable, Iterable, List, Tuple, Union from datetime import datetime import numpy as np import pandas as pd @@ -10,7 +10,7 @@ def _add_spatial_grid_to_df( - df: pd.DataFrame, bins, binsize: Optional[float] + df: pd.DataFrame, bins, binsize: float | None ) -> pd.DataFrame: if binsize is None: # bins from bins @@ -58,7 +58,7 @@ def _groupby_df( *, by: List[str | pd.Grouper], metrics: List[Callable], - n_min: Optional[int] = None, + n_min: int | None = None, ) -> pd.DataFrame: def calc_metrics(group: pd.DataFrame) -> pd.Series: # set index to time column (in most cases a DatetimeIndex, but not always) diff --git a/src/modelskill/matching.py b/src/modelskill/matching.py index b182f0ad7..f2fce0341 100644 --- a/src/modelskill/matching.py +++ b/src/modelskill/matching.py @@ -7,7 +7,6 @@ Iterable, Literal, Mapping, - Optional, Sequence, TypeVar, Union, @@ -27,21 +26,33 @@ from .model.dfsu import DfsuModelResult from .model.dummy import DummyModelResult from .model.grid import GridModelResult +from .model.network import NetworkModelResult, NodeModelResult from .model.track import TrackModelResult -from .obs import Observation, PointObservation, TrackObservation +from .model.point import align_data +from .obs import ( + Observation, + PointObservation, + TrackObservation, + NodeObservation, +) from .timeseries import TimeSeries from .types import Period TimeDeltaTypes = Union[float, int, np.timedelta64, pd.Timedelta, timedelta] -IdxOrNameTypes = Optional[Union[int, str]] -GeometryTypes = Optional[Literal["point", "track", "unstructured", "grid"]] +IdxOrNameTypes = Union[int, str] | None +GeometryTypes = Literal["point", "track", "unstructured", "grid"] | None MRTypes = Union[ PointModelResult, GridModelResult, DfsuModelResult, TrackModelResult, + NetworkModelResult, DummyModelResult, ] +FieldTypes = Union[ + GridModelResult, + DfsuModelResult, +] MRInputType = Union[ str, Path, @@ -56,7 +67,11 @@ TimeSeries, MRTypes, ] -ObsTypes = Union[PointObservation, TrackObservation] +ObsTypes = Union[ + PointObservation, + TrackObservation, + NodeObservation, +] ObsInputType = Union[ str, Path, @@ -67,7 +82,6 @@ pd.Series, ObsTypes, ] - T = TypeVar("T", bound="TimeSeries") @@ -75,14 +89,14 @@ def from_matched( data: Union[str, Path, pd.DataFrame, mikeio.Dfs0, mikeio.Dataset], *, obs_item: str | int | None = 0, - mod_items: Optional[Iterable[str | int]] = None, - aux_items: Optional[Iterable[str | int]] = None, - quantity: Optional[Quantity] = None, - name: Optional[str] = None, + mod_items: Iterable[str | int] | None = None, + aux_items: Iterable[str | int] | None = None, + quantity: Quantity | None = None, + name: str | None = None, weight: float = 1.0, - x: Optional[float] = None, - y: Optional[float] = None, - z: Optional[float] = None, + x: float | None = None, + y: float | None = None, + z: float | None = None, x_item: str | int | None = None, y_item: str | int | None = None, ) -> Comparer: @@ -176,8 +190,8 @@ def match( obs: ObsTypes, mod: MRTypes | Sequence[MRTypes], *, - max_model_gap: Optional[float] = None, - spatial_method: Optional[str] = None, + max_model_gap: float | None = None, + spatial_method: str | None = None, spatial_tolerance: float = 1e-3, obs_no_overlap: Literal["ignore", "error", "warn"] = "error", ) -> Comparer: ... @@ -188,8 +202,8 @@ def match( obs: Iterable[ObsTypes], mod: MRTypes | Sequence[MRTypes], *, - max_model_gap: Optional[float] = None, - spatial_method: Optional[str] = None, + max_model_gap: float | None = None, + spatial_method: str | None = None, spatial_tolerance: float = 1e-3, obs_no_overlap: Literal["ignore", "error", "warn"] = "error", ) -> ComparerCollection: ... @@ -200,7 +214,7 @@ def match( mod, *, max_model_gap=None, - spatial_method: Optional[str] = None, + spatial_method: str | None = None, spatial_tolerance: float = 1e-3, obs_no_overlap: Literal["ignore", "error", "warn"] = "error", ): @@ -248,6 +262,7 @@ def match( -------- from_matched - Create a Comparer from observation and model results that are already matched """ + if isinstance(obs, get_args(ObsInputType)): return _match_single_obs( obs, @@ -267,16 +282,23 @@ def match( if len(obs) > 1 and isinstance(mod, Collection) and len(mod) > 1: if not all( - isinstance(m, (DfsuModelResult, GridModelResult, DummyModelResult)) + isinstance( + m, + ( + DfsuModelResult, + GridModelResult, + NetworkModelResult, + DummyModelResult, + ), + ) for m in mod ): raise ValueError( """ - In case of multiple observations, multiple models can _only_ - be matched if they are _all_ of SpatialField type, e.g. DfsuModelResult - or GridModelResult. + When matching multiple observations with multiple models, all models + must be one of the following types: DfsuModelResult, GridModelResult or NetworkModelResult. - If you want match multiple point observations with multiple point model results, + If you want to match multiple point observations with multiple point model results, please match one observation at a time and then create a collection of these using modelskill.ComparerCollection(cmp_list) afterwards. The same applies to track data. """ @@ -317,14 +339,19 @@ def _match_single_obs( if len(names) != len(set(names)): raise ValueError(f"Duplicate model names found: {names}") - raw_mod_data = { - m.name: ( - m.extract(obs, spatial_method=spatial_method) - if isinstance(m, (DfsuModelResult, GridModelResult, DummyModelResult)) - else m - ) - for m in models - } + raw_mod_data: dict[str, PointModelResult | TrackModelResult | NodeModelResult] = {} + for m in models: + is_field = isinstance(m, (GridModelResult, DfsuModelResult)) + is_dummy = isinstance(m, DummyModelResult) + is_network = isinstance(m, NetworkModelResult) + if is_field or is_dummy: + matching_obs = m.extract(obs, spatial_method=spatial_method) + elif is_network: + matching_obs = m.extract(obs) + else: + matching_obs = m + + raw_mod_data[m.name] = matching_obs matched_data = _match_space_time( observation=obs, @@ -341,7 +368,7 @@ def _match_single_obs( def _get_global_start_end(idxs: Iterable[pd.DatetimeIndex]) -> Period: - assert all([len(x) > 0 for x in idxs]) + assert all([len(x) > 0 for x in idxs]), "All datetime indices must be non-empty" starts = [x[0] for x in idxs] ends = [x[-1] for x in idxs] @@ -351,11 +378,11 @@ def _get_global_start_end(idxs: Iterable[pd.DatetimeIndex]) -> Period: def _match_space_time( observation: Observation, - raw_mod_data: Mapping[str, PointModelResult | TrackModelResult], + raw_mod_data: Mapping[str, PointModelResult | TrackModelResult | NodeModelResult], max_model_gap: float | None, spatial_tolerance: float, obs_no_overlap: Literal["ignore", "error", "warn"], -) -> Optional[xr.Dataset]: +) -> xr.Dataset | None: idxs = [m.time for m in raw_mod_data.values()] period = _get_global_start_end(idxs) @@ -374,7 +401,10 @@ def _match_space_time( observation, spatial_tolerance=spatial_tolerance ) case PointModelResult() as pmr, PointObservation(): - aligned = pmr.align(observation, max_gap=max_model_gap) + aligned = align_data(pmr.data, observation, max_gap=max_model_gap) + case NodeModelResult() as nmr, NodeObservation(): + # mr is the extracted NodeModelResult + aligned = align_data(nmr.data, observation, max_gap=max_model_gap) case _: raise TypeError( f"Matching not implemented for model type {type(mr)} and observation type {type(observation)}" diff --git a/src/modelskill/metrics.py b/src/modelskill/metrics.py index 30e4b02bc..b195fddc6 100644 --- a/src/modelskill/metrics.py +++ b/src/modelskill/metrics.py @@ -11,7 +11,6 @@ Iterable, List, Literal, - Optional, Set, Tuple, TypeVar, @@ -43,7 +42,7 @@ def add_metric(metric: Callable, has_units: bool = False) -> None: def metric( best: Literal["+", "-", 0, 1] | None = None, has_units: bool = False, - display_name: Optional[str] = None, + display_name: str | None = None, ) -> Callable[[F], F]: """Decorator to indicate a function as a metric. @@ -101,7 +100,7 @@ def max_error(obs: ArrayLike, model: ArrayLike) -> Any: @metric(best="-", has_units=True) -def mae(obs: ArrayLike, model: ArrayLike, weights: Optional[ArrayLike] = None) -> Any: +def mae(obs: ArrayLike, model: ArrayLike, weights: ArrayLike | None = None) -> Any: """alias for mean_absolute_error""" assert obs.size == model.size return mean_absolute_error(obs, model, weights) @@ -109,7 +108,7 @@ def mae(obs: ArrayLike, model: ArrayLike, weights: Optional[ArrayLike] = None) - @metric(best="-", has_units=True) def mean_absolute_error( - obs: ArrayLike, model: ArrayLike, weights: Optional[ArrayLike] = None + obs: ArrayLike, model: ArrayLike, weights: ArrayLike | None = None ) -> Any: r"""Mean Absolute Error (MAE) @@ -155,7 +154,7 @@ def mean_absolute_percentage_error(obs: ArrayLike, model: ArrayLike) -> Any: @metric(best="-", has_units=True) -def urmse(obs: ArrayLike, model: ArrayLike, weights: Optional[ArrayLike] = None) -> Any: +def urmse(obs: ArrayLike, model: ArrayLike, weights: ArrayLike | None = None) -> Any: r"""Unbiased Root Mean Squared Error (uRMSE) $$ @@ -183,7 +182,7 @@ def urmse(obs: ArrayLike, model: ArrayLike, weights: Optional[ArrayLike] = None) def rmse( obs: ArrayLike, model: ArrayLike, - weights: Optional[ArrayLike] = None, + weights: ArrayLike | None = None, unbiased: bool = False, ) -> Any: """alias for root_mean_squared_error""" @@ -194,7 +193,7 @@ def rmse( def root_mean_squared_error( obs: ArrayLike, model: ArrayLike, - weights: Optional[ArrayLike] = None, + weights: ArrayLike | None = None, unbiased: bool = False, ) -> Any: r"""Root Mean Squared Error (RMSE) @@ -985,7 +984,7 @@ def c_max_error(obs: ArrayLike, model: ArrayLike) -> Any: def c_mean_absolute_error( obs: ArrayLike, model: ArrayLike, - weights: Optional[ArrayLike] = None, + weights: ArrayLike | None = None, ) -> Any: """Circular mean absolute error @@ -1016,7 +1015,7 @@ def c_mean_absolute_error( def c_mae( obs: ArrayLike, model: ArrayLike, - weights: Optional[ArrayLike] = None, + weights: ArrayLike | None = None, ) -> Any: """alias for circular mean absolute error""" return c_mean_absolute_error(obs, model, weights) @@ -1026,7 +1025,7 @@ def c_mae( def c_root_mean_squared_error( obs: ArrayLike, model: ArrayLike, - weights: Optional[ArrayLike] = None, + weights: ArrayLike | None = None, ) -> Any: """Circular root mean squared error @@ -1056,7 +1055,7 @@ def c_root_mean_squared_error( def c_rmse( obs: ArrayLike, model: ArrayLike, - weights: Optional[ArrayLike] = None, + weights: ArrayLike | None = None, ) -> Any: """alias for circular root mean squared error""" return c_root_mean_squared_error(obs, model, weights) @@ -1066,7 +1065,7 @@ def c_rmse( def c_unbiased_root_mean_squared_error( obs: ArrayLike, model: ArrayLike, - weights: Optional[ArrayLike] = None, + weights: ArrayLike | None = None, ) -> Any: """Circular unbiased root mean squared error @@ -1099,7 +1098,7 @@ def c_unbiased_root_mean_squared_error( def c_urmse( obs: ArrayLike, model: ArrayLike, - weights: Optional[ArrayLike] = None, + weights: ArrayLike | None = None, ) -> Any: """alias for circular unbiased root mean squared error""" return c_unbiased_root_mean_squared_error(obs, model, weights) diff --git a/src/modelskill/model/__init__.py b/src/modelskill/model/__init__.py index b0ba468ae..3390a5430 100644 --- a/src/modelskill/model/__init__.py +++ b/src/modelskill/model/__init__.py @@ -9,6 +9,7 @@ * SpatialField (extractable) - [`GridModelResult`](`modelskill.GridModelResult`) - a spatial field from a dfs2/nc file or a Xarray Dataset - [`DfsuModelResult`](`modelskill.DfsuModelResult`) - a spatial field from a dfsu file + - [`NetworkModelResult`](`modelskill.NetworkModelResult`) - a network field from xarray Dataset with time and node coordinates A model result can be created by explicitly invoking one of the above classes or using the [`model_result()`](`modelskill.model_result`) function which will return the appropriate type based on the input data (if possible). """ @@ -20,6 +21,8 @@ from .track import TrackModelResult from .dfsu import DfsuModelResult from .grid import GridModelResult +from .network import NetworkModelResult +from .network import NodeModelResult from .dummy import DummyModelResult __all__ = [ @@ -27,6 +30,8 @@ "TrackModelResult", "DfsuModelResult", "GridModelResult", + "NetworkModelResult", + "NodeModelResult", "model_result", "DummyModelResult", ] diff --git a/src/modelskill/model/_base.py b/src/modelskill/model/_base.py index 250dcc30d..77c69416f 100644 --- a/src/modelskill/model/_base.py +++ b/src/modelskill/model/_base.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections import Counter from collections.abc import Hashable -from typing import List, Optional, Protocol, Sequence, TYPE_CHECKING +from typing import List, Protocol, Sequence, TYPE_CHECKING from dataclasses import dataclass import warnings @@ -28,7 +28,7 @@ def all(self) -> List[str]: def parse( avail_items: Sequence[Hashable], item: int | str | None, - aux_items: Optional[Sequence[int | str]] = None, + aux_items: Sequence[int | str] | None = None, ) -> SelectedItems: return _parse_items(avail_items, item, aux_items) @@ -36,7 +36,7 @@ def parse( def _parse_items( avail_items: Sequence[Hashable], item: int | str | None, - aux_items: Optional[Sequence[int | str]] = None, + aux_items: Sequence[int | str] | None = None, ) -> SelectedItems: """If input has exactly 1 item we accept item=None""" if item is None: @@ -76,13 +76,13 @@ class SpatialField(Protocol): def extract( self, observation: PointObservation | TrackObservation, - spatial_method: Optional[str] = None, + spatial_method: str | None = None, ) -> PointModelResult | TrackModelResult: ... def _extract_point( - self, observation: PointObservation, spatial_method: Optional[str] = None + self, observation: PointObservation, spatial_method: str | None = None ) -> PointModelResult: ... def _extract_track( - self, observation: TrackObservation, spatial_method: Optional[str] = None + self, observation: TrackObservation, spatial_method: str | None = None ) -> TrackModelResult: ... diff --git a/src/modelskill/model/adapters/__init__.py b/src/modelskill/model/adapters/__init__.py new file mode 100644 index 000000000..33ad255b1 --- /dev/null +++ b/src/modelskill/model/adapters/__init__.py @@ -0,0 +1 @@ +"""Network format adapters.""" diff --git a/src/modelskill/model/adapters/_res1d.py b/src/modelskill/model/adapters/_res1d.py new file mode 100644 index 000000000..c3ec44999 --- /dev/null +++ b/src/modelskill/model/adapters/_res1d.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pandas as pd + +if TYPE_CHECKING: + from mikeio1d.result_network import ResultNode, ResultGridPoint, ResultReach + +from ..network import NetworkNode, EdgeBreakPoint, NetworkEdge + + +def _simplify_res1d_colnames(node: ResultNode | ResultGridPoint) -> pd.DataFrame: + # We remove suffixes and indexes so the columns contain only the quantity names + df = node.to_dataframe() + quantities = node.quantities + renamer_dict = {} + for quantity in quantities: + relevant_columns = [col for col in df.columns if quantity in col] + if len(relevant_columns) != 1: + raise ValueError( + f"There must be exactly one column per quantity, found {relevant_columns}." + ) + renamer_dict[relevant_columns[0]] = quantity + return df.rename(columns=renamer_dict).copy() + + +class Res1DNode(NetworkNode): + def __init__(self, node: ResultNode, boundary: dict[str, ResultGridPoint]): + self._id = node.id + self._data = _simplify_res1d_colnames(node) + self._boundary = { + key: _simplify_res1d_colnames(point) for key, point in boundary.items() + } + + @property + def id(self) -> str: + return self._id + + @property + def data(self) -> pd.DataFrame: + return self._data + + @property + def boundary(self) -> dict[str, pd.DataFrame]: + return self._boundary + + +class GridPoint(EdgeBreakPoint): + def __init__(self, point: ResultGridPoint): + self._id = (point.reach_name, point.chainage) + self._data = _simplify_res1d_colnames(point) + + @property + def id(self) -> tuple[str, float]: + return self._id + + @property + def data(self) -> pd.DataFrame: + return self._data + + +class Res1DReach(NetworkEdge): + """NetworkEdge adapter for a mikeio1d ResultReach.""" + + def __init__( + self, reach: ResultReach, start_node: ResultNode, end_node: ResultNode + ): + self._id = reach.name + + if start_node.id != reach.start_node: + raise ValueError("Incorrect starting node.") + if end_node.id != reach.end_node: + raise ValueError("Incorrect ending node.") + + start_gridpoint = reach.gridpoints[0] + end_gridpoint = reach.gridpoints[-1] + intermediate_gridpoints = ( + reach.gridpoints[1:-1] if len(reach.gridpoints) > 2 else [] + ) + + self._start = Res1DNode(start_node, {reach.name: start_gridpoint}) + self._end = Res1DNode(end_node, {reach.name: end_gridpoint}) + self._length = reach.length + self._breakpoints: list[EdgeBreakPoint] = [ + GridPoint(gridpoint) for gridpoint in intermediate_gridpoints + ] + + @property + def id(self) -> str: + return self._id + + @property + def start(self) -> Res1DNode: + return self._start + + @property + def end(self) -> Res1DNode: + return self._end + + @property + def length(self) -> float: + return self._length + + @property + def breakpoints(self) -> list[EdgeBreakPoint]: + return self._breakpoints diff --git a/src/modelskill/model/dfsu.py b/src/modelskill/model/dfsu.py index 511e829d6..28a632f9e 100644 --- a/src/modelskill/model/dfsu.py +++ b/src/modelskill/model/dfsu.py @@ -1,7 +1,7 @@ from __future__ import annotations import inspect from pathlib import Path -from typing import Literal, Optional, get_args, cast +from typing import Literal, get_args, cast import mikeio import numpy as np @@ -23,7 +23,7 @@ class DfsuModelResult(SpatialField): ---------- data : types.UnstructuredType the input data or file path - name : Optional[str], optional + name : str | None, optional The name of the model result, by default None (will be set to file name or item name) item : str | int | None, optional @@ -31,7 +31,7 @@ class DfsuModelResult(SpatialField): must be given (as either an index or a string), by default None quantity : Quantity, optional Model quantity, for MIKE files this is inferred from the EUM information - aux_items : Optional[list[int | str]], optional + aux_items : list[int | str] | None, optional Auxiliary items, by default None """ @@ -39,10 +39,10 @@ def __init__( self, data: UnstructuredType, *, - name: Optional[str] = None, - item: str | int | None = None, - quantity: Optional[Quantity] = None, - aux_items: Optional[list[int | str]] = None, + name: str | None = None, + item: int | str | None = None, + quantity: Quantity | None = None, + aux_items: list[int | str] | None = None, ) -> None: filename = None @@ -112,7 +112,7 @@ def _in_domain(self, x: float, y: float) -> bool: return self.data.geometry.contains([x, y]) # type: ignore def extract( - self, observation: Observation, spatial_method: Optional[str] = None + self, observation: Observation, spatial_method: str | None = None ) -> PointModelResult | TrackModelResult: """Extract ModelResult at observation positions @@ -122,7 +122,7 @@ def extract( ---------- observation : or positions (and times) at which modelresult should be extracted - spatial_method : Optional[str], optional + spatial_method : str | None, optional spatial selection/interpolation method, 'contained' (=isel), 'nearest', 'inverse_distance' (with 5 nearest points), by default None = 'inverse_distance' @@ -163,7 +163,7 @@ def _parse_spatial_method(method: str | None) -> str | None: return METHOD_MAP[method] def _extract_point( - self, observation: PointObservation, spatial_method: Optional[str] = None + self, observation: PointObservation, spatial_method: str | None = None ) -> PointModelResult: """Extract point. @@ -242,7 +242,7 @@ def _check_interpolation_method( ) def _extract_track( - self, observation: TrackObservation, spatial_method: Optional[str] = None + self, observation: TrackObservation, spatial_method: str | None = None ) -> TrackModelResult: """Extract track. diff --git a/src/modelskill/model/dummy.py b/src/modelskill/model/dummy.py index 6a1efea0d..a80c60d08 100644 --- a/src/modelskill/model/dummy.py +++ b/src/modelskill/model/dummy.py @@ -1,6 +1,6 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Literal, Optional +from typing import Literal import pandas as pd @@ -50,7 +50,7 @@ def __post_init__(self): def extract( self, observation: PointObservation | TrackObservation, - spatial_method: Optional[str] = None, + spatial_method: str | None = None, ) -> PointModelResult | TrackModelResult: if spatial_method is not None: raise NotImplementedError( diff --git a/src/modelskill/model/factory.py b/src/modelskill/model/factory.py index 4ee2cb7f3..12478f1aa 100644 --- a/src/modelskill/model/factory.py +++ b/src/modelskill/model/factory.py @@ -1,6 +1,6 @@ from __future__ import annotations from pathlib import Path -from typing import Literal, Optional, Any +from typing import Literal, Any import pandas as pd import xarray as xr @@ -24,8 +24,8 @@ def model_result( data: DataInputType, *, - aux_items: Optional[list[int | str]] = None, - gtype: Optional[Literal["point", "track", "unstructured", "grid"]] = None, + aux_items: list[int | str] | None = None, + gtype: Literal["point", "track", "unstructured", "grid"] | None = None, **kwargs: Any, ) -> PointModelResult | TrackModelResult | DfsuModelResult | GridModelResult: """A factory function for creating an appropriate object based on the data input. @@ -34,9 +34,9 @@ def model_result( ---------- data : DataInputType The data to be used for creating the ModelResult object. - aux_items : Optional[list[int | str]] + aux_items : list[int | str] | None Auxiliary items, by default None - gtype : Optional[Literal["point", "track", "unstructured", "grid"]] + gtype : Literal["point", "track", "unstructured", "grid"] | None The geometry type of the data. If not specified, it will be guessed from the data. **kwargs Additional keyword arguments to be passed to the ModelResult constructor. diff --git a/src/modelskill/model/grid.py b/src/modelskill/model/grid.py index 67b88b35e..e3a8f6695 100644 --- a/src/modelskill/model/grid.py +++ b/src/modelskill/model/grid.py @@ -1,6 +1,6 @@ from __future__ import annotations from pathlib import Path -from typing import Optional, Sequence, get_args +from typing import Sequence, get_args import mikeio import pandas as pd @@ -30,7 +30,7 @@ class GridModelResult(SpatialField): must be given (as either an index or a string), by default None quantity : Quantity, optional Model quantity, for MIKE files this is inferred from the EUM information - aux_items : Optional[list[int | str]], optional + aux_items : list[int | str] | None, optional Auxiliary items, by default None """ @@ -38,10 +38,10 @@ def __init__( self, data: GridType, *, - name: Optional[str] = None, - item: str | int | None = None, - quantity: Optional[Quantity] = None, - aux_items: Optional[list[int | str]] = None, + name: str | None = None, + item: int | str | None = None, + quantity: Quantity | None = None, + aux_items: list[int | str] | None = None, ) -> None: assert isinstance( data, get_args(GridType) @@ -125,7 +125,7 @@ def _in_domain(self, x: float, y: float) -> bool: def extract( self, observation: PointObservation | TrackObservation, - spatial_method: Optional[str] = None, + spatial_method: str | None = None, ) -> PointModelResult | TrackModelResult: """Extract ModelResult at observation positions @@ -135,7 +135,7 @@ def extract( ---------- observation : or positions (and times) at which modelresult should be extracted - spatial_method : Optional[str], optional + spatial_method : str | None, optional method in xarray.Dataset.interp, typically either "nearest" or "linear", by default None = 'linear' @@ -155,7 +155,7 @@ def extract( ) def _extract_point( - self, observation: PointObservation, spatial_method: Optional[str] = None + self, observation: PointObservation, spatial_method: str | None = None ) -> PointModelResult: """Extract point. @@ -204,7 +204,7 @@ def _extract_point( ) def _extract_track( - self, observation: TrackObservation, spatial_method: Optional[str] = None + self, observation: TrackObservation, spatial_method: str | None = None ) -> TrackModelResult: """Extract track. diff --git a/src/modelskill/model/network.py b/src/modelskill/model/network.py new file mode 100644 index 000000000..a697b062e --- /dev/null +++ b/src/modelskill/model/network.py @@ -0,0 +1,878 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Sequence, Any, overload +from abc import ABC, abstractmethod +from typing_extensions import Self +from pathlib import Path + +import numpy as np +import numpy.typing as npt +import pandas as pd +import xarray as xr +import networkx as nx + +from modelskill.timeseries import TimeSeries, _parse_network_node_input + +if TYPE_CHECKING: + from mikeio1d import Res1D + +from ._base import SelectedItems +from ..obs import NodeObservation +from ..quantity import Quantity +from ..types import PointType + + +class NetworkNode(ABC): + """Abstract base class for a node in a network. + + A node represents a discrete location in the network (e.g. a junction, + reservoir, or boundary point) that carries time-series data for one or + more physical quantities. + + Three properties must be implemented: + + * :attr:`id` - a unique string identifier for the node. + * :attr:`data` - a time-indexed :class:`pandas.DataFrame` whose columns + are quantity names. + * :attr:`boundary` - a dict of boundary-condition metadata (may be empty). + + The concrete helper :class:`BasicNode` is provided for the common case + where the data is already available as a DataFrame. + + Examples + -------- + Minimal subclass backed by a CSV file: + + >>> class CsvNode(NetworkNode): + ... def __init__(self, node_id, csv_path): + ... self._id = node_id + ... self._data = pd.read_csv(csv_path, index_col=0, parse_dates=True) + ... @property + ... def id(self): return self._id + ... @property + ... def data(self): return self._data + ... @property + ... def boundary(self): return {} + + See Also + -------- + BasicNode : Ready-to-use concrete implementation. + NetworkEdge : Connects two NetworkNode instances. + Network : Container that assembles nodes and edges into a graph. + """ + + @property + @abstractmethod + def id(self) -> str: + """Unique string identifier for this node.""" + ... + + @property + @abstractmethod + def data(self) -> pd.DataFrame: + """Time-indexed DataFrame with one column per quantity.""" + ... + + @property + @abstractmethod + def boundary(self) -> dict[str, Any]: + """Boundary-condition metadata dict (may be empty).""" + ... + + @property + def quantities(self) -> list[str]: + """List of quantity names available at this node.""" + return list(self.data.columns) + + +class EdgeBreakPoint(ABC): + """Abstract base class for an intermediate break point along a network edge. + + Break points represent locations between the start and end nodes of an + edge (e.g. cross-section chainage points along a river reach) that carry + their own time-series data. + + Two properties must be implemented: + + * :attr:`id` - a ``(edge_id, distance)`` tuple that uniquely locates the + break point within the network. + * :attr:`data` - a time-indexed :class:`pandas.DataFrame` whose columns + are quantity names. + + The :attr:`distance` convenience property returns ``id[1]`` (the + along-edge distance in the units used by the parent network). + + Examples + -------- + Minimal subclass: + + >>> class MyBreakPoint(EdgeBreakPoint): + ... def __init__(self, edge_id, chainage, df): + ... self._id = (edge_id, chainage) + ... self._data = df + ... @property + ... def id(self): return self._id + ... @property + ... def data(self): return self._data + + See Also + -------- + NetworkEdge : Owns a list of EdgeBreakPoint instances. + NetworkNode : Represents a start/end node of an edge. + Network : Assembles edges (and their break points) into a graph. + """ + + @property + @abstractmethod + def id(self) -> tuple[str, float]: + """``(edge_id, distance)`` tuple uniquely identifying this break point.""" + ... + + @property + @abstractmethod + def data(self) -> pd.DataFrame: + """Time-indexed DataFrame with one column per quantity.""" + ... + + @property + def distance(self) -> float: + """Along-edge distance of this break point (same units as :attr:`NetworkEdge.length`).""" + return self.id[1] + + @property + def quantities(self) -> list[str]: + """List of quantity names available at this break point.""" + return list(self.data.columns) + + +class NetworkEdge(ABC): + """Abstract base class for an edge in a network. + + An edge represents a directed connection between two :class:`NetworkNode` + instances (e.g. a river reach between two junctions). It may also carry + a list of :class:`EdgeBreakPoint` objects for intermediate chainage + locations. + + Subclass this to integrate your own network topology. Five properties + must be implemented: + + * :attr:`id` - a unique string identifier for the edge. + * :attr:`start` - the upstream/start :class:`NetworkNode`. + * :attr:`end` - the downstream/end :class:`NetworkNode`. + * :attr:`length` - total edge length (in the units of your coordinate + system). + * :attr:`breakpoints` - list of :class:`EdgeBreakPoint` instances ordered + by increasing distance from the start node (empty list if none). + + The concrete helper :class:`BasicEdge` is provided for the common case + where all data is already available in memory. + + Examples + -------- + Minimal subclass: + + >>> class MyEdge(NetworkEdge): + ... def __init__(self, eid, start_node, end_node, length): + ... self._id = eid + ... self._start = start_node + ... self._end = end_node + ... self._length = length + ... @property + ... def id(self): return self._id + ... @property + ... def start(self): return self._start + ... @property + ... def end(self): return self._end + ... @property + ... def length(self): return self._length + ... @property + ... def breakpoints(self): return [] + + See Also + -------- + BasicEdge : Ready-to-use concrete implementation. + NetworkNode : Represents the start/end of this edge. + EdgeBreakPoint : Intermediate data points along this edge. + Network : Assembles a list of NetworkEdge objects into a graph. + """ + + @property + @abstractmethod + def id(self) -> str: + """Unique string identifier for this edge.""" + ... + + @property + @abstractmethod + def start(self) -> NetworkNode: + """Start (upstream) node of this edge.""" + ... + + @property + @abstractmethod + def end(self) -> NetworkNode: + """End (downstream) node of this edge.""" + ... + + @property + @abstractmethod + def length(self) -> float: + """Total length of this edge in network units.""" + ... + + @property + @abstractmethod + def breakpoints(self) -> list[EdgeBreakPoint]: + """Ordered list of intermediate :class:`EdgeBreakPoint` objects (may be empty).""" + ... + + @property + def n_breakpoints(self) -> int: + """Number of break points in the edge.""" + return len(self.breakpoints) + + +class BasicNode(NetworkNode): + """Concrete :class:`NetworkNode` for programmatic network construction. + + Parameters + ---------- + id : str + Unique node identifier. + data : pd.DataFrame + Time-indexed DataFrame with one column per quantity. + boundary : dict, optional + Boundary condition metadata, by default empty. + + Examples + -------- + >>> import pandas as pd + >>> time = pd.date_range("2020", periods=3, freq="h") + >>> node = BasicNode("junction_1", pd.DataFrame({"WaterLevel": [1.0, 1.1, 1.2]}, index=time)) + """ + + def __init__( + self, + id: str, + data: pd.DataFrame, + boundary: dict[str, Any] | None = None, + ) -> None: + self._id = id + self._data = data + self._boundary: dict[str, Any] = boundary or {} + + @property + def id(self) -> str: + return self._id + + @property + def data(self) -> pd.DataFrame: + return self._data + + @property + def boundary(self) -> dict[str, Any]: + return self._boundary + + +class BasicEdge(NetworkEdge): + """Concrete :class:`NetworkEdge` for programmatic network construction. + + Parameters + ---------- + id : str + Unique edge identifier. + start : NetworkNode + Start node. + end : NetworkNode + End node. + length : float + Edge length. + breakpoints : list[EdgeBreakPoint], optional + Intermediate break points, by default empty. + + Examples + -------- + >>> edge = BasicEdge("reach_1", node_a, node_b, length=250.0) + """ + + def __init__( + self, + id: str, + start: NetworkNode, + end: NetworkNode, + length: float, + breakpoints: list[EdgeBreakPoint] | None = None, + ) -> None: + self._id = id + self._start = start + self._end = end + self._length = length + self._breakpoints: list[EdgeBreakPoint] = breakpoints or [] + + @property + def id(self) -> str: + return self._id + + @property + def start(self) -> NetworkNode: + return self._start + + @property + def end(self) -> NetworkNode: + return self._end + + @property + def length(self) -> float: + return self._length + + @property + def breakpoints(self) -> list[EdgeBreakPoint]: + return self._breakpoints + + +class Network: + """Network built from a set of edges, with coordinate lookup and data access.""" + + def __init__(self, edges: Sequence[NetworkEdge]): + self._edges: dict[str, NetworkEdge] = {e.id: e for e in edges} + self._graph = self._initialize_graph() + self._alias_map = self._initialize_alias_map() + self._df = self._build_dataframe() + + @classmethod + def from_res1d(cls, res: str | Path | Res1D) -> Network: + """Create a Network from a Res1D file or object. + + Parameters + ---------- + res : str, Path or Res1D + Path to a .res1d file, or an already-opened :class:`mikeio1d.Res1D` object. + + Returns + ------- + Network + + Examples + -------- + >>> from modelskill.model.network import Network + >>> network = Network.from_res1d("model.res1d") + >>> network = Network.from_res1d(Res1D("model.res1d")) + """ + from mikeio1d import Res1D as _Res1D + from .adapters._res1d import Res1DReach + + if isinstance(res, (str, Path)): + path = Path(res) + if path.suffix.lower() != ".res1d": + raise NotImplementedError( + f"Unsupported file extension '{path.suffix}'. Only .res1d files are supported." + ) + res = _Res1D(str(path)) + elif not isinstance(res, _Res1D): + raise TypeError( + f"Expected a str, Path or Res1D object, got {type(res).__name__!r}" + ) + + edges = [ + Res1DReach(reach, res.nodes[reach.start_node], res.nodes[reach.end_node]) + for reach in res.reaches.values() + ] + return cls(edges) + + def _initialize_alias_map(self) -> dict[str | tuple[str, float], int]: + return {self.graph.nodes[id]["alias"]: id for id in self.graph.nodes()} + + def _build_dataframe(self) -> pd.DataFrame: + df = pd.concat({k: v["data"] for k, v in self._graph.nodes.items()}, axis=1) + df.columns = df.columns.set_names(["node", "quantity"]) + df.index.name = "time" + return df.copy() + + def to_dataframe(self, sel: str | None = None) -> pd.DataFrame: + """Dataframe using node ids as column names. + + It will be multiindex unless 'sel' is passed. + + Parameters + ---------- + sel : Optional[str], optional + Quantity to select, by default None + + Returns + ------- + pd.DataFrame + Timeseries contained in graph nodes + """ + df = self._df.copy() + if sel is None: + return df + else: + df.attrs["quantity"] = sel + return df.reorder_levels(["quantity", "node"], axis=1).loc[:, sel] + + def to_dataset(self) -> xr.Dataset: + """Dataset using node ids as coords. + + Returns + ------- + xr.Dataset + Timeseries contained in graph nodes + """ + df = self.to_dataframe().reorder_levels(["quantity", "node"], axis=1) + quantities = df.columns.get_level_values("quantity").unique() + return xr.Dataset( + {q: xr.DataArray(df[q], dims=["time", "node"]) for q in quantities} + ) + + @property + def graph(self) -> nx.Graph: + """Graph of the network.""" + return self._graph + + @property + def quantities(self) -> list[str]: + """Quantities present in data. + + Returns + ------- + List[str] + List of quantities + """ + return list(self.to_dataframe().columns.get_level_values(1).unique()) + + def _initialize_graph(self) -> nx.Graph: + g0 = nx.Graph() + for edge in self._edges.values(): + # 1) Add start and end nodes + for node in [edge.start, edge.end]: + node_key = node.id + if node_key in g0.nodes: + g0.nodes[node_key]["boundary"].update(node.boundary) + else: + g0.add_node(node_key, data=node.data, boundary=node.boundary) + + # 2) Add edges connecting start/end nodes to their adjacent breakpoints + start_key = edge.start.id + end_key = edge.end.id + if edge.n_breakpoints == 0: + g0.add_edge(start_key, end_key, length=edge.length) + else: + bp_keys = [bp.id for bp in edge.breakpoints] + for bp, bp_key in zip(edge.breakpoints, bp_keys): + g0.add_node(bp_key, data=bp.data) + + g0.add_edge(start_key, bp_keys[0], length=edge.breakpoints[0].distance) + g0.add_edge( + bp_keys[-1], + end_key, + length=edge.length - edge.breakpoints[-1].distance, + ) + + # 3) Connect consecutive intermediate breakpoints + for i in range(edge.n_breakpoints - 1): + current_ = edge.breakpoints[i] + next_ = edge.breakpoints[i + 1] + length = next_.distance - current_.distance + g0.add_edge( + current_.id, + next_.id, + length=length, + ) + + return nx.convert_node_labels_to_integers(g0, label_attribute="alias") + + @overload + def find( + self, + *, + node: str, + edge: None = None, + distance: None = None, + ) -> int: ... + + @overload + def find( + self, + *, + node: list[str], + edge: None = None, + distance: None = None, + ) -> list[int]: ... + + @overload + def find( + self, + *, + node: None = None, + edge: str | list[str], + distance: str | float, + ) -> int: ... + + @overload + def find( + self, + *, + node: None = None, + edge: str | list[str], + distance: list[str | float], + ) -> list[int]: ... + + def find( + self, + node: str | list[str] | None = None, + edge: str | list[str] | None = None, + distance: str | float | list[str | float] | None = None, + ) -> int | list[int]: + """Find node or breakpoint id in the Network object based on former coordinates. + + Parameters + ---------- + node : str | List[str], optional + Node id(s) in the original network, by default None + edge : str | List[str], optional + Edge id(s) for breakpoint lookup or edge endpoint lookup, by default None + distance : str | float | List[str | float], optional + Distance(s) along edge for breakpoint lookup, or "start"/"end" + for edge endpoints, by default None + + Returns + ------- + int | List[int] + Node or breakpoint id(s) in the generic network + + Raises + ------ + ValueError + If invalid combination of parameters is provided + KeyError + If requested node/breakpoint is not found in the network + """ + # Determine lookup mode + by_node = node is not None + by_breakpoint = edge is not None or distance is not None + + if by_node and by_breakpoint: + raise ValueError( + "Cannot specify both 'node' and 'edge'/'distance' parameters simultaneously" + ) + + if not by_node and not by_breakpoint: + raise ValueError( + "Must specify either 'node' or both 'edge' and 'distance' parameters" + ) + + ids: list[str | tuple[str, float]] + + if by_node: + # Handle node lookup + assert node is not None + if not isinstance(node, list): + node = [node] + ids = list(node) + + else: + # Handle breakpoint/edge endpoint lookup + if edge is None or distance is None: + raise ValueError( + "Both 'edge' and 'distance' parameters are required for breakpoint/endpoint lookup" + ) + + if not isinstance(edge, list): + edge = [edge] + + if not isinstance(distance, list): + distance = [distance] + + # We can pass one edge and multiple breakpoints/endpoints + if len(edge) == 1: + edge = edge * len(distance) + + if len(edge) != len(distance): + raise ValueError( + "Incompatible lengths of 'edge' and 'distance' arguments. One 'edge' admits multiple distances, otherwise they must be the same length." + ) + + ids = [] + for edge_i, distance_i in zip(edge, distance): + if distance_i in ["start", "end"]: + # Handle edge endpoint lookup + if edge_i not in self._edges: + raise KeyError(f"Edge '{edge_i}' not found in the network.") + + network_edge = self._edges[edge_i] + if distance_i == "start": + ids.append(network_edge.start.id) + else: # distance_i == "end" + ids.append(network_edge.end.id) + else: + # Handle breakpoint lookup + if not isinstance(distance_i, (int, float)): + raise ValueError( + "Invalid 'distance' value for breakpoint lookup: " + f"{distance_i!r}. Expected a numeric value or 'start'/'end'." + ) + ids.append((edge_i, distance_i)) + + # Check if all ids exist in the network + _CHAINAGE_TOLERANCE = 1e-3 + + def _resolve_id(id): + if id in self._alias_map: + return self._alias_map[id] + if isinstance(id, tuple): + edge_id, distance = id + for key, val in self._alias_map.items(): + if ( + isinstance(key, tuple) + and key[0] == edge_id + and abs(key[1] - distance) <= _CHAINAGE_TOLERANCE + ): + return val + return None + + resolved = [_resolve_id(id) for id in ids] + missing_ids = [ids[i] for i, v in enumerate(resolved) if v is None] + if missing_ids: + raise KeyError( + f"Node/breakpoint(s) {missing_ids} not found in the network. Available nodes are {set(self._alias_map.keys())}" + ) + if len(resolved) == 1: + return resolved[0] + return resolved + + @overload + def recall(self, id: int) -> dict[str, Any]: ... + + @overload + def recall(self, id: list[int]) -> list[dict[str, Any]]: ... + + def recall(self, id: int | list[int]) -> dict[str, Any] | list[dict[str, Any]]: + """Recover the original coordinates of an element given the node id(s) in the Network object. + + Parameters + ---------- + id : int | List[int] + Node id(s) in the generic network + + Returns + ------- + Dict[str, Any] | List[Dict[str, Any]] + Original coordinates. For single input returns dict, for multiple inputs returns list of dicts. + Dict contains coordinates: + - For nodes: 'node' key with node id + - For breakpoints: 'edge' and 'distance' keys with edge id and distance + + Raises + ------ + KeyError + If node id is not found in the network + ValueError + If node id string format is invalid + """ + # Convert to list for uniform processing + if not isinstance(id, list): + id = [id] + + # Create reverse lookup map + reverse_alias_map = {v: k for k, v in self._alias_map.items()} + + results: list[dict[str, Any]] = [] + for node_id in id: + if node_id not in reverse_alias_map: + raise KeyError(f"Node ID {node_id} not found in the network.") + + key = reverse_alias_map[node_id] + if isinstance(key, str): + results.append({"node": key}) + else: # tuple[str, float] + results.append({"edge": key[0], "distance": key[1]}) + + # Return single dict if single input, list otherwise + if len(results) == 1: + return results[0] + else: + return results + + +class NodeModelResult(TimeSeries): + """Model result for a single network node. + + Construct a NodeModelResult from timeseries data for a specific node. + This is a simple timeseries class designed for network node data. + + Parameters + ---------- + data : str, Path, mikeio.Dataset, mikeio.DataArray, pd.DataFrame, pd.Series, xr.Dataset or xr.DataArray + filename (.dfs0 or .nc) or object with the data + name : str, optional + The name of the model result, + by default None (will be set to file name or item name) + node : int, optional + node ID (integer), by default None + item : str | int | None, optional + If multiple items/arrays are present in the input an item + must be given (as either an index or a string), by default None + quantity : Quantity, optional + Model quantity, for MIKE files this is inferred from the EUM information + aux_items : list[int | str], optional + Auxiliary items, by default None + + Examples + -------- + >>> import modelskill as ms + >>> mr = ms.NodeModelResult(data, node=123, name="Node_123") + >>> mr2 = ms.NodeModelResult(df, item="Water Level", node=456) + """ + + def __init__( + self, + data: PointType, + node: int, + *, + name: str | None = None, + item: str | int | None = None, + quantity: Quantity | None = None, + aux_items: Sequence[int | str] | None = None, + ): + if not self._is_input_validated(data): + data = _parse_network_node_input( + data, + name=name, + item=item, + quantity=quantity, + node=node, + aux_items=aux_items, + ) + + if not isinstance(data, xr.Dataset): + raise ValueError("'NodeModelResult' requires xarray.Dataset") + if data.coords.get("node") is None: + raise ValueError("'node' coordinate not found in data") + data_var = str(list(data.data_vars)[0]) + data[data_var].attrs["kind"] = "model" + super().__init__(data=data) + + @property + def node(self) -> int: + """Node ID of model result""" + node_val = self.data.coords["node"] + return int(node_val.item()) + + def _create_new_instance(self, data: xr.Dataset) -> Self: + """Extract node from data and create new instance""" + node = int(data.coords["node"].item()) + return self.__class__(data, node=node) + + +class NetworkModelResult: + """Model result for network data with time and node dimensions. + + Construct a NetworkModelResult from a Network object containing + timeseries data for each node. Users must provide exact node IDs + (integers obtained via ``Network.find()``) when creating observations — + no spatial interpolation is performed. + + Parameters + ---------- + data : Network + Network object containing timeseries data for each node. + name : str, optional + The name of the model result, + by default None (will be set to first data variable name) + item : str | int | None, optional + If multiple items/arrays are present in the input an item + must be given (as either an index or a string), by default None + quantity : Quantity, optional + Model quantity + aux_items : list[int | str], optional + Auxiliary items, by default None + + Examples + -------- + >>> import modelskill as ms + >>> from modelskill.model.network import Network + >>> network = Network(edges) # edges is a list[NetworkEdge] + >>> mr = ms.NetworkModelResult(network, name="MyModel") + >>> obs = ms.NodeObservation(data, node=network.find(node="node_A")) + >>> extracted = mr.extract(obs) + """ + + def __init__( + self, + data: Network, + *, + name: str | None = None, + item: str | int | None = None, + quantity: Quantity | None = None, + aux_items: Sequence[int | str] | None = None, + ): + if not isinstance(data, Network): + raise TypeError( + f"NetworkModelResult expects a Network object, got {type(data).__name__!r}" + ) + ds = data.to_dataset() + sel_items = SelectedItems.parse( + list(ds.data_vars), item=item, aux_items=aux_items + ) + name = name or sel_items.values + + self.data = ds[sel_items.all] + self.name = name + self.sel_items = sel_items + + if quantity is None: + da = self.data[sel_items.values] + quantity = Quantity.from_cf_attrs(da.attrs) + self.quantity = quantity + + # Mark data variables as model data + self.data[sel_items.values].attrs["kind"] = "model" + + def __repr__(self) -> str: + return f"<{self.__class__.__name__}>: {self.name}" + + @property + def time(self) -> pd.DatetimeIndex: + """Return the time coordinate as a pandas.DatetimeIndex.""" + return pd.DatetimeIndex(self.data.time.to_index()) + + @property + def nodes(self) -> npt.NDArray[np.intp]: + """Return the node IDs as a numpy array of integers.""" + return self.data.node.values + + def extract( + self, + observation: NodeObservation, + ) -> NodeModelResult: + """Extract ModelResult at exact node locations + + Parameters + ---------- + observation : NodeObservation + observation with node ID (only NodeObservation supported) + + Returns + ------- + NodeModelResult + extracted model result + """ + if not isinstance(observation, NodeObservation): + raise TypeError( + f"NetworkModelResult only supports NodeObservation, got {type(observation).__name__}" + ) + + node_id = observation.node + if node_id not in self.data.node: + raise ValueError( + f"Node {node_id} not found. Available: {list(self.nodes[:5])}..." + ) + + return NodeModelResult( + data=self.data.sel(node=node_id).drop_vars("node"), + node=node_id, + name=self.name, + item=self.sel_items.values, + quantity=self.quantity, + aux_items=self.sel_items.aux, + ) diff --git a/src/modelskill/model/point.py b/src/modelskill/model/point.py index 5a11b1a30..4e071c4e9 100644 --- a/src/modelskill/model/point.py +++ b/src/modelskill/model/point.py @@ -1,14 +1,13 @@ from __future__ import annotations -from typing import Optional, Sequence, Any -import numpy as np +from typing import Sequence, Any import xarray as xr -import pandas as pd from ..obs import Observation from ..types import PointType from ..quantity import Quantity from ..timeseries import TimeSeries, _parse_xyz_point_input +from ..timeseries._align import align_data class PointModelResult(TimeSeries): @@ -22,7 +21,7 @@ class PointModelResult(TimeSeries): ---------- data : str, Path, mikeio.Dataset, mikeio.DataArray, pd.DataFrame, pd.Series, xr.Dataset or xr.DataArray filename (.dfs0 or .nc) or object with the data - name : Optional[str], optional + name : str | None, optional The name of the model result, by default None (will be set to file name or item name) x : float, optional @@ -36,7 +35,7 @@ class PointModelResult(TimeSeries): must be given (as either an index or a string), by default None quantity : Quantity, optional Model quantity, for MIKE files this is inferred from the EUM information - aux_items : Optional[list[int | str]], optional + aux_items : list[int | str] | None, optional Auxiliary items, by default None """ @@ -44,13 +43,13 @@ def __init__( self, data: PointType, *, - name: Optional[str] = None, - x: Optional[float] = None, - y: Optional[float] = None, - z: Optional[float] = None, + name: str | None = None, + x: float | None = None, + y: float | None = None, + z: float | None = None, item: str | int | None = None, - quantity: Optional[Quantity] = None, - aux_items: Optional[Sequence[int | str]] = None, + quantity: Quantity | None = None, + aux_items: Sequence[int | str] | None = None, ) -> None: if not self._is_input_validated(data): data = _parse_xyz_point_input( @@ -89,62 +88,5 @@ def interp_time(self, observation: Observation, **kwargs: Any) -> PointModelResu PointModelResult Interpolated model result """ - ds = self.align(observation, **kwargs) + ds = align_data(self.data, observation, **kwargs) return PointModelResult(ds) - - def align( - self, - observation: Observation, - *, - max_gap: float | None = None, - **kwargs: Any, - ) -> xr.Dataset: - new_time = observation.time - - dati = self.data.dropna("time").interp( - time=new_time, assume_sorted=True, **kwargs - ) - - pmr = PointModelResult(dati) - if max_gap is not None: - pmr = pmr._remove_model_gaps(mod_index=self.time, max_gap=max_gap) - return pmr.data - - def _remove_model_gaps( - self, - mod_index: pd.DatetimeIndex, - max_gap: float | None = None, - ) -> PointModelResult: - """Remove model gaps longer than max_gap from TimeSeries""" - max_gap_delta = pd.Timedelta(max_gap, "s") - valid_times = self._get_valid_times(mod_index, max_gap_delta) - ds = self.data.sel(time=valid_times) - return PointModelResult(ds) - - def _get_valid_times( - self, mod_index: pd.DatetimeIndex, max_gap: pd.Timedelta - ) -> pd.DatetimeIndex: - """Used only by _remove_model_gaps""" - obs_index = self.time - # init dataframe of available timesteps and their index - df = pd.DataFrame(index=mod_index) - df["idx"] = range(len(df)) - - # for query times get available left and right index of source times - df = ( - df.reindex(df.index.union(obs_index)) - .interpolate(method="time", limit_area="inside") - .reindex(obs_index) - .dropna() - ) - df["idxa"] = np.floor(df.idx).astype(int) - df["idxb"] = np.ceil(df.idx).astype(int) - - # time of left and right source times and time delta - df["ta"] = mod_index[df.idxa] - df["tb"] = mod_index[df.idxb] - df["dt"] = df.tb - df.ta - - # valid query times where time delta is less than max_gap - valid_idx = df.dt <= max_gap - return df[valid_idx].index diff --git a/src/modelskill/model/track.py b/src/modelskill/model/track.py index cb37ca8e7..612cb0063 100644 --- a/src/modelskill/model/track.py +++ b/src/modelskill/model/track.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import Literal, Optional, Sequence +from typing import Literal, Sequence import warnings import numpy as np @@ -21,7 +21,7 @@ class TrackModelResult(TimeSeries): ---------- data : types.TrackType The input data or file path - name : Optional[str], optional + name : str | None, optional The name of the model result, by default None (will be set to file name or item name) item : str | int | None, optional @@ -38,7 +38,7 @@ class TrackModelResult(TimeSeries): "first" to keep first occurrence, "last" to keep last occurrence, False to drop all duplicates, "offset" to add milliseconds to consecutive duplicates, by default "first" - aux_items : Optional[list[int | str]], optional + aux_items : list[int | str] | None, optional Auxiliary items, by default None """ @@ -46,13 +46,13 @@ def __init__( self, data: TrackType, *, - name: Optional[str] = None, + name: str | None = None, item: str | int | None = None, - quantity: Optional[Quantity] = None, + quantity: Quantity | None = None, x_item: str | int = 0, y_item: str | int = 1, keep_duplicates: Literal["first", "last", False] = "first", - aux_items: Optional[Sequence[int | str]] = None, + aux_items: Sequence[int | str] | None = None, ) -> None: if not self._is_input_validated(data): data = _parse_track_input( diff --git a/src/modelskill/obs.py b/src/modelskill/obs.py index 5828b2b03..7d8968305 100644 --- a/src/modelskill/obs.py +++ b/src/modelskill/obs.py @@ -1,17 +1,19 @@ """ # Observations -ModelSkill supports two types of observations: +ModelSkill supports three types of observations: * [`PointObservation`](`modelskill.PointObservation`) - a point timeseries from a dfs0/nc file or a DataFrame * [`TrackObservation`](`modelskill.TrackObservation`) - a track (moving point) timeseries from a dfs0/nc file or a DataFrame +* [`NodeObservation`](`modelskill.NodeObservation`) - a network node timeseries for specific node IDs. An observation can be created by explicitly invoking one of the above classes or using the [`observation()`](`modelskill.observation`) function which will return the appropriate type based on the input data (if possible). """ from __future__ import annotations -from typing import Literal, Optional, Any, Union +from typing import Literal, Any, Union, overload +from typing_extensions import Self import warnings import pandas as pd import xarray as xr @@ -22,6 +24,7 @@ TimeSeries, _parse_xyz_point_input, _parse_track_input, + _parse_network_node_input, ) # NetCDF attributes can only be str, int, float https://unidata.github.io/netcdf4-python/#attributes-in-a-netcdf-file @@ -31,9 +34,9 @@ def observation( data: DataInputType, *, - gtype: Optional[Literal["point", "track"]] = None, + gtype: Literal["point", "track", "node"] | None = None, **kwargs, -) -> PointObservation | TrackObservation: +) -> PointObservation | TrackObservation | NodeObservation: """Create an appropriate observation object. A factory function for creating an appropriate observation object @@ -41,19 +44,20 @@ def observation( If 'x' or 'y' is given, a PointObservation is created. If 'x_item' or 'y_item' is given, a TrackObservation is created. + If 'node' is given, a NodeObservation is created. Parameters ---------- data : DataInputType The data to be used for creating the Observation object. - gtype : Optional[Literal["point", "track"]] + gtype : Literal["point", "track", "node"] | None The geometry type of the data. If not specified, it will be guessed from the data. **kwargs Additional keyword arguments to be passed to the Observation constructor. Returns ------- - PointObservation or TrackObservation + PointObservation or TrackObservation or NodeObservation An observation object of the appropriate type Examples @@ -61,6 +65,7 @@ def observation( >>> import modelskill as ms >>> o_pt = ms.observation(df, item=0, x=366844, y=6154291, name="Klagshamn") >>> o_tr = ms.observation("lon_after_lat.dfs0", item="wl", x_item=1, y_item=0) + >>> o_node = ms.observation(df, item="Water Level", node=123, name="123") """ if gtype is None: geometry = _guess_gtype(**kwargs) @@ -80,14 +85,16 @@ def _guess_gtype(**kwargs) -> GeometryType: return GeometryType.POINT elif "x_item" in kwargs or "y_item" in kwargs: return GeometryType.TRACK + elif "node" in kwargs: + return GeometryType.NODE else: warnings.warn( - "Could not guess geometry type from data or args, assuming POINT geometry. Use PointObservation or TrackObservation to be explicit." + "Could not guess geometry type from data or args, assuming POINT geometry. Use PointObservation, TrackObservation, or NodeObservation to be explicit." ) return GeometryType.POINT -def _validate_attrs(data_attrs: dict, attrs: Optional[dict]) -> None: +def _validate_attrs(data_attrs: dict, attrs: dict | None) -> None: # See similar method in xarray https://github.com/pydata/xarray/blob/main/xarray/backends/api.py#L165 if attrs is None: @@ -109,7 +116,7 @@ def __init__( data: xr.Dataset, weight: float, color: str = "#d62728", # TODO: cannot currently be set by user - attrs: Optional[dict] = None, + attrs: dict | None = None, ) -> None: assert isinstance(data, xr.Dataset) @@ -196,15 +203,15 @@ def __init__( self, data: PointType, *, - item: Optional[int | str] = None, - x: Optional[float] = None, - y: Optional[float] = None, - z: Optional[float] = None, - name: Optional[str] = None, + item: int | str | None = None, + x: float | None = None, + y: float | None = None, + z: float | None = None, + name: str | None = None, weight: float = 1.0, - quantity: Optional[Quantity] = None, - aux_items: Optional[list[int | str]] = None, - attrs: Optional[dict] = None, + quantity: Quantity | None = None, + aux_items: list[int | str] | None = None, + attrs: dict | None = None, ) -> None: if not self._is_input_validated(data): data = _parse_xyz_point_input( @@ -315,15 +322,15 @@ def __init__( self, data: TrackType, *, - item: Optional[int | str] = None, - name: Optional[str] = None, + item: int | str | None = None, + name: str | None = None, weight: float = 1.0, - x_item: Optional[int | str] = 0, - y_item: Optional[int | str] = 1, + x_item: int | str | None = 0, + y_item: int | str | None = 1, keep_duplicates: Literal["first", "last", False] = "first", - quantity: Optional[Quantity] = None, - aux_items: Optional[list[int | str]] = None, - attrs: Optional[dict] = None, + quantity: Quantity | None = None, + aux_items: list[int | str] | None = None, + attrs: dict | None = None, ) -> None: if not self._is_input_validated(data): data = _parse_track_input( @@ -340,6 +347,184 @@ def __init__( super().__init__(data=data, weight=weight, attrs=attrs) +class NodeObservation(Observation): + """Class for observations at network nodes. + + Create a NodeObservation from a DataFrame or other data source. + The node ID is specified as an integer. + + To create multiple NodeObservation objects from a single data source, + use :method:`from_multiple`. + + Parameters + ---------- + data : str, Path, mikeio.Dataset, mikeio.DataArray, pd.DataFrame, pd.Series, xr.Dataset or xr.DataArray + data source with time series for the node + item : (int, str), optional + index or name of the wanted item/column, by default None + if data contains more than one item, item must be given + node : int, optional + node ID (integer), by default None + name : str, optional + user-defined name for easy identification in plots etc, by default derived from data + weight : float, optional + weighting factor for skill scores, by default 1.0 + quantity : Quantity, optional + The quantity of the observation, for validation with model results + aux_items : list, optional + list of names or indices of auxiliary items, by default None + attrs : dict, optional + additional attributes to be added to the data, by default None + + Examples + -------- + >>> import modelskill as ms + >>> o1 = ms.NodeObservation(data, node=123, name="123") + >>> o2 = ms.NodeObservation(df, item="Water Level", node=456) + >>> + >>> # Multiple node observations from separate data sources + >>> obs = ms.NodeObservation.from_multiple(nodes={123: df1, 456: df2}) + """ + + def __init__( + self, + data: PointType, + node: int, + *, + item: int | str | None = None, + name: str | None = None, + weight: float = 1.0, + quantity: Quantity | None = None, + aux_items: list[int | str] | None = None, + attrs: dict | None = None, + ) -> None: + if not self._is_input_validated(data): + data = _parse_network_node_input( + data, + name=name, + item=item, + quantity=quantity, + node=node, + aux_items=aux_items, + ) + + assert isinstance(data, xr.Dataset) + super().__init__(data=data, weight=weight, attrs=attrs) + + @property + def node(self) -> int: + """Node ID of observation""" + node_val = self.data.coords["node"] + return int(node_val.item()) + + def _create_new_instance(self, data: xr.Dataset) -> Self: + """Extract node from data and create new instance""" + node = int(data.coords["node"].item()) + return self.__class__(data, node=node) + + @overload + @classmethod + def from_multiple( + cls, + *, + data: PointType, + nodes: dict[int, str | int], + quantity: Quantity | None = None, + aux_items: list[int | str] | None = None, + attrs: dict | None = None, + ) -> list[NodeObservation]: ... + + @overload + @classmethod + def from_multiple( + cls, + *, + nodes: dict[int, PointType], + quantity: Quantity | None = None, + aux_items: list[int | str] | None = None, + attrs: dict | None = None, + ) -> list[NodeObservation]: ... + + @classmethod + def from_multiple( + cls, + *, + data: PointType | None = None, + nodes: dict[int, Any] | None = None, + quantity: Quantity | None = None, + aux_items: list[int | str] | None = None, + attrs: dict | None = None, + ) -> list[NodeObservation]: + """Create multiple NodeObservation objects. + + Two calling conventions are supported: + + 1. **Separate data sources** — pass only ``nodes`` as a dict mapping + each node ID to its own data source (file path, DataFrame, etc.):: + + obs = NodeObservation.from_multiple(nodes={123: df1, 456: "sensor.csv"}) + + 2. **Shared data source** — pass a single ``data`` object together with + ``nodes`` as a dict mapping each node ID to the column name or index + to select from ``data``:: + + obs = NodeObservation.from_multiple(data=df, nodes={123: "col_a", 456: "col_b"}) + + Parameters + ---------- + data : PointType, optional + Shared data source (required when ``nodes`` values are column selectors). + nodes : dict[int, PointType | str | int] + Mapping of node_id -> data source or column selector. + quantity : Quantity | None, optional + Physical quantity metadata, by default None. + aux_items : list[int | str] | None, optional + Auxiliary items, by default None. + attrs : dict | None, optional + Additional attributes, by default None. + + Returns + ------- + list[NodeObservation] + List of NodeObservation objects. + """ + if nodes is None: + raise ValueError("'nodes' argument is required") + if not isinstance(nodes, dict): + raise TypeError( + f"'nodes' must be a dict mapping node_id -> data_source, got {type(nodes).__name__}" + ) + + node_ids = list(nodes.keys()) + + if data is None: + data_sources: list[PointType] = list(nodes.values()) # type: ignore[list-item] + return [ + cls( + data_i, + node=node_i, + item=None, + quantity=quantity, + aux_items=aux_items, + attrs=attrs, + ) + for data_i, node_i in zip(data_sources, node_ids) + ] + else: + node_items: list[int | str | None] = list(nodes.values()) # type: ignore[list-item] + return [ + cls( + data, + node=node_i, + item=item_i, + quantity=quantity, + aux_items=aux_items, + attrs=attrs, + ) + for node_i, item_i in zip(node_ids, node_items) + ] + + def unit_display_name(name: str) -> str: """Display name @@ -364,4 +549,5 @@ def unit_display_name(name: str) -> str: _obs_class_lookup = { GeometryType.POINT: PointObservation, GeometryType.TRACK: TrackObservation, + GeometryType.NODE: NodeObservation, } diff --git a/src/modelskill/plotting/_misc.py b/src/modelskill/plotting/_misc.py index 4716fb799..e41a8e214 100644 --- a/src/modelskill/plotting/_misc.py +++ b/src/modelskill/plotting/_misc.py @@ -1,6 +1,6 @@ from __future__ import annotations import warnings -from typing import Optional, Sequence, Tuple, Union, Mapping +from typing import Sequence, Tuple, Union, Mapping import matplotlib.pyplot as plt from matplotlib.axes import Axes @@ -115,7 +115,7 @@ def sample_points( def quantiles_xy( x: np.ndarray, y: np.ndarray, - quantiles: Optional[Union[int, Sequence[float]]] = None, + quantiles: Union[int, Sequence[float]] | None = None, ): """Calculate quantiles of x and y diff --git a/src/modelskill/plotting/_scatter.py b/src/modelskill/plotting/_scatter.py index de2cd700a..e8d5c0600 100644 --- a/src/modelskill/plotting/_scatter.py +++ b/src/modelskill/plotting/_scatter.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import Literal, Optional, Sequence, Tuple, Callable, TYPE_CHECKING, Mapping +from typing import Literal, Sequence, Tuple, Callable, TYPE_CHECKING, Mapping if TYPE_CHECKING: import matplotlib.axes @@ -29,21 +29,21 @@ def scatter( quantiles: int | Sequence[float] | None = None, fit_to_quantiles: bool = False, show_points: bool | int | float | None = None, - show_hist: Optional[bool] = None, - show_density: Optional[bool] = None, - norm: Optional[colors.Normalize] = None, + show_hist: bool | None = None, + show_density: bool | None = None, + norm: colors.Normalize | None = None, backend: Literal["matplotlib", "plotly"] = "matplotlib", figsize: Tuple[float, float] = (8, 8), - xlim: Optional[Tuple[float, float]] = None, - ylim: Optional[Tuple[float, float]] = None, + xlim: Tuple[float, float] | None = None, + ylim: Tuple[float, float] | None = None, reg_method: str | bool = "ols", title: str = "", xlabel: str = "", ylabel: str = "", - skill_table: Optional[str | Sequence[str] | Mapping[str, str] | bool] = False, + skill_table: str | Sequence[str] | Mapping[str, str] | bool | None = False, skill_scores: Mapping[str, float] | None = None, - skill_score_unit: Optional[str] = "", - ax: Optional[Axes] = None, + skill_score_unit: str | None = "", + ax: Axes | None = None, **kwargs, ) -> Axes: """Scatter plot tailored for model skill comparison. @@ -649,7 +649,7 @@ def _plot_summary_table( skill_scores: Mapping[str, float], units: str, ax, - cbar_width: Optional[float] = None, + cbar_width: float | None = None, ) -> None: # If colorbar, get extents from colorbar label: x0 = options.plot.scatter.skill_table.x_position diff --git a/src/modelskill/plotting/_spatial_overview.py b/src/modelskill/plotting/_spatial_overview.py index 156af04c1..276a94b63 100644 --- a/src/modelskill/plotting/_spatial_overview.py +++ b/src/modelskill/plotting/_spatial_overview.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import Optional, Iterable, Tuple, TYPE_CHECKING +from typing import Iterable, Tuple, TYPE_CHECKING if TYPE_CHECKING: import matplotlib.axes @@ -14,15 +14,16 @@ def spatial_overview( obs: Observation | Iterable[Observation], - mod: Optional[ + mod: ( DfsuModelResult | GeometryFM2D | Iterable[DfsuModelResult] | Iterable[GeometryFM2D] - ] = None, + ) + | None = None, ax=None, - figsize: Optional[Tuple] = None, - title: Optional[str] = None, + figsize: Tuple | None = None, + title: str | None = None, ) -> matplotlib.axes.Axes: """Plot observation points on a map showing the model domain diff --git a/src/modelskill/plotting/_wind_rose.py b/src/modelskill/plotting/_wind_rose.py index b10e018bf..730f197ed 100644 --- a/src/modelskill/plotting/_wind_rose.py +++ b/src/modelskill/plotting/_wind_rose.py @@ -1,6 +1,6 @@ from __future__ import annotations from dataclasses import dataclass -from typing import List, Optional, Tuple, Union, TYPE_CHECKING +from typing import List, Tuple, Union, TYPE_CHECKING if TYPE_CHECKING: import matplotlib.axes @@ -387,10 +387,10 @@ def directional_labels(n: int) -> Tuple[str, ...]: def pretty_intervals( magmax: float, - mag_bins: Optional[List[float]] = None, - mag_step: Optional[float] = None, - vmin: Optional[float] = None, - max_bin: Optional[float] = None, + mag_bins: List[float] | None = None, + mag_step: float | None = None, + vmin: float | None = None, + max_bin: float | None = None, n_decimals: int = 3, ): """Pretty intervals for the magnitude bins""" @@ -497,7 +497,7 @@ def _calc_mag_step(magmax: float) -> float: return mag_step -def _calc_radial_ticks(*, counts: np.ndarray, step: float, stop: Optional[float]): +def _calc_radial_ticks(*, counts: np.ndarray, step: float, stop: float | None): cmax = counts.sum(axis=0).max() if stop is None: rmax = np.ceil((cmax + step) / step) * step diff --git a/src/modelskill/settings.py b/src/modelskill/settings.py index 3d4df4816..73e7063ba 100644 --- a/src/modelskill/settings.py +++ b/src/modelskill/settings.py @@ -74,7 +74,6 @@ Iterable, List, NamedTuple, - Optional, Dict, Tuple, Type, @@ -87,8 +86,8 @@ class RegisteredOption(NamedTuple): key: str defval: object doc: str - validator: Optional[Callable[[object], Any]] - # cb: Optional[Callable[[str], Any]] + validator: Callable[[object], Any] | None + # cb: Callable[[str], Any] | None # holds registered option metadata @@ -180,7 +179,7 @@ def _option_to_dict(pat: str = "") -> Dict: return d -def _describe_option_short(pat: str = "", _print_desc: bool = True) -> Optional[str]: +def _describe_option_short(pat: str = "", _print_desc: bool = True) -> str | None: keys = _select_options(pat) if len(keys) == 0: raise OptionError("No such keys(s)") @@ -193,7 +192,7 @@ def _describe_option_short(pat: str = "", _print_desc: bool = True) -> Optional[ return s -def _describe_option(pat: str = "", _print_desc: bool = True) -> Optional[str]: +def _describe_option(pat: str = "", _print_desc: bool = True) -> str | None: keys = _select_options(pat) if len(keys) == 0: raise OptionError("No such keys(s)") @@ -350,8 +349,8 @@ def register_option( key: str, defval: object, doc: str = "", - validator: Optional[Callable[[Any], Any]] = None, - # cb: Optional[Callable[[str], Any]] = None, + validator: Callable[[Any], Any] | None = None, + # cb: Callable[[str], Any] | None = None, ) -> None: """ Register an option in the package-wide modelskill settingss object diff --git a/src/modelskill/timeseries/__init__.py b/src/modelskill/timeseries/__init__.py index cb1ca3ffc..4885c9987 100644 --- a/src/modelskill/timeseries/__init__.py +++ b/src/modelskill/timeseries/__init__.py @@ -1,9 +1,10 @@ from ._timeseries import TimeSeries -from ._point import _parse_xyz_point_input +from ._point import _parse_xyz_point_input, _parse_network_node_input from ._track import _parse_track_input __all__ = [ "TimeSeries", "_parse_xyz_point_input", "_parse_track_input", + "_parse_network_node_input", ] diff --git a/src/modelskill/timeseries/_align.py b/src/modelskill/timeseries/_align.py new file mode 100644 index 000000000..5e0b6b1b8 --- /dev/null +++ b/src/modelskill/timeseries/_align.py @@ -0,0 +1,85 @@ +"""Time series alignment utilities.""" + +from __future__ import annotations +import numpy as np +import pandas as pd +import xarray as xr +from typing import Any +from ..obs import Observation + + +def _get_valid_times( + obs_time: pd.DatetimeIndex, mod_index: pd.DatetimeIndex, max_gap: pd.Timedelta +) -> pd.DatetimeIndex: + """Get valid times where interpolation gaps are within max_gap""" + # init dataframe of available timesteps and their index + df = pd.DataFrame(index=mod_index) + df["idx"] = range(len(df)) + + # for query times get available left and right index of source times + df = ( + df.reindex(df.index.union(obs_time)) + .interpolate(method="time", limit_area="inside") + .reindex(obs_time) + .dropna() + ) + df["idxa"] = np.floor(df.idx).astype(int) + df["idxb"] = np.ceil(df.idx).astype(int) + + # time of left and right source times and time delta + df["ta"] = mod_index[df.idxa] + df["tb"] = mod_index[df.idxb] + df["dt"] = df.tb - df.ta + + # valid query times where time delta is less than max_gap + valid_idx = df.dt <= max_gap + return df[valid_idx].index + + +def _remove_model_gaps( + data: xr.Dataset, + mod_index: pd.DatetimeIndex, + max_gap: float | None = None, +) -> xr.Dataset: + """Remove model gaps longer than max_gap from Dataset""" + max_gap_delta = pd.Timedelta(max_gap, "s") + obs_time = pd.DatetimeIndex(data.time.to_index()) + valid_times = _get_valid_times(obs_time, mod_index, max_gap_delta) + return data.sel(time=valid_times) + + +def align_data( + data: xr.Dataset, + observation: Observation, + *, + max_gap: float | None = None, + **kwargs: Any, +) -> xr.Dataset: + """Align model data to observation time. + + Interpolates model result to the time of the observation. + + Parameters + ---------- + data : xr.Dataset + The model dataset to align + observation : Observation + The observation to align to + max_gap : float | None, optional + Maximum gap in seconds for interpolation gaps removal, by default None + **kwargs : Any + Additional keyword arguments passed to xarray.interp + + Returns + ------- + xr.Dataset + Aligned dataset + """ + new_time = observation.time + + dati = data.dropna("time").interp(time=new_time, assume_sorted=True, **kwargs) + + if max_gap is not None: + model_time = pd.DatetimeIndex(data.time.to_index()) + dati = _remove_model_gaps(dati, mod_index=model_time, max_gap=max_gap) + return dati diff --git a/src/modelskill/timeseries/_coords.py b/src/modelskill/timeseries/_coords.py index 899dd5e63..5e5aa626a 100644 --- a/src/modelskill/timeseries/_coords.py +++ b/src/modelskill/timeseries/_coords.py @@ -1,14 +1,12 @@ import numpy as np -from typing import Optional - class XYZCoords: def __init__( self, - x: Optional[float] = None, - y: Optional[float] = None, - z: Optional[float] = None, + x: float | None = None, + y: float | None = None, + z: float | None = None, ): self.x = x if x is not None else np.nan self.y = y if y is not None else np.nan @@ -17,3 +15,17 @@ def __init__( @property def as_dict(self) -> dict: return {"x": self.x, "y": self.y, "z": self.z} + + +class NetworkCoords: + def __init__( + self, + node: int | None = None, + boundary: str | None = None, + ): + self.node = node if node is not None else np.nan + self.boundary = boundary if boundary is not None else np.nan + + @property + def as_dict(self) -> dict: + return {"node": self.node, "boundary": self.boundary} diff --git a/src/modelskill/timeseries/_point.py b/src/modelskill/timeseries/_point.py index 5e2a0c86a..a9fb076b6 100644 --- a/src/modelskill/timeseries/_point.py +++ b/src/modelskill/timeseries/_point.py @@ -2,10 +2,9 @@ from collections.abc import Hashable from dataclasses import dataclass from pathlib import Path -from typing import Sequence, get_args, List, Optional, Tuple, Union, Any +from typing import Sequence, get_args, List, Tuple, Union, Any import pandas as pd import xarray as xr -import numpy as np import mikeio @@ -13,7 +12,7 @@ from ..quantity import Quantity from ..utils import _get_name from ._timeseries import _validate_data_var_name -from ._coords import XYZCoords +from ._coords import XYZCoords, NetworkCoords @dataclass @@ -29,7 +28,7 @@ def all(self) -> List[str]: def _parse_point_items( items: Sequence[Hashable], item: int | str | None, - aux_items: Optional[Sequence[int | str]] = None, + aux_items: Sequence[int | str] | None = None, ) -> PointItem: """If input has exactly 1 item we accept item=None""" if item is None: @@ -62,8 +61,8 @@ def _select_items( pd.Series, pd.DataFrame, ], - item: Optional[str | int] = None, - aux_items: Optional[Sequence[int | str]] = None, + item: str | int | None = None, + aux_items: Sequence[int | str] | None = None, ) -> PointItem: if isinstance(data, (mikeio.DataArray, pd.Series, xr.DataArray)): item_name = data.name if data.name is not None else "PointModelResult" @@ -144,7 +143,7 @@ def _convert_to_dataset( def _include_coords( ds: xr.Dataset, *, - coords: Optional[XYZCoords] = None, + coords: XYZCoords | NetworkCoords | None = None, ) -> xr.Dataset: ds = ds.copy() if coords is not None: @@ -153,7 +152,9 @@ def _include_coords( coords_to_add = {} for k, v in coords.as_dict.items(): # Add if coordinate doesn't exist, or if user provided a non-null value - if k not in ds.coords or (v is not None and not np.isnan(v)): + # - pd.isna(v) returns True for NaN, False otherwise + # - for string values: pd.isna(v) returns False (strings are never considered "NA" unless specifically None) + if k not in ds.coords or (v is not None and not pd.isna(v)): coords_to_add[k] = v ds.coords.update(coords_to_add) @@ -165,7 +166,10 @@ def _include_attributes( ) -> xr.Dataset: ds = ds.copy() - ds.attrs["gtype"] = str(GeometryType.POINT) + if "node" in ds.coords: + ds.attrs["gtype"] = str(GeometryType.NODE) + else: + ds.attrs["gtype"] = str(GeometryType.POINT) ds[name].attrs["long_name"] = quantity.name ds[name].attrs["units"] = quantity.unit @@ -178,7 +182,7 @@ def _include_attributes( def _open_and_name( - data: PointType, name: Optional[str] + data: PointType, name: str | None ) -> Tuple[ Union[ mikeio.Dataset, @@ -204,6 +208,8 @@ def _open_and_name( elif suffix == ".nc": data = xr.open_dataset(data) name = name if name_is_arg else data.attrs.get("name") or stem + elif suffix == ".csv": + data = pd.read_csv(data, parse_dates=True, index_col=0) elif isinstance(data, mikeio.Dfs0): data = data.read() # now mikeio.Dataset @@ -232,14 +238,18 @@ def _select_variable_name(name: str, sel_items: PointItem) -> str: def _parse_point_input( data: PointType, - name: Optional[str], - item: Optional[str | int], - quantity: Optional[Quantity], - aux_items: Optional[Sequence[int | str]], + name: str | None, + item: str | int | None, + quantity: Quantity | None, + aux_items: Sequence[int | str] | None, *, - coords: XYZCoords, + coords: XYZCoords | NetworkCoords, ) -> xr.Dataset: - """Convert accepted input data to an xr.Dataset""" + """Convert accepted input data to an xr.Dataset.""" + + # name -> the name of the model result + # item -> the name of the element of interest in the data + # quantity -> the physical quantity that the variable of interest represents data, name = _open_and_name(data, name) sel_items = _select_items(data, item, aux_items) @@ -247,21 +257,36 @@ def _parse_point_input( varname = _select_variable_name(name, sel_items) ds = _convert_to_dataset(data, varname, sel_items) - ds = _include_attributes(ds, varname, quantity, sel_items) ds = _include_coords(ds, coords=coords) + ds = _include_attributes(ds, varname, quantity, sel_items) return ds def _parse_xyz_point_input( data: PointType, - name: Optional[str], + name: str | None, item: str | int | None, - quantity: Optional[Quantity], - x: Optional[float], - y: Optional[float], - z: Optional[float], - aux_items: Optional[Sequence[int | str]], + quantity: Quantity | None, + x: float | None, + y: float | None, + z: float | None, + aux_items: Sequence[int | str] | None, ) -> xr.Dataset: coords = XYZCoords(x, y, z) ds = _parse_point_input(data, name, item, quantity, aux_items, coords=coords) return ds + + +def _parse_network_node_input( + data: PointType, + name: str | None, + item: str | int | None, + quantity: Quantity | None, + node: int | None, + aux_items: Sequence[int | str] | None, +) -> xr.Dataset: + if node is None: + raise ValueError("'node' argument cannot be empty.") + coords = NetworkCoords(node=node) + ds = _parse_point_input(data, name, item, quantity, aux_items, coords=coords) + return ds diff --git a/src/modelskill/timeseries/_timeseries.py b/src/modelskill/timeseries/_timeseries.py index 52ff26ca4..6b6d02193 100644 --- a/src/modelskill/timeseries/_timeseries.py +++ b/src/modelskill/timeseries/_timeseries.py @@ -1,7 +1,8 @@ from __future__ import annotations from copy import deepcopy from dataclasses import dataclass -from typing import ClassVar, Literal, Optional, TypeVar, Any +from typing import ClassVar, Literal, TypeVar, Any +from typing_extensions import Self import warnings import numpy as np import pandas as pd @@ -63,13 +64,17 @@ def _validate_dataset(ds: xr.Dataset) -> xr.Dataset: ), "time must be increasing (please check for duplicate times))" # Validate coordinates - if "x" not in ds.coords: - raise ValueError("data must have an x-coordinate") - if "y" not in ds.coords: - raise ValueError("data must have a y-coordinate") - if "z" not in ds.coords: - ds.coords["z"] = np.nan - # assert "z" in ds.coords, "data must have a z-coordinate" + # Check for either traditional x,y coordinates OR node-based coordinates + has_spatial_coords = "x" in ds.coords and "y" in ds.coords + has_node_coord = "node" in ds.coords + + if not has_spatial_coords and not has_node_coord: + raise ValueError("data must have either x,y coordinates or a node coordinate") + + if has_spatial_coords: + # Traditional spatial data - ensure z coordinate exists + if "z" not in ds.coords: + ds.coords["z"] = np.nan # Validate data vars = [v for v in ds.data_vars] @@ -108,9 +113,10 @@ def _validate_dataset(ds: xr.Dataset) -> xr.Dataset: if ds.attrs["gtype"] not in [ str(GeometryType.POINT), str(GeometryType.TRACK), + str(GeometryType.NODE), ]: raise ValueError( - f"data attribute 'gtype' must be one of {GeometryType.POINT} or {GeometryType.TRACK}" + f"data attribute 'gtype' must be one of {GeometryType.POINT}, {GeometryType.TRACK}, or {GeometryType.NODE}" ) if "long_name" not in da.attrs: da.attrs["long_name"] = Quantity.undefined().name @@ -222,7 +228,14 @@ def y(self) -> Any: def y(self, value: Any) -> None: self.data["y"] = value - def _coordinate_values(self, coord: str) -> float | np.ndarray: + @property + def node(self) -> Any: + """node-coordinate""" + return self._coordinate_values("node") + + def _coordinate_values(self, coord: str) -> None | float | np.ndarray: + if coord not in self.data.coords: + return None # Node-based data doesn't have y coordinate vals = self.data[coord].values return np.atleast_1d(vals)[0] if vals.ndim == 0 else vals @@ -248,7 +261,12 @@ def __repr__(self) -> str: res = [] res.append(f"<{self.__class__.__name__}>: {self.name}") if self.gtype == str(GeometryType.POINT): - res.append(f"Location: {self.x}, {self.y}") + # Show location based on available coordinates + if "node" in self.data.coords: + node_id = self.data.coords["node"].item() + res.append(f"Node: {node_id}") + elif self.x is not None and self.y is not None: + res.append(f"Location: {self.x}, {self.y}") res.append(f"Time: {self.time[0]} - {self.time[-1]}") res.append(f"Quantity: {self.quantity}") if len(self._aux_vars) > 0: @@ -298,23 +316,32 @@ def to_dataframe(self) -> pd.DataFrame: if self.gtype == str(GeometryType.POINT): # we remove the scalar coordinate variables as they # will otherwise be columns in the dataframe - return self.data.drop_vars(["x", "y", "z"]).to_dataframe() + # Only drop coordinates that exist + coords_to_drop = [] + for coord in ["x", "y", "z"]: + if coord in self.data.coords: + coords_to_drop.append(coord) + return self.data.drop_vars(coords_to_drop).to_dataframe() elif self.gtype == str(GeometryType.TRACK): - df = self.data.drop_vars(["z"]).to_dataframe() - # make sure that x, y cols are first - cols = ["x", "y"] + [c for c in df.columns if c not in ["x", "y"]] + coords_to_drop = ["z"] if "z" in self.data.coords else [] + df = self.data.drop_vars(coords_to_drop).to_dataframe() + # makes sure that x comes first, then y, then other columns alphabetically + priority = {"x": 0, "y": 1} + cols = sorted(df.columns, key=lambda col: (priority.get(col, 999), col)) return df[cols] + elif self.gtype == str(GeometryType.NODE): + return self.data.drop_vars(["node"]).to_dataframe() else: raise NotImplementedError(f"Unknown gtype: {self.gtype}") def sel(self: T, **kwargs: Any) -> T: """Select data by label""" - return self.__class__(self.data.sel(**kwargs)) + return self._create_new_instance(self.data.sel(**kwargs)) def trim( self: T, - start_time: Optional[pd.Timestamp] = None, - end_time: Optional[pd.Timestamp] = None, + start_time: pd.Timestamp | None = None, + end_time: pd.Timestamp | None = None, buffer: str = "1s", no_overlap: Literal["ignore", "error", "warn"] = "error", ) -> T: @@ -347,4 +374,11 @@ def trim( case _: pass + return self._create_new_instance(data) + + def _create_new_instance(self, data: xr.Dataset) -> Self: + """Create a new instance of this class with the given data. + + Subclasses can override this to handle their specific constructor requirements. + """ return self.__class__(data) diff --git a/src/modelskill/timeseries/_track.py b/src/modelskill/timeseries/_track.py index c0779a10e..9dcee6dea 100644 --- a/src/modelskill/timeseries/_track.py +++ b/src/modelskill/timeseries/_track.py @@ -2,7 +2,7 @@ from collections.abc import Hashable from dataclasses import dataclass from pathlib import Path -from typing import Literal, get_args, Optional, List, Sequence +from typing import Literal, get_args, List, Sequence import warnings import pandas as pd import xarray as xr @@ -32,7 +32,7 @@ def _parse_track_items( x_item: int | str | None, y_item: int | str | None, item: int | str | None, - aux_items: Optional[Sequence[int | str]] = None, + aux_items: Sequence[int | str] | None = None, ) -> TrackItem: """If input has exactly 3 items we accept item=None""" if len(items) < 3: @@ -64,14 +64,14 @@ def _parse_track_items( def _parse_track_input( data: TrackType, - name: Optional[str], + name: str | None, item: str | int | None, - quantity: Optional[Quantity], + quantity: Quantity | None, x_item: str | int | None, y_item: str | int | None, keep_duplicates: Literal["first", "last", False] = "first", offset_duplicates: float = 0.001, - aux_items: Optional[Sequence[int | str]] = None, + aux_items: Sequence[int | str] | None = None, ) -> xr.Dataset: assert isinstance( data, get_args(TrackType) diff --git a/src/modelskill/types.py b/src/modelskill/types.py index 0ba3dcd63..4efae505c 100644 --- a/src/modelskill/types.py +++ b/src/modelskill/types.py @@ -1,6 +1,6 @@ from enum import Enum from pathlib import Path -from typing import Union, List, Optional +from typing import Union, List from dataclasses import dataclass import pandas as pd import xarray as xr @@ -14,6 +14,7 @@ class GeometryType(Enum): TRACK = "track" UNSTRUCTURED = "unstructured" GRID = "grid" + NODE = "node" def __str__(self) -> str: return self.name.lower() @@ -37,6 +38,8 @@ def from_string(s: str) -> "GeometryType": >>> GeometryType.from_string("grid") + >>> GeometryType.from_string("node") + """ try: @@ -93,5 +96,5 @@ def from_string(s: str) -> "GeometryType": class Period: """Period of data, defined by start and end time, can be open ended""" - start: Optional[pd.Timestamp] = None - end: Optional[pd.Timestamp] = None + start: pd.Timestamp | None = None + end: pd.Timestamp | None = None diff --git a/tests/test_match.py b/tests/test_match.py index 54b8d7a23..612c8aed2 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -7,6 +7,19 @@ import modelskill as ms from modelskill.comparison._comparison import ItemSelection from modelskill.model.dfsu import DfsuModelResult +from modelskill.model.network import Network, BasicNode, BasicEdge + + +def _make_network(node_ids, time, data, quantity="WaterLevel"): + nodes = [ + BasicNode(nid, pd.DataFrame({quantity: data[:, i]}, index=time)) + for i, nid in enumerate(node_ids) + ] + edges = [ + BasicEdge(f"e{i}", nodes[i], nodes[i + 1], length=100.0) + for i in range(len(nodes) - 1) + ] + return Network(edges) @pytest.fixture @@ -71,6 +84,100 @@ def mr3(): return ms.model_result(fn, item=0, name="SW_3") +# Network-related fixtures +@pytest.fixture +def network(): + """Network fixture with 3 nodes""" + time = pd.date_range("2017-10-27", periods=20, freq="h") + np.random.seed(42) + data = np.random.normal(1.5, 0.3, (20, 3)) + return _make_network(["100", "200", "300"], time, data) + + +@pytest.fixture +def network2(): + """Second network fixture with offset data for multi-model tests""" + time = pd.date_range("2017-10-27", periods=20, freq="h") + np.random.seed(42) + data = np.random.normal(1.5, 0.3, (20, 3)) + 0.1 + return _make_network(["100", "200", "300"], time, data) + + +@pytest.fixture +def network_mr(network): + """NetworkModelResult fixture""" + return ms.NetworkModelResult(network, name="Network_Model") + + +@pytest.fixture +def node_obs1(network): + """NodeObservation for node '100'""" + node_id = network.find(node="100") + time = pd.date_range("2017-10-27", periods=18, freq="h") + # Add some noise to make it different from model + np.random.seed(123) + data = np.random.normal(1.4, 0.2, len(time)) + df = pd.DataFrame({"WaterLevel": data}, index=time) + return ms.NodeObservation(df, node=node_id, name="Station_A") + + +@pytest.fixture +def node_obs2(network): + """NodeObservation for node '200'""" + node_id = network.find(node="200") + time = pd.date_range("2017-10-27", periods=15, freq="h") + np.random.seed(456) + data = np.random.normal(1.6, 0.25, len(time)) + df = pd.DataFrame({"WaterLevel": data}, index=time) + return ms.NodeObservation(df, node=node_id, name="Station_B") + + +@pytest.fixture +def node_obs_invalid(network): + """NodeObservation for a node that doesn't exist in the network""" + time = pd.date_range("2017-10-27", periods=10, freq="h") + data = np.random.normal(1.5, 0.2, len(time)) + df = pd.DataFrame({"WaterLevel": data}, index=time) + return ms.NodeObservation(df, node=999, name="Node_999_Obs") + + +@pytest.fixture +def network_mr1(network): + """First NetworkModelResult fixture""" + return ms.NetworkModelResult(network, name="Network_1") + + +@pytest.fixture +def network_mr2(network2): + """Second NetworkModelResult fixture with offset data""" + return ms.NetworkModelResult(network2, name="Network_2") + + +@pytest.fixture +def node_obs_gaps(network): + """NodeObservation with time gaps""" + node_id = network.find(node="100") + time = pd.date_range("2017-10-27", periods=10, freq="2h") # Different frequency + data = np.random.normal(1.5, 0.2, len(time)) + df = pd.DataFrame({"WaterLevel": data}, index=time) + return ms.NodeObservation(df, node=node_id, name="Node_100_Gaps") + + +@pytest.fixture +def network_mr_gaps(network): + """NetworkModelResult for gap testing""" + return ms.NetworkModelResult(network, name="Network_Gaps") + + +@pytest.fixture +def point_obs_error(): + """PointObservation for error testing (should not work with NetworkModelResult)""" + df = pd.DataFrame( + {"WL": [1, 2, 3]}, index=pd.date_range("2017-10-27", periods=3, freq="h") + ) + return ms.PointObservation(df, x=0.0, y=0.0) + + def test_properties_after_match(o1, mr1): cmp = ms.match(o1, mr1) assert cmp.n_models == 1 @@ -522,7 +629,10 @@ def test_multiple_obs_not_allowed_with_non_spatial_modelresults(): assert "m2" in cmp.mod_names # but this is not allowed - with pytest.raises(ValueError, match="SpatialField type"): + with pytest.raises( + ValueError, + match="When matching multiple observations with multiple models, all models", + ): ms.match(obs=[o1, o2], mod=[m1, m2, m3]) @@ -608,3 +718,87 @@ def test_multiple_models_same_name(tmp_path: Path) -> None: with pytest.raises(ValueError, match="HKZN_local_2017_DutchCoast"): ms.match(obs, [mr1, mr2]) + + +def test_match_node_obs_with_network_model(node_obs1, network_mr): + cmp = ms.match(node_obs1, network_mr) + assert cmp is not None + assert cmp.n_points > 0 + assert "Network_Model" in cmp.mod_names + + assert cmp.n_models == 1 + assert cmp.n_points == 18 + assert cmp.name == "Station_A" + assert cmp.gtype == "node" + assert cmp.mod_names == ["Network_Model"] + + +def test_match_multiple_node_obs_with_network(node_obs1, node_obs2, network_mr): + cc = ms.match([node_obs1, node_obs2], network_mr) + assert cc.n_models == 1 + assert cc.n_observations == 2 + assert "Station_A" in cc + assert "Station_B" in cc + assert cc["Station_A"].n_points == 18 # Limited by shortest time overlap + assert cc["Station_B"].n_points == 15 + + +def test_match_node_obs_with_multiple_network_models( + node_obs1, network_mr1, network_mr2 +): + # Test matching one observation with multiple network models + cmp = ms.match(node_obs1, [network_mr1, network_mr2]) + assert cmp.n_models == 2 + assert cmp.mod_names == ["Network_1", "Network_2"] + + +def test_match_network_invalid_node_error(node_obs_invalid, network_mr): + with pytest.raises(ValueError, match="Node 999 not found"): + ms.match(node_obs_invalid, network_mr) + + +def test_skill_index(node_obs1, node_obs2, network_mr): + """Test that NetworkModelResult correctly extracts node data during matching""" + cmp = ms.match([node_obs1, node_obs2], network_mr) + # Test that we can get skill metrics + skill = cmp.skill() + assert "Station_A" in skill.index + assert "Station_B" in skill.index + + +def test_network_match_with_time_gaps(node_obs_gaps, network_mr_gaps): + """Test network matching with gaps in observation data""" + cmp = ms.match(node_obs_gaps, network_mr_gaps) + assert cmp.n_points > 0 # Should still match some points + + +def test_network_match_multi_obs_multi_model_comprehensive( + node_obs1, node_obs2, network_mr1, network_mr2 +): + """Test comprehensive multi-observation multi-model network matching""" + # Match multiple observations with multiple network models + cc = ms.match([node_obs1, node_obs2], [network_mr1, network_mr2]) + + assert cc.n_models == 2 + assert cc.n_observations == 2 + assert cc["Station_A"].n_models == 2 + assert cc["Station_B"].n_models == 2 + assert cc["Station_A"].mod_names == ["Network_1", "Network_2"] + assert cc["Station_B"].mod_names == ["Network_1", "Network_2"] + + +def test_network_match_error_non_node_observation(network_mr, point_obs_error): + """Test that non-NodeObservation raises appropriate error""" + with pytest.raises( + TypeError, match="NetworkModelResult only supports NodeObservation" + ): + ms.match(point_obs_error, network_mr) + + +def test_match_nodeobs_with_other_result(node_obs1, mr1): + """Test matching attempt between a node observation and non-network model""" + with pytest.raises( + NotImplementedError, + match="Extraction from .* to is not implemented.", + ): + ms.match(node_obs1, mr1) diff --git a/tests/test_network.py b/tests/test_network.py new file mode 100644 index 000000000..226f9280f --- /dev/null +++ b/tests/test_network.py @@ -0,0 +1,427 @@ +"""Test network models and observations""" + +import pytest +import pandas as pd +import xarray as xr +import numpy as np +import modelskill as ms +from modelskill.model.network import ( + Network, + NetworkModelResult, + NodeModelResult, + BasicNode, + BasicEdge, +) +from modelskill.obs import NodeObservation +from modelskill.quantity import Quantity + + +def _make_network(node_ids, time, data, quantity="WaterLevel"): + nodes = [ + BasicNode(nid, pd.DataFrame({quantity: data[:, i]}, index=time)) + for i, nid in enumerate(node_ids) + ] + edges = [ + BasicEdge(f"e{i}", nodes[i], nodes[i + 1], length=100.0) + for i in range(len(nodes) - 1) + ] + return Network(edges) + + +@pytest.fixture +def sample_network_data(): + """Sample network data as xr.Dataset""" + time = pd.date_range("2010-01-01", periods=10, freq="h") + nodes = [123, 456, 789] + + # Create sample data + np.random.seed(42) # For reproducible tests + data = np.random.randn(len(time), len(nodes)) + + ds = xr.Dataset( + { + "WaterLevel": (["time", "node"], data), + }, + coords={ + "time": time, + "node": nodes, + }, + ) + ds["WaterLevel"].attrs["units"] = "m" + ds["WaterLevel"].attrs["long_name"] = "Water Level" + + return ds + + +@pytest.fixture +def sample_network(): + """Sample Network with 3 nodes (WaterLevel quantity)""" + time = pd.date_range("2010-01-01", periods=10, freq="h") + np.random.seed(42) + data = np.random.randn(10, 3) + return _make_network(["123", "456", "789"], time, data) + + +@pytest.fixture +def sample_network_multivars(): + """Sample Network with 2 nodes and 2 quantities (WaterLevel + Discharge)""" + time = pd.date_range("2010-01-01", periods=10, freq="h") + np.random.seed(42) + raw = np.random.randn(10, 2) + nodes = [ + BasicNode( + nid, + pd.DataFrame( + {"WaterLevel": raw[:, i], "Discharge": raw[:, i] * 10}, + index=time, + ), + ) + for i, nid in enumerate(["123", "456"]) + ] + edges = [BasicEdge("e1", nodes[0], nodes[1], length=100.0)] + return Network(edges) + + +@pytest.fixture +def dataset_without_node(): + time = pd.date_range("2010-01-01", periods=10, freq="h") + + # Create sample data + np.random.seed(42) # For reproducible tests + data = np.random.randn(len(time)) + + ds = xr.Dataset( + { + "WaterLevel": (["time"], data), + }, + coords={ + "time": time, + }, + ) + ds["WaterLevel"].attrs["units"] = "m" + ds["WaterLevel"].attrs["long_name"] = "Water Level" + + return ds + + +@pytest.fixture +def sample_node_data(): + """Sample node observation data""" + time = pd.date_range("2010-01-01", periods=10, freq="h") + + # Create sample data with some variation + np.random.seed(42) + data = np.random.randn(len(time)) * 0.1 + 1.5 + + df = pd.DataFrame({"WaterLevel": data}, index=time) + + return df + + +@pytest.fixture +def sample_series(sample_node_data): + """Sample node observation data as series""" + return sample_node_data["WaterLevel"] + + +class TestNetworkModelResult: + """Test NetworkModelResult class""" + + def test_init_with_network(self, sample_network): + """Test initialization with a Network object""" + nmr = NetworkModelResult(sample_network) + + assert len(nmr.time) == 10 + assert isinstance(nmr.time, pd.DatetimeIndex) + assert len(nmr.nodes) == 3 + + def test_init_with_name(self, sample_network): + """Test initialization with explicit name""" + nmr = NetworkModelResult(sample_network, name="Test_Network") + assert nmr.name == "Test_Network" + + def test_init_with_item_selection(self, sample_network_multivars): + """Test initialization with specific item selection""" + nmr = NetworkModelResult( + sample_network_multivars, item="WaterLevel", name="Network_WL" + ) + + assert nmr.name == "Network_WL" + assert "WaterLevel" in nmr.data.data_vars + assert "Discharge" not in nmr.data.data_vars + + def test_init_fails_with_unsupported_type(self): + """Test that passing a non-Network object raises TypeError""" + with pytest.raises(TypeError, match="NetworkModelResult expects a Network"): + NetworkModelResult(xr.Dataset()) # type: ignore[arg-type] + + def test_repr(self, sample_network): + """Test string representation""" + nmr = NetworkModelResult(sample_network, name="Test_Network") + repr_str = repr(nmr) + + assert "NetworkModelResult" in repr_str + assert "Test_Network" in repr_str + + def test_extract_valid_node(self, sample_network, sample_node_data): + """Test extraction of a valid node""" + nmr = NetworkModelResult(sample_network) + node_id = sample_network.find(node="123") + obs = NodeObservation(sample_node_data, node=node_id, name="Node_123") + + extracted = nmr.extract(obs) + + assert isinstance(extracted, NodeModelResult) + assert extracted.node == node_id + assert len(extracted.time) == 10 + + def test_extract_invalid_node(self, sample_network, sample_node_data): + """Test extraction of a node not present in the network""" + nmr = NetworkModelResult(sample_network) + obs = NodeObservation(sample_node_data, node=999, name="Node_999") + + with pytest.raises(ValueError, match="Node 999 not found"): + nmr.extract(obs) + + def test_extract_wrong_observation_type(self, sample_network): + """Test extraction with wrong observation type""" + nmr = NetworkModelResult(sample_network) + + df = pd.DataFrame( + {"WL": [1, 2, 3]}, index=pd.date_range("2010-01-01", periods=3, freq="h") + ) + obs = ms.PointObservation(df, x=0.0, y=0.0) + + with pytest.raises( + TypeError, match="NetworkModelResult only supports NodeObservation" + ): + nmr.extract(obs) + + +class TestNodeObservation: + """Test NodeObservation class""" + + @pytest.fixture + def multi_data(self, sample_node_data): + """Multi-column DataFrame with 3 stations""" + return pd.DataFrame( + { + "station_0": sample_node_data["WaterLevel"], + "station_1": sample_node_data["WaterLevel"] + 0.1, + "station_2": sample_node_data["WaterLevel"] + 0.2, + } + ) + + def test_init_with_df(self, sample_node_data): + """Test initialization with pandas DataFrame""" + + obs = NodeObservation( + sample_node_data, node=123, name="Sensor_1", item="WaterLevel" + ) + + assert obs.node == 123 + assert obs.name == "Sensor_1" + assert len(obs.time) == 10 + assert isinstance(obs.time, pd.DatetimeIndex) + + def test_init_with_series(self, sample_series): + """Test initialization with pandas Series""" + obs = NodeObservation(sample_series, node=456, name="Node_456") + + assert obs.node == 456 + assert obs.name == "Node_456" + assert len(obs.time) == 10 + + def test_node_attrs(self, sample_node_data): + """Test attrs property""" + attrs = {"source": "test", "version": "1.0"} + obs = NodeObservation(sample_node_data, node=123, attrs=attrs, weight=2.5) + + assert obs.attrs["source"] == "test" + assert obs.attrs["version"] == "1.0" + assert obs.weight == 2.5 + assert obs.quantity == Quantity.undefined() + + def test_multiple_nodes_returns_list_of_observations(self, multi_data): + """Test that from_multiple returns a list of NodeObservation objects""" + obs_list = NodeObservation.from_multiple( + data=multi_data, nodes={123: "station_0", 456: "station_1", 789: "station_2"} + ) + + assert len(obs_list) == 3 + assert all(isinstance(obs, NodeObservation) for obs in obs_list) + + def test_node_ids_are_assigned_correctly(self, multi_data): + obs_list = NodeObservation.from_multiple( + data=multi_data, nodes={123: "station_0", 456: "station_1", 789: "station_2"} + ) + + assert obs_list[0].node == 123 + assert obs_list[1].node == 456 + assert obs_list[2].node == 789 + + def test_names_derived_from_column_names(self, multi_data): + obs_list = NodeObservation.from_multiple( + data=multi_data, nodes={123: "station_0", 456: "station_1", 789: "station_2"} + ) + + assert obs_list[0].name == "station_0" + assert obs_list[1].name == "station_1" + assert obs_list[2].name == "station_2" + + def test_from_xarray_dataset(self, sample_node_data): + ds = xr.Dataset( + { + "station_0": ("time", sample_node_data["WaterLevel"].values), + "station_1": ("time", sample_node_data["WaterLevel"].values + 0.1), + }, + coords={"time": sample_node_data.index}, + ) + obs_list = NodeObservation.from_multiple( + data=ds, nodes={123: "station_0", 456: "station_1"} + ) + + assert len(obs_list) == 2 + assert obs_list[0].node == 123 + assert obs_list[1].node == 456 + + def test_nodes_must_be_dict(self, multi_data): + with pytest.raises(TypeError, match="'nodes' must be a dict"): + NodeObservation.from_multiple(data=multi_data, nodes=123) + + def test_attrs_propagated_to_all_observations(self, multi_data): + attrs = {"source": "sensor_array", "version": 2} + obs_list = NodeObservation.from_multiple( + data=multi_data, + nodes={1: "station_0", 2: "station_1", 3: "station_2"}, + attrs=attrs, + ) + + for obs in obs_list: + assert obs.attrs["source"] == "sensor_array" + assert obs.attrs["version"] == 2 + + def test_init_from_csv(self): + obs = NodeObservation( + "tests/testdata/network_sensor_1.csv", node=1, item="water_level@sens1" + ) + + assert obs.node == 1 + assert len(obs.time) == 110 + assert isinstance(obs.time, pd.DatetimeIndex) + + def test_from_multiple_csvs_via_dict(self): + obs_list = NodeObservation.from_multiple( + nodes={ + 1: "tests/testdata/network_sensor_1.csv", + 2: "tests/testdata/network_sensor_2.csv", + 3: "tests/testdata/network_sensor_3.csv", + } + ) + + assert len(obs_list) == 3 + assert all(isinstance(obs, NodeObservation) for obs in obs_list) + assert obs_list[0].node == 1 + assert obs_list[1].node == 2 + assert obs_list[2].node == 3 + for obs in obs_list: + assert len(obs.time) > 0 + + def test_nodes_dict_maps_node_to_item(self, multi_data): + obs_list = NodeObservation.from_multiple( + data=multi_data, nodes={123: "station_0", 456: "station_1"} + ) + + assert len(obs_list) == 2 + assert obs_list[0].node == 123 + assert obs_list[1].node == 456 + assert obs_list[0].name == "station_0" + assert obs_list[1].name == "station_1" + + def test_nodes_none_raises(self, multi_data): + with pytest.raises(ValueError, match="'nodes' argument is required"): + NodeObservation.from_multiple(data=multi_data, nodes=None) + + def test_single_node_dict(self, sample_node_data): + obs_list = NodeObservation.from_multiple( + data=sample_node_data, nodes={123: "WaterLevel"} + ) + + assert len(obs_list) == 1 + assert isinstance(obs_list[0], NodeObservation) + assert obs_list[0].node == 123 + + +class TestNodeModelResult: + """Test NodeModelResult class""" + + @pytest.mark.parametrize("fixture_name", ["sample_node_data", "sample_series"]) + def test_init_(self, request, fixture_name): + """Test initialization with pandas DataFrame""" + data = request.getfixturevalue(fixture_name) + nmr = NodeModelResult(data, node=123, name="Node_123_Model") + + assert nmr.node == 123 + assert nmr.name == "Node_123_Model" + assert len(nmr.time) == 10 + + +class TestNetworkIntegration: + """Test integration between network models and observations""" + + def test_network_to_node_extraction(self, sample_network, sample_node_data): + """Test complete workflow from network model to node extraction""" + nmr = NetworkModelResult(sample_network, name="Network_Model") + node_id = sample_network.find(node="123") + obs = NodeObservation(sample_node_data, node=node_id, name="Node_123_Obs") + + extracted = nmr.extract(obs) + + assert isinstance(extracted, NodeModelResult) + assert extracted.node == node_id + assert extracted.name == "Network_Model" + assert len(extracted.time) == len(obs.time) + + def test_matching_workflow(self, sample_network, sample_node_data): + """Test matching workflow with network data""" + nmr = NetworkModelResult(sample_network, name="Network_Model") + node_id = sample_network.find(node="123") + obs = NodeObservation(sample_node_data, node=node_id, name="Node_123_Obs") + + comparer = ms.match(obs, nmr) + + assert comparer is not None + assert "Network_Model" in comparer.mod_names + assert comparer.n_points > 0 + + def test_matching_workflow_multiple_nodes(self, sample_network, sample_node_data): + """Test matching workflow with multiple node observations""" + nmr = NetworkModelResult(sample_network, name="Network_Model") + + multi_data = pd.DataFrame( + { + "station_0": sample_node_data["WaterLevel"], + "station_1": sample_node_data["WaterLevel"] + 0.1, + "station_2": sample_node_data["WaterLevel"] + 0.2, + } + ) + + node_0 = sample_network.find(node="123") + node_1 = sample_network.find(node="456") + node_2 = sample_network.find(node="789") + + # Create multiple NodeObservations using .from_multiple + obs_list = NodeObservation.from_multiple( + data=multi_data, + nodes={node_0: "station_0", node_1: "station_1", node_2: "station_2"}, + ) + + # Test that matching works + comparer_collection = ms.match(obs_list, nmr) + + assert comparer_collection is not None + assert len(comparer_collection) == 3 + + for comparer in comparer_collection: + assert "Network_Model" in comparer.mod_names + assert comparer.n_points > 0 diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index d362a2943..f2510e862 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -80,12 +80,12 @@ def test_timeseries_validation_fails_kind(ds_point): def test_timeseries_validation_fails_xy(ds_point): ds_without_x = ds_point.drop_vars("x") - with pytest.raises(ValueError, match="data must have an x-coordinate"): + with pytest.raises(ValueError, match="data must have either x,y"): TimeSeries(ds_without_x) # ds_point.coords["x"] = 0 ds_without_y = ds_point.drop_vars("y") - with pytest.raises(ValueError, match="data must have a y-coordinate"): + with pytest.raises(ValueError, match="data must have either x,y"): TimeSeries(ds_without_y) diff --git a/tests/testdata/network.nc b/tests/testdata/network.nc new file mode 100644 index 000000000..951c38b25 Binary files /dev/null and b/tests/testdata/network.nc differ diff --git a/tests/testdata/network.res1d b/tests/testdata/network.res1d new file mode 100644 index 000000000..45a661f6e Binary files /dev/null and b/tests/testdata/network.res1d differ diff --git a/tests/testdata/network_sensor_1.csv b/tests/testdata/network_sensor_1.csv new file mode 100644 index 000000000..a9c689f51 --- /dev/null +++ b/tests/testdata/network_sensor_1.csv @@ -0,0 +1,111 @@ +,water_level@sens1 +1994-08-07 16:34:59.916743481,167.23052927589947 +1994-08-07 16:36:02.520381990,85.05599635973702 +1994-08-07 16:37:01.408066485,355.8042290632703 +1994-08-07 16:39:02.338328361,316.2071070583193 +1994-08-07 16:39:54.538628092,409.39521002925596 +1994-08-07 16:40:53.558280023,192.85643936434022 +1994-08-07 16:41:52.716739768,311.4946809256513 +1994-08-07 16:42:57.358547836,66.38307784533708 +1994-08-07 16:44:01.935622278,160.14187799679112 +1994-08-07 16:44:55.322277302,96.87648480002893 +1994-08-07 16:45:58.086478637,17.885614797259677 +1994-08-07 16:46:53.312784109,216.33988056363233 +1994-08-07 16:47:54.114071434,236.14026914737627 +1994-08-07 16:49:02.291270422,204.56151657741464 +1994-08-07 16:50:04.692714435,257.670879715313 +1994-08-07 16:51:30.324372616,241.30800578783283 +1994-08-07 16:52:40.759544960,197.4405499299096 +1994-08-07 16:54:13.612484107,243.8219630615323 +1994-08-07 16:56:11.158479390,367.7122518033965 +1994-08-07 16:57:17.361278062,402.90977536191235 +1994-08-07 16:58:07.269557220,50.73589238463779 +1994-08-07 16:59:22.398304267,85.40797587139824 +1994-08-07 17:00:18.558245694,403.15224024207964 +1994-08-07 17:01:03.881696980,176.53915393071222 +1994-08-07 17:02:15.031149715,102.20869684545725 +1994-08-07 17:03:04.530930456,246.11197268761592 +1994-08-07 17:04:16.386744067,362.82001073222875 +1994-08-07 17:05:21.907990290,-2.549888267445141 +1994-08-07 17:06:16.589712418,74.12265455144187 +1994-08-07 17:07:13.887780009,207.70864170073227 +1994-08-07 17:08:20.450495556,246.51842385425505 +1994-08-07 17:09:16.583888310,128.78934142873598 +1994-08-07 17:10:19.686709557,260.70444708749073 +1994-08-07 17:11:15.543512682,138.30129118169177 +1994-08-07 17:12:22.312821893,300.00831259753056 +1994-08-07 17:13:04.432321636,164.65586498429246 +1994-08-07 17:14:07.152981144,361.2992113721085 +1994-08-07 17:15:18.579752508,246.09592644658005 +1994-08-07 17:16:22.425274118,204.32380865752367 +1994-08-07 17:17:08.700507174,283.2446286522264 +1994-08-07 17:18:03.668820247,232.4158955891213 +1994-08-07 17:19:18.648617136,44.344306557957196 +1994-08-07 17:20:25.908601054,232.19879220054509 +1994-08-07 17:21:40.970467165,180.12814414358346 +1994-08-07 17:22:36.912236577,212.7277495537098 +1994-08-07 17:23:38.985978291,124.6649425993981 +1994-08-07 17:24:24.441536074,81.7500554250784 +1994-08-07 17:25:32.874773652,142.2537340575725 +1994-08-07 17:26:25.401565588,242.0956236030642 +1994-08-07 17:27:25.073765221,177.89205652565076 +1994-08-07 17:28:33.522201357,225.06894289176623 +1994-08-07 17:29:34.586918085,257.99797901662714 +1994-08-07 17:30:33.103502998,149.75615780184341 +1994-08-07 17:31:36.504089179,49.62289183325291 +1994-08-07 17:32:41.233084868,125.12748755452027 +1994-08-07 17:33:35.605982334,141.48347102618757 +1994-08-07 17:34:27.306567953,423.87454279967204 +1994-08-07 17:35:27.922513654,135.07222809944773 +1994-08-07 17:36:34.698632552,462.6341247214628 +1994-08-07 17:37:25.083252430,118.84559200402573 +1994-08-07 17:38:41.152117039,275.1234203974428 +1994-08-07 17:39:38.957599204,221.19318725161997 +1994-08-07 17:40:34.319654043,197.03411256022272 +1994-08-07 17:41:34.135215670,225.72217395511137 +1994-08-07 17:42:22.532041375,229.03733838465573 +1994-08-07 17:43:24.654695190,139.50676601254344 +1994-08-07 17:44:45.387012746,359.33330945274525 +1994-08-07 17:45:41.767323847,240.5373105036362 +1994-08-07 17:46:53.332215604,87.5815634180884 +1994-08-07 17:47:50.359086796,73.54874887335501 +1994-08-07 17:48:49.805600224,232.62388009233422 +1994-08-07 17:50:02.656776555,214.2749535936352 +1994-08-07 17:51:08.063673892,83.62344647281094 +1994-08-07 17:52:20.916773637,115.79022364626495 +1994-08-07 17:53:24.362244275,173.8730078444849 +1994-08-07 17:54:32.558884338,114.75706104280903 +1994-08-07 17:55:57.574982817,25.90741655163481 +1994-08-07 17:56:56.679740311,166.80129701531067 +1994-08-07 17:58:26.519642317,196.82728198210944 +1994-08-07 18:00:05.070664111,181.41919456628085 +1994-08-07 18:00:58.521666090,181.92563149625855 +1994-08-07 18:02:02.028766151,182.6224247835789 +1994-08-07 18:04:01.930572256,193.71912619968398 +1994-08-07 18:05:12.041307794,130.81605567774682 +1994-08-07 18:06:10.047894987,262.59142376672105 +1994-08-07 18:07:03.746078530,207.79614935042974 +1994-08-07 18:07:58.302130875,199.17141064997472 +1994-08-07 18:09:02.179255097,304.917428228716 +1994-08-07 18:10:10.818138094,100.43380902561123 +1994-08-07 18:11:49.866490826,344.4224221074761 +1994-08-07 18:13:20.845192559,150.55505513208024 +1994-08-07 18:14:51.455212084,131.77561833721597 +1994-08-07 18:15:56.413584766,211.8261739061975 +1994-08-07 18:17:13.301112494,162.94594731806174 +1994-08-07 18:19:07.083715659,98.5778859902367 +1994-08-07 18:20:17.680130913,230.72024547582373 +1994-08-07 18:21:04.092481084,233.08837040742546 +1994-08-07 18:22:17.251353919,174.08408166061196 +1994-08-07 18:23:04.423774252,267.4220402209745 +1994-08-07 18:24:12.475292906,186.2144558588623 +1994-08-07 18:24:58.093157869,261.1728084547993 +1994-08-07 18:26:05.601775332,316.2994961695004 +1994-08-07 18:27:03.548158179,369.9364588715807 +1994-08-07 18:28:03.669845179,173.5849032167869 +1994-08-07 18:29:17.901676906,259.72027939782413 +1994-08-07 18:30:06.488353467,58.65344705064422 +1994-08-07 18:31:12.905541258,419.1883236788559 +1994-08-07 18:32:15.103910305,194.96315889538167 +1994-08-07 18:33:00.390716872,334.76290732227324 +1994-08-07 18:35:08.875557815,369.0692262390708 diff --git a/tests/testdata/network_sensor_2.csv b/tests/testdata/network_sensor_2.csv new file mode 100644 index 000000000..e04c8755e --- /dev/null +++ b/tests/testdata/network_sensor_2.csv @@ -0,0 +1,81 @@ +,water_level@sens2 +1994-08-07 17:08:11.873469575,174.57847294285173 +1994-08-07 17:09:20.328933713,283.16018742962945 +1994-08-07 17:10:06.792521033,283.79482890997684 +1994-08-07 17:11:02.927325790,209.61009728226128 +1994-08-07 17:12:19.295917286,212.98874588241395 +1994-08-07 17:13:04.185500840,20.25287101453617 +1994-08-07 17:14:09.619665083,450.26753902416937 +1994-08-07 17:15:22.770186054,261.32173823923017 +1994-08-07 17:16:12.441971699,324.73118205685563 +1994-08-07 17:17:13.950235446,101.7984614188045 +1994-08-07 17:18:04.996426763,287.2098879197744 +1994-08-07 17:19:09.082843216,21.914800515702296 +1994-08-07 17:20:27.153077145,173.7121100215564 +1994-08-07 17:21:22.425382529,206.66664889944684 +1994-08-07 17:22:26.532804209,175.3190538978668 +1994-08-07 17:23:33.818129570,186.67932301739563 +1994-08-07 17:24:34.591126973,-59.72482299309178 +1994-08-07 17:25:41.353043475,-2.0833498832457167 +1994-08-07 17:26:24.189227482,139.25819320575488 +1994-08-07 17:27:32.789789073,163.43759122739934 +1994-08-07 17:28:31.819990392,127.31691576399996 +1994-08-07 17:29:33.891906893,166.36804896723334 +1994-08-07 17:30:29.409062470,-112.46254063640521 +1994-08-07 17:31:23.536589697,135.58921114859757 +1994-08-07 17:32:35.268174041,113.55594346485859 +1994-08-07 17:33:29.669537872,334.57861390036123 +1994-08-07 17:34:31.862740656,191.6396250897312 +1994-08-07 17:35:24.860636283,292.73815821602005 +1994-08-07 17:36:36.673372689,130.26902669315785 +1994-08-07 17:37:35.152425106,151.5319700020455 +1994-08-07 17:38:27.440725477,174.0054019607765 +1994-08-07 17:39:41.176577434,178.49069467771437 +1994-08-07 17:40:26.885964988,223.43875617435486 +1994-08-07 17:41:27.377013093,317.8852488362144 +1994-08-07 17:42:37.880707085,235.8021206166363 +1994-08-07 17:43:36.186747559,214.7613327891079 +1994-08-07 17:44:46.365640206,170.8739068780763 +1994-08-07 17:45:51.762486664,116.99379737111082 +1994-08-07 17:46:42.594281510,259.49115761582163 +1994-08-07 17:47:43.931874386,170.3358471090804 +1994-08-07 17:48:49.121174024,215.86233353111962 +1994-08-07 17:49:50.811816785,126.26826123459163 +1994-08-07 17:51:05.312269182,199.42420771723923 +1994-08-07 17:52:04.493439906,145.30328242155147 +1994-08-07 17:53:27.288786837,160.9777306117574 +1994-08-07 17:54:46.729356895,220.92989375877863 +1994-08-07 17:55:58.633960515,322.495898467954 +1994-08-07 17:56:55.371707295,259.6872893795062 +1994-08-07 17:58:18.630843282,420.9051999586968 +1994-08-07 17:59:52.008620826,257.7509297031801 +1994-08-07 18:00:58.713272844,193.85146673780056 +1994-08-07 18:02:13.486479660,324.0013418368758 +1994-08-07 18:04:12.680482398,250.94255425623717 +1994-08-07 18:05:01.120477340,109.9341225128633 +1994-08-07 18:06:11.755034897,377.0375615526377 +1994-08-07 18:07:02.068441929,459.7145712323054 +1994-08-07 18:08:07.641993395,291.575462606011 +1994-08-07 18:09:07.925191993,335.20944832818765 +1994-08-07 18:09:55.997623076,187.67728879860223 +1994-08-07 18:11:45.455472128,171.63388607172163 +1994-08-07 18:13:09.524859630,235.33565840943754 +1994-08-07 18:14:46.386765383,75.45539861664042 +1994-08-07 18:16:04.081723463,367.8364877117184 +1994-08-07 18:17:09.834824572,95.57472202663222 +1994-08-07 18:19:02.155781694,340.61976188631695 +1994-08-07 18:20:15.554703797,287.06320258279794 +1994-08-07 18:21:16.814084790,302.76595040412343 +1994-08-07 18:22:14.564366672,190.1253861213318 +1994-08-07 18:23:12.883079170,-52.09727675180616 +1994-08-07 18:24:05.246318389,339.5607259886848 +1994-08-07 18:25:09.553716912,134.8420855160598 +1994-08-07 18:26:05.025507314,208.04165438111707 +1994-08-07 18:27:05.421452618,101.4790440231178 +1994-08-07 18:28:02.891871094,237.77373887299558 +1994-08-07 18:29:08.274159965,170.93762638237718 +1994-08-07 18:30:04.682890017,294.83643258829244 +1994-08-07 18:30:59.237455149,276.22927891716665 +1994-08-07 18:32:17.356019832,276.69317042601676 +1994-08-07 18:33:13.012694301,177.53748053567455 +1994-08-07 18:35:02.228548145,346.0155700843511 diff --git a/tests/testdata/network_sensor_3.csv b/tests/testdata/network_sensor_3.csv new file mode 100644 index 000000000..051d30fcf --- /dev/null +++ b/tests/testdata/network_sensor_3.csv @@ -0,0 +1,91 @@ +,water_level@sens3 +1994-08-07 16:35:05.248735972,306.3825856578406 +1994-08-07 16:36:02.792665684,137.7681422634567 +1994-08-07 16:37:13.928814766,219.07825933298014 +1994-08-07 16:38:46.663244775,104.51644625842242 +1994-08-07 16:39:59.644985932,231.67488956729926 +1994-08-07 16:40:53.837790186,75.54747321008136 +1994-08-07 16:42:05.766348258,175.96741780420948 +1994-08-07 16:42:50.720667807,197.82574625464574 +1994-08-07 16:43:55.675251143,154.47904841283113 +1994-08-07 16:44:53.183610723,215.27047106442387 +1994-08-07 16:45:53.062128154,117.5370947342592 +1994-08-07 16:47:02.003593092,276.3395190480064 +1994-08-07 16:47:55.616188753,216.39097395947178 +1994-08-07 16:48:59.678672111,213.10240184655748 +1994-08-07 16:50:16.675711079,373.4036578210961 +1994-08-07 16:51:38.724681615,145.8891017300103 +1994-08-07 16:52:46.523033071,174.9462619765196 +1994-08-07 16:54:14.757786278,145.36514065764936 +1994-08-07 16:56:11.969150430,324.91082746918653 +1994-08-07 16:57:11.507766903,285.37458128392325 +1994-08-07 16:58:03.938938278,166.88089481167185 +1994-08-07 16:59:11.808035933,362.62395061983943 +1994-08-07 17:00:03.710420804,286.6603656299535 +1994-08-07 17:01:21.145575522,280.54048931039347 +1994-08-07 17:02:14.986894132,259.38873907181136 +1994-08-07 17:03:21.932851982,106.19690264824621 +1994-08-07 17:04:04.917266829,277.72913184632546 +1994-08-07 17:05:03.966726955,282.7694584931943 +1994-08-07 17:06:13.563564299,267.4375200668418 +1994-08-07 17:07:19.923907648,281.2745974941316 +1994-08-07 17:08:18.811903364,198.8921716224498 +1994-08-07 17:09:19.923664791,43.107671024491026 +1994-08-07 17:10:15.929458506,-63.017070809963855 +1994-08-07 17:11:22.057538230,93.98967363936461 +1994-08-07 17:12:21.601802756,274.37705559340395 +1994-08-07 17:13:13.041217686,100.07549452699257 +1994-08-07 17:14:22.215957368,161.86170262430701 +1994-08-07 17:15:16.071098517,374.84363184515803 +1994-08-07 17:16:14.900600530,336.28438637721547 +1994-08-07 17:17:07.669648351,12.947432833121582 +1994-08-07 17:18:05.893897096,47.99062568758001 +1994-08-07 17:19:11.014289026,108.82411247288799 +1994-08-07 17:20:29.317537356,104.90061548780523 +1994-08-07 17:21:25.016629346,159.04577734197227 +1994-08-07 17:22:34.217807950,235.2039998425845 +1994-08-07 17:23:36.345326189,278.84473914651994 +1994-08-07 17:24:33.802928161,285.9321769663818 +1994-08-07 17:25:25.282115083,302.379494759022 +1994-08-07 17:26:31.520636203,141.6547711436936 +1994-08-07 17:27:25.237382917,156.33330001122482 +1994-08-07 17:48:57.943261220,199.67862263710154 +1994-08-07 17:49:52.383948692,78.13303846715726 +1994-08-07 17:50:58.135436888,106.52901261303226 +1994-08-07 17:52:16.090762613,316.60790404587544 +1994-08-07 17:53:34.483565061,200.2886348424788 +1994-08-07 17:54:37.646076864,137.56300536324432 +1994-08-07 17:56:02.535400985,239.965992869096 +1994-08-07 17:57:01.679251771,46.45835907862295 +1994-08-07 17:58:24.926942423,195.64791562666605 +1994-08-07 17:59:49.979729764,327.39685280834806 +1994-08-07 18:01:01.536562556,314.690114250383 +1994-08-07 18:02:19.413189342,343.3897864000886 +1994-08-07 18:03:58.675917167,371.2988172058402 +1994-08-07 18:05:12.726346901,60.14435755661768 +1994-08-07 18:06:15.004469319,91.55612091866094 +1994-08-07 18:07:13.665680822,86.13980413157414 +1994-08-07 18:08:06.029039827,177.47083630260875 +1994-08-07 18:09:05.868276734,6.099784160132657 +1994-08-07 18:10:05.337347301,391.3686052352192 +1994-08-07 18:11:39.655615203,163.58253396574932 +1994-08-07 18:13:15.064478312,324.92067949190675 +1994-08-07 18:14:50.177913632,175.1711029889557 +1994-08-07 18:16:13.317964162,267.677328076304 +1994-08-07 18:17:24.748783223,78.03523939833546 +1994-08-07 18:19:03.086692455,129.26664911168245 +1994-08-07 18:20:00.417416621,68.33812941391122 +1994-08-07 18:21:02.933072256,89.45036471702987 +1994-08-07 18:22:06.843352688,268.9317313779161 +1994-08-07 18:23:14.284925478,199.52431688847136 +1994-08-07 18:24:06.392013597,282.7447930205622 +1994-08-07 18:25:15.868006161,261.5955238299176 +1994-08-07 18:26:04.500768106,268.6376956782832 +1994-08-07 18:27:01.823188772,413.1511934552418 +1994-08-07 18:28:13.410347251,16.984293033431584 +1994-08-07 18:29:03.821136410,154.32913434976223 +1994-08-07 18:30:11.528371867,204.18071093290808 +1994-08-07 18:31:04.846866726,204.79566618718647 +1994-08-07 18:32:09.536570981,120.62506774652809 +1994-08-07 18:33:04.230531161,133.13589951744825 +1994-08-07 18:35:01.608973619,347.9919741958588