parasolpy 0.0.1__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.
- parasolpy/__init__.py +127 -0
- parasolpy/dash_tools.py +57 -0
- parasolpy/file_processing.py +355 -0
- parasolpy/interactive.py +138 -0
- parasolpy/ism.py +31 -0
- parasolpy/nowak.py +118 -0
- parasolpy/plotting.py +393 -0
- parasolpy/reservoir.py +24 -0
- parasolpy/tradeoff.py +663 -0
- parasolpy/util.py +216 -0
- parasolpy-0.0.1.dist-info/METADATA +43 -0
- parasolpy-0.0.1.dist-info/RECORD +14 -0
- parasolpy-0.0.1.dist-info/WHEEL +4 -0
- parasolpy-0.0.1.dist-info/licenses/LICENSE +21 -0
parasolpy/__init__.py
ADDED
|
@@ -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
|
+
]
|
parasolpy/dash_tools.py
ADDED
|
@@ -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
|
+
}
|
parasolpy/interactive.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Interactive CLI helpers for epsilon experiment workflows.
|
|
2
|
+
|
|
3
|
+
These functions handle user prompting and input validation; they contain no
|
|
4
|
+
analysis logic. Import them in scripts that need a terminal-based interface
|
|
5
|
+
for iterating on epsilon non-dominance parameters.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ExitInteractiveSession(Exception):
|
|
13
|
+
"""Raised when the user requests to exit an interactive CLI workflow."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _raise_if_exit_requested(value):
|
|
17
|
+
"""Exit the interactive workflow when the user types a quit command."""
|
|
18
|
+
if value.lower() in {"q", "quit", "exit"}:
|
|
19
|
+
raise ExitInteractiveSession()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def prompt_experiment_name(prompt_text="\nExperiment name: "):
|
|
23
|
+
"""Prompt the user for a filesystem-safe experiment name.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
str: A sanitized, non-empty experiment name suitable for use in filenames.
|
|
27
|
+
"""
|
|
28
|
+
while True:
|
|
29
|
+
name = input(prompt_text).strip()
|
|
30
|
+
_raise_if_exit_requested(name)
|
|
31
|
+
if not name:
|
|
32
|
+
print(" Name cannot be empty.")
|
|
33
|
+
continue
|
|
34
|
+
safe = "".join(c if c.isalnum() or c in "_-" else "_" for c in name)
|
|
35
|
+
if safe != name:
|
|
36
|
+
print(f" Sanitized to: {safe}")
|
|
37
|
+
return safe
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def prompt_epsilons(objective_names, current_epsilons):
|
|
41
|
+
"""Prompt for an epsilon value per objective, carrying forward the current value as default.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
objective_names: List of objective name strings shown as labels.
|
|
45
|
+
current_epsilons: List of current epsilon floats used as defaults.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
list of float: New epsilon values, one per objective.
|
|
49
|
+
"""
|
|
50
|
+
if len(objective_names) != len(current_epsilons):
|
|
51
|
+
raise ValueError(
|
|
52
|
+
"objective_names and current_epsilons must have the same length."
|
|
53
|
+
)
|
|
54
|
+
print("Enter epsilon for each objective (press Enter to keep current value, or 'q' to quit):")
|
|
55
|
+
new_epsilons = []
|
|
56
|
+
for name, current in zip(objective_names, current_epsilons):
|
|
57
|
+
while True:
|
|
58
|
+
raw = input(f" {name} [{current}]: ").strip()
|
|
59
|
+
_raise_if_exit_requested(raw)
|
|
60
|
+
if raw == "":
|
|
61
|
+
new_epsilons.append(current)
|
|
62
|
+
break
|
|
63
|
+
try:
|
|
64
|
+
val = float(raw)
|
|
65
|
+
if val <= 0:
|
|
66
|
+
print(" Epsilon must be positive.")
|
|
67
|
+
continue
|
|
68
|
+
new_epsilons.append(val)
|
|
69
|
+
break
|
|
70
|
+
except ValueError:
|
|
71
|
+
print(" Invalid number, try again.")
|
|
72
|
+
return new_epsilons
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load_experiment_epsilons(output_folder, experiment_name, objective_names):
|
|
76
|
+
"""Load epsilon defaults from a saved experiment JSON file.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
output_folder: Folder containing per-experiment JSON files.
|
|
80
|
+
experiment_name: Experiment suffix used in eps_experiment_<name>.json.
|
|
81
|
+
objective_names: Ordered list of objectives to align the returned epsilons.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
list of float: Epsilons ordered to match objective_names.
|
|
85
|
+
"""
|
|
86
|
+
log_path = Path(output_folder) / f"eps_experiment_{experiment_name}.json"
|
|
87
|
+
if not log_path.exists():
|
|
88
|
+
raise FileNotFoundError(f"Experiment file not found: {log_path}")
|
|
89
|
+
|
|
90
|
+
with open(log_path, "r", encoding="utf-8") as f:
|
|
91
|
+
record = json.load(f)
|
|
92
|
+
|
|
93
|
+
if not isinstance(record, dict) or "epsilons" not in record:
|
|
94
|
+
raise ValueError(f"Experiment file is missing an 'epsilons' object: {log_path}")
|
|
95
|
+
|
|
96
|
+
epsilon_map = record["epsilons"]
|
|
97
|
+
missing = [name for name in objective_names if name not in epsilon_map]
|
|
98
|
+
if missing:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"Experiment file {log_path.name} is missing epsilon values for: {missing}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return [float(epsilon_map[name]) for name in objective_names]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def prompt_starting_epsilons(output_folder, objective_names, default_epsilons):
|
|
107
|
+
"""Optionally seed starting epsilons from a previously saved experiment.
|
|
108
|
+
|
|
109
|
+
If no prior experiment is chosen, returns default_epsilons unchanged along
|
|
110
|
+
with None for the experiment name.
|
|
111
|
+
"""
|
|
112
|
+
folder = Path(output_folder)
|
|
113
|
+
available = sorted(
|
|
114
|
+
path.stem.removeprefix("eps_experiment_")
|
|
115
|
+
for path in folder.glob("eps_experiment_*.json")
|
|
116
|
+
)
|
|
117
|
+
if not available:
|
|
118
|
+
return default_epsilons, None
|
|
119
|
+
|
|
120
|
+
print("Saved epsilon experiments:")
|
|
121
|
+
print(f" {', '.join(available)}")
|
|
122
|
+
if len(available) == 1:
|
|
123
|
+
prompt = (
|
|
124
|
+
"Load saved experiment by name"
|
|
125
|
+
f" (or 'y' to use '{available[0]}')? [Enter to skip]: "
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
prompt = "Load saved experiment by name? [Enter to skip, q to quit]: "
|
|
129
|
+
|
|
130
|
+
choice = input(prompt).strip()
|
|
131
|
+
_raise_if_exit_requested(choice)
|
|
132
|
+
if not choice:
|
|
133
|
+
return default_epsilons, None
|
|
134
|
+
|
|
135
|
+
if len(available) == 1 and choice.lower() in {"y", "yes"}:
|
|
136
|
+
choice = available[0]
|
|
137
|
+
|
|
138
|
+
return load_experiment_epsilons(folder, choice, objective_names), choice
|