├── .DS_Store ├── .gitignore ├── CHANGELOG.md ├── README.md ├── assets ├── XmR_Example.gif ├── XmR_Example.html └── XmR_Sloped_Example.png ├── pyproject.toml ├── setup.py └── src ├── __init__.py ├── spc_plotly ├── __init__.py ├── helpers │ ├── __init__.py │ ├── annotations.py │ ├── axes_formats.py │ ├── base_traces.py │ ├── limit_lines.py │ ├── menus.py │ └── signals.py ├── utils │ ├── __init__.py │ ├── calc_xmr_func.py │ ├── combine_paths.py │ ├── endpoints.py │ ├── rounded_value.py │ └── rounding_multiple.py └── xmr.py └── tests ├── __init__.py └── test_xmr.py /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JeremyColon/spc_plotly/1895f157613f4284d7388aacbed81b7c90867031/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/python,visualstudiocode 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,visualstudiocode 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | .DS_STORE 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | cover/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | .pybuilder/ 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | .ipynb 86 | 87 | # IPython 88 | profile_default/ 89 | ipython_config.py 90 | 91 | # pyenv 92 | # For a library or package, you might want to ignore these files since the code is 93 | # intended to run in multiple environments; otherwise, check them in: 94 | # .python-version 95 | 96 | # pipenv 97 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 98 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 99 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 100 | # install all needed dependencies. 101 | #Pipfile.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/#use-with-ide 116 | .pdm.toml 117 | 118 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 119 | __pypackages__/ 120 | 121 | # Celery stuff 122 | celerybeat-schedule 123 | celerybeat.pid 124 | 125 | # SageMath parsed files 126 | *.sage.py 127 | 128 | # Environments 129 | .env 130 | .venv 131 | env/ 132 | venv/ 133 | ENV/ 134 | env.bak/ 135 | venv.bak/ 136 | 137 | # Spyder project settings 138 | .spyderproject 139 | .spyproject 140 | 141 | # Rope project settings 142 | .ropeproject 143 | 144 | # mkdocs documentation 145 | /site 146 | 147 | # mypy 148 | .mypy_cache/ 149 | .dmypy.json 150 | dmypy.json 151 | 152 | # Pyre type checker 153 | .pyre/ 154 | 155 | # pytype static type analyzer 156 | .pytype/ 157 | 158 | # Cython debug symbols 159 | cython_debug/ 160 | 161 | # PyCharm 162 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 163 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 164 | # and can be added to the global gitignore or merged into this file. For a more nuclear 165 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 166 | #.idea/ 167 | 168 | ### Python Patch ### 169 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 170 | poetry.toml 171 | 172 | # ruff 173 | .ruff_cache/ 174 | 175 | # LSP config files 176 | pyrightconfig.json 177 | 178 | ### VisualStudioCode ### 179 | .vscode/* 180 | !.vscode/settings.json 181 | !.vscode/tasks.json 182 | !.vscode/launch.json 183 | !.vscode/extensions.json 184 | !.vscode/*.code-snippets 185 | 186 | # Local History for Visual Studio Code 187 | .history/ 188 | 189 | # Built Visual Studio Code Extensions 190 | *.vsix 191 | 192 | ### VisualStudioCode Patch ### 193 | # Ignore all local history of files 194 | .history 195 | .ionide 196 | 197 | # End of https://www.toptal.com/developers/gitignore/api/python,visualstudiocode 198 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Release History 2 | 3 | --- 4 | 5 | ## 0.2.1 6 | - Added test for `x_begin` parameter 7 | 8 | ## 0.2.0 9 | - Added `x_begin` parameter that gives ability to calculate limits based on a specific window, when paired with existing `x_cutoff` parameter. Previously you had to filter the data being passed into the `XmR_Chart` method if you wanted to specify a start date for calculating limits. 10 | 11 | ## 0.1.3 12 | - Fixed bug when using sloped data that plotted annotation and shapes prior to actual data. xref now is set at paper and x-value is calculated accordingly. 13 | - Fixed bug that threw error when x Series is not datetime. 14 | 15 | ## 0.1.2 16 | - Added tests for all parameters under `src/tests/` 17 | 18 | ## 0.1.1 19 | - Updates to package structure and README. 20 | 21 | ## 0.1.0 22 | 23 | - Initial commit. Ability to create XmR Charts from a pandas dataframe. Package will automatically calculate the limits for you and identify signals in your data according to the SPC framework. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # spc_plotly 2 | 3 | **spc_plotly** is a Python helper library for creating XmR Charts according to the theories of Statistical Process Control using the Plotly library. 4 | 5 | XmR Charts allow the viewer to quickly identify signals in a data set and ignore routine variation. 6 | 7 | ## Installation 8 | 9 | ```shell 10 | pip install spc-plotly 11 | ``` 12 | 13 | ## Usage 14 | 15 | ```python 16 | from spc_plotly import xmr 17 | import pandas as pd 18 | 19 | counts = [ 20 | 2478, 2350, 2485, 2296, 2359, 2567, 3089, 2668, 1788, 2854, 21 | 2365, 1883, 1959, 1927, 2640, 2626, 2144, 2409, 4412, 3287, 22 | 3049, 3364, 3078, 2972, 3415, 2753, 3102, 2191, 3693, 4385, 23 | 4699, 3031, 2659, 3885, 2628, 2621, 3071, 2002 24 | ] 25 | periods = [ 26 | '2021-01', '2021-02', '2021-03', '2021-04', '2021-05', '2021-06', 27 | '2021-07', '2021-08', '2021-09', '2021-10', '2021-11', '2021-12', 28 | '2022-01', '2022-02', '2022-03', '2022-04', '2022-05', '2022-06', 29 | '2022-07', '2022-08', '2022-09', '2022-10', '2022-11', '2022-12', 30 | '2023-01', '2023-02', '2023-03', '2023-04', '2023-05', '2023-06', 31 | '2023-07', '2023-08', '2023-09', '2023-10', '2023-11', '2023-12', 32 | '2024-01', '2024-02' 33 | ] 34 | data = pd.DataFrame({ 35 | "Period": periods, 36 | "Count": counts 37 | }) 38 | 39 | xmr_chart = xmr.XmR( 40 | data=data, 41 | x_ser_name="Period", 42 | y_ser_name="Count", 43 | x_cutoff="2023-06", 44 | date_part_resolution="month", # This should match your data 45 | custom_date_part="", 46 | xmr_function="mean" 47 | ) 48 | 49 | xmr_chart.mR_limit_values 50 | # { 51 | # 'mR_xmr_func': 571.8918918918919, 52 | # 'mR_upper_limit': 1868.9427027027025, 53 | # 'xmr_func': 'mean' 54 | # } 55 | xmr_chart.npl_limit_values 56 | # { 57 | # 'y_xmr_func': 2820.6315789473683, 58 | # 'npl_upper_limit': 4341.864011379801, 59 | # 'npl_lower_limit': 1299.3991465149359, 60 | # 'xmr_func': 'mean' 61 | # } 62 | xmr_chart.signals 63 | # {'anomalies': [(datetime.datetime(2022, 7, 1, 0, 0), 4412, 'High'), 64 | # (datetime.datetime(2023, 6, 1, 0, 0), 4385, 'High'), 65 | # (datetime.datetime(2023, 7, 1, 0, 0), 4699, 'High')], 66 | # 'long_runs': [[('2021-11', 2365, 'Low'), 67 | # ('2021-12', 1883, 'Low'), 68 | # ('2022-01', 1959, 'Low'), 69 | # ('2022-02', 1927, 'Low'), 70 | # ('2022-03', 2640, 'Low'), 71 | # ('2022-04', 2626, 'Low'), 72 | # ('2022-05', 2144, 'Low'), 73 | # ('2022-06', 2409, 'Low')]], 74 | # 'short_runs': [[('2023-04', 2191, 'High'), 75 | # ('2023-05', 3693, 'High'), 76 | # ('2023-06', 4385, 'High'), 77 | # ('2023-07', 4699, 'High'), 78 | # ('2023-08', 3031, 'High')], 79 | # [('2021-11', 2365, 'Low'), 80 | # ('2021-12', 1883, 'Low'), 81 | # ('2022-01', 1959, 'Low'), 82 | # ('2022-02', 1927, 'Low'), 83 | # ('2022-03', 2640, 'Low')]]} 84 | 85 | xmr_chart.xmr_chart 86 | ``` 87 | 88 | 89 | 90 | For reference, please read [Making Sense of Data by Donald Wheeler](https://www.amazon.com/Making-Sense-Data-Donald-Wheeler/dp/0945320728) and [Twenty Things You Need To Know](https://www.amazon.com/Twenty-Things-You-Need-Know/dp/094532068X) 91 | 92 | 93 | ### Sloped Limits 94 | 95 | Some data naturally increases over time, e.g., price of some goods. When modeling this data, you should use sloped limit lines to identify signals. 96 | 97 | ```python 98 | xmr_chart = xmr.XmR( 99 | data=data, 100 | x_ser_name="Period", 101 | y_ser_name="Count", 102 | x_cutoff="2023-06", 103 | xmr_function="mean" 104 | sloped=True 105 | ) 106 | ``` 107 | 108 | 109 | 110 | 111 | ### Use the Median 112 | 113 | If your data contains extreme outliers, you can update the xmr_function parameter to "median" 114 | 115 | ```python 116 | xmr_chart = xmr.XmR( 117 | data=data, 118 | x_ser_name="Period", 119 | y_ser_name="Count", 120 | x_cutoff="2023-06", 121 | xmr_function="median" 122 | sloped=False 123 | ) 124 | ``` 125 | 126 | ### Calculate Limits from Subset of Data 127 | 128 | If you want to calculate the limits from a subset of your data, use the x_cutoff and x_begin parameters. 129 | 130 | ```python 131 | xmr_chart = xmr.XmR( 132 | data=data, 133 | x_ser_name="Period", 134 | y_ser_name="Count", 135 | x_begin="2022-01", 136 | x_cutoff="2023-06", 137 | xmr_function="median" 138 | sloped=False 139 | ) 140 | ``` 141 | These paremeters are *inclusive*, so they will include all data between "2022-01" and "2023-06". If no value is passed, `x_begin` and `x_cutoff` will be set to the minimum and maximum values, respectively. 142 | 143 | ## Dependencies 144 | Plotly, Pandas, and Numpy -------------------------------------------------------------------------------- /assets/XmR_Example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JeremyColon/spc_plotly/1895f157613f4284d7388aacbed81b7c90867031/assets/XmR_Example.gif -------------------------------------------------------------------------------- /assets/XmR_Sloped_Example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JeremyColon/spc_plotly/1895f157613f4284d7388aacbed81b7c90867031/assets/XmR_Sloped_Example.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # pyproject.toml 2 | 3 | [build-system] 4 | requires = ["setuptools>=65.3.0", "wheel"] 5 | build-backend = "setuptools.build_meta" 6 | 7 | [project] 8 | name = "spc-plotly" 9 | version = "0.2.1" 10 | description = "XmR charts with Plotly" 11 | readme = "README.md" 12 | authors = [{ name = "Jeremy Colón", email = "jeremycolon24@gmail.com" }] 13 | classifiers = [ 14 | "Intended Audience :: Developers", 15 | "License :: OSI Approved :: MIT License", 16 | "Programming Language :: Python", 17 | "Programming Language :: Python :: 3", 18 | "Programming Language :: Python :: 3.6", 19 | "Programming Language :: Python :: 3.7", 20 | "Programming Language :: Python :: 3.8", 21 | "Programming Language :: Python :: 3.9", 22 | "Operating System :: OS Independent" 23 | ] 24 | dependencies = [ 25 | "pandas >= 2.2.1", 26 | "numpy >= 1.26.4", 27 | "plotly >= 5.20.0", 28 | ] 29 | requires-python = ">=3.10.5" 30 | 31 | [project.urls] 32 | Homepage = "https://github.com/JeremyColon/spc_plotly" 33 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | from .spc_plotly.xmr import XmR 2 | -------------------------------------------------------------------------------- /src/spc_plotly/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JeremyColon/spc_plotly/1895f157613f4284d7388aacbed81b7c90867031/src/spc_plotly/__init__.py -------------------------------------------------------------------------------- /src/spc_plotly/helpers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JeremyColon/spc_plotly/1895f157613f4284d7388aacbed81b7c90867031/src/spc_plotly/helpers/__init__.py -------------------------------------------------------------------------------- /src/spc_plotly/helpers/annotations.py: -------------------------------------------------------------------------------- 1 | from pandas import DataFrame 2 | from spc_plotly.utils import rounded_value, rounding_multiple 3 | 4 | 5 | def _limit_line_annotation( 6 | font: dict, 7 | text: str, 8 | x: float, 9 | xanchor: str, 10 | xref: str, 11 | y: float, 12 | yanchor: str, 13 | yref: str, 14 | showarrow: bool = False, 15 | ): 16 | """ 17 | Annotation formatter. For documentation, see plotly's official docs 18 | 19 | Parameters: 20 | font (dict): 21 | text (str): 22 | x (float): 23 | xanchor (str): 24 | xref (str): 25 | y (float): 26 | yanchor (str): 27 | yref (str): 28 | showarrow (bool): 29 | 30 | Returns: 31 | dict: Axis formatting 32 | """ 33 | return { 34 | "font": font, 35 | "text": text, 36 | "x": x, 37 | "xanchor": xanchor, 38 | "xref": xref, 39 | "y": y, 40 | "yanchor": yanchor, 41 | "yref": yref, 42 | "showarrow": showarrow, 43 | } 44 | 45 | 46 | def _create_limit_line_annotations( 47 | data: DataFrame, 48 | chart_title: str, 49 | y_xmr_func, 50 | mR_upper, 51 | mR_xmr_func, 52 | npl_upper, 53 | npl_lower, 54 | y_name: str, 55 | sloped: bool, 56 | ): 57 | """ 58 | Annotation formatter. For documentation, see plotly's official docs 59 | 60 | Parameters: 61 | data (DataFrame): All data 62 | chart_title (str): Chart title 63 | y_xmr_func (float|list): Natural process limit mid-line. 64 | If sloped is True, this is a list of tuples. 65 | mr_Upper (float|list): Upper moving range limit. 66 | mR_xmr_func (float|list): Moving range mid-line. 67 | npl_upper (float|list): Upper process limit. If sloped is True, this is a list of tuples. 68 | npl_lower (float|list): Lower process limit. If sloped is True, this is a list of tuples. 69 | y_name (str): Y-axis title. 70 | sloped (bool): Use sloping approach for limit values. 71 | 72 | Returns: 73 | dict: Axis formatting 74 | """ 75 | 76 | other_font = {"size": 10} 77 | other_x = 0.01 78 | other_xanchor = "left" 79 | other_xref = "paper" 80 | 81 | # Create natural limits, mid-range lines, and center line annotations 82 | annotations = [ 83 | _limit_line_annotation( 84 | font={"size": 16}, 85 | text=chart_title, 86 | x=0.5, 87 | xanchor="center", 88 | xref="paper", 89 | y=1.1, 90 | yanchor="top", 91 | yref="paper", 92 | ), 93 | _limit_line_annotation( 94 | font=other_font, 95 | text=f"mR Upper Limit = {round(mR_upper,3)}", 96 | x=other_x, 97 | xanchor=other_xanchor, 98 | xref=other_xref, 99 | y=mR_upper + (mR_upper * 0.05), 100 | yanchor="auto", 101 | yref="y2", 102 | ), 103 | ] 104 | 105 | if sloped: 106 | # If using sloped lines, find the middle of the first and second half of the chart 107 | 108 | value_range = npl_upper[len(npl_upper) - 1][1] - npl_lower[0][1] 109 | 110 | half_idx = data.shape[0] // 2 111 | first_half_idx = data.values[:half_idx].shape[0] // 2 112 | first_half_loc = first_half_idx / data.shape[0] 113 | second_half_idx = data.values[half_idx:].shape[0] // 2 114 | second_half_loc = (second_half_idx + half_idx) / data.shape[0] 115 | 116 | x_annotations = [ 117 | _limit_line_annotation( 118 | font=other_font, 119 | text=f"{round(y_xmr_func[first_half_idx][1],2)} " 120 | + "\u00B1" 121 | + f" {round(mR_xmr_func,2)}", 122 | x=first_half_loc, 123 | xanchor="center", 124 | xref="paper", 125 | y=npl_upper[first_half_idx][1] + (value_range * 0.1), 126 | yanchor="auto", 127 | yref="y", 128 | ), 129 | _limit_line_annotation( 130 | font=other_font, 131 | text=f"{round(y_xmr_func[second_half_idx+half_idx][1],2)} " 132 | + "\u00B1" 133 | + f" {round(mR_xmr_func,2)}", 134 | x=second_half_loc, 135 | xanchor="center", 136 | xref="paper", 137 | y=npl_lower[half_idx + second_half_idx][1] - (value_range * 0.1), 138 | yanchor="auto", 139 | yref="y", 140 | ), 141 | ] 142 | 143 | else: 144 | 145 | value_range = npl_upper - npl_lower 146 | 147 | x_annotations = [ 148 | _limit_line_annotation( 149 | font=other_font, 150 | text=f"{y_name} Upper Limit = {round(npl_upper,3)}", 151 | x=other_x, 152 | xanchor=other_xanchor, 153 | xref=other_xref, 154 | y=npl_upper + (value_range * 0.03), 155 | yanchor="auto", 156 | yref="y", 157 | ), 158 | _limit_line_annotation( 159 | font=other_font, 160 | text=f"{y_name} Lower Limit = {round(npl_lower,3)}", 161 | x=other_x, 162 | xanchor=other_xanchor, 163 | xref=other_xref, 164 | y=npl_lower - (value_range * 0.03), 165 | yanchor="auto", 166 | yref="y", 167 | ), 168 | ] 169 | 170 | annotations.extend(x_annotations) 171 | 172 | return annotations 173 | -------------------------------------------------------------------------------- /src/spc_plotly/helpers/axes_formats.py: -------------------------------------------------------------------------------- 1 | from pandas import Series 2 | import plotly.graph_objects as go 3 | from spc_plotly.utils import rounded_value, rounding_multiple 4 | 5 | 6 | def _format_xaxis(anchor: str, matches: str, showticklabels: bool): 7 | """ 8 | X-axis formatter 9 | 10 | Parameters: 11 | anchor (str): Axis to anchor on 12 | matches (str): Axis to match on 13 | showticklabels (bool): Show axis ticklabels 14 | 15 | Returns: 16 | dict: Axis formatting 17 | """ 18 | return { 19 | "anchor": anchor, 20 | "domain": [0.0, 1.0], 21 | "automargin": True, 22 | "dtick": "M1", 23 | "matches": matches, 24 | "showticklabels": showticklabels, 25 | "tickformat": "%b\n%Y", 26 | "ticklabelmode": "period", 27 | "tickangle": 0, 28 | "showspikes": True, 29 | "spikemode": "across+toaxis", 30 | "spikesnap": "cursor", 31 | "showline": True, 32 | "showgrid": True, 33 | "spikedash": "solid", 34 | "spikecolor": "lightgreen", 35 | } 36 | 37 | 38 | def _format_yaxis( 39 | anchor: str, title: str, domain: list, range: list, tickformat: str, dtick: int 40 | ): 41 | """ 42 | Y-axis formatter 43 | 44 | Parameters: 45 | anchor (str): Axis to anchor on 46 | matches (str): Axis to match on 47 | showticklabels (bool): Show axis ticklabels 48 | 49 | Returns: 50 | dict: Axis formatting 51 | """ 52 | return { 53 | "anchor": anchor, 54 | "title": {"text": title}, 55 | "domain": domain, 56 | "range": range, 57 | "tickformat": tickformat, 58 | "dtick": dtick, 59 | } 60 | 61 | 62 | def _format_XmR_axes( 63 | npl_upper: float | list, 64 | npl_lower: float | list, 65 | mR_upper: float, 66 | y_Ser: Series, 67 | mR_data: Series, 68 | sloped: bool, 69 | ) -> go.Figure: 70 | """ 71 | Apply axes formats 72 | 73 | Parameters: 74 | npl_upper (float|list): Upper process limit. If sloped is True, this is a list of tuples. 75 | npl_lower (float|list): Lower process limit. If sloped is True, this is a list of tuples. 76 | mR_upper (float): Upper moving range limit. 77 | y_Ser (Series): Series of y-values 78 | mR_data (Series): Series of moving range values 79 | sloped (bool): Use sloping approach for limit values. 80 | 81 | Returns: 82 | dict: Axis formatting 83 | """ 84 | 85 | xaxis_values = _format_xaxis(anchor="y", matches="x", showticklabels=True) 86 | xaxis_mR = _format_xaxis(anchor="y2", matches="x2", showticklabels=False) 87 | 88 | if sloped: 89 | value_range = npl_upper[len(npl_upper) - 1][1] - npl_lower[0][1] 90 | dtick = rounding_multiple.rounding_multiple(value_range) 91 | min_range = rounded_value.rounded_value(npl_lower[0][1], dtick) 92 | max_range = rounded_value.rounded_value( 93 | npl_upper[len(npl_upper) - 1][1], dtick, "up" 94 | ) 95 | else: 96 | value_range = npl_upper - npl_lower 97 | dtick = rounding_multiple.rounding_multiple(value_range) 98 | min_range = min( 99 | rounded_value.rounded_value(y_Ser.min(), dtick, "down"), 100 | rounded_value.rounded_value(npl_lower - (value_range * 0.1), dtick, "down"), 101 | ) 102 | max_range = max( 103 | rounded_value.rounded_value(y_Ser.max(), dtick, "up"), 104 | rounded_value.rounded_value(npl_upper + (value_range * 0.1), dtick, "up"), 105 | ) 106 | 107 | yaxis_values = _format_yaxis( 108 | anchor="x", 109 | title=y_Ser.name, 110 | domain=[0.4, 1.0], 111 | range=[min_range, max_range], 112 | tickformat="0", 113 | dtick=dtick, 114 | ) 115 | 116 | dtick = rounding_multiple.rounding_multiple(mR_upper) 117 | max_range = max( 118 | rounded_value.rounded_value(mR_upper.max(), dtick, "up"), 119 | rounded_value.rounded_value(mR_data.max() + (mR_data.max() * 0.1), dtick, "up"), 120 | ) 121 | 122 | yaxis_mR = _format_yaxis( 123 | anchor="x2", 124 | title="Moving Range", 125 | domain=[0.0, 0.3], 126 | range=[0, max_range], 127 | tickformat="0", 128 | dtick=dtick, 129 | ) 130 | 131 | return { 132 | "x_values": xaxis_values, 133 | "x_mR": xaxis_mR, 134 | "y_values": yaxis_values, 135 | "y_mR": yaxis_mR, 136 | } 137 | -------------------------------------------------------------------------------- /src/spc_plotly/helpers/base_traces.py: -------------------------------------------------------------------------------- 1 | from pandas import Series 2 | from plotly.graph_objects import Figure, Scatter 3 | from plotly.subplots import make_subplots 4 | 5 | 6 | def _base_traces( 7 | x_Ser: Series, x_Ser_dt: Series, y_Ser: Series, mr_Data: Series 8 | ) -> Figure: 9 | """ 10 | Create base traces for XmR chart 11 | 12 | Parameters: 13 | x_Ser (Series): Series of x-values 14 | x_Ser_dt (Series): Series of x-values, datetime format 15 | y_Ser (Series): Series of y-values 16 | mR_data (Series): Series of moving range values 17 | 18 | Returns: 19 | Figure: Base XmR figure object 20 | """ 21 | 22 | # Add XmR traces to figure 23 | fig = make_subplots( 24 | rows=2, 25 | cols=1, 26 | row_heights=[6, 4], 27 | vertical_spacing=0.5, 28 | shared_xaxes=True, 29 | shared_yaxes=False, 30 | column_titles=list(x_Ser), 31 | ) 32 | 33 | fig.add_trace( 34 | Scatter( 35 | x=x_Ser, 36 | y=y_Ser, 37 | name=y_Ser.name, 38 | marker_color="black", 39 | hovertemplate=f"""{x_Ser.name}: """ 40 | """%{x|%B %Y}
""" 41 | f"""{y_Ser.name}: """ 42 | """%{y}
""" 43 | """""", 44 | ), 45 | row=1, 46 | col=1, 47 | ) 48 | fig.add_trace( 49 | Scatter( 50 | x=x_Ser, 51 | y=mr_Data, 52 | name="Moving Range (mR)", 53 | marker_color="black", 54 | hovertemplate=f"""{x_Ser.name}: """ 55 | """%{x|%B %Y}
""" 56 | f"""{y_Ser.name} mR: """ 57 | """%{y}
""" 58 | """""", 59 | ), 60 | row=2, 61 | col=1, 62 | ) 63 | 64 | return fig 65 | -------------------------------------------------------------------------------- /src/spc_plotly/helpers/limit_lines.py: -------------------------------------------------------------------------------- 1 | import plotly.graph_objects as go 2 | from pandas import DataFrame 3 | from spc_plotly.utils import endpoints, rounded_value, rounding_multiple 4 | from numpy import array 5 | 6 | 7 | def _limit_line_shape( 8 | line_color: str, 9 | line_type: str, 10 | y0: float, 11 | y1: float, 12 | yref: str, 13 | x0: float = 0, 14 | x1: float = 1, 15 | xref: str = "x domain", 16 | ) -> go.layout.Shape: 17 | return go.layout.Shape( 18 | { 19 | "line": {"color": line_color, "dash": line_type}, 20 | "type": "line", 21 | "x0": x0, 22 | "x1": x1, 23 | "xref": xref, 24 | "y0": y0, 25 | "y1": y1, 26 | "yref": yref, 27 | } 28 | ) 29 | 30 | 31 | def _create_limit_lines( 32 | data: DataFrame, 33 | y_xmr_func: float, 34 | npl_upper: float, 35 | npl_lower: float, 36 | mR: float, 37 | mR_upper: float, 38 | sloped: bool, 39 | chart_line_color: str = "gray", 40 | chart_limit_color: str = "red", 41 | chart_midrange_color: str = "pink", 42 | chart_line_type: str = "dashdot", 43 | chart_midrange_line_type: str = "dot", 44 | ) -> list: 45 | """ 46 | Create limit lines for X-chart and mR-Chart 47 | 48 | Parameters: 49 | data (DataFrame): All data 50 | y_xmr_func (float|list): Upper moving range limit. If sloped is True, this is a list of tuples. 51 | npl_upper (float|list): Upper process limit. If sloped is True, this is a list of tuples. 52 | npl_lower (float|list): Lower process limit. If sloped is True, this is a list of tuples. 53 | mR (float|list): Moving range mid-line. 54 | mr_Upper (float|list): Upper moving range limit. 55 | y_name (str): Y-axis title. 56 | sloped (bool): Use sloping approach for limit values. 57 | chart_line_color (str): Mid-line color 58 | chart_limit_color (str): Limit line color 59 | chart_midrange_color (str): Midrange line color (i.e., line between mid-line and limit line) 60 | chart_line_type (str): Mid- & limit line type 61 | chart_midrange_line_type (str): Midrange line type 62 | 63 | Returns: 64 | list[go.layout.Shape]: List of shape objects representing all XmR chart lines 65 | """ 66 | # Create natural limits, mid-range lines, and center lines 67 | if sloped: 68 | y_endpoints = endpoints.get_line_endpoints(y_xmr_func, data) 69 | 70 | upper_endpoints = endpoints.get_line_endpoints(npl_upper, data) 71 | lower_endpoints = endpoints.get_line_endpoints(npl_lower, data) 72 | 73 | npl_upper_mid = [ 74 | (mid[0], mid[1] + ((upper[1] - mid[1]) / 2)) 75 | for (upper, mid) in zip(npl_upper, y_xmr_func) 76 | ] 77 | npl_lower_mid = [ 78 | (mid[0], mid[1] - ((mid[1] - lower[1]) / 2)) 79 | for (lower, mid) in zip(npl_lower, y_xmr_func) 80 | ] 81 | 82 | upper_mid_endpoints = endpoints.get_line_endpoints(npl_upper_mid, data) 83 | lower_mid_endpoints = endpoints.get_line_endpoints(npl_lower_mid, data) 84 | 85 | half_idx = data.shape[0] // 2 86 | first_half_idx = data.values[:half_idx].shape[0] // 2 87 | first_half_loc = first_half_idx / data.shape[0] 88 | second_half_idx = data.values[half_idx:].shape[0] // 2 89 | second_half_loc = (second_half_idx + half_idx) / data.shape[0] 90 | 91 | value_range = npl_upper[len(npl_upper) - 1][1] - npl_lower[0][1] 92 | multiple = rounding_multiple.rounding_multiple(value_range) 93 | range_min = rounded_value.rounded_value(npl_lower[0][1], multiple) 94 | range_max = rounded_value.rounded_value( 95 | npl_upper[len(npl_upper) - 1][1], multiple, "up" 96 | ) 97 | sloped_vertical_lines = [ 98 | { 99 | "fillcolor": "gray", 100 | "line": {"color": "gray", "dash": "dot", "width": 1}, 101 | "name": "limit sloped line", 102 | "opacity": 1, 103 | "path": f"M {first_half_loc} {npl_lower[first_half_idx][1]} L {first_half_loc} {npl_upper[first_half_idx][1]+((range_max-range_min)*.05)}", 104 | "type": "path", 105 | "xref": "paper", 106 | }, 107 | { 108 | "fillcolor": "gray", 109 | "line": {"color": "gray", "dash": "dot", "width": 1}, 110 | "name": "limit sloped line", 111 | "opacity": 1, 112 | "path": f"M {second_half_loc} {npl_upper[second_half_idx+half_idx][1]} L {second_half_loc} {npl_lower[second_half_idx+half_idx][1]-((range_max-range_min)*.05)}", 113 | "type": "path", 114 | "xref": "paper", 115 | }, 116 | ] 117 | else: 118 | upper_midrange = y_xmr_func + ((npl_upper - y_xmr_func) / 2) 119 | lower_midrange = y_xmr_func - ((y_xmr_func - npl_lower) / 2) 120 | 121 | mR_upper_midrange = mR + ((mR_upper - mR) / 2) 122 | 123 | shapes = [ 124 | # Individual Values Chart 125 | # Average line 126 | _limit_line_shape( 127 | line_color=chart_line_color, 128 | line_type=chart_line_type, 129 | y0=y_endpoints["start"]["y"] if sloped else y_xmr_func, 130 | y1=y_endpoints["end"]["y"] if sloped else y_xmr_func, 131 | yref="y", 132 | ), 133 | # Limit Lines 134 | _limit_line_shape( 135 | line_color=chart_limit_color, 136 | line_type=chart_line_type, 137 | y0=upper_endpoints["start"]["y"] if sloped else npl_upper, 138 | y1=upper_endpoints["end"]["y"] if sloped else npl_upper, 139 | yref="y", 140 | ), 141 | _limit_line_shape( 142 | line_color=chart_limit_color, 143 | line_type=chart_line_type, 144 | y0=lower_endpoints["start"]["y"] if sloped else npl_lower, 145 | y1=lower_endpoints["end"]["y"] if sloped else npl_lower, 146 | yref="y", 147 | ), 148 | # Mid-Range Lines 149 | _limit_line_shape( 150 | line_color=chart_midrange_color, 151 | line_type=chart_midrange_line_type, 152 | y0=upper_mid_endpoints["start"]["y"] if sloped else upper_midrange, 153 | y1=upper_mid_endpoints["end"]["y"] if sloped else upper_midrange, 154 | yref="y", 155 | ), 156 | _limit_line_shape( 157 | line_color=chart_midrange_color, 158 | line_type=chart_midrange_line_type, 159 | y0=lower_mid_endpoints["start"]["y"] if sloped else lower_midrange, 160 | y1=lower_mid_endpoints["end"]["y"] if sloped else lower_midrange, 161 | yref="y", 162 | ), 163 | # Moving Range Chart 164 | # Average line 165 | _limit_line_shape( 166 | line_color=chart_line_color, 167 | line_type=chart_line_type, 168 | y0=mR, 169 | y1=mR, 170 | yref="y2", 171 | ), 172 | # Limit Lines 173 | _limit_line_shape( 174 | line_color=chart_limit_color, 175 | line_type=chart_line_type, 176 | y0=mR_upper, 177 | y1=mR_upper, 178 | yref="y2", 179 | ), 180 | # Mid-Range Lines 181 | _limit_line_shape( 182 | line_color=chart_midrange_color, 183 | line_type=chart_midrange_line_type, 184 | y0=mR_upper_midrange, 185 | y1=mR_upper_midrange, 186 | yref="y2", 187 | ), 188 | ] 189 | 190 | if sloped: 191 | shapes.extend(sloped_vertical_lines) 192 | 193 | return shapes 194 | -------------------------------------------------------------------------------- /src/spc_plotly/helpers/menus.py: -------------------------------------------------------------------------------- 1 | from plotly.graph_objects import Figure 2 | 3 | 4 | def _menu( 5 | fig: Figure, 6 | limit_lines: list, 7 | limit_line_annotations: list, 8 | long_run_shapes: list, 9 | short_run_shapes: list, 10 | ) -> Figure: 11 | """ 12 | Creates menu on figure for user to show anomalous points, long runs, or short runs 13 | 14 | Parameters: 15 | fig (Figure): XmR Chart figure object to be updated 16 | limit_lines (list): List of dictionaries representing limit line shapes, specifically "lines". 17 | limit_line_annotations (list): List of dictionaries representing chart annotations. 18 | long_run_shapes (list): List of dictionaries representing "path" shapes for long runs. 19 | short_run_shapes (list): List of dictionaries representing "path" shapes for short runs. 20 | 21 | Returns: 22 | Figure: Passed in XmR chart figure object updated to include menu for selecting anomalous point, long runs, or short runs 23 | """ 24 | return fig.update_layout( 25 | updatemenus=[ 26 | dict( 27 | type="buttons", 28 | direction="right", 29 | active=0, 30 | x=0.5, 31 | xanchor="center", 32 | y=1.2, 33 | buttons=list( 34 | [ 35 | dict( 36 | label="None", 37 | method="update", 38 | args=[ 39 | {"visible": [True, True, False, False]}, 40 | { 41 | "shapes": limit_lines, 42 | "annotations": limit_line_annotations, 43 | }, 44 | ], 45 | ), 46 | dict( 47 | label="Anomalies", 48 | method="update", 49 | args=[ 50 | {"visible": [True, True, True, True]}, 51 | { 52 | "shapes": limit_lines, 53 | "annotations": limit_line_annotations, 54 | }, 55 | ], 56 | ), 57 | dict( 58 | label="Long Runs", 59 | method="update", 60 | args=[ 61 | {"visible": [True, True, False, False]}, 62 | { 63 | "shapes": limit_lines + long_run_shapes, 64 | "annotations": limit_line_annotations, 65 | }, 66 | ], 67 | ), 68 | dict( 69 | label="Short Runs", 70 | method="update", 71 | args=[ 72 | {"visible": [True, True, False, False]}, 73 | { 74 | "shapes": limit_lines + short_run_shapes, 75 | "annotations": limit_line_annotations, 76 | }, 77 | ], 78 | ), 79 | dict( 80 | label="All", 81 | method="update", 82 | args=[ 83 | {"visible": [True, True, True, True]}, 84 | { 85 | "shapes": limit_lines 86 | + long_run_shapes 87 | + short_run_shapes, 88 | "annotations": limit_line_annotations, 89 | }, 90 | ], 91 | ), 92 | ] 93 | ), 94 | ) 95 | ] 96 | ) 97 | -------------------------------------------------------------------------------- /src/spc_plotly/helpers/signals.py: -------------------------------------------------------------------------------- 1 | from plotly.graph_objects import Figure, Scatter 2 | from math import ceil 3 | from numpy import sum as numpy_sum, array 4 | from spc_plotly.utils import combine_paths 5 | 6 | 7 | def _anomalies( 8 | fig: Figure, 9 | npl_upper: float | list, 10 | npl_lower: float | list, 11 | mR_upper: float, 12 | sloped: bool, 13 | ) -> tuple: 14 | """ 15 | Identifies all points that lie outside of the natural process limits 16 | 17 | Parameters: 18 | fig (Figure): Passed in Figure object 19 | npl_upper (float|list): Upper process limit. If sloped is True, this is a list of tuples. 20 | npl_lower (float|list): Lower process limit. If sloped is True, this is a list of tuples. 21 | mR_upper (float): Upper moving range limit. 22 | sloped (bool): Use sloping approach for limit values. 23 | 24 | Returns: 25 | Figure: Passed in Figure object with added traces for anomalous points 26 | list[tuple]: All points that lie outside of the limits -> (x-value, y-value, "High"|"Low") 27 | """ 28 | # Detect point outside of natural limits 29 | 30 | fig_data = fig.data 31 | 32 | if sloped: 33 | anomaly_points_raw = [ 34 | (x, y, "High" if y >= upper[1] else None, "Low" if y <= lower[1] else None) 35 | for x, y, upper, lower in zip( 36 | fig_data[0].x, fig_data[0].y, npl_upper, npl_lower 37 | ) 38 | ] 39 | anomaly_points = [ 40 | (x, y, upper if upper is not None else lower) 41 | for (x, y, upper, lower) in anomaly_points_raw 42 | if not (upper is None and lower is None) 43 | ] 44 | else: 45 | anomaly_points = [ 46 | (x, y, "High" if y >= npl_upper else "Low") 47 | for x, y in zip(fig_data[0].x, fig_data[0].y) 48 | if y >= npl_upper or y <= npl_lower 49 | ] 50 | 51 | fig.add_trace( 52 | Scatter( 53 | x=[x[0] for x in anomaly_points], 54 | y=[x[1] for x in anomaly_points], 55 | texttemplate="%{y}", 56 | mode="markers", 57 | marker=dict(size=8, color="red", symbol="cross"), 58 | visible=False, 59 | ), 60 | row=1, 61 | col=1, 62 | ) 63 | 64 | mR_anomaly_points = [ 65 | (x, y) for x, y in zip(fig_data[1].x, fig_data[1].y) if y >= mR_upper 66 | ] 67 | 68 | fig.add_trace( 69 | Scatter( 70 | x=[x[0] for x in mR_anomaly_points], 71 | y=[x[1] for x in mR_anomaly_points], 72 | mode="markers", 73 | marker=dict(size=8, color="red", symbol="cross"), 74 | visible=False, 75 | ), 76 | row=2, 77 | col=1, 78 | ) 79 | 80 | return fig, anomaly_points 81 | 82 | 83 | def _short_run_test( 84 | fig: Figure, 85 | npl_upper: float | list, 86 | npl_lower: float | list, 87 | y_xmr_func: float | list, 88 | sloped: bool, 89 | fill_color: str = "purple", 90 | line_color: str = "blue", 91 | line_width: int = 2, 92 | line_type: str = "longdashdot", 93 | opacity: float = 0.2, 94 | shape_buffer_pct: float = 0.05, 95 | ) -> tuple: 96 | """ 97 | Identifies "short runs", defined as 3 out of 4 consecutive points closer to a limit 98 | line than the mid line. 99 | 100 | Parameters: 101 | fig (Figure): Passed in Figure object 102 | npl_upper (float|list): Upper process limit. If sloped is True, this is a list of tuples. 103 | npl_lower (float|list): Lower process limit. If sloped is True, this is a list of tuples. 104 | y_xmr_func (float|list): Upper moving range limit. If sloped is True, this is a list of tuples. 105 | sloped (bool): Use sloping approach for limit values. 106 | fill_color (str): Fill color of shape 107 | line_color (str): Line color of shape 108 | line_width (int): Line width of shape border 109 | line_type (str): Line type of shape border 110 | opacity (float): Opacity of shape fill 111 | shape_buffer_pct (float): % buffer to use for shape build. For example: 112 | If y-value = 100, a shape buffer of 5% would mean the lower and upper values 113 | of the shape for that y-value are [95, 105]. 114 | 115 | Returns: 116 | list[dict]: List of dictionaries that represent a shape for each short run that will 117 | highlight the area of the chart containing the short run. 118 | list[list]: List of lists, each sublist is a path, containing a tuple that represents 119 | each point in the long run. 120 | """ 121 | # Detect 8 consecutive points on one side of center line 122 | fig_data = fig.data 123 | dates = [el for el in fig_data[0].x] 124 | results = fig_data[0].y 125 | short_runs = [] 126 | y_range = fig.layout.yaxis.range 127 | 128 | if sloped: 129 | upper_midrange = [ 130 | (mid[0], mid[1] + ((upper[1] - mid[1]) / 2)) 131 | for (upper, mid) in zip(npl_upper, y_xmr_func) 132 | ] 133 | lower_midrange = [ 134 | (mid[0], mid[1] - ((mid[1] - lower[1]) / 2)) 135 | for (lower, mid) in zip(npl_lower, y_xmr_func) 136 | ] 137 | run_test_upper = results > [el[1] for el in upper_midrange] 138 | run_test_lower = results < [el[1] for el in lower_midrange] 139 | 140 | shape_buffer = (y_range[1] - y_range[0]) * shape_buffer_pct 141 | 142 | for i, el in enumerate(results): 143 | min_i = max(0, i - 3) 144 | max_i = i + 1 145 | trailing_sum_upper = numpy_sum(run_test_upper[min_i:max_i]) 146 | trailing_sum_lower = numpy_sum(run_test_lower[min_i:max_i]) 147 | if trailing_sum_upper >= 3: 148 | short_runs.append( 149 | zip( 150 | dates[min_i:max_i], 151 | results[min_i:max_i], 152 | ["High"] * (max_i - min_i), 153 | ) 154 | ) 155 | elif trailing_sum_lower >= 3: 156 | short_runs.append( 157 | zip( 158 | dates[min_i:max_i], 159 | results[min_i:max_i], 160 | ["Low"] * (max_i - min_i), 161 | ) 162 | ) 163 | else: 164 | pass 165 | 166 | else: 167 | upper_midrange = y_xmr_func + ((npl_upper - y_xmr_func) / 2) 168 | lower_midrange = y_xmr_func - ((y_xmr_func - npl_lower) / 2) 169 | run_test_upper = results > upper_midrange 170 | run_test_lower = results < lower_midrange 171 | 172 | shape_buffer = (y_range[1] - y_range[0]) * shape_buffer_pct 173 | 174 | for i, el in enumerate(results): 175 | min_i = max(0, i - 3) 176 | max_i = i + 1 177 | trailing_sum_upper = numpy_sum(run_test_upper[min_i:max_i]) 178 | trailing_sum_lower = numpy_sum(run_test_lower[min_i:max_i]) 179 | if trailing_sum_upper >= 3: 180 | short_runs.append( 181 | zip( 182 | dates[min_i:max_i], 183 | results[min_i:max_i], 184 | ["High"] * (max_i - min_i), 185 | ) 186 | ) 187 | elif trailing_sum_lower >= 3: 188 | short_runs.append( 189 | zip( 190 | dates[min_i:max_i], 191 | results[min_i:max_i], 192 | ["Low"] * (max_i - min_i), 193 | ) 194 | ) 195 | else: 196 | pass 197 | 198 | paths = [] 199 | for run in short_runs: 200 | path_build_list = [] 201 | for i, (d, v, t) in enumerate(run): 202 | path_build_list.append((d, v, t)) 203 | 204 | paths.append(path_build_list) 205 | 206 | # combine overlapping paths 207 | c_paths = combine_paths.combine_paths(paths) 208 | 209 | path_strings = [] 210 | for path in c_paths: 211 | path_string = "" 212 | for i, el in enumerate(path): 213 | d = el[0] 214 | v = el[1] 215 | if i == 0: 216 | path_string += "M {} {}".format(d, v + shape_buffer) 217 | else: 218 | path_string += " L {} {}".format(d, v + shape_buffer) 219 | 220 | for el in path[::-1]: 221 | d = el[0] 222 | v = el[1] 223 | path_string += " L {} {}".format(d, v - shape_buffer) 224 | 225 | path_string += " Z" 226 | 227 | path_strings.append(path_string) 228 | 229 | shapes = [] 230 | for path_string in path_strings: 231 | shapes.append( 232 | { 233 | "fillcolor": fill_color, 234 | "line": {"color": line_color, "dash": line_type, "width": line_width}, 235 | "name": "Short Run", 236 | "opacity": opacity, 237 | "path": (path_string), 238 | "type": "path", 239 | } 240 | ) 241 | 242 | return shapes, c_paths 243 | 244 | 245 | def _long_run_test( 246 | fig: Figure, 247 | y_xmr_func, 248 | sloped: bool, 249 | fill_color: str = "pink", 250 | line_color: str = "purple", 251 | line_width: int = 2, 252 | line_type: str = "longdashdot", 253 | opacity: float = 0.2, 254 | shape_buffer_pct: float = 0.05, 255 | ) -> tuple: 256 | """ 257 | Identifies "long runs", defined as 8 consecutive points above or below the mid line. 258 | 259 | Parameters: 260 | fig (Figure): Passed in Figure object 261 | y_xmr_func (float|list): Upper moving range limit. If sloped is True, this is a list of tuples. 262 | sloped (bool): Use sloping approach for limit values. 263 | fill_color (str): Fill color of shape 264 | line_color (str): Line color of shape 265 | line_width (int): Line width of shape border 266 | line_type (str): Line type of shape border 267 | opacity (float): Opacity of shape fill 268 | shape_buffer_pct (float): % buffer to use for shape build. For example: 269 | If y-value = 100, a shape buffer of 5% would mean the lower and upper values 270 | of the shape for that y-value are [95, 105]. 271 | 272 | Returns: 273 | list[dict]: List of dictionaries that represent a shape for each long run that will 274 | highlight the area of the chart containing the long run. 275 | list[list]: List of lists, each sublist is a path, containing a tuple that represents 276 | each point in the long run. 277 | """ 278 | fig_data = fig.data 279 | dates = [el for el in fig_data[0].x] 280 | results = fig_data[0].y 281 | 282 | long_runs = [] 283 | y_range = fig.layout.yaxis.range 284 | shape_buffer = (y_range[1] - y_range[0]) * shape_buffer_pct 285 | 286 | if sloped: 287 | y_func_values = [el[1] for el in y_xmr_func] 288 | run_test_upper = results > array(y_func_values) 289 | run_test_lower = results < array(y_func_values) 290 | 291 | for i, el in enumerate(results): 292 | trailing_sum_upper = numpy_sum(run_test_upper[max(0, i - 7) : i + 1]) 293 | trailing_sum_lower = numpy_sum(run_test_lower[max(0, i - 7) : i + 1]) 294 | if trailing_sum_upper >= 8 or trailing_sum_lower >= 8: 295 | long_runs.append( 296 | zip(dates[max(0, i - 7) : i + 1], results[max(0, i - 7) : i + 1]) 297 | ) 298 | 299 | paths = [] 300 | for run in long_runs: 301 | path_build_list = [] 302 | for i, (d, v) in enumerate(run): 303 | y_value = [el[1] for el in y_xmr_func if el[0] == d] 304 | path_build_list.append((d, v, "High" if v >= y_value else "Low")) 305 | 306 | paths.append(path_build_list) 307 | else: 308 | run_test_upper = results > y_xmr_func 309 | run_test_lower = results < y_xmr_func 310 | 311 | for i, el in enumerate(results): 312 | trailing_sum_upper = numpy_sum(run_test_upper[max(0, i - 7) : i + 1]) 313 | trailing_sum_lower = numpy_sum(run_test_lower[max(0, i - 7) : i + 1]) 314 | if trailing_sum_upper >= 8 or trailing_sum_lower >= 8: 315 | long_runs.append( 316 | zip(dates[max(0, i - 7) : i + 1], results[max(0, i - 7) : i + 1]) 317 | ) 318 | 319 | paths = [] 320 | for run in long_runs: 321 | path_build_list = [] 322 | for i, (d, v) in enumerate(run): 323 | path_build_list.append((d, v, "High" if v >= y_xmr_func else "Low")) 324 | 325 | paths.append(path_build_list) 326 | 327 | # combine overlapping paths 328 | c_paths = combine_paths.combine_paths(paths) 329 | 330 | path_strings = [] 331 | for path in c_paths: 332 | path_string = "" 333 | for i, el in enumerate(path): 334 | d = el[0] 335 | v = el[1] 336 | if i == 0: 337 | path_string += "M {} {}".format(d, v + shape_buffer) 338 | else: 339 | path_string += " L {} {}".format(d, v + shape_buffer) 340 | 341 | for el in path[::-1]: 342 | d = el[0] 343 | v = el[1] 344 | path_string += " L {} {}".format(d, v - shape_buffer) 345 | 346 | path_string += " Z" 347 | 348 | path_strings.append(path_string) 349 | 350 | shapes = [] 351 | for path_string in path_strings: 352 | shapes.append( 353 | { 354 | "fillcolor": fill_color, 355 | "line": {"color": line_color, "dash": line_type, "width": line_width}, 356 | "name": "Long Run", 357 | "opacity": opacity, 358 | "path": (path_string), 359 | "type": "path", 360 | } 361 | ) 362 | 363 | return shapes, c_paths 364 | -------------------------------------------------------------------------------- /src/spc_plotly/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JeremyColon/spc_plotly/1895f157613f4284d7388aacbed81b7c90867031/src/spc_plotly/utils/__init__.py -------------------------------------------------------------------------------- /src/spc_plotly/utils/calc_xmr_func.py: -------------------------------------------------------------------------------- 1 | def calc_xmr_func(data, func="mean"): 2 | """ 3 | Calculate aggregate function 4 | 5 | Parameters: 6 | data (Series): Series of values 7 | func (str): Mean or median 8 | 9 | Returns: 10 | Float: Mean or median value of data 11 | """ 12 | if func == "mean": 13 | return data.mean() 14 | elif func == "median": 15 | return data.median() 16 | else: 17 | raise ValueError("Invalid function") 18 | -------------------------------------------------------------------------------- /src/spc_plotly/utils/combine_paths.py: -------------------------------------------------------------------------------- 1 | def combine_paths(paths): 2 | """ 3 | Combines one or more overlapping paths into one so that it shows up as a contiguous 4 | shape on the chart. 5 | NOTE: 6 | This function assumes that the paths parameter is sorted; i.e., the first value 7 | of the first sublist is before the first value of the second sublist, etc, etc. 8 | This function most likely will not work if, for example, there are 3 paths and 9 | the first path does not intersect with the second path but DOES intersect with 10 | the third path. Given how long runs and short runs are calculated, this should 11 | never be a problem 12 | 13 | Returns: 14 | list[list[tuple]]: List of lists that each contain a collection of points representing 15 | a contiguous path 16 | """ 17 | 18 | for i, p in enumerate(paths): 19 | # Ensure this is not the last path 20 | if len(paths) > i + 1: 21 | next_p = paths[i + 1] 22 | 23 | # Convert to sets to take advantage of set operations 24 | p_set = set(p) 25 | next_p_set = set(next_p) 26 | 27 | # If sets overlap OR start of next path comes directly after end of current path 28 | # TODO: the second clause needs to be adjusted. Currently checks if last value of 29 | # current path EQUALS first value of next path. 30 | if len(p_set.intersection(next_p_set)) > 0 or p[len(p) - 1] == next_p[0]: 31 | # Get union of two paths, convert to list, and sort 32 | c_path = sorted(list(p_set.union(next_p_set))) 33 | # Remove two paths from the current list 34 | paths.remove(p) 35 | paths.remove(next_p) 36 | # Insert combined path back into list 37 | paths.insert(0, c_path) 38 | # Re-enter function with updated list of paths 39 | combine_paths(paths) 40 | else: 41 | pass 42 | else: 43 | break 44 | 45 | return paths 46 | -------------------------------------------------------------------------------- /src/spc_plotly/utils/endpoints.py: -------------------------------------------------------------------------------- 1 | from pandas import DataFrame 2 | 3 | 4 | def get_line_endpoints(path: list, data: DataFrame): 5 | """ 6 | Get line endpoints from a path 7 | 8 | Parameters: 9 | path (list[tuple]): List of tuples representing points in a contiguous path 10 | data (DataFrame): Series of values 11 | 12 | Returns: 13 | dict: Start and end (x,y) values 14 | """ 15 | start_y = path[0][1] 16 | end_y = path[len(path) - 1][1] 17 | 18 | start_x = data.index[path[0][0]] 19 | end_x = data.index[data.shape[0] - 1] 20 | 21 | return {"start": dict(x=start_x, y=start_y), "end": dict(x=end_x, y=end_y)} 22 | -------------------------------------------------------------------------------- /src/spc_plotly/utils/rounded_value.py: -------------------------------------------------------------------------------- 1 | from math import floor, ceil 2 | 3 | 4 | def rounded_value( 5 | value: float, multiple: int | float, rounding_direction: str = "down" 6 | ) -> int | float: 7 | """ 8 | Calculate rounded value based on multiple. This is primarily used to help calculate 9 | the y-axis min/max range values. 10 | 11 | Parameters: 12 | value (float): Value to round 13 | multiple (int | float): Multiple to use for rounding (i.e., round to the nearest multiple) 14 | rounding_direction (str): Round up or down 15 | 16 | Returns: 17 | int | float: Rounded value 18 | """ 19 | 20 | if rounding_direction not in ["up", "down"]: 21 | raise "rounding direction must be 'up' or 'down'" 22 | 23 | if multiple >= 1: 24 | if rounding_direction == "down": 25 | return int(floor(value / multiple) * multiple) 26 | else: 27 | return int(ceil(value / multiple) * multiple) 28 | else: 29 | if rounding_direction == "down": 30 | return floor(value / multiple) * multiple 31 | else: 32 | return ceil(value / multiple) * multiple 33 | -------------------------------------------------------------------------------- /src/spc_plotly/utils/rounding_multiple.py: -------------------------------------------------------------------------------- 1 | from math import log10, floor, ceil 2 | 3 | 4 | def rounding_multiple(value: float, rounding_direction: str = "down") -> int: 5 | """ 6 | Calculate ideal multiple based on value. This is primarily used to help calculate 7 | the y-axis min/max range values. 8 | 9 | Parameters: 10 | value (float): Raw value to use for calculations 11 | rounding_direction (str): Round up or down 12 | 13 | Returns: 14 | dict: Start and end (x,y) values 15 | """ 16 | 17 | if rounding_direction not in ["up", "down"]: 18 | raise "rounding direction must be 'up' or 'down'" 19 | 20 | orders_of_magnitude = log10(value) - 1 21 | rounding_multiple = (10 ** floor(orders_of_magnitude)) / 2 22 | dtick_multiple = (10**orders_of_magnitude) / rounding_multiple 23 | 24 | # If there is a positive order of magnitude then we are most likely working with 25 | # counts. In this case, we want a multiple that is easily countable and will produce 26 | # a clean y-axis. 50, 500, etc. 27 | # If order of magnitude is negative, then we are most likely working with rates and 28 | # so we have to use a decimal multiple. 29 | if orders_of_magnitude > 0: 30 | if rounding_direction == "down": 31 | return int(floor(dtick_multiple) * rounding_multiple) 32 | else: 33 | return int(ceil(dtick_multiple) * rounding_multiple) 34 | else: 35 | return round(dtick_multiple * rounding_multiple, -floor(orders_of_magnitude)) 36 | -------------------------------------------------------------------------------- /src/spc_plotly/xmr.py: -------------------------------------------------------------------------------- 1 | from pandas import DataFrame, Series, to_datetime 2 | from numpy import abs 3 | from spc_plotly.helpers import ( 4 | axes_formats, 5 | base_traces, 6 | limit_lines, 7 | annotations, 8 | signals, 9 | menus, 10 | ) 11 | from spc_plotly.utils import calc_xmr_func 12 | from tests import test_xmr 13 | from plotly.graph_objects import Figure 14 | 15 | date_parts = { 16 | "year": "%Y", 17 | "month": "%Y-%m", 18 | "day": "%Y-%m-%d", 19 | "hour": "%Y-%m-%d %H", 20 | "minute": "%Y-%m-%d %H:%M", 21 | "custom": None, 22 | } 23 | 24 | XmR_constants = { 25 | "mean": {"mR_Upper": 3.268, "npl_Constant": 2.660}, 26 | "median": {"mR_Upper": 3.865, "npl_Constant": 3.145}, 27 | } 28 | 29 | 30 | class XmR: 31 | """ 32 | A class representing an XmR chart. 33 | 34 | Attributes: 35 | data (str): Dataframe to use for XmR chart. 36 | y_ser_name (int): Name of column containing values to plot on y-axis. 37 | x_ser_name (str): Name of column or index containing values to plot on x-axis. 38 | Column or index should represent a date or date/time 39 | x_cutoff (str): Value of x_ser_name, after which the data is excluded for purposes 40 | of calculating limits. If None, all data is included. 41 | date_part_resolution (str): Level of resolution to show on x-axis. This must match your data. 42 | Valid options: 43 | - year 44 | - quarter 45 | - month 46 | - day 47 | - hour 48 | - minute 49 | - custom 50 | title (str): Custom chart title 51 | sloped (bool): Use sloping approach for limit values. Only use this if your data 52 | is expected to increase over time (e.g., energy prices). 53 | xmr_function (str): Use "mean" or "median" function for calculating limit values 54 | chart_height (int): Adjust chart height 55 | """ 56 | 57 | def __init__( 58 | self, 59 | data: DataFrame, 60 | y_ser_name: str, 61 | x_ser_name: str, 62 | x_begin: str = None, 63 | x_cutoff: str = None, 64 | date_part_resolution: str = "month", 65 | custom_date_part: str = "", 66 | title: str = None, 67 | sloped: bool = False, 68 | xmr_function: str = "mean", 69 | chart_height: int = None, 70 | ) -> None: 71 | """ 72 | Initializes an XmR Chart object. 73 | 74 | Parameters: 75 | data (str): Dataframe to use for XmR chart. 76 | y_ser_name (int): Name of column containing values to plot on y-axis. 77 | x_ser_name (str): Name of column or index containing values to plot on x-axis. 78 | Column or index should represent a date, date/time, or a proxy for such 79 | (e.g., increasing integer value) 80 | x_begin (str): Value of x_ser_name, before which the data is excluded for purposes 81 | of calculating limits. If None, minimum value is set. 82 | x_cutoff (str): Value of x_ser_name, after which the data is excluded for purposes 83 | of calculating limits. If None, maximum value is set. 84 | date_part_resolution (str): Resolution of your data, for formatting the x-axis. Valid options: 85 | - year 86 | - month 87 | - day 88 | - hour 89 | - minute 90 | - custom 91 | custom_date_part (str): If you choose custom, please specify the d3 format corresponding to your data. 92 | title (str): Custom chart title 93 | sloped (bool): Use sloping approach for limit values. Only use this if your data 94 | is expected to increase over time (e.g., energy prices). 95 | xmr_function (str): Use "mean" or "median" function for calculating limit values 96 | chart_height (int): Adjust chart height 97 | """ 98 | 99 | self.data = data 100 | self.xmr_function = xmr_function.lower() 101 | self.sloped = sloped 102 | self.date_part_resolution = date_part_resolution.lower() 103 | if self.date_part_resolution == "custom": 104 | self.custom_date_part = custom_date_part 105 | else: 106 | self.custom_date_part = date_parts.get(self.date_part_resolution, None) 107 | 108 | test_xmr.test_inputs(self, date_parts) 109 | 110 | test_xmr.test_y_ser_name_val(y_ser_name, self.data) 111 | self._y_ser_name = y_ser_name 112 | self._y_Ser = data[self._y_ser_name] 113 | 114 | test_xmr.test_x_ser_name_val(x_ser_name, self.data) 115 | self._x_ser_name = x_ser_name 116 | 117 | self._x_Ser = ( 118 | data[self._x_ser_name] if self._x_ser_name in data.columns else data.index 119 | ) 120 | 121 | test_xmr.test_x_ser_is_date(self._x_Ser) 122 | self._x_Ser_dt = to_datetime(self._x_Ser) 123 | self._x_Ser = self._x_Ser_dt.dt.strftime(self.custom_date_part) 124 | 125 | test_xmr.test_cutoff_val(x_cutoff, self._x_Ser) 126 | # Check if cutoff value exists 127 | if x_cutoff is None: 128 | self.x_cutoff = self._x_Ser.max() 129 | else: 130 | self.x_cutoff = x_cutoff 131 | 132 | test_xmr.test_begin_val(x_begin, self._x_Ser) 133 | # Check if cutoff value exists 134 | if x_begin is None: 135 | self.x_begin = self._x_Ser.min() 136 | else: 137 | self.x_begin = x_begin 138 | 139 | self._title = ( 140 | f"{y_ser_name} XmR Chart by {self._x_Ser.name}" if title is None else title 141 | ) 142 | 143 | # Set constant values for mean or median 144 | self.mR_Upper_Constant = XmR_constants.get(xmr_function).get("mR_Upper") 145 | self.npl_Constant = XmR_constants.get(xmr_function).get("npl_Constant") 146 | 147 | # Calculate limit values 148 | ( 149 | self.data_for_limits, 150 | self.mR_data, 151 | self.mR_limit_values, 152 | self.npl_limit_values, 153 | ) = self._limits() 154 | 155 | # Add selected function to mR and npl dictionaries for reference 156 | self.mR_limit_values["xmr_func"] = self.xmr_function 157 | self.npl_limit_values["xmr_func"] = self.xmr_function 158 | 159 | self._height = chart_height 160 | self.xmr_chart, self.signals = self._XmR_chart() 161 | 162 | def _limits(self) -> tuple[DataFrame, Series, dict, dict]: 163 | """ 164 | Calculates limits for XmR chart. 165 | 166 | Returns: 167 | tuple: A tuple containing the following; 168 | - pd.DataFrame: Data used to calculate limits 169 | - pd.Series: Data used for moving range chart 170 | - dict: Contains the moving range upper limit and mean/median value 171 | - dict: Contains the natural process limits and mean/median value 172 | """ 173 | 174 | data_for_limits = self.data.loc[ 175 | (self._x_Ser >= self.x_begin) & (self._x_Ser <= self.x_cutoff) 176 | ] 177 | 178 | mR_data_for_limits = abs( 179 | data_for_limits[self._y_ser_name] 180 | - data_for_limits[self._y_ser_name].shift(1) 181 | ) 182 | 183 | # Calculate mean/median values 184 | mR_xmr_func = calc_xmr_func.calc_xmr_func(mR_data_for_limits, self.xmr_function) 185 | y_xmr_func = calc_xmr_func.calc_xmr_func( 186 | data_for_limits[self._y_ser_name], self.xmr_function 187 | ) 188 | 189 | mR_upper = mR_xmr_func * self.mR_Upper_Constant 190 | 191 | if self.sloped: 192 | # According to "Understanding Variation: The Key to Managing Chaos", 193 | # we derive the slope of the mean/median line by getting the mean/median 194 | # of the first half and second half of the data. We then solve for the 195 | # y-intercept (b) with the slope 196 | half_idx = data_for_limits.shape[0] // 2 197 | first_half_idx = data_for_limits.values[:half_idx].shape[0] // 2 198 | second_half_idx = data_for_limits.values[half_idx:].shape[0] // 2 199 | 200 | x_delta = (half_idx + second_half_idx) - first_half_idx 201 | y_delta = calc_xmr_func.calc_xmr_func( 202 | data_for_limits[self._y_ser_name].values[half_idx:], self.xmr_function 203 | ) - calc_xmr_func.calc_xmr_func( 204 | data_for_limits[self._y_ser_name].values[:half_idx], self.xmr_function 205 | ) 206 | m = y_delta / x_delta 207 | b = calc_xmr_func.calc_xmr_func( 208 | data_for_limits[self._y_ser_name].values[:half_idx], self.xmr_function 209 | ) - (m * (first_half_idx)) 210 | 211 | # Create list representing upper, mid, and lower sloped paths 212 | sloped_path = [(i, (((i + 1) * m) + b)) for i in range(self.data.shape[0])] 213 | lower_limit_sloped_path = [ 214 | (i[0], i[1] - (mR_xmr_func * self.mR_Upper_Constant)) 215 | for i in sloped_path 216 | ] 217 | upper_limit_sloped_path = [ 218 | (i[0], i[1] + (mR_xmr_func * self.mR_Upper_Constant)) 219 | for i in sloped_path 220 | ] 221 | 222 | return ( 223 | data_for_limits, 224 | abs(self.data[self._y_ser_name] - self.data[self._y_ser_name].shift(1)), 225 | {"mR_xmr_func": mR_xmr_func, "mR_upper_limit": mR_upper}, 226 | { 227 | "y_xmr_func": sloped_path, 228 | "npl_upper_limit": upper_limit_sloped_path, 229 | "npl_lower_limit": lower_limit_sloped_path, 230 | }, 231 | ) 232 | else: 233 | npl_upper = y_xmr_func + (self.npl_Constant * mR_xmr_func) 234 | npl_lower = max(y_xmr_func - (self.npl_Constant * mR_xmr_func), 0) 235 | 236 | return ( 237 | data_for_limits, 238 | abs(self.data[self._y_ser_name] - self.data[self._y_ser_name].shift(1)), 239 | {"mR_xmr_func": mR_xmr_func, "mR_upper_limit": mR_upper}, 240 | { 241 | "y_xmr_func": y_xmr_func, 242 | "npl_upper_limit": npl_upper, 243 | "npl_lower_limit": npl_lower, 244 | }, 245 | ) 246 | 247 | def _XmR_chart(self) -> tuple[Figure, dict]: 248 | """ 249 | Creates the XmR chart 250 | 251 | Returns: 252 | Figure: XmR chart figure object 253 | dict: A dictionary containing the following: 254 | - list: All points lying outside the limits. 255 | - list: List of lists, where each sublist contains points that are part 256 | of a "long run", which is defined as 8 consecutive points above 257 | or below the mean/median line. 258 | - list: List of lists, where each sublist contains points that are part 259 | of a "short run", which is defined as 3 out of 4 points closer 260 | to the limit lines than they are to the mean/median line. 261 | """ 262 | 263 | fig_XmR = base_traces._base_traces( 264 | self._x_Ser, self._x_Ser_dt, self._y_Ser, self.mR_data 265 | ) 266 | axis_formats = axes_formats._format_XmR_axes( 267 | npl_upper=self.npl_limit_values.get("npl_upper_limit"), 268 | npl_lower=self.npl_limit_values.get("npl_lower_limit"), 269 | mR_upper=self.mR_limit_values.get("mR_upper_limit"), 270 | y_Ser=self._y_Ser, 271 | mR_data=self.mR_data, 272 | sloped=self.sloped, 273 | ) 274 | fig_XmR.layout.xaxis = axis_formats.get("x_values") 275 | fig_XmR.layout.xaxis2 = axis_formats.get("x_mR") 276 | fig_XmR.layout.yaxis = axis_formats.get("y_values") 277 | fig_XmR.layout.yaxis2 = axis_formats.get("y_mR") 278 | 279 | limit_line_shapes = limit_lines._create_limit_lines( 280 | data=self.data, 281 | y_xmr_func=self.npl_limit_values.get("y_xmr_func"), 282 | npl_upper=self.npl_limit_values.get("npl_upper_limit"), 283 | npl_lower=self.npl_limit_values.get("npl_lower_limit"), 284 | mR=self.mR_limit_values.get("mR_xmr_func"), 285 | mR_upper=self.mR_limit_values.get("mR_upper_limit"), 286 | sloped=self.sloped, 287 | ) 288 | fig_XmR.layout.shapes = limit_line_shapes 289 | 290 | limit_line_annotations = annotations._create_limit_line_annotations( 291 | data=self.data, 292 | chart_title=self._title, 293 | y_xmr_func=self.npl_limit_values.get("y_xmr_func"), 294 | mR_upper=self.mR_limit_values.get("mR_upper_limit"), 295 | mR_xmr_func=self.mR_limit_values.get("mR_xmr_func"), 296 | npl_upper=self.npl_limit_values.get("npl_upper_limit"), 297 | npl_lower=self.npl_limit_values.get("npl_lower_limit"), 298 | y_name=self._y_ser_name, 299 | sloped=self.sloped, 300 | ) 301 | fig_XmR.layout.annotations = limit_line_annotations 302 | 303 | fig_XmR, anomalies = signals._anomalies( 304 | fig=fig_XmR, 305 | npl_upper=self.npl_limit_values.get("npl_upper_limit"), 306 | npl_lower=self.npl_limit_values.get("npl_lower_limit"), 307 | mR_upper=self.mR_limit_values.get("mR_upper_limit"), 308 | sloped=self.sloped, 309 | ) 310 | 311 | long_run_shapes, long_runs = signals._long_run_test( 312 | fig=fig_XmR, 313 | y_xmr_func=self.npl_limit_values.get("y_xmr_func"), 314 | sloped=self.sloped, 315 | ) 316 | 317 | short_run_shapes, short_runs = signals._short_run_test( 318 | fig_XmR, 319 | npl_upper=self.npl_limit_values.get("npl_upper_limit"), 320 | npl_lower=self.npl_limit_values.get("npl_lower_limit"), 321 | y_xmr_func=self.npl_limit_values.get("y_xmr_func"), 322 | sloped=self.sloped, 323 | ) 324 | 325 | fig_XmR = menus._menu( 326 | fig=fig_XmR, 327 | limit_lines=limit_line_shapes, 328 | limit_line_annotations=limit_line_annotations, 329 | long_run_shapes=long_run_shapes, 330 | short_run_shapes=short_run_shapes, 331 | ) 332 | 333 | fig_XmR.update_layout( 334 | plot_bgcolor="white", 335 | font={"size": 10}, 336 | showlegend=False, 337 | height=self._height, 338 | hovermode="x", 339 | ) 340 | 341 | signals_dict = { 342 | "anomalies": anomalies, 343 | "long_runs": long_runs, 344 | "short_runs": short_runs, 345 | } 346 | 347 | return fig_XmR, signals_dict 348 | -------------------------------------------------------------------------------- /src/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JeremyColon/spc_plotly/1895f157613f4284d7388aacbed81b7c90867031/src/tests/__init__.py -------------------------------------------------------------------------------- /src/tests/test_xmr.py: -------------------------------------------------------------------------------- 1 | from pandas import to_datetime 2 | 3 | 4 | def test_inputs(XmR, date_parts) -> bool: 5 | 6 | test_xmr_func_val(XmR.xmr_function) 7 | test_sloped_val(XmR.sloped) 8 | test_date_part_resolution_val(XmR.date_part_resolution, date_parts) 9 | test_custom_date_part_val(XmR.date_part_resolution, XmR.custom_date_part) 10 | 11 | return True 12 | 13 | 14 | def test_xmr_func_val(xmr_func_val): 15 | if xmr_func_val.lower() not in ["mean", "median"]: 16 | e = f"{xmr_func_val} not a valid xmr function option. Must be 'mean' or 'median'" 17 | raise ValueError(e) 18 | 19 | return True 20 | 21 | 22 | def test_date_part_resolution_val(date_part_resolution_val, date_parts): 23 | if date_part_resolution_val.lower() not in date_parts.keys(): 24 | vals = list(date_parts.keys()) 25 | e = f"{date_part_resolution_val} not a valid date part resolution. Must be {vals}" 26 | raise ValueError(e) 27 | 28 | return True 29 | 30 | 31 | def test_custom_date_part_val(date_part_resolution_val, custom_date_part_val): 32 | if date_part_resolution_val == "custom": 33 | if custom_date_part_val is None or custom_date_part_val == "": 34 | e = "Must specify a valid date part format. Please visit https://d3js.org/d3-time-format for reference." 35 | raise ValueError(e) 36 | 37 | return True 38 | 39 | 40 | def test_x_ser_name_val(x_ser_name, data): 41 | if x_ser_name in data.columns or x_ser_name == data.index.name: 42 | return True 43 | else: 44 | e = f"{x_ser_name} not a valid column or index in your dataframe" 45 | raise ValueError(e) 46 | 47 | 48 | def test_x_ser_is_date(x_Ser): 49 | try: 50 | to_datetime(x_Ser) 51 | except: 52 | e = f"{x_Ser.name} can not be converted to datetime format. Please inspect data for erroneous values." 53 | raise TypeError(e) 54 | 55 | 56 | def test_y_ser_name_val(y_ser_name, data): 57 | if y_ser_name not in data.columns: 58 | e = f"{y_ser_name} not a valid column" 59 | raise ValueError(e) 60 | 61 | return True 62 | 63 | 64 | def test_cutoff_val(cutoff_val, x_Ser): 65 | if cutoff_val is None or cutoff_val in x_Ser.values: 66 | return True 67 | else: 68 | print(cutoff_val) 69 | print(x_Ser) 70 | e = f"{cutoff_val} not present in {x_Ser.name}" 71 | raise ValueError(e) 72 | 73 | 74 | def test_begin_val(begin_val, x_Ser): 75 | if begin_val is None or begin_val in x_Ser.values: 76 | return True 77 | else: 78 | print(begin_val) 79 | print(x_Ser) 80 | e = f"{begin_val} not present in {x_Ser.name}" 81 | raise ValueError(e) 82 | 83 | 84 | def test_sloped_val(sloped_val): 85 | if not isinstance(sloped_val, bool): 86 | e = f"sloped parameter must be a boolean value" 87 | raise ValueError(e) 88 | --------------------------------------------------------------------------------