topasmoo 0.2.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.
TopasMOO/__init__.py ADDED
@@ -0,0 +1,81 @@
1
+ """
2
+ TopasMOO: Multi-Objective Optimization for TOPAS Monte Carlo Simulations
3
+
4
+ TopasMOO extends the TopasOpt framework to handle multiple competing objectives
5
+ using pymoo algorithms, particularly NSGA-II.
6
+
7
+ Each optimization must import:
8
+ ``NSGAII_Optimizer``
9
+ The optimizer you construct and run (``.RunOptimization()``). This is the
10
+ main entry point for most users.
11
+ ``TopasMOOBaseClass``
12
+ Base class to subclass if you want to drive a different pymoo algorithm.
13
+
14
+ The full visualization toolbox (Pareto fronts, petal diagrams, convergence plots,
15
+ etc.) lives in the TopasMOO.plotting subpackage; only the style entry
16
+ points are re-exported here for convenience. Pareto-analysis metrics live in
17
+ TopasMOO.metrics and log helpers in TopasMOO.io.
18
+ """
19
+
20
+ import importlib.metadata
21
+
22
+ __author__ = "Mindy Harkness"
23
+ try:
24
+ __version__ = importlib.metadata.version("topasmoo")
25
+ except importlib.metadata.PackageNotFoundError:
26
+ __version__ = "0.2.0-dev"
27
+
28
+ # Exceptions
29
+ from .exceptions import (
30
+ InvalidParameterError,
31
+ MalformedOutputError,
32
+ ObjectiveFunctionError,
33
+ TopasExecutionError,
34
+ TopasMOOError,
35
+ )
36
+
37
+ # I/O utilities
38
+ from .io import LogParetoFrontToFile, ReadInMultiObjectiveLogFile
39
+
40
+ # Metrics
41
+ from .metrics import (
42
+ calculate_crowding_distance,
43
+ calculate_dominance_rank,
44
+ calculate_knee_point,
45
+ )
46
+
47
+ # Core optimizer classes (TopasProblem is an internal pymoo adapter that stays
48
+ # in TopasMOO.optimizers).
49
+ from .optimizers import NSGAII_Optimizer, TopasMOOBaseClass
50
+
51
+ # Plotting style entry points (the full plotting API lives in TopasMOO.plotting).
52
+ from .plotting import (
53
+ apply_style,
54
+ available_publication_variants,
55
+ publication_style,
56
+ save_publication_figure,
57
+ )
58
+
59
+ __all__ = [
60
+ # Core classes
61
+ "NSGAII_Optimizer",
62
+ "TopasMOOBaseClass",
63
+ # Exceptions
64
+ "TopasMOOError",
65
+ "TopasExecutionError",
66
+ "InvalidParameterError",
67
+ "ObjectiveFunctionError",
68
+ "MalformedOutputError",
69
+ # Metrics
70
+ "calculate_knee_point",
71
+ "calculate_crowding_distance",
72
+ "calculate_dominance_rank",
73
+ # IO
74
+ "ReadInMultiObjectiveLogFile",
75
+ "LogParetoFrontToFile",
76
+ # Plotting style entry points (full plotting API under TopasMOO.plotting)
77
+ "apply_style",
78
+ "available_publication_variants",
79
+ "publication_style",
80
+ "save_publication_figure",
81
+ ]
TopasMOO/exceptions.py ADDED
@@ -0,0 +1,23 @@
1
+ """
2
+ Custom exception classes for TopasMOO.
3
+ """
4
+
5
+
6
+ class TopasMOOError(Exception):
7
+ """Base exception for all TopasMOO errors."""
8
+
9
+
10
+ class TopasExecutionError(TopasMOOError):
11
+ """Raised when a TOPAS simulation fails to execute."""
12
+
13
+
14
+ class InvalidParameterError(TopasMOOError):
15
+ """Raised when optimization parameters are invalid or inconsistent."""
16
+
17
+
18
+ class ObjectiveFunctionError(TopasMOOError):
19
+ """Raised when the objective function returns unexpected results."""
20
+
21
+
22
+ class MalformedOutputError(TopasMOOError):
23
+ """Raised when TOPAS output files are missing or malformed."""
TopasMOO/io.py ADDED
@@ -0,0 +1,118 @@
1
+ """
2
+ I/O utilities for reading and writing TopasMOO optimization logs.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import os
9
+ from collections.abc import Iterable, Sequence
10
+ from typing import TYPE_CHECKING, Union
11
+
12
+ from .exceptions import MalformedOutputError
13
+
14
+ if TYPE_CHECKING:
15
+ import numpy as np
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ PathLike = Union[str, os.PathLike]
20
+
21
+
22
+ def ReadInMultiObjectiveLogFile(LogFilePath: PathLike) -> dict[str, list[float]]:
23
+ """Read a multi-objective optimization log file into a dictionary.
24
+
25
+ Parses the comma-separated log format produced by TopasMOO during
26
+ optimization, extracting iteration numbers, parameter values, and
27
+ objective function values.
28
+
29
+ :param LogFilePath: Path to the optimization log file
30
+ (typically ``logs/OptimizationLogs.txt``).
31
+
32
+ :returns: Dictionary mapping column names to lists of float values.
33
+ Keys include ``'Iteration'``, parameter names, and
34
+ ``'ObjectiveFunction_1'``, ``'ObjectiveFunction_2'``, etc.
35
+
36
+ :raises FileNotFoundError: If the log file does not exist.
37
+ :raises MalformedOutputError: If a data row contains a non-numeric value
38
+ after the ``key: value`` split.
39
+ """
40
+ log_path = os.fspath(LogFilePath)
41
+ if not os.path.isfile(log_path):
42
+ raise FileNotFoundError(f"Could not find log file at {log_path}")
43
+
44
+ results: dict[str, list[float]] = {}
45
+ with open(log_path, "r") as f:
46
+ lines = f.readlines()
47
+
48
+ if not lines:
49
+ return results
50
+
51
+ if not any("Iteration" in line for line in lines):
52
+ logger.warning("Log file format not recognized or empty: %s", log_path)
53
+ return results
54
+
55
+ for line_number, line in enumerate(lines, start=1):
56
+ if not line.startswith("Iteration"):
57
+ continue
58
+ for entry in line.split(","):
59
+ parts = entry.split(":")
60
+ if len(parts) < 2:
61
+ continue
62
+
63
+ key = parts[0].strip()
64
+ raw_value = parts[1].strip()
65
+ try:
66
+ value = float(raw_value)
67
+ except ValueError as exc:
68
+ raise MalformedOutputError(
69
+ f"Could not parse value '{raw_value}' for key '{key}' "
70
+ f"on line {line_number} of {log_path}"
71
+ ) from exc
72
+
73
+ results.setdefault(key, []).append(value)
74
+
75
+ return results
76
+
77
+
78
+ def LogParetoFrontToFile(
79
+ LogFilePath: PathLike,
80
+ ParetoObjectives: np.ndarray | Iterable[Iterable[float]],
81
+ ParameterNames: Sequence[str],
82
+ n_objectives: int,
83
+ ParetoDecisionVars: np.ndarray | Iterable[Iterable[float]] | None = None,
84
+ ) -> None:
85
+ """Write the current Pareto front to a CSV-style log file.
86
+
87
+ :param LogFilePath: Output file path.
88
+ :param ParetoObjectives: Array of shape ``(n_solutions, n_objectives)``
89
+ containing objective values for all non-dominated solutions.
90
+ :param ParameterNames: Decision-variable names, used as the column headers for
91
+ the decision-variable values when ``ParetoDecisionVars`` is given.
92
+ :param n_objectives: Number of objective functions.
93
+ :param ParetoDecisionVars: Optional array of shape
94
+ ``(n_solutions, len(ParameterNames))`` aligned row-for-row with
95
+ ``ParetoObjectives``. When provided, each parameter is written as an
96
+ additional column so the file fully describes each solution. When
97
+ ``None``, only objective columns are written.
98
+ """
99
+ with open(os.fspath(LogFilePath), "w") as f:
100
+ header = "Solution_Index"
101
+ for i in range(n_objectives):
102
+ header += f",Objective_{i+1}"
103
+ if ParetoDecisionVars is not None:
104
+ for name in ParameterNames:
105
+ header += f",{name}"
106
+ header += "\n"
107
+ f.write(header)
108
+
109
+ decision_rows = list(ParetoDecisionVars) if ParetoDecisionVars is not None else None
110
+ for i, objectives in enumerate(ParetoObjectives):
111
+ line = f"{i}"
112
+ for obj_val in objectives:
113
+ line += f",{obj_val:.6f}"
114
+ if decision_rows is not None:
115
+ for var_val in decision_rows[i]:
116
+ line += f",{var_val:.6f}"
117
+ line += "\n"
118
+ f.write(line)
TopasMOO/metrics.py ADDED
@@ -0,0 +1,141 @@
1
+ """
2
+ Multi-objective optimization metrics for Pareto front analysis.
3
+
4
+ All metrics assume minimization of objectives.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+
11
+
12
+ def normalize_objectives(
13
+ objectives: np.ndarray,
14
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
15
+ """Min-max normalize objectives per column to ``[0, 1]``.
16
+
17
+ Each objective (column) is scaled by its observed range across the given
18
+ solutions. Columns with zero range (a single distinct value) are left at
19
+ ``0`` rather than dividing by zero.
20
+
21
+ :param objectives: Array of shape ``(n_solutions, n_objectives)``.
22
+
23
+ :returns: Tuple ``(normalized, ideal, nadir)`` where ``ideal`` and ``nadir`` are
24
+ the per-objective minimum and maximum, and ``normalized`` has the same
25
+ shape as ``objectives``.
26
+ """
27
+ objectives = np.asarray(objectives, dtype=float)
28
+ ideal = objectives.min(axis=0)
29
+ nadir = objectives.max(axis=0)
30
+ obj_range = nadir - ideal
31
+ obj_range[obj_range == 0] = 1
32
+ normalized = (objectives - ideal) / obj_range
33
+ return normalized, ideal, nadir
34
+
35
+
36
+ def calculate_knee_point(pareto_objectives: np.ndarray) -> int:
37
+ """Find the knee point (best trade-off) on a Pareto front.
38
+
39
+ Uses a trade-off method: the knee is the solution with the minimum
40
+ sum of min-max normalized objectives, representing the best balanced
41
+ compromise across all objectives.
42
+
43
+ :param pareto_objectives: Array of shape ``(n_solutions, n_objectives)``.
44
+ All objectives are assumed to be minimized.
45
+
46
+ :returns: Index (int) of the knee point solution in the input array.
47
+ """
48
+ normalized, _ideal, _nadir = normalize_objectives(pareto_objectives)
49
+
50
+ total_objectives = normalized.sum(axis=1)
51
+ return int(np.argmin(total_objectives))
52
+
53
+
54
+ def calculate_crowding_distance(pareto_objectives: np.ndarray) -> np.ndarray:
55
+ """Calculate NSGA-II crowding distance for each solution.
56
+
57
+ Crowding distance measures how isolated a solution is in objective
58
+ space. Higher values indicate solutions in less crowded regions,
59
+ which are preferred during selection to maintain diversity.
60
+
61
+ Boundary solutions (best/worst in any single objective) receive
62
+ infinite crowding distance.
63
+
64
+ :param pareto_objectives: Array of shape ``(n_solutions, n_objectives)``.
65
+
66
+ :returns: Array of crowding distances with shape ``(n_solutions,)``.
67
+ """
68
+ n_solutions, n_objectives = pareto_objectives.shape
69
+
70
+ if n_solutions <= 2:
71
+ return np.full(n_solutions, np.inf)
72
+
73
+ crowding = np.zeros(n_solutions)
74
+
75
+ for m in range(n_objectives):
76
+ sorted_indices = np.argsort(pareto_objectives[:, m])
77
+ obj_values = pareto_objectives[sorted_indices, m]
78
+
79
+ crowding[sorted_indices[0]] = np.inf
80
+ crowding[sorted_indices[-1]] = np.inf
81
+
82
+ obj_range = obj_values[-1] - obj_values[0]
83
+ if obj_range == 0:
84
+ continue
85
+
86
+ for i in range(1, n_solutions - 1):
87
+ crowding[sorted_indices[i]] += (
88
+ obj_values[i + 1] - obj_values[i - 1]
89
+ ) / obj_range
90
+
91
+ return crowding
92
+
93
+
94
+ def calculate_dominance_rank(objectives: np.ndarray) -> np.ndarray:
95
+ """Assign dominance ranks using fast non-dominated sorting.
96
+
97
+ Rank 0 contains the Pareto front (non-dominated solutions).
98
+ Rank 1 contains solutions dominated only by rank-0 solutions,
99
+ and so on.
100
+
101
+ All objectives are assumed to be minimized.
102
+
103
+ :param objectives: Array of shape ``(n_solutions, n_objectives)``.
104
+
105
+ :returns: Integer array of dominance ranks with shape ``(n_solutions,)``.
106
+ """
107
+ n_solutions = len(objectives)
108
+ domination_count = np.zeros(n_solutions, dtype=int)
109
+ dominated_solutions = [[] for _ in range(n_solutions)]
110
+ ranks = np.zeros(n_solutions, dtype=int)
111
+
112
+ for i in range(n_solutions):
113
+ for j in range(i + 1, n_solutions):
114
+ if np.all(objectives[i] <= objectives[j]) and np.any(
115
+ objectives[i] < objectives[j]
116
+ ):
117
+ dominated_solutions[i].append(j)
118
+ domination_count[j] += 1
119
+ elif np.all(objectives[j] <= objectives[i]) and np.any(
120
+ objectives[j] < objectives[i]
121
+ ):
122
+ dominated_solutions[j].append(i)
123
+ domination_count[i] += 1
124
+
125
+ current_front = np.where(domination_count == 0)[0]
126
+ rank = 0
127
+
128
+ while len(current_front) > 0:
129
+ ranks[current_front] = rank
130
+ next_front = []
131
+
132
+ for i in current_front:
133
+ for j in dominated_solutions[i]:
134
+ domination_count[j] -= 1
135
+ if domination_count[j] == 0:
136
+ next_front.append(j)
137
+
138
+ current_front = np.array(next_front)
139
+ rank += 1
140
+
141
+ return ranks