├── .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 |
--------------------------------------------------------------------------------