diff --git a/documentation/controls.rst b/documentation/controls.rst index 8349c426f..73bbfa8cd 100644 --- a/documentation/controls.rst +++ b/documentation/controls.rst @@ -10,13 +10,16 @@ EPANET uses "controls" and "rules" to define conditions [Ross00]_. WNTR replicat **Controls** are defined using an "IF condition; THEN action" format. Controls use a single action (i.e., closing/opening a link or changing the setting) based on a single condition (i.e., time based or tank level based). +Unlike EPANET simple controls which are evaluated based on the order in which they are defined in the INP file, +controls in WNTR can be prioritized to set the order of operation. +If controls with conflicting actions should occur at the same time, the control with the highest priority will override all others. +Controls are evaluated after each simulation timestep. If a time based or tank level condition is not exactly matched at a simulation timestep, controls make use of partial timesteps to match the condition before the control is deployed. -Controls in WNTR emulate EPANET simple controls. **Rules** are more complex; rules are defined using an "IF condition; THEN action1; ELSE action2" format, where the ELSE block is optional. Rules can use multiple conditions and multiple actions in each of the logical blocks. Rules can also be prioritized to set the order of operation. If rules with conflicting actions should occur at the same time, the rule with the highest priority will override all others. -Rules operate on a rule timestep specified by the user, which can be different from the simulation timestep. +Rules operate on a rule timestep, which can be different from the simulation timestep. Rules in WNTR emulate EPANET rule-based controls. When generating a water network model from an EPANET INP file, WNTR generates controls and rules based on input from the [CONTROLS] and [RULES] sections. @@ -91,6 +94,20 @@ repeat conditions that are defined with :class:`~wntr.network.controls.TimeOfDay not repeat conditions that are defined within :class:`~wntr.network.controls.SimTimeCondition`. The WNTRSimulator can handle repeat or not repeat options for both of these conditions. +Priority +----------- + +Priority levels are defined in the :class:`~wntr.network.controls.ControlPriority` class and +include the following options. + +* :class:`~wntr.network.controls.ControlPriority.very_low` or 0 +* :class:`~wntr.network.controls.ControlPriority.low` or 1 +* :class:`~wntr.network.controls.ControlPriority.medium_low` or 2 +* :class:`~wntr.network.controls.ControlPriority.medium` or 3 +* :class:`~wntr.network.controls.ControlPriority.medium_high` or 4 +* :class:`~wntr.network.controls.ControlPriority.high` or 5 +* :class:`~wntr.network.controls.ControlPriority.very_high` or 6 + Controls --------------------- @@ -212,3 +229,37 @@ The control or rule should be named so that it can be retrieved and modified if ValueError: The name provided for the control is already used. Please either remove the control with that name first or use a different name for this control. >>> wn.remove_control('NewTimeControl') >>> wn.add_control('NewTimeControl', ctrl2) # doctest: +SKIP + +Accessing and modifying controls/rules +--------------------------------------- + +Controls and rules can be accessed and modified in several ways. +For example, the following example returns a list of control names that are included in the model. + +.. doctest:: + + >>> control_name_list = wn.control_name_list + >>> print(control_name_list) + ['control 1', 'control 2', 'control 3', 'control 4', 'control 5', 'control 6', 'control 7', 'control 8', 'control 9', 'control 10', 'control 11', 'control 12', 'control 13', 'control 14', 'control 15', 'control 16', 'control 17', 'control 18'] + +The following example loops through all controls in the model and identifies controls that require pipe '330'. + +.. doctest:: + + >>> pipe = wn.get_link('330') + >>> for name, control in wn.controls(): + ... if pipe in control.requires(): + ... print(name, control) + control 17 IF TANK 1 LEVEL BELOW 5.21208 THEN PIPE 330 STATUS IS CLOSED PRIORITY 3 + control 18 IF TANK 1 LEVEL ABOVE 5.821680000000001 THEN PIPE 330 STATUS IS OPEN PRIORITY 3 + +The following example changes the priority of 'control 5' from medium (3) to low (1). + +.. doctest:: + + >>> control = wn.get_control('control 5') + >>> print(control) + IF SYSTEM TIME IS 49:00:00 THEN PUMP 10 STATUS IS OPEN PRIORITY 3 + >>> control.update_priority(1) # low + >>> print(control) + IF SYSTEM TIME IS 49:00:00 THEN PUMP 10 STATUS IS OPEN PRIORITY 1 diff --git a/documentation/figures/plot_subplot_basic_network.png b/documentation/figures/plot_subplot_basic_network.png new file mode 100644 index 000000000..8dd09c32b Binary files /dev/null and b/documentation/figures/plot_subplot_basic_network.png differ diff --git a/documentation/gis.rst b/documentation/gis.rst index f61f68ae5..dd5cfe884 100644 --- a/documentation/gis.rst +++ b/documentation/gis.rst @@ -54,9 +54,12 @@ The following examples use a water network generated from Net1.inp. The :class:`~wntr.gis.geospatial.snap` and :class:`~wntr.gis.geospatial.intersect` examples also use additional GIS data stored in the `examples/data `_ directory. -For simplicity, the examples assume that all data coordinates are in + +For simplicity, the examples in this section assume that all network and data coordinates are in the EPSG:4326 coordinate reference system (CRS). +**Note that EPANET does not have a standard or default coordinate reference system.** More information on setting and transforming CRS is included in :ref:`crs`. + .. doctest:: :skipif: gpd is None @@ -66,6 +69,8 @@ More information on setting and transforming CRS is included in :ref:`crs`. >>> wn = wntr.network.WaterNetworkModel('networks/Net1.inp') # doctest: +SKIP +.. _gis_data: + Water network GIS data ------------------------ @@ -188,10 +193,12 @@ Geometry Valve LineString or Point ============================== =============================== -A WaterNetworkGIS object can also be written to GeoJSON and Shapefile files using +A WaterNetworkGIS object can also be written to GeoJSON and Shapefiles using the object's :class:`~wntr.gis.network.WaterNetworkGIS.write_geojson` and :class:`~wntr.gis.network.WaterNetworkGIS.write_shapefile` methods. -The GeoJSON and Shapefile files can be loaded into GIS platforms for further analysis and visualization. +See :ref:`shapefile_format` for more information on Shapefile format. + +The GeoJSON and Shapefiles can be loaded into GIS platforms for further analysis and visualization. An example of creating GeoJSON files from a WaterNetworkModel using the function :class:`~wntr.gis.network.WaterNetworkGIS.write_geojson` is shown below. @@ -234,7 +241,7 @@ WNTR to add attributes to the water network model and analysis. Examples of thes The snap and intersect examples below used additional GIS data stored in the `examples/data `_ directory. -Note, the GeoPandas ``read_file`` and ``to_file`` functions can be used to read/write external GeoJSON and Shapefile files in Python. +Note, the GeoPandas ``read_file`` and ``to_file`` functions can be used to read/write external GeoJSON and Shapefiles in Python. .. _crs: diff --git a/documentation/graphics.rst b/documentation/graphics.rst index a6fc97931..c6f0dad30 100644 --- a/documentation/graphics.rst +++ b/documentation/graphics.rst @@ -8,6 +8,8 @@ >>> import wntr >>> import numpy as np >>> import matplotlib.pylab as plt + >>> import pandas as pd + >>> pd.set_option("display.precision", 3) >>> try: ... wn = wntr.network.model.WaterNetworkModel('../examples/networks/Net3.inp') ... except: @@ -19,7 +21,7 @@ Graphics ====================================== WNTR includes several functions to plot water network models and to plot -fragility, pump curves, tank curves, and valve layers. +fragility curves, pump curves, tank curves, and valve layers. Networks -------------------- @@ -69,6 +71,62 @@ which can be further customized by the user. Basic network graphic. +Additional network plot examples are included below (:numref:`fig-network-3`). +This includes the use of data stored as +a Pandas Series (pipe velocity from simulation results), +a dictionary (the length of the five longest pipes), and +a list of strings (tank names). +The example also combines multiple images into one figure using subplots and +changes the colormap from the default `Spectral_r` to `viridis` in one plot. +See https://matplotlib.org for more colormap options. + +.. doctest:: + + >>> sim = wntr.sim.EpanetSimulator(wn) + >>> results = sim.run_sim() + >>> velocity = results.link['velocity'].loc[3600,:] + >>> print(velocity.head()) + name + 20 0.039 + 40 0.013 + 50 0.004 + 60 2.824 + 101 1.320 + Name: 3600, dtype: float32 + + >>> length = wn.query_link_attribute('length') + >>> length_top5 = length.sort_values(ascending=False)[0:5] + >>> length_top5 = length_top5.round(2).to_dict() + >>> print(length_top5) + {'329': 13868.4, '101': 4328.16, '137': 1975.1, '169': 1389.89, '204': 1380.74} + + >>> tank_names = wn.tank_name_list + >>> print(tank_names) + ['1', '2', '3'] + + >>> fig, axes = plt.subplots(1, 3, figsize=(15, 5)) + >>> ax = wntr.graphics.plot_network(wn, link_attribute=velocity, + ... title='Pipe velocity at hour 1', link_colorbar_label='Velocity (m/s)', ax=axes[0]) + >>> ax = wntr.graphics.plot_network(wn, link_attribute=length_top5, link_width=2, + ... title='Longest 5 pipes', link_cmap = plt.cm.viridis, + ... link_colorbar_label='Pipe length (m)', ax=axes[1]) + >>> ax = wntr.graphics.plot_network(wn, node_attribute=tank_names, + ... title='Location of tanks', ax=axes[2]) + +.. doctest:: + :hide: + + >>> plt.tight_layout() + >>> plt.savefig('plot_subplot_basic_network.png', dpi=300) + +.. _fig-network-3: +.. figure:: figures/plot_subplot_basic_network.png + :width: 800 + :alt: Network + + Additional network graphics. + + Interactive plotly networks --------------------------------- diff --git a/documentation/model_io.rst b/documentation/model_io.rst index f4cfe1889..3d3dfb972 100644 --- a/documentation/model_io.rst +++ b/documentation/model_io.rst @@ -6,6 +6,7 @@ :hide: >>> import wntr + >>> import pandas as pd >>> try: ... import geopandas as gpd ... except ModuleNotFoundError: @@ -29,6 +30,8 @@ EPANET INP file The :class:`~wntr.network.io.read_inpfile` function builds a WaterNetworkModel from an EPANET INP file. The EPANET INP file can be in the EPANET 2.00.12 or 2.2.0 format. +See https://epanet22.readthedocs.io for more information on EPANET INP file format. + The function can also be used to append information from an EPANET INP file into an existing WaterNetworkModel. .. doctest:: @@ -53,7 +56,9 @@ EPANET INP files can be saved in the EPANET 2.00.12 or 2.2.0 format. .. doctest:: >>> wntr.network.write_inpfile(wn, 'filename.inp', version=2.2) - + +.. _dictionary_representation: + Dictionary representation ------------------------- @@ -172,8 +177,12 @@ a NetworkX graph could be added in a future version of WNTR. JSON file --------------------------------------------------------- +JSON (JavaScript Object Notation) files store a collection of name/value pairs that is easy to read in text format. +More information on JSON files is available at https://www.json.org. +The format of JSON files in WNTR is based on the :ref:`dictionary_representation` of the WaterNetworkModel. + The :class:`~wntr.network.io.write_json` function writes a -JSON (JavaScript Object Notation) file from a WaterNetworkModel. +JSON file from a WaterNetworkModel. The JSON file is a formatted version of the dictionary representation. .. doctest:: @@ -194,6 +203,33 @@ They simply ignore extraneous or invalid dictionary keys. GeoJSON files ------------- +GeoJSON files are commonly used to store geographic data structures. +More information on GeoJSON files can be found at https://geojson.org. + +To use GeoJSON files in WNTR, a set of valid base column names are required. +Valid base GeoJSON column names can be obtained using the +:class:`~wntr.network.io.valid_gis_names` function. +The following example returns valid base GeoJSON column names for junctions. + +.. doctest:: + :skipif: gpd is None + + >>> geojson_column_names = wntr.network.io.valid_gis_names() + >>> print(geojson_column_names['junctions']) + ['name', 'base_demand', 'pattern_name', 'elevation', 'coordinates', 'demand_category', 'emitter_coefficient', 'initial_quality', 'minimum_pressure', 'required_pressure', 'pressure_exponent', 'tag'] + +A minimal list of valid column names can also be obtained by setting ``complete_list`` to False. +Column names that are optional (i.e., ``initial_quality``) and not included in the GeoJSON file are defined using default values. + +.. doctest:: + :skipif: gpd is None + + >>> geojson_column_names = wntr.network.io.valid_gis_names(complete_list=False) + >>> print(geojson_column_names['junctions']) + ['name', 'base_demand', 'pattern_name', 'elevation', 'coordinates', 'demand_category'] + +Note that GeoJSON files can contain additional custom column names that are assigned to WaterNetworkModel objects. + The :class:`~wntr.network.io.write_geojson` function writes a collection of GeoJSON files from a WaterNetworkModel. The GeoJSON files can be loaded into geographic information @@ -204,7 +240,7 @@ system (GIS) platforms for further analysis and visualization. >>> wntr.network.write_geojson(wn, 'Net3') -This creates the following GeoJSON files for junctions, tanks, reservoirs, pipes, and pumps: +This creates the following GeoJSON files for junctions, tanks, reservoirs, pipes, and pumps: * Net3_junctions.geojson * Net3_tanks.geojson @@ -216,7 +252,8 @@ A GeoJSON file for valves, Net3_valves.geojson, is not created since Net3 has no Note that patterns, curves, sources, controls, and options are not stored in the GeoJSON files. The :class:`~wntr.network.io.read_geojson` function creates a WaterNetworkModel from a -dictionary of GeoJSON files. +dictionary of GeoJSON files. +Valid base column names and additional custom attributes are added to the model. The function can also be used to append information from GeoJSON files into an existing WaterNetworkModel. .. doctest:: @@ -233,12 +270,65 @@ The function can also be used to append information from GeoJSON files into an e :class:`~wntr.gis.network.WaterNetworkGIS.write_geojson` and :class:`~wntr.gis.network.WaterNetworkGIS.read_geojson` are also methods on the WaterNetworkGIS object. - -Shapefile files + + +.. _shapefile_format: + +Shapefile ------------------- +A Shapefile is a collection of vector data storage files used to store geographic data. +The file format is developed and regulated by Esri. +For more information on Shapefiles, see https://www.esri.com. + +To use Esri Shapefiles in WNTR, several formatting requirements are enforced: + +* Geospatial data containing junction, tank, reservoir, pipe, pump, and valve data + are stored in separate Shapefile directories. + +* The namespace for Node names (which includes junctions, tanks, and reservoirs) + must be unique. Likewise, the namespace for Links (which includes pipes, + pumps, and valves) must be unique. For example, this means that a junction + cannot have the same name as a tank. + +* The Shapefile geometry is in a format compatible with GeoPandas, namely a + Point, LineString, or MultiLineString. See :ref:`gis_data` for + more information on geometries. + +* Shapefiles truncate field names to 10 characters, while WaterNetworkModel + node and link attribute names are often longer. For this reason, it is + assumed that the first 10 characters of each attribute are unique. + +* To create WaterNetworkModel from Shapefiles, a set of valid field names are required. + Valid base Shapefiles field names can be obtained using the + :class:`~wntr.network.io.valid_gis_names` function. + For Shapefiles, the `truncate` input parameter should be set to 10 (characters). + The following example returns valid base Shapefile field names for junctions. + Note that attributes like ``base_demand`` are truncated to ``base_deman``. + + .. doctest:: + :skipif: gpd is None + + >>> shapefile_field_names = wntr.network.io.valid_gis_names(truncate_names=10) + >>> print(shapefile_field_names['junctions']) + ['name', 'base_deman', 'pattern_na', 'elevation', 'coordinate', 'demand_cat', 'emitter_co', 'initial_qu', 'minimum_pr', 'required_p', 'pressure_e', 'tag'] + + A minimal list of valid field names can also be obtained by setting ``complete_list`` to False. + Field names that are optional (i.e., ``initial_quality``) and not included in the Shapefile are defined using default values. + + .. doctest:: + :skipif: gpd is None + + >>> shapefile_field_names = wntr.network.io.valid_gis_names(complete_list=False, + ... truncate_names=10) + >>> print(shapefile_field_names['junctions']) + ['name', 'base_deman', 'pattern_na', 'elevation', 'coordinate', 'demand_cat'] + +* Shapefiles can contain additional custom field names that are assigned to WaterNetworkModel objects. + + The :class:`~wntr.network.io.write_shapefile` function creates -Shapefile files from a WaterNetworkModel. +Shapefiles from a WaterNetworkModel. The Shapefiles can be loaded into GIS platforms for further analysis and visualization. .. doctest:: @@ -255,10 +345,11 @@ This creates the following Shapefile directories for junctions, tanks, reservoir * Net3_pumps A Shapefile for valves, Net3_valves, is not created since Net3 has no valves. -Note that patterns, curves, sources, controls, and options are not stored in the Shapefile files. +Note that patterns, curves, sources, controls, and options are not stored in the Shapefiles. The :class:`~wntr.network.io.read_shapefile` function creates a WaterNetworkModel from a dictionary of Shapefile directories. +Valid base field names and additional custom field names are added to the model. The function can also be used to append information from Shapefiles into an existing WaterNetworkModel. .. doctest:: diff --git a/documentation/morph.rst b/documentation/morph.rst index 6a1234ad0..268c43291 100644 --- a/documentation/morph.rst +++ b/documentation/morph.rst @@ -17,9 +17,9 @@ Network skeletonization in WNTR follows the procedure outlined in [WCSG03]_. The skeletonization process retains all tanks, reservoirs, valves, and pumps, along with all junctions and pipes that are associated with controls. Junction demands and demand patterns are retained in the skeletonized model, as described below. Merged pipes are assigned equivalent properties for diameter, length, and roughness to approximate the updated system behavior. -Pipes that fall below a user-defined pipe diameter threshold are candidates for removal based on three operations, including: +Pipes that are less than or equal to a user-defined pipe diameter threshold are candidates for removal based on three operations, including: -1. **Branch trimming**: Dead-end pipes that are below the pipe diameter threshold are removed from the model (:numref:`fig-branch-trim`). +1. **Branch trimming**: Dead-end pipes that are less than or equal to the pipe diameter threshold are removed from the model (:numref:`fig-branch-trim`). The demand and demand pattern assigned to the dead-end junction is moved to the junction that is retained in the model. Dead-end pipes that are connected to tanks or reservoirs are not removed from the model. @@ -30,7 +30,7 @@ Pipes that fall below a user-defined pipe diameter threshold are candidates for Branch trimming. -2. **Series pipe merge**: Pipes in series are merged if both pipes are below the pipe diameter threshold (:numref:`fig-series-merge`). +2. **Series pipe merge**: Pipes in series are merged if both pipes are less than or equal to the pipe diameter threshold (:numref:`fig-series-merge`). The demand and demand pattern assigned to the connecting junction is moved to the nearest junction that is retained in the model. The merged pipe is assigned the following equivalent properties: @@ -52,7 +52,7 @@ Pipes that fall below a user-defined pipe diameter threshold are candidates for Series pipe merge. -3. **Parallel pipe merge**: Pipes in parallel are merged if both pipes are below the pipe diameter threshold (:numref:`fig-parallel-merge`). +3. **Parallel pipe merge**: Pipes in parallel are merged if both pipes are less than or equal to the pipe diameter threshold (:numref:`fig-parallel-merge`). This operation does not reduce the number of junctions in the system. The merged pipe is assigned the following equivalent properties: @@ -75,7 +75,7 @@ Pipes that fall below a user-defined pipe diameter threshold are candidates for Parallel pipe merge. The :class:`~wntr.morph.skel.skeletonize` function is used to perform network skeletonization. -The iterative algorithm first loops over all candidate pipes (pipes below the pipe diameter threshold) and removes branch pipes. +The iterative algorithm first loops over all candidate pipes (pipes less than or equal to the pipe diameter threshold) and removes branch pipes. Then the algorithm loops over all candidate pipes and merges pipes in series. Finally, the algorithm loops over all candidate pipes and merges pipes in parallel. This initial set of operations can generate new branch pipes, pipes in series, and pipes in parallel. @@ -109,7 +109,8 @@ the original 'Junction 1' and 'Junction 2.' The following example performs network skeletonization on Net6 and compares system pressure using the original and skeletonized networks. -The example starts by creating a water network model for Net6, listing the number of network components (e.g., 3356 nodes, 3892 links), and then skeletonizing it using a using a pipe diameter threshold of 12 inches. +The example starts by creating a water network model for Net6, listing the number of network components +(e.g., 3356 nodes, 3892 links), and then skeletonizing it using pipes with diameter less than or equal to 12 inches. The skeletonization procedure reduces the number of nodes in the network from approximately 3000 to approximately 1000 (:numref:`fig-skel-example`). diff --git a/documentation/options.rst b/documentation/options.rst index fc5937912..03a19985c 100644 --- a/documentation/options.rst +++ b/documentation/options.rst @@ -49,7 +49,8 @@ Individual sections are selected as follows. >>> wn.options.graphics # doctest: +SKIP >>> wn.options.user # doctest: +SKIP -Options can be modified, as shown in the example below. +Options can be modified, as shown in the example below +(note, duration is in seconds and required pressure is in meters). .. doctest:: diff --git a/documentation/resultsobject.rst b/documentation/resultsobject.rst index 6f5051109..76480a7a4 100644 --- a/documentation/resultsobject.rst +++ b/documentation/resultsobject.rst @@ -87,7 +87,7 @@ Link results include DataFrames for each of the following attributes: * Velocity * Flowrate * Setting -* Status (0 indicates closed, 1 indicates open) +* Status (0 indicates closed pipe/pump/valve, 1 indicates open pipe/pump/valve, 2 indicates active valve) * Headloss (only when the EpanetSimulator is used) * Friction factor (only when the EpanetSimulator is used) * Reaction rate (only when the EpanetSimulator is used) diff --git a/documentation/units.rst b/documentation/units.rst index 8f3ca4a41..3c55d6337 100644 --- a/documentation/units.rst +++ b/documentation/units.rst @@ -7,19 +7,22 @@ Units All data in WNTR is stored in the following SI (International System) units: -* Length = :math:`m` +* Acceleration = :math:`g` (1 :math:`g` = 9.81 :math:`m/s^2`) +* Concentration = :math:`kg/m^3` +* Demand = :math:`m^3/s` * Diameter = :math:`m` -* Water pressure = :math:`m` (this assumes a fluid density of 1000 :math:`kg/m^3`) * Elevation = :math:`m` +* Energy = :math:`J` +* Flow rate = :math:`m^3/s` +* Head = :math:`m` +* Headloss = :math:`m` +* Length = :math:`m` * Mass = :math:`kg` +* Mass injection = :math:`kg/s` +* Power = :math:`W` +* Pressure head = :math:`m` (this assumes a fluid density of 1000 :math:`kg/m^3`) * Time = :math:`s` -* Concentration = :math:`kg/m^3` -* Demand = :math:`m^3/s` * Velocity = :math:`m/s` -* Acceleration = :math:`g` (1 :math:`g` = 9.81 :math:`m/s^2`) -* Energy = :math:`J` -* Power = :math:`W` -* Mass injection = :math:`kg/s` * Volume = :math:`m^3` When setting up analysis in WNTR, all input values should be specified in SI units. diff --git a/documentation/users.rst b/documentation/users.rst index 3073c43b3..2d1c5e9c5 100644 --- a/documentation/users.rst +++ b/documentation/users.rst @@ -17,16 +17,24 @@ Related software * CriticalityMaps: https://github.com/pshassett/CriticalityMaps -* Digital HydrAuLic SIMulator (DHALSIM): https://github.com/afmurillo/DHALSIM +* Digital HydrAuLic SIMulator (DHALSIM): https://github.com/Critical-Infrastructure-Systems-Lab/DHALSIM * LeakDB: https://github.com/KIOS-Research/LeakDB +* MAGNets: https://github.com/meghnathomas/MAGNets + +* MILPNet: https://github.com/meghnathomas/MILPNet + * PPMTools: https://github.com/USEPA/PPMtools +* PTSNet: https://github.com/gandresr/ptsnet + * pyincore: https://github.com/IN-CORE/pyincore * TSNet: https://github.com/glorialulu/TSNet +* VisWaterNet: https://github.com/tylertrimble/viswaternet + Publications ----------------- diff --git a/documentation/waternetworkmodel.rst b/documentation/waternetworkmodel.rst index ff16243ea..13cad3ed4 100644 --- a/documentation/waternetworkmodel.rst +++ b/documentation/waternetworkmodel.rst @@ -5,6 +5,8 @@ .. doctest:: :hide: + >>> import numpy as np + >>> import pandas as pd >>> try: ... import geopandas as gpd ... except ModuleNotFoundError: @@ -199,12 +201,59 @@ Add custom element attributes New attributes can be added to model elements simply by defining a new attribute name and value. These attributes can be used in custom analysis and graphics. +While this is similar to using external datasets directly, there can be benefits +to adding custom attributes to model objects. + +The following example uses a dataset that defines pipe material as +polyvinyl chloride (PVC), cast iron, steel, or high-density polyethylene (HDPE). +The dataset is indexed by pipe name. The first 10 lines of the dataset are shown below. .. doctest:: + :hide: - >>> pipe = wn.get_link('122') - >>> pipe.material = 'PVC' + >>> np.random.seed(6789) + >>> material = pd.Series() + >>> for name, pipe in wn.pipes(): + ... val = np.random.choice(['PVC', 'Cast iron', 'Steel', 'HDPE'], 1, [0.45, 0.2, 0.1, 0.25])[0] + ... material[name] = val + +.. doctest:: + + >>> print(material.head(10)) + 20 Steel + 40 Cast iron + 50 HDPE + 60 PVC + 101 Cast iron + 103 PVC + 105 Steel + 107 Steel + 109 Cast iron + 111 Steel + dtype: object +The data can be used to create a custom `material` attribute for each pipe object. + +.. doctest:: + + >>> for name, pipe in wn.pipes(): + ... pipe.material = material[name] + +The custom attribute can be used in analysis in several ways. +For example, the following example closes the pipe if pipe material is 'Steel.' + + >>> for name, pipe in wn.pipes(): + ... if pipe.material == 'Steel': + ... pipe.initial_status = 'Closed' + +A complete list of custom attributes can also be obtained using a query, +as shown below. + +.. doctest:: + + >>> material = wn.query_link_attribute('material') + + Iterate over elements ------------------------- diff --git a/wntr/gis/network.py b/wntr/gis/network.py index ae0d78c05..fd70ec924 100644 --- a/wntr/gis/network.py +++ b/wntr/gis/network.py @@ -339,7 +339,7 @@ def read_geojson(self, files, index_col='index'): def read_shapefile(self, files, index_col='index'): """ - Append information from ESRI Shapefiles to a WaterNetworkGIS object + Append information from Esri Shapefiles to a WaterNetworkGIS object Parameters ---------- @@ -352,34 +352,7 @@ def read_shapefile(self, files, index_col='index'): """ self._read(files, index_col) - # ESRI Shapefiles truncate field names to 10 characters. The field_name_map - # maps truncated names to long names. The following code assumes the - # first 10 characters are unique. - element_attributes = { - 'junctions': dir(wntr.network.elements.Junction), - 'tanks': dir(wntr.network.elements.Tank), - 'reservoirs': dir(wntr.network.elements.Reservoir), - 'pipes': dir(wntr.network.elements.Pipe), - 'pumps': dir(wntr.network.elements.Pump) + - dir(wntr.network.elements.PowerPump) + - dir(wntr.network.elements.HeadPump), - 'valves': dir(wntr.network.elements.Valve) + - dir(wntr.network.elements.PRValve) + - dir(wntr.network.elements.PSValve) + - dir(wntr.network.elements.PBValve) + - dir(wntr.network.elements.FCValve) + - dir(wntr.network.elements.TCValve) + - dir(wntr.network.elements.GPValve)} - - field_name_map = {} - for element, attribute in element_attributes.items(): - field_name_map[element] = {} - for field_name in attribute: - if (len(field_name) > 10) and (not field_name.startswith('_')): - field_name_map[element][field_name[0:10]] = field_name - - # TODO: pipe property is cv instead of check_valve, this should be updated - field_name_map['pipes']['check_valv'] = 'check_valve' + field_name_map = self._shapefile_field_name_map() self.junctions.rename(columns=field_name_map['junctions'], inplace=True) self.tanks.rename(columns=field_name_map['tanks'], inplace=True) @@ -402,7 +375,7 @@ def _write(self, prefix: str, driver="GeoJSON") -> None: pipes, etc.) appended driver : str, optional GeoPandas driver. Use "GeoJSON" for GeoJSON files, use :code:`None` - for ESRI shapefile folders, by default "GeoJSON" + for Esri Shapefile folders, by default "GeoJSON" """ @@ -449,7 +422,7 @@ def write_geojson(self, prefix: str): def write_shapefile(self, prefix: str): """ - Write the WaterNetworkGIS object to a set of ESRI Shapefiles, one + Write the WaterNetworkGIS object to a set of Esri Shapefiles, one directory for each network element. Parameters @@ -458,3 +431,71 @@ def write_shapefile(self, prefix: str): File and directory prefix """ self._write(prefix=prefix, driver=None) + + def _valid_names(self, complete_list=True, truncate_names=None): + """ + Valid column/field names for GeoJSON or Shapefiles + + Note that Shapefile field names are truncated to 10 characters + (set truncate=10) + + Parameters + ---------- + complete_list : bool + Include a complete list of column/field names (beyond basic attributes) + truncate_names : None or int + Truncate column/field names to specified number of characters, + set truncate=10 for Shapefiles. None indicates no truncation. + + Returns + --------- + dict : Dictionary of valid GeoJSON or Shapefile column/field names + """ + + valid_names = {} + + element_objects = { + 'junctions': wntr.network.elements.Junction, + 'tanks': wntr.network.elements.Tank, + 'reservoirs': wntr.network.elements.Reservoir, + 'pipes': wntr.network.elements.Pipe, + 'pumps': wntr.network.elements.Pump, + 'valves': wntr.network.elements.Valve} + + valid_names = {} + for element, obj in element_objects.items(): + if complete_list: + valid_names[element] = obj._base_attributes + obj._optional_attributes + else: + valid_names[element] = obj._base_attributes + + if truncate_names is not None and truncate_names > 0: + for element, attributes in valid_names.items(): + valid_names[element] = [attribute[:truncate_names] for attribute in attributes] + + return valid_names + + def _shapefile_field_name_map(self): + """ + Return a map (dictionary) of tuncated shapefile field names to + valid base WaterNetworkModel attribute names + + Esri Shapefiles truncate field names to 10 characters. The field name + map links truncated shapefile field names to complete (and ofen longer) + WaterNetworkModel attribute names. This assumes that the first 10 + characters of each attribute name are unique. + + Returns + ------- + field_name_map : dict + Map (dictionary) of valid base shapefile field names to + WaterNetworkModel attribute names + """ + valid_names = self._valid_names() + + field_name_map = {} + for element, attributes in valid_names.items(): + truncated = [attribute[:10] for attribute in attributes] + field_name_map[element] = pd.Series(dict(zip(truncated, attributes))) + + return field_name_map diff --git a/wntr/morph/skel.py b/wntr/morph/skel.py index bf91275fc..ee80f1316 100644 --- a/wntr/morph/skel.py +++ b/wntr/morph/skel.py @@ -26,8 +26,8 @@ def skeletonize(wn, pipe_diameter_threshold, branch_trim=True, series_pipe_merge wn: wntr WaterNetworkModel Water network model pipe_diameter_threshold: float - Pipe diameter threshold used to determine candidate pipes for - skeletonization + Pipe diameter threshold. Pipes with diameter <= threshold are + candidates for removal branch_trim: bool, optional If True, include branch trimming in skeletonization series_pipe_merge: bool, optional diff --git a/wntr/network/elements.py b/wntr/network/elements.py index 16b53825d..d55b7e2ab 100644 --- a/wntr/network/elements.py +++ b/wntr/network/elements.py @@ -75,17 +75,17 @@ class Junction(Node): name node_type - head - demand + base_demand + demand_pattern demand_timeseries_list elevation - required_pressure - minimum_pressure - pressure_exponent - emitter_coefficient - base_demand coordinates + demand_category + emitter_coefficient initial_quality + minimum_pressure + required_pressure + pressure_exponent tag .. rubric:: Read-only simulation results @@ -102,6 +102,22 @@ class Junction(Node): leak_discharge_coeff """ + + # base and optional attributes used to create a Junction in _from_dict + # base attributes are used in add_junction + _base_attributes = ["name", + "base_demand", + "pattern_name", + "elevation", + "coordinates", + "demand_category"] + _optional_attributes = ["emitter_coefficient", + "initial_quality", + "minimum_pressure", + "required_pressure", + "pressure_exponent", + "tag"] + def __init__(self, name, wn): super(Junction, self).__init__(wn, name) self._demand_timeseries_list = Demands(self._pattern_reg) @@ -401,7 +417,25 @@ class Tank(Node): leak_discharge_coeff """ - + + # base and optional attributes used to create a Tank in _from_dict + # base attributes are used in add_tank + _base_attributes = ["name", + "elevation", + "init_level", + "min_level", + "max_level", + "diameter", + "min_vol" + "vol_curve_name", + "overflow", + "coordinates"] + _optional_attributes = ["initial_quality", + "mixing_fraction", + "mixing_model", + "bulk_coeff", + "tag"] + def __init__(self, name, wn): super(Tank, self).__init__(wn, name) self._elevation=0.0 @@ -714,9 +748,9 @@ class Reservoir(Node): base_head head_pattern_name head_timeseries - tag - initial_quality coordinates + initial_quality + tag .. rubric:: Read-only simulation results @@ -729,6 +763,15 @@ class Reservoir(Node): """ + # base and optional attributes used to create a Reservoir in _from_dict + # base attributes are used in add_reservoir + _base_attributes = ["name", + "base_head", + "head_pattern_name", + "coordinates"] + _optional_attributes = ["initial_quality", + "tag"] + def __init__(self, name, wn, base_head=0.0, head_pattern=None): super(Reservoir, self).__init__(wn, name) self._head_timeseries = TimeSeries(wn._pattern_reg, base_head) @@ -807,22 +850,21 @@ class Pipe(Link): .. autosummary:: name + link_type + start_node start_node_name + end_node end_node_name - link_type length diameter roughness minor_loss - cv + initial_status + check_valve bulk_coeff wall_coeff - initial_status - start_node - end_node - tag vertices - + tag .. rubric:: Read-only simulation results @@ -837,7 +879,23 @@ class Pipe(Link): status """ - + + # base and optional attributes used to create a Pipe in _from_dict + # base attributes are used in add_pipe + _base_attributes = ["name", + "start_node_name", + "end_node_name", + "length", + "diameter", + "roughness", + "minor_loss", + "initial_status", + "check_valve"] + _optional_attributes = ["bulk_coeff", + "wall_coeff", + "vertices", + "tag"] + def __init__(self, name, start_node_name, end_node_name, wn): super(Pipe, self).__init__(wn, name, start_node_name, end_node_name) self._length = 304.8 @@ -984,15 +1042,16 @@ class Pump(Link): start_node_name end_node end_node_name + base_speed + speed_pattern_name + speed_timeseries initial_status initial_setting - speed_timeseries efficiency energy_price energy_pattern - tag vertices - + tag .. rubric:: Read-only simulation results @@ -1006,7 +1065,25 @@ class Pump(Link): setting """ - + + # base and optional attributes used to create a Pump in _from_dict + # base attributes are used in add_pump + _base_attributes = ["name", + "start_node_name", + "end_node_name", + "pump_type", + "pump_curve_name", + "power" + "base_speed", + "speed_pattern_name", + "initial_status"] + _optional_attributes = ["initial_setting", + "efficiency", + "energy_pattern", + "energy_price", + "vertices", + "tag"] + def __init__(self, name, start_node_name, end_node_name, wn): super(Pump, self).__init__(wn, name, start_node_name, end_node_name) self._speed_timeseries = TimeSeries(wn._pattern_reg, 1.0) @@ -1177,17 +1254,18 @@ class HeadPump(Pump): start_node_name end_node end_node_name + base_speed + speed_pattern_name + speed_timeseries initial_status initial_setting pump_type pump_curve_name - speed_timeseries efficiency energy_price energy_pattern - tag vertices - + tag .. rubric:: Read-only simulation results @@ -1201,6 +1279,7 @@ class HeadPump(Pump): setting """ + # def __init__(self, name, start_node_name, end_node_name, wn): # super(HeadPump,self).__init__(name, start_node_name, # end_node_name, wn) @@ -1208,7 +1287,7 @@ class HeadPump(Pump): # self._coeffs_curve_points = None # these are used to verify whether # # the pump curve was changed since # # the _curve_coeffs were calculated - + def __repr__(self): return "".format(self._link_name, self.start_node, self.end_node, 'HEAD', self.pump_curve_name, @@ -1395,17 +1474,18 @@ class PowerPump(Pump): start_node_name end_node end_node_name + base_speed + speed_pattern_name + speed_timeseries initial_status initial_setting pump_type power - speed_timeseries efficiency energy_price energy_pattern - tag vertices - + tag .. rubric:: Read-only simulation results @@ -1419,7 +1499,7 @@ class PowerPump(Pump): setting """ - + def __repr__(self): return "".format(self._link_name, self.start_node, self.end_node, 'POWER', self._base_power, @@ -1486,12 +1566,12 @@ class Valve(Link): start_node_name end_node end_node_name + diameter + valve_type initial_status initial_setting - valve_type - tag vertices - + tag .. rubric:: Result attributes @@ -1505,7 +1585,20 @@ class Valve(Link): setting """ - + + # base and optional attributes used to create a Valve in _from_dict + # base attributes are used in add_valve + _base_attributes = ["name", + "start_node_name", + "end_node_name", + "diameter", + "valve_type", + "minor_loss", + "initial_setting", + "initial_status"] + _optional_attributes = ["vertices", + "tag"] + def __init__(self, name, start_node_name, end_node_name, wn): super(Valve, self).__init__(wn, name, start_node_name, end_node_name) self.diameter = 0.3048 @@ -1602,7 +1695,7 @@ class PRValve(Valve): setting """ - + def __init__(self, name, start_node_name, end_node_name, wn): super(PRValve, self).__init__(name, start_node_name, end_node_name, wn) @@ -1663,7 +1756,7 @@ class PSValve(Valve): setting """ - + def __init__(self, name, start_node_name, end_node_name, wn): super(PSValve, self).__init__(name, start_node_name, end_node_name, wn) @@ -1724,7 +1817,7 @@ class PBValve(Valve): setting """ - + def __init__(self, name, start_node_name, end_node_name, wn): super(PBValve, self).__init__(name, start_node_name, end_node_name, wn) @@ -1784,7 +1877,7 @@ class FCValve(Valve): setting """ - + def __init__(self, name, start_node_name, end_node_name, wn): super(FCValve, self).__init__(name, start_node_name, end_node_name, wn) @@ -1845,7 +1938,7 @@ class TCValve(Valve): setting """ - + def __init__(self, name, start_node_name, end_node_name, wn): super(TCValve, self).__init__(name, start_node_name, end_node_name, wn) @@ -1908,7 +2001,7 @@ class GPValve(Valve): setting """ - + def __init__(self, name, start_node_name, end_node_name, wn): super(GPValve, self).__init__(name, start_node_name, end_node_name, wn) self._headloss_curve_name = None diff --git a/wntr/network/io.py b/wntr/network/io.py index d3901e8c2..b8d009937 100644 --- a/wntr/network/io.py +++ b/wntr/network/io.py @@ -172,9 +172,10 @@ def from_dict(d: dict, append=None): name, elevation=node.setdefault("elevation"), init_level=node.setdefault("init_level", node.setdefault("min_level", 0)), + min_level=node.setdefault("min_level", 0), max_level=node.setdefault("max_level", node.setdefault("min_level", 0) + 10), diameter=node.setdefault("diameter", 0), - min_level=node.setdefault("min_level", 0), + min_vol=node.setdefault("min_vol", 0), vol_curve=node.setdefault("vol_curve_name"), overflow=node.setdefault("overflow", False), coordinates=coordinates, @@ -185,8 +186,8 @@ def from_dict(d: dict, append=None): t.mixing_fraction = node.setdefault("mixing_fraction") if node.setdefault("mixing_model"): t.mixing_model = node.setdefault("mixing_model") - t.tag = node.setdefault("tag") t.bulk_coeff = node.setdefault("bulk_coeff") + t.tag = node.setdefault("tag") # custom additional attributes for attr in list(set(node.keys()) - set(dir(t))): setattr( t, attr, node[attr] ) @@ -600,7 +601,7 @@ def read_geojson(files, index_col='index', append=None): def write_shapefile(wn, prefix: str, crs=None, pumps_as_points=True, valves_as_points=True): """ - Write the WaterNetworkModel to a set of ESRI Shapefiles, one directory for + Write the WaterNetworkModel to a set of Esri Shapefiles, one directory for each network element. The Shapefiles only includes information from the water network model. @@ -633,7 +634,7 @@ def write_shapefile(wn, prefix: str, crs=None, pumps_as_points=True, def read_shapefile(files, index_col='index', append=None): """ - Create or append a WaterNetworkModel from ESRI Shapefiles + Create or append a WaterNetworkModel from Esri Shapefiles Parameters ---------- @@ -657,3 +658,28 @@ def read_shapefile(files, index_col='index', append=None): wn = gis_data._create_wn(append=append) return wn + +def valid_gis_names(complete_list=True, truncate_names=None): + """ + Valid column/field names for GeoJSON or Shapefiles + + Note that Shapefile field names are truncated to 10 characters + (set truncate=10) + + Parameters + ---------- + complete_list : bool + Include a complete list of column/field names (beyond basic attributes) + truncate_names : None or int + Truncate column/field names to specified number of characters, + set truncate=10 for Shapefiles. None indicates no truncation. + + Returns + --------- + dict : Dictionary of valid GeoJSON or Shapefile column/field names + """ + + gis_data = WaterNetworkGIS() + column_names = gis_data._valid_names(complete_list, truncate_names) + + return column_names diff --git a/wntr/network/model.py b/wntr/network/model.py index 21749373a..938da1421 100644 --- a/wntr/network/model.py +++ b/wntr/network/model.py @@ -2506,12 +2506,7 @@ def add_valve( # A PRV, PSV or FCV cannot be directly connected to a reservoir or tank (use a length of pipe to separate the two) if valve_type in ["PRV", "PSV", "FCV"]: - if ( - type(start_node) == Tank - or type(end_node) == Tank - or type(start_node) == Reservoir - or type(end_node) == Reservoir - ): + if type(start_node) == Tank or type(end_node) == Tank: msg = ( "%ss cannot be directly connected to a tank. Add a pipe to separate the valve from the tank." % valve_type