parasolpy 0.0.1__tar.gz
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.
- parasolpy-0.0.1/LICENSE +21 -0
- parasolpy-0.0.1/PKG-INFO +43 -0
- parasolpy-0.0.1/README.md +17 -0
- parasolpy-0.0.1/parasolpy/__init__.py +127 -0
- parasolpy-0.0.1/parasolpy/dash_tools.py +57 -0
- parasolpy-0.0.1/parasolpy/file_processing.py +355 -0
- parasolpy-0.0.1/parasolpy/interactive.py +138 -0
- parasolpy-0.0.1/parasolpy/ism.py +31 -0
- parasolpy-0.0.1/parasolpy/nowak.py +118 -0
- parasolpy-0.0.1/parasolpy/plotting.py +393 -0
- parasolpy-0.0.1/parasolpy/reservoir.py +24 -0
- parasolpy-0.0.1/parasolpy/tradeoff.py +663 -0
- parasolpy-0.0.1/parasolpy/util.py +216 -0
- parasolpy-0.0.1/pyproject.toml +28 -0
parasolpy-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joseph Kasprzyk
|
|
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.
|
parasolpy-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: parasolpy
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Python tools for multi-objective decision analysis and visualization
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: Joseph Kasprzyk
|
|
8
|
+
Author-email: joseph.kasprzyk@colorado.edu
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Requires-Dist: beautifulsoup4 (>=4.14.3,<5.0.0)
|
|
15
|
+
Requires-Dist: dash (>=4.1.0,<5.0.0)
|
|
16
|
+
Requires-Dist: hiplot (>=0.1.33,<0.2.0)
|
|
17
|
+
Requires-Dist: matplotlib (>=3.10.8,<4.0.0)
|
|
18
|
+
Requires-Dist: numpy (>=2.4.4,<3.0.0)
|
|
19
|
+
Requires-Dist: pandas (>=3.0.2,<4.0.0)
|
|
20
|
+
Requires-Dist: platypus-opt (>=1.4.1,<2.0.0)
|
|
21
|
+
Requires-Dist: plotly (>=6.7.0,<7.0.0)
|
|
22
|
+
Requires-Dist: scikit-learn (>=1.8.0,<2.0.0)
|
|
23
|
+
Requires-Dist: scipy (>=1.17.1,<2.0.0)
|
|
24
|
+
Requires-Dist: seaborn (>=0.13.2,<0.14.0)
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# parasolpy
|
|
28
|
+
|
|
29
|
+
Python tools for multi-objective decision analysis and visualization, continuing the Parasol project originally published as:
|
|
30
|
+
|
|
31
|
+
Raseman, W. J., Jacobson, J., & Kasprzyk, J. R. (2019). Parasol: An open source, interactive parallel coordinates library for multi-objective decision making. *Environmental Modelling & Software*, 116, 153–163. https://doi.org/10.1016/j.envsoft.2019.03.005
|
|
32
|
+
|
|
33
|
+
## Status
|
|
34
|
+
|
|
35
|
+
🚧 Early development. More documentation and functionality coming.
|
|
36
|
+
|
|
37
|
+
## Lineage
|
|
38
|
+
|
|
39
|
+
This package is developed by the Kasprzyk Research Group at CU Boulder. It is not affiliated with the unrelated `parasol` or `pyparasol` packages on PyPI, which are independent projects that share the name.
|
|
40
|
+
|
|
41
|
+
## License
|
|
42
|
+
|
|
43
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# parasolpy
|
|
2
|
+
|
|
3
|
+
Python tools for multi-objective decision analysis and visualization, continuing the Parasol project originally published as:
|
|
4
|
+
|
|
5
|
+
Raseman, W. J., Jacobson, J., & Kasprzyk, J. R. (2019). Parasol: An open source, interactive parallel coordinates library for multi-objective decision making. *Environmental Modelling & Software*, 116, 153–163. https://doi.org/10.1016/j.envsoft.2019.03.005
|
|
6
|
+
|
|
7
|
+
## Status
|
|
8
|
+
|
|
9
|
+
🚧 Early development. More documentation and functionality coming.
|
|
10
|
+
|
|
11
|
+
## Lineage
|
|
12
|
+
|
|
13
|
+
This package is developed by the Kasprzyk Research Group at CU Boulder. It is not affiliated with the unrelated `parasol` or `pyparasol` packages on PyPI, which are independent projects that share the name.
|
|
14
|
+
|
|
15
|
+
## License
|
|
16
|
+
|
|
17
|
+
MIT
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Top-level package exports for parasolpy.
|
|
2
|
+
|
|
3
|
+
Importing names here lets callers write:
|
|
4
|
+
from parasolpy import has_superheader
|
|
5
|
+
instead of the longer:
|
|
6
|
+
from parasolpy.file_processing import has_superheader
|
|
7
|
+
|
|
8
|
+
Both styles always work — the short form is just a convenience. The long
|
|
9
|
+
form (importing directly from the submodule) is never broken by anything here:
|
|
10
|
+
from parasolpy.tradeoff import append_Kmeans # always valid
|
|
11
|
+
from parasolpy import append_Kmeans # also valid
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
__version__ = "0.0.1"
|
|
15
|
+
|
|
16
|
+
from parasolpy.dash_tools import (
|
|
17
|
+
build_tradeoff_dash_app,
|
|
18
|
+
load_dash_inputs,
|
|
19
|
+
)
|
|
20
|
+
from parasolpy.file_processing import (
|
|
21
|
+
convert_solutions_csv_to_single_header,
|
|
22
|
+
has_superheader,
|
|
23
|
+
load_solutions_dataframe,
|
|
24
|
+
split_solutions_csv,
|
|
25
|
+
split_solutions_dataframe,
|
|
26
|
+
)
|
|
27
|
+
from parasolpy.interactive import (
|
|
28
|
+
ExitInteractiveSession,
|
|
29
|
+
load_experiment_epsilons,
|
|
30
|
+
prompt_epsilons,
|
|
31
|
+
prompt_experiment_name,
|
|
32
|
+
prompt_starting_epsilons,
|
|
33
|
+
)
|
|
34
|
+
from parasolpy.ism import create_ism_traces
|
|
35
|
+
from parasolpy.nowak import (
|
|
36
|
+
choose_analog_index,
|
|
37
|
+
sim_multi_trace,
|
|
38
|
+
sim_single_year,
|
|
39
|
+
)
|
|
40
|
+
from parasolpy.plotting import (
|
|
41
|
+
plot_trace_exceedance,
|
|
42
|
+
plot_trace_fan_chart,
|
|
43
|
+
plot_trace_heatmap,
|
|
44
|
+
plot_trace_monthly_seasonality,
|
|
45
|
+
plot_trace_spaghetti,
|
|
46
|
+
)
|
|
47
|
+
from parasolpy.reservoir import sequent_peak
|
|
48
|
+
from parasolpy.tradeoff import (
|
|
49
|
+
append_Kmeans,
|
|
50
|
+
build_radar_figure,
|
|
51
|
+
df_to_pt,
|
|
52
|
+
label_eps_nd,
|
|
53
|
+
load_objective_names,
|
|
54
|
+
load_objectives_and_solutions,
|
|
55
|
+
log_eps_experiment,
|
|
56
|
+
normalize_for_radar,
|
|
57
|
+
parallel_plot_hp,
|
|
58
|
+
resolve_solutions_csv,
|
|
59
|
+
run_eps_experiment,
|
|
60
|
+
)
|
|
61
|
+
from parasolpy.util import (
|
|
62
|
+
convert_cfs_to_af,
|
|
63
|
+
convert_cfs_to_cms,
|
|
64
|
+
convert_cms_to_mcm,
|
|
65
|
+
ensure_dir,
|
|
66
|
+
pivot_timeseries_by_year,
|
|
67
|
+
process_xml,
|
|
68
|
+
read_xml,
|
|
69
|
+
script_local_path,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# __all__ controls what is exported when someone does `from parasolpy import *`.
|
|
73
|
+
# It also signals to IDEs and documentation tools which names are part of the
|
|
74
|
+
# intentional public API (as opposed to internal helpers imported for convenience).
|
|
75
|
+
# Names starting with _ are intentionally excluded — they are private implementation details.
|
|
76
|
+
__all__ = [
|
|
77
|
+
# dash_tools
|
|
78
|
+
"build_tradeoff_dash_app",
|
|
79
|
+
"load_dash_inputs",
|
|
80
|
+
# file_processing
|
|
81
|
+
"has_superheader",
|
|
82
|
+
"load_solutions_dataframe",
|
|
83
|
+
"convert_solutions_csv_to_single_header",
|
|
84
|
+
"split_solutions_dataframe",
|
|
85
|
+
"split_solutions_csv",
|
|
86
|
+
# interactive
|
|
87
|
+
"ExitInteractiveSession",
|
|
88
|
+
"load_experiment_epsilons",
|
|
89
|
+
"prompt_epsilons",
|
|
90
|
+
"prompt_experiment_name",
|
|
91
|
+
"prompt_starting_epsilons",
|
|
92
|
+
# ism
|
|
93
|
+
"create_ism_traces",
|
|
94
|
+
# nowak
|
|
95
|
+
"choose_analog_index",
|
|
96
|
+
"sim_multi_trace",
|
|
97
|
+
"sim_single_year",
|
|
98
|
+
# plotting
|
|
99
|
+
"plot_trace_exceedance",
|
|
100
|
+
"plot_trace_fan_chart",
|
|
101
|
+
"plot_trace_heatmap",
|
|
102
|
+
"plot_trace_monthly_seasonality",
|
|
103
|
+
"plot_trace_spaghetti",
|
|
104
|
+
# reservoir
|
|
105
|
+
"sequent_peak",
|
|
106
|
+
# tradeoff
|
|
107
|
+
"append_Kmeans",
|
|
108
|
+
"build_radar_figure",
|
|
109
|
+
"df_to_pt",
|
|
110
|
+
"label_eps_nd",
|
|
111
|
+
"load_objective_names",
|
|
112
|
+
"load_objectives_and_solutions",
|
|
113
|
+
"log_eps_experiment",
|
|
114
|
+
"normalize_for_radar",
|
|
115
|
+
"parallel_plot_hp",
|
|
116
|
+
"resolve_solutions_csv",
|
|
117
|
+
"run_eps_experiment",
|
|
118
|
+
# util
|
|
119
|
+
"convert_cfs_to_af",
|
|
120
|
+
"convert_cfs_to_cms",
|
|
121
|
+
"convert_cms_to_mcm",
|
|
122
|
+
"ensure_dir",
|
|
123
|
+
"pivot_timeseries_by_year",
|
|
124
|
+
"process_xml",
|
|
125
|
+
"read_xml",
|
|
126
|
+
"script_local_path",
|
|
127
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import plotly.express as px
|
|
3
|
+
from dash import Dash, Input, Output, dcc, html
|
|
4
|
+
|
|
5
|
+
from parasolpy.tradeoff import load_objectives_and_solutions
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_dash_inputs(output_folder, config_filename=None, solutions_filename=None):
|
|
9
|
+
"""Load objective names and solutions DataFrame for a tradeoff Dash app."""
|
|
10
|
+
objective_names, solutions = load_objectives_and_solutions(
|
|
11
|
+
output_folder,
|
|
12
|
+
config_filename=config_filename,
|
|
13
|
+
solutions_filename=solutions_filename,
|
|
14
|
+
return_paths=False,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
default_color_column = next((name for name in objective_names if name in solutions.columns), solutions.columns[0])
|
|
18
|
+
return objective_names, solutions, default_color_column
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_tradeoff_dash_app(solutions,
|
|
22
|
+
default_color_column,
|
|
23
|
+
title="Tradeoff Explorer",
|
|
24
|
+
default_colorscale="viridis"):
|
|
25
|
+
"""Create a Dash app for exploring solution tradeoffs via parallel coordinates."""
|
|
26
|
+
if not isinstance(solutions, pd.DataFrame):
|
|
27
|
+
raise TypeError("Input 'solutions' must be a pandas DataFrame.")
|
|
28
|
+
if solutions.empty:
|
|
29
|
+
raise ValueError("Input 'solutions' cannot be empty.")
|
|
30
|
+
if not isinstance(default_color_column, str) or default_color_column not in solutions.columns:
|
|
31
|
+
raise ValueError("Input 'default_color_column' must be a column name in 'solutions'.")
|
|
32
|
+
|
|
33
|
+
colorscales = px.colors.named_colorscales()
|
|
34
|
+
if default_colorscale not in colorscales:
|
|
35
|
+
default_colorscale = "viridis"
|
|
36
|
+
|
|
37
|
+
app = Dash()
|
|
38
|
+
app.layout = [
|
|
39
|
+
html.H1(children=title, style={"textAlign": "center"}),
|
|
40
|
+
dcc.Dropdown(options=list(solutions.columns), value=default_color_column, id="colorcolumn"),
|
|
41
|
+
dcc.Dropdown(options=colorscales, value=default_colorscale, id="colorscale"),
|
|
42
|
+
dcc.Graph(figure={}, id="tradeoff-plot"),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
@app.callback(
|
|
46
|
+
Output("tradeoff-plot", "figure"),
|
|
47
|
+
Input("colorcolumn", "value"),
|
|
48
|
+
Input("colorscale", "value"),
|
|
49
|
+
)
|
|
50
|
+
def update_graph(colorcolumn, colorscale):
|
|
51
|
+
return px.parallel_coordinates(
|
|
52
|
+
solutions,
|
|
53
|
+
color=colorcolumn,
|
|
54
|
+
color_continuous_scale=colorscale,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return app
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""File-processing helpers for Borg/RiverWare solution CSVs.
|
|
2
|
+
|
|
3
|
+
This module focuses on common post-processing tasks for solution files,
|
|
4
|
+
including:
|
|
5
|
+
1) Normalizing two-line "superheader" CSVs to single-header CSVs.
|
|
6
|
+
2) Splitting decision-variable and objective columns for downstream analysis.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import csv
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pandas as pd
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_DECISION_GROUP_NAMES = {
|
|
18
|
+
"decision variables",
|
|
19
|
+
"decision variable",
|
|
20
|
+
"decisions",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
_OBJECTIVE_GROUP_NAMES = {
|
|
24
|
+
"objectives",
|
|
25
|
+
"objective",
|
|
26
|
+
"objective functions",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _normalize_text(value):
|
|
31
|
+
"""Normalize header/group labels for case-insensitive matching."""
|
|
32
|
+
if value is None:
|
|
33
|
+
return ""
|
|
34
|
+
return str(value).strip()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_number(value):
|
|
38
|
+
"""Return True when a string can be parsed as a finite float."""
|
|
39
|
+
text = _normalize_text(value)
|
|
40
|
+
if not text:
|
|
41
|
+
return False
|
|
42
|
+
try:
|
|
43
|
+
float(text)
|
|
44
|
+
return True
|
|
45
|
+
except ValueError:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _row_is_mostly_numeric(row):
|
|
50
|
+
"""Heuristic check used to distinguish header rows from data rows."""
|
|
51
|
+
cells = [_normalize_text(cell) for cell in row if _normalize_text(cell) != ""]
|
|
52
|
+
if not cells:
|
|
53
|
+
return False
|
|
54
|
+
numeric_count = sum(_is_number(cell) for cell in cells)
|
|
55
|
+
return numeric_count / len(cells) >= 0.8
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _read_first_rows(csv_path, n_rows=2):
|
|
59
|
+
"""Read the first N rows from a CSV with UTF-8 BOM compatibility."""
|
|
60
|
+
with open(csv_path, "r", encoding="utf-8-sig", newline="") as handle:
|
|
61
|
+
reader = csv.reader(handle)
|
|
62
|
+
rows = []
|
|
63
|
+
for _ in range(n_rows):
|
|
64
|
+
try:
|
|
65
|
+
rows.append(next(reader))
|
|
66
|
+
except StopIteration:
|
|
67
|
+
break
|
|
68
|
+
return rows
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def has_superheader(csv_path):
|
|
72
|
+
"""Detect whether a solutions CSV likely uses a two-line superheader.
|
|
73
|
+
|
|
74
|
+
A superheader file usually has grouped labels on row 1 (for example,
|
|
75
|
+
"Decision Variables", "Objectives") and the actual column names on row 2.
|
|
76
|
+
"""
|
|
77
|
+
path = Path(csv_path)
|
|
78
|
+
if not path.exists() or not path.is_file():
|
|
79
|
+
raise FileNotFoundError(f"CSV file not found: {path}")
|
|
80
|
+
|
|
81
|
+
rows = _read_first_rows(path, n_rows=2)
|
|
82
|
+
if len(rows) < 2:
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
row1 = [_normalize_text(cell) for cell in rows[0]]
|
|
86
|
+
row2 = [_normalize_text(cell) for cell in rows[1]]
|
|
87
|
+
|
|
88
|
+
if _row_is_mostly_numeric(row2):
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
group_label_set = {
|
|
92
|
+
"decision variables",
|
|
93
|
+
"objectives",
|
|
94
|
+
"metrics",
|
|
95
|
+
}
|
|
96
|
+
has_known_group_label = any(cell.lower() in group_label_set for cell in row1)
|
|
97
|
+
|
|
98
|
+
first_cell_blank = bool(row1) and row1[0] == ""
|
|
99
|
+
has_any_group_text = any(cell != "" for cell in row1[1:])
|
|
100
|
+
|
|
101
|
+
return has_known_group_label or (first_cell_blank and has_any_group_text)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _flatten_columns_and_groups(columns):
|
|
105
|
+
"""Flatten MultiIndex columns and capture per-column group labels."""
|
|
106
|
+
flattened = []
|
|
107
|
+
column_to_group = {}
|
|
108
|
+
used = {}
|
|
109
|
+
current_group = None
|
|
110
|
+
|
|
111
|
+
for idx, column in enumerate(columns):
|
|
112
|
+
if isinstance(column, tuple):
|
|
113
|
+
top = _normalize_text(column[0])
|
|
114
|
+
bottom = _normalize_text(column[1])
|
|
115
|
+
if top.lower().startswith("unnamed"):
|
|
116
|
+
top = ""
|
|
117
|
+
if bottom.lower().startswith("unnamed"):
|
|
118
|
+
bottom = ""
|
|
119
|
+
|
|
120
|
+
if top:
|
|
121
|
+
current_group = top
|
|
122
|
+
group = current_group
|
|
123
|
+
|
|
124
|
+
name = bottom or top or f"column_{idx + 1}"
|
|
125
|
+
else:
|
|
126
|
+
name = _normalize_text(column) or f"column_{idx + 1}"
|
|
127
|
+
group = None
|
|
128
|
+
|
|
129
|
+
count = used.get(name, 0) + 1
|
|
130
|
+
used[name] = count
|
|
131
|
+
unique_name = name if count == 1 else f"{name}__{count}"
|
|
132
|
+
|
|
133
|
+
flattened.append(unique_name)
|
|
134
|
+
column_to_group[unique_name] = group
|
|
135
|
+
|
|
136
|
+
return flattened, column_to_group
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def load_solutions_dataframe(csv_path, superheader="auto", return_metadata=False):
|
|
140
|
+
"""Load a solution CSV and normalize headers for downstream processing.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
csv_path: Path to the input CSV.
|
|
144
|
+
superheader: One of "auto", "yes", "no".
|
|
145
|
+
return_metadata: If True, also returns a metadata dict with grouped column info.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
DataFrame, or (DataFrame, metadata) when return_metadata is True.
|
|
149
|
+
"""
|
|
150
|
+
path = Path(csv_path)
|
|
151
|
+
if not path.exists() or not path.is_file():
|
|
152
|
+
raise FileNotFoundError(f"CSV file not found: {path}")
|
|
153
|
+
|
|
154
|
+
if superheader not in {"auto", "yes", "no"}:
|
|
155
|
+
raise ValueError("Input 'superheader' must be one of: 'auto', 'yes', 'no'.")
|
|
156
|
+
|
|
157
|
+
if superheader == "auto":
|
|
158
|
+
has_multi_header = has_superheader(path)
|
|
159
|
+
else:
|
|
160
|
+
has_multi_header = superheader == "yes"
|
|
161
|
+
|
|
162
|
+
if has_multi_header:
|
|
163
|
+
df = pd.read_csv(path, header=[0, 1])
|
|
164
|
+
else:
|
|
165
|
+
df = pd.read_csv(path)
|
|
166
|
+
|
|
167
|
+
if df.empty:
|
|
168
|
+
raise ValueError(f"Solutions file is empty: {path}")
|
|
169
|
+
|
|
170
|
+
flat_columns, column_to_group = _flatten_columns_and_groups(df.columns)
|
|
171
|
+
df.columns = flat_columns
|
|
172
|
+
|
|
173
|
+
groups_to_columns = {}
|
|
174
|
+
for column in df.columns:
|
|
175
|
+
group = column_to_group[column]
|
|
176
|
+
if group is None:
|
|
177
|
+
continue
|
|
178
|
+
groups_to_columns.setdefault(group, []).append(column)
|
|
179
|
+
|
|
180
|
+
metadata = {
|
|
181
|
+
"has_superheader": has_multi_header,
|
|
182
|
+
"column_to_group": column_to_group,
|
|
183
|
+
"groups_to_columns": groups_to_columns,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if return_metadata:
|
|
187
|
+
return df, metadata
|
|
188
|
+
return df
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def convert_solutions_csv_to_single_header(input_csv, output_csv=None, superheader="auto", index=False):
|
|
192
|
+
"""Convert a solutions CSV to a normalized single-header CSV file.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
input_csv: Source CSV path.
|
|
196
|
+
output_csv: Destination path. If None, writes next to input using
|
|
197
|
+
'<stem>.single_header.csv'.
|
|
198
|
+
superheader: One of "auto", "yes", "no".
|
|
199
|
+
index: Whether to write DataFrame index to output CSV.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
pathlib.Path to the written CSV.
|
|
203
|
+
"""
|
|
204
|
+
input_path = Path(input_csv)
|
|
205
|
+
if output_csv is None:
|
|
206
|
+
output_path = input_path.with_name(f"{input_path.stem}.single_header.csv")
|
|
207
|
+
else:
|
|
208
|
+
output_path = Path(output_csv)
|
|
209
|
+
|
|
210
|
+
solutions = load_solutions_dataframe(input_path, superheader=superheader, return_metadata=False)
|
|
211
|
+
solutions.to_csv(output_path, index=index)
|
|
212
|
+
return output_path
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def split_solutions_dataframe(
|
|
216
|
+
solutions,
|
|
217
|
+
metadata=None,
|
|
218
|
+
decision_columns=None,
|
|
219
|
+
objective_columns=None,
|
|
220
|
+
id_columns=("Solution", "Solution ID"),
|
|
221
|
+
include_id_columns=True,
|
|
222
|
+
):
|
|
223
|
+
"""Split a solutions DataFrame into decision and objective DataFrames.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
solutions: Input DataFrame.
|
|
227
|
+
metadata: Metadata returned from load_solutions_dataframe(..., return_metadata=True).
|
|
228
|
+
Used to auto-detect grouped columns from a superheader.
|
|
229
|
+
decision_columns: Explicit decision-variable columns. If None, inferred from metadata.
|
|
230
|
+
objective_columns: Explicit objective columns. If None, inferred from metadata.
|
|
231
|
+
id_columns: Candidate ID columns to preserve in each output DataFrame.
|
|
232
|
+
include_id_columns: Whether to keep detected ID columns in outputs.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
dict with keys 'decisions', 'objectives', 'decision_columns', 'objective_columns'.
|
|
236
|
+
"""
|
|
237
|
+
if not isinstance(solutions, pd.DataFrame):
|
|
238
|
+
raise TypeError("Input 'solutions' must be a pandas DataFrame.")
|
|
239
|
+
|
|
240
|
+
if decision_columns is not None:
|
|
241
|
+
if not isinstance(decision_columns, list) or not all(isinstance(c, str) for c in decision_columns):
|
|
242
|
+
raise TypeError("Input 'decision_columns' must be a list of strings or None.")
|
|
243
|
+
if objective_columns is not None:
|
|
244
|
+
if not isinstance(objective_columns, list) or not all(isinstance(c, str) for c in objective_columns):
|
|
245
|
+
raise TypeError("Input 'objective_columns' must be a list of strings or None.")
|
|
246
|
+
|
|
247
|
+
if decision_columns is None or objective_columns is None:
|
|
248
|
+
groups_to_columns = {}
|
|
249
|
+
if metadata is not None:
|
|
250
|
+
groups_to_columns = metadata.get("groups_to_columns", {})
|
|
251
|
+
|
|
252
|
+
if decision_columns is None:
|
|
253
|
+
decision_columns = []
|
|
254
|
+
for group, cols in groups_to_columns.items():
|
|
255
|
+
if _normalize_text(group).lower() in _DECISION_GROUP_NAMES:
|
|
256
|
+
decision_columns.extend(cols)
|
|
257
|
+
|
|
258
|
+
if objective_columns is None:
|
|
259
|
+
objective_columns = []
|
|
260
|
+
for group, cols in groups_to_columns.items():
|
|
261
|
+
if _normalize_text(group).lower() in _OBJECTIVE_GROUP_NAMES:
|
|
262
|
+
objective_columns.extend(cols)
|
|
263
|
+
|
|
264
|
+
missing_decision = [c for c in decision_columns if c not in solutions.columns]
|
|
265
|
+
if missing_decision:
|
|
266
|
+
raise ValueError(f"Decision columns missing from DataFrame: {missing_decision}")
|
|
267
|
+
|
|
268
|
+
missing_objective = [c for c in objective_columns if c not in solutions.columns]
|
|
269
|
+
if missing_objective:
|
|
270
|
+
raise ValueError(f"Objective columns missing from DataFrame: {missing_objective}")
|
|
271
|
+
|
|
272
|
+
if not decision_columns and not objective_columns:
|
|
273
|
+
raise ValueError(
|
|
274
|
+
"Could not infer decision/objective columns. Provide explicit column lists "
|
|
275
|
+
"or load a CSV with a superheader for automatic grouping."
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
id_cols_present = [c for c in id_columns if c in solutions.columns]
|
|
279
|
+
prefix = id_cols_present if include_id_columns else []
|
|
280
|
+
|
|
281
|
+
decisions_df = solutions[prefix + decision_columns].copy()
|
|
282
|
+
objectives_df = solutions[prefix + objective_columns].copy()
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
"decisions": decisions_df,
|
|
286
|
+
"objectives": objectives_df,
|
|
287
|
+
"decision_columns": decision_columns,
|
|
288
|
+
"objective_columns": objective_columns,
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def split_solutions_csv(
|
|
293
|
+
input_csv,
|
|
294
|
+
decisions_csv=None,
|
|
295
|
+
objectives_csv=None,
|
|
296
|
+
superheader="auto",
|
|
297
|
+
decision_columns=None,
|
|
298
|
+
objective_columns=None,
|
|
299
|
+
id_columns=("Solution", "Solution ID"),
|
|
300
|
+
include_id_columns=True,
|
|
301
|
+
index=False,
|
|
302
|
+
):
|
|
303
|
+
"""Split a solutions CSV and write decisions-only and objectives-only CSVs.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
input_csv: Source solutions CSV path.
|
|
307
|
+
decisions_csv: Destination for decision-variable subset.
|
|
308
|
+
Defaults to '<stem>.decisions.csv'.
|
|
309
|
+
objectives_csv: Destination for objective subset.
|
|
310
|
+
Defaults to '<stem>.objectives.csv'.
|
|
311
|
+
superheader: One of "auto", "yes", "no".
|
|
312
|
+
decision_columns: Explicit decision columns, optional.
|
|
313
|
+
objective_columns: Explicit objective columns, optional.
|
|
314
|
+
id_columns: Candidate ID columns to keep.
|
|
315
|
+
include_id_columns: Include ID columns in both outputs.
|
|
316
|
+
index: Whether to write DataFrame index to output CSVs.
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
dict with output paths and detected column groupings.
|
|
320
|
+
"""
|
|
321
|
+
input_path = Path(input_csv)
|
|
322
|
+
if decisions_csv is None:
|
|
323
|
+
decisions_path = input_path.with_name(f"{input_path.stem}.decisions.csv")
|
|
324
|
+
else:
|
|
325
|
+
decisions_path = Path(decisions_csv)
|
|
326
|
+
|
|
327
|
+
if objectives_csv is None:
|
|
328
|
+
objectives_path = input_path.with_name(f"{input_path.stem}.objectives.csv")
|
|
329
|
+
else:
|
|
330
|
+
objectives_path = Path(objectives_csv)
|
|
331
|
+
|
|
332
|
+
solutions, metadata = load_solutions_dataframe(
|
|
333
|
+
input_path,
|
|
334
|
+
superheader=superheader,
|
|
335
|
+
return_metadata=True,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
split = split_solutions_dataframe(
|
|
339
|
+
solutions,
|
|
340
|
+
metadata=metadata,
|
|
341
|
+
decision_columns=decision_columns,
|
|
342
|
+
objective_columns=objective_columns,
|
|
343
|
+
id_columns=id_columns,
|
|
344
|
+
include_id_columns=include_id_columns,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
split["decisions"].to_csv(decisions_path, index=index)
|
|
348
|
+
split["objectives"].to_csv(objectives_path, index=index)
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
"decisions_path": decisions_path,
|
|
352
|
+
"objectives_path": objectives_path,
|
|
353
|
+
"decision_columns": split["decision_columns"],
|
|
354
|
+
"objective_columns": split["objective_columns"],
|
|
355
|
+
}
|