From 17b7fcec6465fff6bf2129a26cde6954eeb3f6e4 Mon Sep 17 00:00:00 2001 From: lpawluczuk Date: Thu, 1 Jun 2023 13:51:58 +0200 Subject: [PATCH 1/5] moved form setup to pyproject --- foodwebviz/__init__.py | 2 - pyproject.toml | 66 +++++++++++++++++++++++++++++++ requirements.txt | 11 ------ setup.py | 88 ------------------------------------------ tox.ini | 2 - 5 files changed, 66 insertions(+), 103 deletions(-) create mode 100644 pyproject.toml delete mode 100644 requirements.txt delete mode 100644 setup.py diff --git a/foodwebviz/__init__.py b/foodwebviz/__init__.py index 34c06b1..7811350 100644 --- a/foodwebviz/__init__.py +++ b/foodwebviz/__init__.py @@ -4,8 +4,6 @@ foodwebviz is a Python package for the analysis and visualization of throphic networks. """ -__version__ = "0.1" - from foodwebviz.io import * from foodwebviz.utils import * diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..182a383 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,66 @@ +[project] +name = "foodwebviz" +description = "Foodwebs visualization package" +version = "0.1" +readme = "README.md" +authors = [ + { name = "Mateusz Ikrzyński", email = "mateusz.iskrzynski@ibspan.waw.pl" }, + { name = "Łukasz Pawluczuk", email = "lukpawlucz@gmail.com" } +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Scientific/Engineering :: Bio-Informatics", + "Topic :: Scientific/Engineering :: Information Analysis", + "Topic :: Scientific/Engineering :: Mathematics", + "Topic :: Scientific/Engineering :: Physics", +] +requires-python = ">=3.7" + +dependencies = [ + "pandas==1.1.3", + "networkx==2.6.2", + "xlwt==1.3.0", + "xlrd==2.0.1", + "seaborn==0.11.2", + "plotly==4.9.0", + "pyvis==0.1.8.2", + "kaleido", + "psutil", + "wand" +] + +[project.urls] +Documentation = "https://github.com/lpawluczuk/foodwebviz" +"Source Code" = "https://github.com/lpawluczuk/foodwebviz" +"Issue Tracker" = "https://github.com/lpawluczuk/foodwebviz/issues" + +[project.optional-dependencies] +test = [ + "pytest", + "ruff ==0.0.138" +] + +[build-system] +build-backend = "flit_core.buildapi" +requires = ["flit_core >=3.2,<4"] + + +[tool.ruff] +# Decrease the maximum line length to 79 characters. +line-length = 110 + +[tool.ruff.per-file-ignores] +"__init__.py" = ["F401", "F403"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 9f1e711..0000000 --- a/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -pandas==1.1.3 -networkx==2.6.2 -xlwt==1.3.0 -xlrd==2.0.1 -pytest==6.1.1 -seaborn==0.11.2 -plotly==4.9.0 -pyvis==0.1.8.2 -kaleido -psutil -wand diff --git a/setup.py b/setup.py deleted file mode 100644 index 7945569..0000000 --- a/setup.py +++ /dev/null @@ -1,88 +0,0 @@ -import sys -from setuptools import setup - -if sys.version_info[:2] < (3, 7): - sys.stderr.write(f'foodwebviz requires Python 3.7 or later ({sys.version_info[:2]} detected).\n') - sys.exit(1) - - -name = "foodwebviz" -description = "Python package for creating and visualizing foodwebs" -authors = { - "Pawluczuk": ("Łukasz Pawluczuk", ""), - "Iskrzyński": ("Mateusz Ikrzyński", ""), -} -maintainer = "" -maintainer_email = "" -url = "" -project_urls = { - "Bug Tracker": "https://github.com/lpawluczuk/foodwebviz/issues", - "Documentation": "https://github.com/lpawluczuk/foodwebviz", - "Source Code": "https://github.com/lpawluczuk/foodwebviz", -} -platforms = ["Linux", "Mac OSX", "Windows", "Unix"] -keywords = [ - "foodwebs", -] -classifiers = [ # TODO - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "Intended Audience :: Science/Research", - "License :: OSI Approved :: BSD License", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3 :: Only", - "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: Scientific/Engineering :: Bio-Informatics", - "Topic :: Scientific/Engineering :: Information Analysis", - "Topic :: Scientific/Engineering :: Mathematics", - "Topic :: Scientific/Engineering :: Physics", -] - - -with open("foodwebviz/__init__.py") as fid: - for line in fid: - if line.startswith("__version__"): - version = line.strip().split()[-1][1:-1] - break - -packages = [ - "foodwebviz", - "foodwebviz.animation" -] - - -def parse_requirements_file(filename): - with open(filename) as f: - requires = [x.strip() for x in f.readlines() if not x.startswith("#")] - return requires - - -install_requires = parse_requirements_file("requirements.txt") - -with open("README.md", "r", encoding='utf-8') as fh: - long_description = fh.read() - -if __name__ == "__main__": - setup( - name=name, - version=version, - maintainer=maintainer, - maintainer_email=maintainer_email, - author=authors["Pawluczuk"][0], - author_email=authors["Pawluczuk"][1], - description=description, - keywords=keywords, - long_description=long_description, - platforms=platforms, - packages=packages, - url=url, - project_urls=project_urls, - classifiers=classifiers, - install_requires=install_requires, - python_requires=">=3.7", - zip_safe=False, - ) diff --git a/tox.ini b/tox.ini index d6e7d14..e69de29 100644 --- a/tox.ini +++ b/tox.ini @@ -1,2 +0,0 @@ -[flake8] -max-line-length=110 From b0d41e4dbd9c21f7a5281ea108e47b4fb427e719 Mon Sep 17 00:00:00 2001 From: lpawluczuk Date: Fri, 2 Jun 2023 11:26:02 +0200 Subject: [PATCH 2/5] pyproject cleanup --- pyproject.toml | 1 - tox.ini | 0 2 files changed, 1 deletion(-) delete mode 100644 tox.ini diff --git a/pyproject.toml b/pyproject.toml index 182a383..408c846 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,6 @@ requires = ["flit_core >=3.2,<4"] [tool.ruff] -# Decrease the maximum line length to 79 characters. line-length = 110 [tool.ruff.per-file-ignores] diff --git a/tox.ini b/tox.ini deleted file mode 100644 index e69de29..0000000 From eec485f219d0d4647d962fd6f24b225c733061fb Mon Sep 17 00:00:00 2001 From: lpawluczuk Date: Fri, 2 Jun 2023 11:34:20 +0200 Subject: [PATCH 3/5] Development workflow added --- .github/workflows/Development | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/Development diff --git a/.github/workflows/Development b/.github/workflows/Development new file mode 100644 index 0000000..527e0f3 --- /dev/null +++ b/.github/workflows/Development @@ -0,0 +1,40 @@ +name: Development + +on: + push: + branches: + - master + pull_request: + types: [opened, synchronize] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache-dependency-path: pyproject.toml + - uses: actions/cache@v3 + id: cache + with: + path: ${{ env.pythonLocation }} + key: ${{ runner.os }}-python-${{ env.pythonLocation }}-${{ hashFiles('pyproject.toml') }}-test-v03 + - name: Install dependencies + if: steps.cache.outputs.cache-hit != 'true' + run: pip install -e .[test] + - name: Lint with ruff + run: | + # stop the build if there are Python syntax errors or undefined names + ruff --format=github --select=E9,F63,F7,F82 --target-version=py37 . + # default set of ruff rules with GitHub Annotations + ruff --format=github --target-version=py37 . + - name: Test with pytest + run: | + pytest \ No newline at end of file From e63f219a857963f460b08e5178e777d7541d3d2a Mon Sep 17 00:00:00 2001 From: lpawluczuk Date: Fri, 2 Jun 2023 17:16:56 +0200 Subject: [PATCH 4/5] type checking added --- pyproject.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 408c846..1e9af03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,8 @@ Documentation = "https://github.com/lpawluczuk/foodwebviz" [project.optional-dependencies] test = [ "pytest", - "ruff ==0.0.138" + "ruff ==0.0.138", + "mypy ==0.982" ] [build-system] @@ -61,5 +62,8 @@ line-length = 110 [tool.ruff.per-file-ignores] "__init__.py" = ["F401", "F403"] +[tool.mypy] +ignore_missing_imports = true + [tool.pytest.ini_options] testpaths = ["tests"] From ae53f4655f13a0b2954549753b0e17b3eeff2e56 Mon Sep 17 00:00:00 2001 From: lpawluczuk Date: Fri, 2 Jun 2023 17:59:28 +0200 Subject: [PATCH 5/5] type annotations added --- foodwebviz/create_animated_food_web.py | 44 +++++++--- foodwebviz/foodweb.py | 61 ++++++++----- foodwebviz/io.py | 32 +++---- foodwebviz/normalization.py | 14 +-- foodwebviz/utils.py | 31 +++++-- foodwebviz/visualization.py | 116 ++++++++++++++++--------- 6 files changed, 192 insertions(+), 106 deletions(-) diff --git a/foodwebviz/create_animated_food_web.py b/foodwebviz/create_animated_food_web.py index 04610db..0a3b312 100644 --- a/foodwebviz/create_animated_food_web.py +++ b/foodwebviz/create_animated_food_web.py @@ -4,20 +4,33 @@ The master functions for animation. @author: Mateusz """ +from __future__ import annotations + import numpy as np import matplotlib.pyplot as plt from matplotlib import animation +from typing import Callable, Optional, TYPE_CHECKING from foodwebviz.animation.network_image import NetworkImage from foodwebviz.animation import animation_utils +if TYPE_CHECKING: + import foodwebviz as fw + __all__ = [ - 'animate_foodweb', + 'animate_foodweb' ] -def _run_animation(filename, func, frames, interval, fig=None, figsize=(6.5, 6.5), fps=None, dpi=None): +def _run_animation(filename: str, + func: Callable[[int], None], + frames: int, + interval: float, + fig: Optional[tuple[float, float]] = None, + figsize: tuple[float, float] = (6.5, 6.5), + fps: Optional[int] = None, + dpi: Optional[float] = None) -> None: r""" Creates an animated GIF of a matplotlib. Parameters @@ -53,16 +66,26 @@ def forward(frame): if fig is None: fig = plt.figure(figsize=figsize) - forward.first = True - anim = animation.FuncAnimation(fig, forward, frames=frames, interval=interval) + forward.first = True # type: ignore + anim = animation.FuncAnimation( + fig, forward, frames=frames, interval=interval) anim.save(filename, writer='imagemagick', fps=fps, dpi=dpi) -def animate_foodweb(foodweb, gif_file_out, fps=10, anim_len=1, trails=1, - min_node_radius=0.5, min_part_num=1, - max_part_num=20, map_fun=np.sqrt, include_imports=True, include_exports=False, - cmap=plt.cm.get_cmap('viridis'), max_luminance=0.85, - particle_size=8): +def animate_foodweb(foodweb: fw.FoodWeb, + gif_file_out: str, + fps: int = 10, + anim_len: int = 1, + trails: int = 1, + min_node_radius: float = 0.5, + min_part_num: int = 1, + max_part_num: int = 20, + map_fun: Callable = np.sqrt, + include_imports: bool = True, + include_exports: bool = False, + cmap: plt.Colormap = plt.cm.get_cmap('viridis'), + max_luminance: float = 0.85, + particle_size: int = 8) -> None: '''foodweb_animation creates a GIF animation saved as gif_file_out based on the food web provided as a SCOR file scor_file_in. The canvas size in units relevant to further parameters is [0,100]x[0,100]. @@ -139,5 +162,6 @@ def animate_frame(frame): frames=fps * anim_len, # number of frames, interval=interval, figsize=(20, 20), - dpi=100 + 1.75 * len(network_image.nodes), # adapt the resolution to the number of nodes + # adapt the resolution to the number of nodes + dpi=100 + 1.75 * len(network_image.nodes), fps=fps) diff --git a/foodwebviz/foodweb.py b/foodwebviz/foodweb.py index f5c91d6..b6914e9 100644 --- a/foodwebviz/foodweb.py +++ b/foodwebviz/foodweb.py @@ -1,10 +1,12 @@ '''Class for foodwebs.''' +import pandas as pd import networkx as nx import foodwebviz as fw from .normalization import normalization_factory + __all__ = [ 'FoodWeb' ] @@ -16,7 +18,7 @@ class FoodWeb(object): It stores species and flows between them with additional data like Biomass. ''' - def __init__(self, title, node_df, flow_matrix): + def __init__(self, title: str, node_df: pd.DataFrame, flow_matrix: pd.DataFrame) -> None: '''Initialize a foodweb with title, nodes and flow matrix. Parameters ---------- @@ -44,9 +46,11 @@ def __init__(self, title, node_df, flow_matrix): self.node_df['TrophicLevel'] = fw.calculate_trophic_levels(self) self._graph = self._init_graph() - def _init_graph(self): + def _init_graph(self) -> nx.DiGraph: '''Returns networkx.DiGraph initialized using foodweb's flow matrix.''' - graph = nx.from_pandas_adjacency(self.get_flow_matrix(boundary=True), create_using=nx.DiGraph) + graph = nx.from_pandas_adjacency( + df=self.get_flow_matrix(boundary=True), + create_using=nx.DiGraph) nx.set_node_attributes(graph, self.node_df.to_dict(orient='index')) exclude_edges = [] @@ -57,13 +61,16 @@ def _init_graph(self): graph.remove_edges_from(exclude_edges) return graph - def get_diet_matrix(self): + def get_diet_matrix(self) -> pd.DataFrame: '''Returns a matrix of system flows express as diet proportions= =fraction of node inflows this flow contributes''' return self.flow_matrix.div(self.flow_matrix.sum(axis=0), axis=1).fillna(0.0) - def get_graph(self, boundary=False, mark_alive_nodes=False, normalization=None, - no_flows_to_detritus=False): + def get_graph(self, + boundary: bool = False, + mark_alive_nodes: bool = False, + normalization: str = 'linear', + no_flows_to_detritus: bool = False) -> nx.DiGraph: '''Returns foodweb as networkx.SubGraph View fo networkx.DiGraph. Parameters @@ -90,16 +97,23 @@ def get_graph(self, boundary=False, mark_alive_nodes=False, normalization=None, exclude_edges = [] if no_flows_to_detritus: not_alive_nodes = self.node_df[~self.node_df.IsAlive].index.values - exclude_edges = [edge for edge in self._graph.edges() if edge[1] in not_alive_nodes] + exclude_edges = [ + edge for edge in self._graph.edges() if edge[1] in not_alive_nodes] - g = nx.restricted_view(self._graph.copy(), exclude_nodes, exclude_edges) + g = nx.restricted_view( + G=self._graph.copy(), + nodes=exclude_nodes, + edges=exclude_edges) if mark_alive_nodes: g = nx.relabel_nodes(g, fw.is_alive_mapping(self)) g = normalization_factory(g, norm_type=normalization) return g - def get_flows(self, boundary=False, mark_alive_nodes=False, normalization=None, - no_flows_to_detritus=False): + def get_flows(self, + boundary: bool = False, + mark_alive_nodes: bool = False, + normalization: str = 'linear', + no_flows_to_detritus: bool = False) -> list[tuple[str, str, dict[str, float]]]: '''Returns a list of all flows within foodweb. Parameters @@ -126,7 +140,7 @@ def get_flows(self, boundary=False, mark_alive_nodes=False, normalization=None, return (self.get_graph(boundary, mark_alive_nodes, normalization, no_flows_to_detritus) .edges(data=True)) - def get_flow_matrix(self, boundary=False, to_alive_only=False): + def get_flow_matrix(self, boundary: bool = False, to_alive_only: bool = False) -> pd.DataFrame: '''Returns the flow (adjacency) matrix. Parameters @@ -153,9 +167,9 @@ def get_flow_matrix(self, boundary=False, to_alive_only=False): return flow_matrix flow_matrix_with_boundary = self.flow_matrix.copy() - flow_matrix_with_boundary.loc['Import'] = self.node_df.Import.to_dict() - flow_matrix_with_boundary.loc['Export'] = self.node_df.Export.to_dict() - flow_matrix_with_boundary.loc['Respiration'] = self.node_df.Respiration.to_dict() + flow_matrix_with_boundary.loc['Import'] = self.node_df.Import.to_dict() # type: ignore + flow_matrix_with_boundary.loc['Export'] = self.node_df.Export.to_dict() # type: ignore + flow_matrix_with_boundary.loc['Respiration'] = self.node_df.Respiration.to_dict() # type: ignore return ( flow_matrix_with_boundary .join(self.node_df.Import) @@ -163,20 +177,26 @@ def get_flow_matrix(self, boundary=False, to_alive_only=False): .join(self.node_df.Respiration) .fillna(0.0)) - def get_links_number(self): + def get_links_number(self) -> int: '''Returns the number of nonzero flows. ''' return self.get_graph(False).number_of_edges() - def get_flow_sum(self): + def get_flow_sum(self) -> pd.Series: '''Returns the sum of all flows. ''' return self.get_flow_matrix(boundary=True).sum() - def get_norm_node_prop(self): - num_node_prop = self.node_df[["Biomass", "Import", "Export", "Respiration"]] + def get_norm_node_prop(self) -> pd.DataFrame: + cols = ["Biomass", "Import", "Export", "Respiration"] + num_node_prop = self.node_df[cols] return num_node_prop.div(num_node_prop.sum(axis=0), axis=1) + def get_outflows_to_living(self) -> pd.Series: + # node's system outflows to living + # TODO doc + return self.flow_matrix[self.node_df[self.node_df.IsAlive].index].sum(axis='columns') + def __str__(self): return f''' {self.title}\n @@ -188,8 +208,3 @@ def __str__(self): {self.node_df["Respiration"]}\n {self.node_df["TrophicLevel"]}\n ''' - - def get_outflows_to_living(self): - # node's system outflows to living - # TODO doc - return self.flow_matrix[self.node_df[self.node_df.IsAlive].index].sum(axis='columns') diff --git a/foodwebviz/io.py b/foodwebviz/io.py index fad6d97..92efd7b 100644 --- a/foodwebviz/io.py +++ b/foodwebviz/io.py @@ -6,6 +6,7 @@ Create a foodweb from a SCOR file >>> food_web = read_from_SCOR(file_path) ''' +from __future__ import annotations import numpy as np import pandas as pd @@ -22,7 +23,7 @@ ] -def read_from_SCOR(scor_path): +def read_from_SCOR(scor_path: str) -> fw.FoodWeb: '''Reads a TXT file in the SCOR format and returns a FoodWeb object. Parameters @@ -30,7 +31,6 @@ def read_from_SCOR(scor_path): scor_path : string Path to the foodweb in SCOR format. - Returns ------- foodweb : foodwebs.Foodweb @@ -81,8 +81,6 @@ def read_from_SCOR(scor_path): 1 2 0.002519108 -1 -------------------- - - ''' with open(scor_path, 'r', encoding='utf-8') as f: print(f'Reading file: {scor_path}') @@ -123,12 +121,12 @@ def read_from_SCOR(scor_path): break flow_matrix.at[int(line[0]), int(line[1])] = float(line[2]) flow_matrix = flow_matrix.fillna(0.0) - flow_matrix.index = net.Names - flow_matrix.columns = net.Names + flow_matrix.index = net.Names # type: ignore + flow_matrix.columns = net.Names # type: ignore return fw.FoodWeb(title=title, flow_matrix=flow_matrix, node_df=net) -def write_to_SCOR(food_web, scor_path): +def write_to_SCOR(food_web: fw.FoodWeb, scor_path: str) -> None: '''Write foodweb to a SCOR file. Parameters @@ -209,7 +207,7 @@ def write_col(node_df, f, col): f.write('\n') -def write_to_XLS(food_web, filename): +def write_to_XLS(food_web: fw.FoodWeb, filename: str) -> None: '''Write foodweb as an XLS (spreadsheet) file. Parameters @@ -223,10 +221,10 @@ def write_to_XLS(food_web, filename): pd.DataFrame([food_web.title]).to_excel(writer, sheet_name="Title") food_web.node_df.to_excel(writer, sheet_name="Node properties") food_web.flow_matrix.to_excel(writer, sheet_name="Internal flows") - writer.save() + writer.close() -def read_from_XLS(filename): +def read_from_XLS(filename: str) -> fw.FoodWeb: '''Read foodweb from an XLS (spreadsheet) file, see examples/data/Richards_Bay_C_Summer.xls. Parameters @@ -262,18 +260,18 @@ def read_from_XLS(filename): 'Respiration': np.float64 }) flow_matrix = pd.read_excel(filename, sheet_name='Internal flows') - if not np.array_equal(flow_matrix.columns.values[1:], flow_matrix.Names.values): + if not np.array_equal(flow_matrix.columns.values[1:], flow_matrix.Names.values): # type: ignore raise Exception('Flow matrix (Internal flows sheet) should have exactly same rows as columns.') names = flow_matrix.Names flow_matrix.drop('Names', inplace=True, axis=1) - flow_matrix.index = names - flow_matrix.columns = names + flow_matrix.index = names # type: ignore + flow_matrix.columns = names # type: ignore if (flow_matrix < 0).any().any(): raise Exception('Flow matrix contains negative values.') return fw.FoodWeb(title=title.values[0][1], node_df=node_df, flow_matrix=flow_matrix) -def write_to_CSV(food_web, filename): +def write_to_CSV(food_web: fw.FoodWeb, filename: str) -> None: '''Writes a food web to a CSV (spreadsheet) file, using semicolon as a separator. Parameters @@ -285,17 +283,16 @@ def write_to_CSV(food_web, filename): ''' data = food_web.flow_matrix data = data.join(food_web.node_df[['IsAlive', 'Biomass', 'Export', 'Respiration', 'TrophicLevel']]) - data = data.append(food_web.node_df.Import) + data = data.append(food_web.node_df.Import) # type: ignore data = data.fillna(0.0) data.to_csv(filename, sep=';', encoding='utf-8') -def read_from_CSV(filename): +def read_from_CSV(filename: str) -> fw.FoodWeb: '''Reads a food web from a CSV (spreadsheet) file. Parameters ---------- - filename: string Path to the CSV file. The expected format of a semicolon-separated file (see examples/data/Richards_Bay_C_Summer): @@ -312,7 +309,6 @@ def read_from_CSV(filename): Returns ------- foodwebs.FoodWeb object - ''' data = pd.read_csv(filename, sep=';', encoding='utf-8').set_index('Names') diff --git a/foodwebviz/normalization.py b/foodwebviz/normalization.py index 5cc7c98..c4c441b 100644 --- a/foodwebviz/normalization.py +++ b/foodwebviz/normalization.py @@ -13,7 +13,7 @@ ] -def diet_normalization(foodweb_graph_view): +def diet_normalization(foodweb_graph_view: nx.Graph) -> nx.Graph: '''In this normalization method, each weight is divided by node's diet. Diet is sum of all input weights, inlcuding external import. @@ -36,7 +36,7 @@ def get_node_diet(node): return foodweb_graph_view -def log_normalization(foodweb_graph_view): +def log_normalization(foodweb_graph_view: nx.Graph) -> nx.Graph: '''Normalized weigth is a logarithm of original weight. Parameters @@ -54,7 +54,7 @@ def log_normalization(foodweb_graph_view): return foodweb_graph_view -def donor_control_normalization(foodweb_graph_view): +def donor_control_normalization(foodweb_graph_view: nx.Graph) -> nx.Graph: '''Each weight is divided by biomass of the "from" node. Parameters @@ -73,7 +73,7 @@ def donor_control_normalization(foodweb_graph_view): return foodweb_graph_view -def predator_control_normalization(foodweb_graph_view): +def predator_control_normalization(foodweb_graph_view: nx.Graph) -> nx.Graph: '''Each weight is divided by biomass of the "to" node. Parameters @@ -92,7 +92,7 @@ def predator_control_normalization(foodweb_graph_view): return foodweb_graph_view -def mixed_control_normalization(foodweb_graph_view): +def mixed_control_normalization(foodweb_graph_view: nx.Graph) -> nx.Graph: '''Each weight is equal to donor_control * predator_control. Parameters @@ -112,7 +112,7 @@ def mixed_control_normalization(foodweb_graph_view): return foodweb_graph_view -def tst_normalization(foodweb_graph_view): +def tst_normalization(foodweb_graph_view: nx.Graph) -> nx.Graph: '''Function returning a list of internal flows normalized to TST. Parameters @@ -131,7 +131,7 @@ def tst_normalization(foodweb_graph_view): return foodweb_graph_view -def normalization_factory(foodweb_graph_view, norm_type): +def normalization_factory(foodweb_graph_view: nx.Graph, norm_type: str) -> nx.Graph: '''Applies apropiate normalization method according to norm_type argument. Parameters diff --git a/foodwebviz/utils.py b/foodwebviz/utils.py index 1631b9e..cc46d77 100644 --- a/foodwebviz/utils.py +++ b/foodwebviz/utils.py @@ -1,7 +1,13 @@ '''Foodweb's utils methods.''' +from __future__ import annotations +from typing import Callable, Union, Any, TYPE_CHECKING + import numpy as np import pandas as pd +if TYPE_CHECKING: + import foodwebviz as fw + __all__ = [ 'NOT_ALIVE_MARK', @@ -13,7 +19,12 @@ NOT_ALIVE_MARK = '\u2717' -def squeeze_map(x, min_x, max_x, map_fun, min_out, max_out): +def squeeze_map(x: float, + min_x: float, + max_x: float, + map_fun: Callable[[float], float], + min_out: int, + max_out: int) -> float: ''' we map the interval [min_x, max_x] into [min_out, max_out] so that the points are squeezed using the function map_fun @@ -32,7 +43,8 @@ def squeeze_map(x, min_x, max_x, map_fun, min_out, max_out): return min_out + (max_out - min_out) * map_fun(x / min_x) / map_fun(max_x / min_x) -def calculate_trophic_levels(food_web): +def calculate_trophic_levels(food_web: fw.FoodWeb + ) -> Union[pd.api.extensions.ExtensionArray, np.ndarray[Any, Any]]: '''Calculate the fractional trophic levels of nodes using their the recursive relation. This implementation uses diet matrix to improve the numerical behavior of computation. @@ -61,30 +73,35 @@ def calculate_trophic_levels(food_web): tl = pd.DataFrame(food_web.flow_matrix.sum(axis=0), columns=['inflow']) # here we identify nodes at trophic level 1 - tl['is_fixed_to_one'] = (tl.inflow <= 0.0) | (np.arange(data_size) >= food_web.n_living) + tl['is_fixed_to_one'] = (tl.inflow <= 0.0) | ( + np.arange(data_size) >= food_web.n_living) tl['data_trophic_level'] = tl.is_fixed_to_one.astype(float) # counting the nodes with TL fixed to 1 if (sum(tl.is_fixed_to_one) != 0): # update the equation due to the prescribed trophic level 1 - reduce the dimension of the matrix A_tmp = A.loc[~tl['is_fixed_to_one'], ~tl['is_fixed_to_one']] - A_tmp = A_tmp*-1 + pd.DataFrame(np.identity(len(A_tmp)), index=A_tmp.index, columns=A_tmp.columns) + A_tmp = A_tmp*-1 + \ + pd.DataFrame(np.identity(len(A_tmp)), + index=A_tmp.index, columns=A_tmp.columns) B = pd.DataFrame(tl[~tl.is_fixed_to_one].is_fixed_to_one.copy()) # filling the constants vector with ones - the constant 1 contribution B['b'] = 1 # this is the diet fraction from non-living denoted as b in the function description - B['b'] = B['b'] + A.loc[~tl['is_fixed_to_one'], tl['is_fixed_to_one']].sum(axis=1) + B['b'] = B['b'] + A.loc[~tl['is_fixed_to_one'], + tl['is_fixed_to_one']].sum(axis=1) A_inverse = np.linalg.pinv(A_tmp) - tl.loc[~tl['is_fixed_to_one'], 'data_trophic_level'] = np.dot(A_inverse, B['b']) + tl.loc[~tl['is_fixed_to_one'], 'data_trophic_level'] = np.dot( + A_inverse, B['b']) else: # fails with negative trophic levels = some problems np.linalg.pinv(A) return tl.data_trophic_level.values -def is_alive_mapping(food_web): +def is_alive_mapping(food_web: fw.FoodWeb) -> dict[str, str]: '''Creates dictionary which special X mark to names, which are not alive. Parameters diff --git a/foodwebviz/visualization.py b/foodwebviz/visualization.py index cf083b4..ed13257 100644 --- a/foodwebviz/visualization.py +++ b/foodwebviz/visualization.py @@ -1,4 +1,6 @@ '''Foodweb's visualization methods.''' +from __future__ import annotations + import decimal import numpy as np import pandas as pd @@ -10,6 +12,7 @@ from matplotlib import pyplot as plt from pyvis.network import Network from collections import defaultdict +from typing import Any, Union, Optional import foodwebviz as fw __all__ = [ @@ -39,12 +42,13 @@ ] -def _get_title(food_web, limit=150): +def _get_title(food_web: fw.FoodWeb, limit: int = 150) -> str: return food_web.title if len(food_web.title) <= limit else food_web.title[:limit] + '...' -def _get_log_colorbar(z_orginal): - tickvals = range(int(np.log10(min(z_orginal))) + 1, int(np.log10(max(z_orginal))) + 1) +def _get_log_colorbar(z_orginal: Union[pd.Series[Any], list[Any]]) -> dict: + tickvals = range(int(np.log10(min(z_orginal))) + 1, + int(np.log10(max(z_orginal))) + 1) return dict( tick0=0, @@ -54,7 +58,7 @@ def _get_log_colorbar(z_orginal): ) -def _get_trophic_layer(graph, from_nodes, to_nodes): +def _get_trophic_layer(graph: nx.SubGraph, from_nodes: list[str], to_nodes: list[str]) -> go.Heatmap: '''Creates Trace for Heatmap to show thropic levels of X axis nodes. Parameters @@ -72,7 +76,8 @@ def _get_trophic_layer(graph, from_nodes, to_nodes): ''' trophic_flows = [] for n in set(from_nodes): - trophic_flows.extend([(n, m, graph.nodes(data='TrophicLevel', default=0)[n]) for m in set(to_nodes)]) + trophic_flows.extend( + [(n, m, graph.nodes(data='TrophicLevel', default=0)[n]) for m in set(to_nodes)]) fr, to, z = list(zip(*trophic_flows)) return go.Heatmap( @@ -91,7 +96,7 @@ def _get_trophic_layer(graph, from_nodes, to_nodes): ) -def _get_trophic_flows(food_web): +def _get_trophic_flows(food_web: fw.FoodWeb) -> pd.DataFrame: '''For each pair of trophic levels assigns sum of all nodes' weights in that pair. Parameters @@ -108,26 +113,38 @@ def _get_trophic_flows(food_web): trophic_flows : pd.DataFrame Columns: ["from", "to", "wegiths"], where "from" and "to" are trophic levels. ''' - graph = food_web.get_graph(False, mark_alive_nodes=False, normalization='linear') + graph = food_web.get_graph( + False, mark_alive_nodes=False, normalization='linear') - trophic_flows = defaultdict(float) - trophic_levels = {node: level for node, level in graph.nodes(data='TrophicLevel')} + trophic_flows: defaultdict = defaultdict(float) + trophic_levels = {node: level for node, + level in graph.nodes(data='TrophicLevel')} for edge in graph.edges(data=True): - trophic_from = decimal.Decimal(trophic_levels[edge[0]]).to_integral_value() - trophic_to = decimal.Decimal(trophic_levels[edge[1]]).to_integral_value() + trophic_from = decimal.Decimal( + trophic_levels[edge[0]]).to_integral_value() + trophic_to = decimal.Decimal( + trophic_levels[edge[1]]).to_integral_value() trophic_flows[(trophic_from, trophic_to)] += edge[2]['weight'] return pd.DataFrame([(x, y, w) for (x, y), w in trophic_flows.items()], columns=['from', 'to', 'weights']) -def _get_array_order(graph, nodes, reverse=False): - def sort_key(x): return (x[1].get('TrophicLevel', 0), x[1].get('IsAlive', 0)) +def _get_array_order(graph: nx.SubGraph, nodes: list[str], reverse: bool = False) -> list[str]: + def sort_key(x): return ( + x[1].get('TrophicLevel', 0), x[1].get('IsAlive', 0)) return [x[0] for x in sorted(graph.nodes(data=True), key=sort_key, reverse=reverse) if x[0] in nodes] -def draw_heatmap(food_web, boundary=False, normalization='log', - show_trophic_layer=True, switch_axes=False, - width=1200, height=800, font_size=14, save=False, output_filename='heatmap.pdf'): +def draw_heatmap(food_web: fw.FoodWeb, + boundary: bool = False, + normalization: str = 'log', + show_trophic_layer: bool = True, + switch_axes: bool = False, + width: int = 1200, + height: int = 800, + font_size: int = 14, + save: bool = False, + output_filename: str = 'heatmap.pdf') -> go.Heatmap: '''Visualize foodweb as a heatmap. On the interesction of X axis ("from" node) and Y axis ("to" node) flow weight is indicated. @@ -164,7 +181,8 @@ def draw_heatmap(food_web, boundary=False, normalization='log', heatmap : plotly.graph_objects.Figure ''' - graph = food_web.get_graph(boundary, mark_alive_nodes=True, normalization=normalization) + graph = food_web.get_graph( + boundary, mark_alive_nodes=True, normalization=normalization) if switch_axes: to_nodes, from_nodes, z = list(zip(*graph.edges(data=True))) hovertemplate = '%{x} --> %{y}: %{z:.3f}' @@ -224,18 +242,19 @@ def draw_heatmap(food_web, boundary=False, normalization='log', font={'size': font_size} ) fig.update_xaxes(showspikes=True, spikethickness=0.5) - fig.update_yaxes(showspikes=True, spikesnap="cursor", spikemode="across", spikethickness=0.5) + fig.update_yaxes(showspikes=True, spikesnap="cursor", + spikemode="across", spikethickness=0.5) if save: fig.write_image(output_filename) return fig -def draw_trophic_flows_heatmap(food_web, - switch_axes=False, - log_scale=False, - width=1200, - height=800, - font_size=24): +def draw_trophic_flows_heatmap(food_web: fw.FoodWeb, + switch_axes: bool = False, + log_scale: bool = False, + width: int = 1200, + height: int = 800, + font_size: int = 24) -> go.Figure: '''Visualize flows between foodweb's trophic levels as a heatmap. The color at (x,y) represents the sum of flows from trophic level x to trophic level y. @@ -267,7 +286,8 @@ def draw_trophic_flows_heatmap(food_web, tf_pd = _get_trophic_flows(food_web) heatmap = go.Heatmap(x=tf_pd['to' if not switch_axes else 'from'], y=tf_pd['from' if not switch_axes else 'to'], - z=np.log10(tf_pd.weights) if log_scale else tf_pd.weights, + z=np.log10( + tf_pd.weights) if log_scale else tf_pd.weights, xgap=0.2, ygap=0.2, colorscale=HEATMAP_COLORS, @@ -300,7 +320,11 @@ def draw_trophic_flows_heatmap(food_web, return fig -def draw_trophic_flows_distribution(food_web, normalize=True, width=1000, height=800, font_size=24): +def draw_trophic_flows_distribution(food_web: fw.FoodWeb, + normalize: bool = True, + width: int = 1000, + height: int = 800, + font_size: int = 24) -> go.Figure: '''Visualize flows between trophic levels as a stacked bar chart. Parameters @@ -323,13 +347,15 @@ def draw_trophic_flows_distribution(food_web, normalize=True, width=1000, height tf_pd = tf_pd.sort_values('to') if normalize: - tf_pd['percentage'] = tf_pd['weights'] / tf_pd.groupby('from')['weights'].transform('sum') * 100 + tf_pd['percentage'] = tf_pd['weights'] / \ + tf_pd.groupby('from')['weights'].transform('sum') * 100 fig = px.bar(tf_pd, y="from", x="weights" if not normalize else "percentage", color="to", - color_discrete_sequence=[x[1] for x in TROPHIC_LAYER_COLORS[1:]], + color_discrete_sequence=[x[1] + for x in TROPHIC_LAYER_COLORS[1:]], # title=_get_title(food_web), height=height, width=width, @@ -346,15 +372,15 @@ def draw_trophic_flows_distribution(food_web, normalize=True, width=1000, height return fig -def draw_network_for_nodes(food_web, - nodes=None, - file_name='interactive_food_web_graph.html', - notebook=True, - height="800px", - width="100%", - no_flows_to_detritus=True, - cmap='viridis', - **kwargs): +def draw_network_for_nodes(food_web: fw.FoodWeb, + nodes: Optional[list[str]] = None, + file_name: str = 'interactive_food_web_graph.html', + notebook: bool = True, + height: str = "800px", + width: str = "100%", + no_flows_to_detritus: bool = True, + cmap: str = 'viridis', + **kwargs) -> Network: '''Visualize subgraph of foodweb as a network. Parameters notebook, height, and width refer to initialization parameters of pyvis.network.Network. Additional parameters may be passed to hierachical repulsion layout as defined in @@ -393,13 +419,20 @@ def draw_network_for_nodes(food_web, layout=True, font_color='white', heading='') # food_web.title) - g = food_web.get_graph(mark_alive_nodes=True, no_flows_to_detritus=no_flows_to_detritus).copy() + g = food_web.get_graph(mark_alive_nodes=True, + no_flows_to_detritus=no_flows_to_detritus).copy() if not nodes: nodes = g.nodes() - g = g.edge_subgraph([(x[0], x[1]) for x in g.edges() if x[0].replace( - f'{fw.NOT_ALIVE_MARK} ', '') in nodes or x[1].replace(f'{fw.NOT_ALIVE_MARK} ', '') in nodes]) + edges = [] + for x in g.edges(): + from_node = x[0].replace(f'{fw.NOT_ALIVE_MARK} ', '') + to_node = x[1].replace(f'{fw.NOT_ALIVE_MARK} ', '') + if from_node in nodes or to_node in nodes: # type: ignore + edges.append((x[0], x[1])) + + g = g.edge_subgraph(edges) colors = plt.cm.get_cmap(cmap) norm = matplotlib.colors.Normalize(vmin=food_web.node_df.TrophicLevel.min(), @@ -416,7 +449,8 @@ def draw_network_for_nodes(food_web, nx.set_node_attributes(g, a) # rename weight attribute to value - nx.set_edge_attributes(g, {(edge[0], edge[1]): edge[2] for edge in g.edges(data='weight')}, 'value') + nx.set_edge_attributes(g, {(edge[0], edge[1]): edge[2] + for edge in g.edges(data='weight')}, 'value') nt.from_nx(g) nt.hrepulsion(node_distance=220, **kwargs)