perceul 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
perceul/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ from .clustering import explore_clusters, final_clustering
2
+ from .interpretation import get_pca_loadings
3
+ from .selectors import NumericSelector
4
+
5
+ __all__ = [
6
+ "explore_clusters",
7
+ "final_clustering",
8
+ "get_pca_loadings",
9
+ "NumericSelector"
10
+ ]
File without changes
Binary file
Binary file
perceul/clustering.py ADDED
@@ -0,0 +1,152 @@
1
+ import pandas as pd
2
+ import matplotlib.pyplot as plt
3
+ import joblib
4
+ from importlib import resources
5
+
6
+ from .utils.cluster import *
7
+ from .utils.auxiliary import build_umap, build_hdbscan, format_outliers, format_deviations_as_columns
8
+
9
+ from sklearn.cluster import KMeans
10
+
11
+ __all__ = [
12
+ "explore_clusters",
13
+ "final_clustering"
14
+ ]
15
+
16
+ # ============================================================
17
+ # CLUSTER EXPLORATION
18
+ # ============================================================
19
+ def explore_clusters(file: str):
20
+ """
21
+ Clusters data points using PCA + KMeans, identifies top drivers for each cluster.
22
+
23
+ Args:
24
+ file (str): Path to the input CSV file containing worker data.
25
+
26
+ Returns:
27
+ A tuple containing:
28
+ * fig (matplotlib.figure.Figure): The HDBSCAN scatter plot figure with clusters and outliers colored.
29
+ * cluster_summary (dict): A dictionary containing cluster numbers (key) and number of data points within it (value).
30
+ * outliers (str): Markdown-formatted string showing number of outliers.
31
+ """
32
+
33
+ df = pd.read_csv(file)
34
+
35
+ pkg_resource = resources.files("perceul.assets").joinpath("exploration_pipeline.pkl")
36
+
37
+ with pkg_resource.open("rb") as f:
38
+ exploration_pipeline = joblib.load(f)
39
+
40
+ X_exp = exploration_pipeline.fit_transform(df)
41
+
42
+ # dynamic UMAP constructor
43
+ umap_model = build_umap(df)
44
+ X_umap = umap_model.fit_transform(X_exp)
45
+
46
+ # dynamic HDBSCAN constructor
47
+ hdb = build_hdbscan(df)
48
+ labels_hdb = hdb.fit_predict(X_umap)
49
+
50
+ # --- cluster statistics ---
51
+ from collections import Counter
52
+ label_counts = Counter(labels_hdb)
53
+
54
+ n_outliers = label_counts.pop(-1, 0)
55
+
56
+ cluster_summary = {
57
+ f"Cluster {key}": value
58
+ for key, value in sorted(label_counts.items())
59
+ }
60
+
61
+ # --- visualization ---
62
+ fig, ax = plt.subplots(figsize=(7, 5))
63
+
64
+ ax.scatter(
65
+ X_umap[:, 0],
66
+ X_umap[:, 1],
67
+ c=labels_hdb,
68
+ s=5,
69
+ alpha=0.8
70
+ )
71
+
72
+ ax.set_title("Cluster Exploration of Worker Profiles")
73
+ ax.set_xlabel("Dimension 1")
74
+ ax.set_ylabel("Dimension 2")
75
+
76
+ fig.tight_layout()
77
+
78
+ return fig, cluster_summary, format_outliers(n_outliers)
79
+
80
+ # ============================================================
81
+ # FINAL CLUSTERING
82
+ # ============================================================
83
+ def final_clustering(file: str, top_features: int):
84
+ """
85
+ Clusters data points using PCA + KMeans, identifies top drivers for each cluster.
86
+
87
+ Args:
88
+ file (str): Path to the input CSV file containing worker data.
89
+ top_features (int): Number of top features to identify as drivers for each cluster.
90
+
91
+ Returns:
92
+ A tuple containing:
93
+ * fig (matplotlib.figure.Figure): The PCA scatter plot figure with clusters colored.
94
+ * best_k (int): optimal number of clusters determined by silhouette score.
95
+ * deviations_markdown (str): markdown-formatted string showing top feature deviations for each cluster.
96
+ * pca (sklearn.decomposition.PCA): The fitted PCA model.
97
+ * feature_names (list): List of feature names corresponding to the original data.
98
+ """
99
+ df = pd.read_csv(file)
100
+
101
+ pkg_resource = resources.files("perceul.assets").joinpath("core_pipeline.pkl")
102
+
103
+ with pkg_resource.open("rb") as f:
104
+ core_pipeline = joblib.load(f)
105
+
106
+ X_pca = core_pipeline.fit_transform(df)
107
+
108
+ best_k = choose_k(X_pca) # choose_k() is from utils.cluster; dynamic `k` selection
109
+
110
+ kmeans = KMeans(
111
+ n_clusters=best_k,
112
+ random_state=42,
113
+ n_init="auto"
114
+ )
115
+ labels = kmeans.fit_predict(X_pca)
116
+
117
+ # ===================================================
118
+ # Plotting PCA Clusters
119
+ # ===================================================
120
+
121
+ fig, ax = plt.subplots(figsize=(7, 5))
122
+ scatter = ax.scatter(
123
+ X_pca[:, 0],
124
+ X_pca[:, 1],
125
+ c=labels,
126
+ s=5,
127
+ alpha=0.8
128
+ )
129
+ ax.set_title("Final Clustering of Worker Profiles (PCA Space)")
130
+ ax.set_xlabel("PC1")
131
+ ax.set_ylabel("PC2")
132
+ legend1 = ax.legend(*scatter.legend_elements(), title="Clusters")
133
+ ax.add_artist(legend1)
134
+ fig.tight_layout()
135
+
136
+ # ===================================================
137
+ # Cluster Analysis
138
+ # ===================================================
139
+ pca = core_pipeline.named_steps["pca"] # Named Step Access to PCA model
140
+ scaler = core_pipeline.named_steps["scaler"] # Named Step Access to Scaler model
141
+ feature_names = df.columns.tolist()
142
+ centroids = compute_cluster_centroids_pca(X_pca, labels) # function is from `cluster_utils.py`; returns a DataFrame with PCA-space centroids for each cluster
143
+ original_centroids = inverse_project_centroids(
144
+ centroids,
145
+ pca,
146
+ scaler,
147
+ feature_names
148
+ ) # converts PCA-space centroids to original features; returns a DataFrame
149
+ top_drivers = identify_top_drivers(original_centroids, top_features) # returns a dict
150
+ deviations_markdown = format_deviations_as_columns(top_drivers) # formats the output as a markdown table string
151
+
152
+ return fig, best_k, deviations_markdown, pca, feature_names
@@ -0,0 +1,28 @@
1
+ import pandas as pd
2
+ from sklearn.decomposition import PCA
3
+
4
+ # ============================================================
5
+ # A function to retreive the loadings of the PCA components
6
+ # ============================================================
7
+
8
+ def get_pca_loadings(pca: PCA, feature_names: list) -> pd.DataFrame:
9
+ """
10
+ Get loadings of PCA components.
11
+
12
+ Args:
13
+ pca (sklearn.decomposition.PCA): PCA object from sklearn
14
+ feature_names (list): List of feature names corresponding to the original data
15
+
16
+ Returns:
17
+ pd.DataFrame: DataFrame with PCA loadings for each component and feature
18
+ """
19
+ loadings = pd.DataFrame(
20
+ pca.components_.T, # transposes the get features as rows and components as columns
21
+ columns=[f'PC{i+1}' for i in range(pca.n_components_)], # names the columns as PC1, PC2, ...
22
+ index=feature_names # names the rows with the original feature names
23
+ )
24
+
25
+ # Convert the index into a visible column named 'Feature'
26
+ loadings = loadings.reset_index().rename(columns={'index': 'Feature'})
27
+
28
+ return loadings
perceul/selectors.py ADDED
@@ -0,0 +1,12 @@
1
+ from sklearn.base import BaseEstimator, TransformerMixin
2
+
3
+ class NumericSelector(BaseEstimator, TransformerMixin):
4
+ def __init__(self):
5
+ pass
6
+
7
+ def fit(self, X, y=None):
8
+ self.numeric_cols_ = X.select_dtypes(include=[float, int]).columns
9
+ return self
10
+
11
+ def transform(self, X):
12
+ return X[self.numeric_cols_]
@@ -0,0 +1,14 @@
1
+ from .auxiliary import choose_umap_params, build_hdbscan, build_umap, format_outliers, format_deviations_as_columns
2
+ from .cluster import choose_k, compute_cluster_centroids_pca, compute_cluster_stats, inverse_project_centroids, identify_top_drivers
3
+
4
+ __all__ = [
5
+ "choose_umap_params",
6
+ "build_hdbscan",
7
+ "build_umap",
8
+ "format_outliers",
9
+ "format_deviations_as_columns",
10
+ "choose_k", "compute_cluster_centroids_pca",
11
+ "compute_cluster_stats",
12
+ "inverse_project_centroids",
13
+ "identify_top_drivers"
14
+ ]
@@ -0,0 +1,80 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+
4
+ import umap as UMAP
5
+ import hdbscan
6
+
7
+ def choose_umap_params(n_samples, n_features): # created to emphasize local structure of data
8
+ # use simple heuristic
9
+ sample_based = int(np.sqrt(n_samples) - 1)
10
+ feature_based = int(np.log2(n_features) - 1)
11
+
12
+ floor = 2
13
+ min_n_neighbors = min(sample_based, feature_based)
14
+
15
+ # find best_min_dist
16
+ best_min_dist = 0.1 if n_features < 20 else 0.0
17
+
18
+ return max(floor, min_n_neighbors), best_min_dist
19
+
20
+ def build_umap(X):
21
+ n_samples, n_features = X.shape
22
+
23
+ # helper function defined above
24
+ best_n_neighbors, best_min_dist = choose_umap_params(n_samples, n_features)
25
+
26
+ return UMAP(
27
+ n_neighbors=best_n_neighbors,
28
+ min_dist=best_min_dist,
29
+ n_components=2,
30
+ random_state=42
31
+ )
32
+
33
+ def build_hdbscan(X):
34
+ n_samples = X.shape[0]
35
+
36
+ min_cluster_size = int(max(2, 0.01 * n_samples))
37
+ min_samples = max(2, int(0.5 * min_cluster_size))
38
+
39
+ return hdbscan.HDBSCAN(
40
+ min_cluster_size=min_cluster_size,
41
+ min_samples=min_samples,
42
+ cluster_selection_method="eom", # default
43
+ prediction_data=True # default
44
+ )
45
+
46
+ def format_outliers(n_outliers):
47
+ if n_outliers == 0:
48
+ return "โœ… No outliers detected."
49
+
50
+ return f"โš ๏ธ **{n_outliers} workers** do not strongly belong to any cluster."
51
+
52
+
53
+ def format_deviations_as_columns(drivers: dict) -> str:
54
+ """
55
+ Format the top feature deviations for each cluster into a markdown table with clusters as columns.
56
+
57
+ Args:
58
+ drivers (dict): A dictionary where keys are cluster IDs and values are dictionaries containing feature deviations.
59
+
60
+ Returns:
61
+ str: A markdown table with clusters as columns and feature deviations as rows.
62
+ """
63
+ headers = []
64
+ cells = []
65
+
66
+ for cid, data in drivers.items():
67
+ headers.append(f"Cluster {cid}")
68
+
69
+ dev_lines = [
70
+ f"{feature}: {value:.3f}"
71
+ for feature, value in data["deviations"].items()
72
+ ]
73
+
74
+ cells.append("<br>".join(dev_lines))
75
+
76
+ table = "| " + " | ".join(headers) + " |\n"
77
+ table += "|" + "|".join(["---"] * len(headers)) + "|\n"
78
+ table += "| " + " | ".join(cells) + " |"
79
+
80
+ return table
@@ -0,0 +1,117 @@
1
+ import pandas as pd
2
+ from sklearn.metrics import silhouette_score
3
+ from sklearn.cluster import KMeans
4
+
5
+ __all__ = [
6
+ "choose_k",
7
+ "compute_cluster_centroids_pca",
8
+ "inverse_project_centroids",
9
+ "compute_cluster_stats",
10
+ "identify_top_drivers"
11
+ ]
12
+
13
+ #========== Before Final Clustering ==========
14
+ def choose_k(X_pca):
15
+ best_k = 2
16
+ best_score = -1
17
+
18
+ # Ensure k does not exceed n_samples - 1 for silhouette_score validity
19
+ n_samples = X_pca.shape[0]
20
+ max_k_for_silhouette = n_samples # range is exclusive of end, so this will allow k up to n_samples - 1
21
+
22
+ for k in range(2, min(12, max_k_for_silhouette)):
23
+ km = KMeans(n_clusters=k, random_state=42, n_init='auto') # Added n_init='auto' to suppress future warning
24
+ labels = km.fit_predict(X_pca)
25
+ score = silhouette_score(X_pca, labels)
26
+
27
+ if score > best_score:
28
+ best_score = score
29
+ best_k = k
30
+
31
+ print(f"Executing choose_k()... Best Score: {best_score}")
32
+
33
+ return best_k
34
+
35
+ #========== During Cluster Analysis ==========
36
+ def compute_cluster_centroids_pca(df_pca: pd.DataFrame, labels: list) -> pd.DataFrame:
37
+ """
38
+ Compute cluster centroids in PCA space.
39
+
40
+ Args:
41
+ df_pca (pd.DataFrame): DataFrame with PCA-transformed data.
42
+ labels (list): List of cluster labels for each data point.
43
+
44
+ Returns:
45
+ pd.DataFrame: DataFrame with cluster centroids.
46
+ """
47
+ df = pd.DataFrame(df_pca)
48
+ df['cluster'] = labels
49
+
50
+ return df.groupby('cluster').mean()
51
+
52
+ # maps PCA-space centroids back to original feature space
53
+ def inverse_project_centroids(pca_centroids, pca_model, scaler_model, original_feature_names) -> pd.DataFrame:
54
+ """
55
+ Inverse project PCA-space centroids back to original feature space.
56
+
57
+ Args:
58
+ pca_centroids (pd.DataFrame): DataFrame with PCA-space centroids.
59
+ pca_model (sklearn.decomposition.PCA): Fitted PCA model.
60
+ scaler_model (sklearn.preprocessing.StandardScaler): Fitted scaler model.
61
+ original_feature_names (list): List of original feature names.
62
+
63
+ Returns:
64
+ pd.DataFrame: DataFrame with centroids in original feature space.
65
+ """
66
+
67
+ scaled_centroids = pca_model.inverse_transform(pca_centroids.values) # back-project from PCA space to scaled feature space
68
+ original_space_centroids = scaler_model.inverse_transform(scaled_centroids) # undo scaling
69
+
70
+ df_original = pd.DataFrame(
71
+ original_space_centroids,
72
+ columns=original_feature_names,
73
+ index=pca_centroids.index
74
+ )
75
+
76
+ return df_original
77
+
78
+ # function to compute and save cluster stats
79
+ def compute_cluster_stats(df_pca, labels, feature_names):
80
+ df = pd.DataFrame(df_pca, columns=feature_names)
81
+ df['cluster'] = labels
82
+
83
+ stats = {}
84
+
85
+ for cluster_id in sorted(df['cluster'].unique()):
86
+ cluster_data = df[df['cluster'] == cluster_id].drop(columns=['cluster'])
87
+
88
+ stats[cluster_id] = {
89
+ "count": len(cluster_data),
90
+ "mean": cluster_data.mean().to_dict(),
91
+ "median": cluster_data.median().to_dict(),
92
+ "std": cluster_data.std().to_dict(),
93
+ "min": cluster_data.min().to_dict(),
94
+ "max": cluster_data.max().to_dict(),
95
+ "range": (cluster_data.max() - cluster_data.min()).to_dict()
96
+ }
97
+
98
+ return stats
99
+
100
+ # ranks top drivers based on `top_n`
101
+ def identify_top_drivers(original_space_centroids: pd.DataFrame, top_n: int) -> dict:
102
+ global_mean = original_space_centroids.mean()
103
+
104
+ drivers = {}
105
+
106
+ for cluster_id, row in original_space_centroids.iterrows():
107
+ deviation = row - global_mean
108
+ ranked = deviation.abs().sort_values(ascending=False)
109
+
110
+ top_features = ranked.head(top_n).index.tolist()
111
+
112
+ drivers[cluster_id] = {
113
+ # "top_features": top_features,
114
+ "deviations": deviation[top_features].to_dict()
115
+ }
116
+
117
+ return drivers
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.4
2
+ Name: perceul
3
+ Version: 0.4.0
4
+ Summary: A framework for unsupervised profiling of perception and cognitive-environmental ergonomics in the workplace.
5
+ Author-email: Jasper Gomez <gomezjasper123@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jasper-gomez/perceul
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
10
+ Classifier: Intended Audience :: Science/Research
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: gradio>=6.20.0
15
+ Requires-Dist: hdbscan>=0.8.40
16
+ Requires-Dist: joblib>=1.5.0
17
+ Requires-Dist: matplotlib>=3.11.0
18
+ Requires-Dist: numpy>=2.5.0
19
+ Requires-Dist: pandas>=3.0.0
20
+ Requires-Dist: scikit-learn>=1.9.0
21
+ Requires-Dist: umap-learn>=0.5.10
22
+ Dynamic: license-file
23
+
24
+ # PERCEUL
25
+ A Framework and Prototype for Unsupervised Profiling of Perception and Cognitive Ergonomics in the Workplace
26
+
27
+ ---
28
+
29
+ ## ๐Ÿ“Œ Overview
30
+
31
+ PERCEUL aims to bridge ergonomics and AI, with the project using Unsupervised Learning for clustering latent perceptual profiles of workers in different work settings. It can be used by Ergonomics Analysts and Consultants who are sufficiently knowledgeable on Unsupervised Learning implementation, workflow, and cluster interpretation. The project includes using psychological, perceptual, self-reporting scales or tools, and Unsupervised Learning algorithms and workflow. PERCEUL exists to address the gap in AI and ergonomics, serving as an innovation that automates the profiling of workers for workspace reorganization, resource allocation, and strategic workplace dynamics, ultimately becoming a decision-support tool.
32
+
33
+ ---
34
+
35
+ ## ๐Ÿ“‚ Contents
36
+ ```
37
+ - artifacts
38
+ - perceul-framework.png
39
+ - concept-brief.md
40
+ - pipelines
41
+ - core_pipeline.pkl
42
+ - exploration_pipeline.pkl
43
+ - notebooks
44
+ - PERCEUL.ipynb
45
+ - README.md
46
+ - LICENSE
47
+ - CITATION.cff
48
+ ```
49
+
50
+ ---
51
+ ## ๐Ÿ“„ License
52
+
53
+ This project is licensed under the **MIT License**, which allows commercial and non-commercial use, modification, and distribution, provided that proper attribution is given.
54
+
55
+ See the full license text in the [`LICENSE`](./LICENSE) file.
56
+
57
+ ---
58
+
59
+ ## ๐Ÿ“‘ Citation
60
+
61
+ If you use PERCEUL โ€” whether the framework, methodology, prototype implementation, or any derived components โ€” please cite the repository using the metadata provided in the [`CITATION.cff`](./CITATION.cff) file.
62
+
63
+ GitHub will automatically generate a โ€œCite this repositoryโ€ button that you can use.
64
+
65
+ ---
66
+
67
+ ## ๐Ÿ“ Attribution Requirements
68
+
69
+ When building upon or reusing this work, please include:
70
+
71
+ - **Author name**: Jasper Gomez
72
+ - **Repository name**: PERCEUL
73
+ - **Repository link**: https://github.com/jasper-gomez/perceul/tree/main
74
+ - **Citation using the `CITATION.cff` metadata**
75
+ - A note in your documentation, publication, or code comments acknowledging the original work
76
+
77
+ Example acknowledgment:
78
+
79
+ > โ€œThis work builds on *PERCEUL: A Framework and Prototype for Unsupervised Profiling of Perception and Cognitive Ergonomics in the Workplace* by Jasper Gomez (MIT Licensed).โ€
80
+
81
+ Following these guidelines helps support academic and ethical reuse.
@@ -0,0 +1,15 @@
1
+ perceul/__init__.py,sha256=_7pu5IAKaUif5f52QCaO_V9Wb1OBnHd4yallg2KNESQ,260
2
+ perceul/clustering.py,sha256=KQeAWy7Kic2iAQv71tgYdF1Kgxg8mI7JAyi17ibFD5U,5444
3
+ perceul/interpretation.py,sha256=F0R_c-Jo8maeNMR_Zeearu7zLqL8F78gIf8KHUuieKk,1200
4
+ perceul/selectors.py,sha256=B9UaerWPpS5-XOwf-rpEqaLWUnz0c2AnG2p7vDgqJMw,342
5
+ perceul/assets/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ perceul/assets/core_pipeline.pkl,sha256=UA8eYl3m0TE1hm1XF48LFqi3NEAB5nuMAFYlytFqzmM,678
7
+ perceul/assets/exploration_pipeline.pkl,sha256=UA8eYl3m0TE1hm1XF48LFqi3NEAB5nuMAFYlytFqzmM,678
8
+ perceul/utils/__init__.py,sha256=6FQ-YDiEIYePsjhkscbBXIqMAyUUrBdl3bkO7-zKB_Q,538
9
+ perceul/utils/auxiliary.py,sha256=9dFixtYxCBWjGmgjwIgzvzHQRwlU80yixAd6U-TATIY,2329
10
+ perceul/utils/cluster.py,sha256=4I38KB7AOYvZBkJKTtPI-ZPOKi8fDd6Ax5HgQLhTu0Q,3902
11
+ perceul-0.4.0.dist-info/licenses/LICENSE,sha256=56nX4htom3J4eI-Z-DUhIgJa9rfTdcJuaWUydXvxCZA,1090
12
+ perceul-0.4.0.dist-info/METADATA,sha256=6ZFuse673RHLN9684A4aNYiMeMzndwQvuXt6zh1dygA,3252
13
+ perceul-0.4.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
14
+ perceul-0.4.0.dist-info/top_level.txt,sha256=WXygJgoGUI3LLm1fxYDHeBIc0-McvCNvMcDeyyNSG-E,8
15
+ perceul-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Jasper Gomez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ perceul