mrsuite 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.
Files changed (104) hide show
  1. mrsuite/__init__.py +54 -0
  2. mrsuite/basic_driver.py +112 -0
  3. mrsuite/benchmark/__init__.py +24 -0
  4. mrsuite/benchmark/radar.py +247 -0
  5. mrsuite/benchmark/scoring.py +554 -0
  6. mrsuite/bridges/__init__.py +21 -0
  7. mrsuite/bridges/augmentrum.py +165 -0
  8. mrsuite/bridges/base.py +349 -0
  9. mrsuite/bridges/fid_a.py +861 -0
  10. mrsuite/bridges/fsl_mrs.py +604 -0
  11. mrsuite/bridges/lcmodel.py +177 -0
  12. mrsuite/bridges/mrspeclab.py +386 -0
  13. mrsuite/bridges/mrspeclab_driver.py +141 -0
  14. mrsuite/bridges/nifti_mrs.py +99 -0
  15. mrsuite/bridges/osprey.py +558 -0
  16. mrsuite/bridges/pyamares.py +315 -0
  17. mrsuite/bridges/spant.py +383 -0
  18. mrsuite/bridges/suspect.py +331 -0
  19. mrsuite/bridges/tarquin.py +211 -0
  20. mrsuite/bridges/vespa.py +259 -0
  21. mrsuite/collect.py +364 -0
  22. mrsuite/core/__init__.py +0 -0
  23. mrsuite/core/basis.py +230 -0
  24. mrsuite/core/method.py +370 -0
  25. mrsuite/core/parallel.py +158 -0
  26. mrsuite/core/pipeline.py +613 -0
  27. mrsuite/core/provenance.py +226 -0
  28. mrsuite/core/result.py +243 -0
  29. mrsuite/datasets/__init__.py +13 -0
  30. mrsuite/datasets/agnostic.py +126 -0
  31. mrsuite/datasets/challenge2016.py +501 -0
  32. mrsuite/datasets/mr_science_lab.py +149 -0
  33. mrsuite/datasets/mrsi_challenge.py +624 -0
  34. mrsuite/datasets/openneuro.py +195 -0
  35. mrsuite/externals.py +236 -0
  36. mrsuite/gui/__init__.py +13 -0
  37. mrsuite/gui/__main__.py +107 -0
  38. mrsuite/gui/app.py +1872 -0
  39. mrsuite/gui/blocks.py +947 -0
  40. mrsuite/gui/canvas.js +183 -0
  41. mrsuite/gui/canvas.py +134 -0
  42. mrsuite/gui/catalog.py +203 -0
  43. mrsuite/gui/files.py +135 -0
  44. mrsuite/gui/graph.py +1257 -0
  45. mrsuite/gui/static/logo.png +0 -0
  46. mrsuite/gui/static/mrsuite.css +237 -0
  47. mrsuite/gui/vendor/vue-flow-background/LICENSE +20 -0
  48. mrsuite/gui/vendor/vue-flow-background/index.js +126 -0
  49. mrsuite/gui/vendor/vue-flow-core/LICENSE +20 -0
  50. mrsuite/gui/vendor/vue-flow-core/index.js +10186 -0
  51. mrsuite/gui/vendor/vue-flow-core/style.css +248 -0
  52. mrsuite/gui/vendor/vue-flow-core/theme-default.css +143 -0
  53. mrsuite/io.py +426 -0
  54. mrsuite/models/__init__.py +97 -0
  55. mrsuite/models/base.py +515 -0
  56. mrsuite/models/convert.py +109 -0
  57. mrsuite/models/mlp.py +223 -0
  58. mrsuite/models/pytorch.py +89 -0
  59. mrsuite/models/reference.py +116 -0
  60. mrsuite/models/wand.py +263 -0
  61. mrsuite/mrsi.py +167 -0
  62. mrsuite/octave/__init__.py +285 -0
  63. mrsuite/octave/docker.py +194 -0
  64. mrsuite/octave/dockerfile +42 -0
  65. mrsuite/octave/matlab.py +172 -0
  66. mrsuite/octave/sessions.py +91 -0
  67. mrsuite/octave/shims/contains.m +42 -0
  68. mrsuite/octave/shims/fftshift.m +52 -0
  69. mrsuite/octave/shims/hilbert.m +42 -0
  70. mrsuite/octave/shims/lbfgsb.m +200 -0
  71. mrsuite/octave/shims/lsqcurvefit.m +22 -0
  72. mrsuite/octave/shims/lsqnonlin.m +45 -0
  73. mrsuite/octave/shims/mrsuite_lm.m +99 -0
  74. mrsuite/octave/shims/nanmedian.m +16 -0
  75. mrsuite/octave/shims/nlinfit.m +132 -0
  76. mrsuite/octave/shims/optimoptions.m +11 -0
  77. mrsuite/octave/shims/optimset.m +39 -0
  78. mrsuite/octave/shims/padarray.m +47 -0
  79. mrsuite/octave/shims/sscanf.m +59 -0
  80. mrsuite/octave/shims/statset.m +56 -0
  81. mrsuite/octave/src/lbfgsb_quadratic.c +208 -0
  82. mrsuite/r/__init__.py +135 -0
  83. mrsuite/r/dockerfile +22 -0
  84. mrsuite/r/driver.R +135 -0
  85. mrsuite/simulators/__init__.py +13 -0
  86. mrsuite/simulators/bases.py +251 -0
  87. mrsuite/simulators/digital_phantom.py +111 -0
  88. mrsuite/simulators/digital_phantom_driver.py +60 -0
  89. mrsuite/simulators/mrs_sim.py +636 -0
  90. mrsuite/simulators/mrs_sim_driver.py +202 -0
  91. mrsuite/simulators/mrsandbox.py +426 -0
  92. mrsuite/simulators/pulses.py +86 -0
  93. mrsuite/simulators/synthetic.py +143 -0
  94. mrsuite/testing.py +217 -0
  95. mrsuite/training/__init__.py +21 -0
  96. mrsuite/training/loop.py +177 -0
  97. mrsuite/training/recipe.py +198 -0
  98. mrsuite/training/source.py +151 -0
  99. mrsuite-0.0.1.dist-info/METADATA +161 -0
  100. mrsuite-0.0.1.dist-info/RECORD +104 -0
  101. mrsuite-0.0.1.dist-info/WHEEL +5 -0
  102. mrsuite-0.0.1.dist-info/entry_points.txt +2 -0
  103. mrsuite-0.0.1.dist-info/licenses/LICENSE +21 -0
  104. mrsuite-0.0.1.dist-info/top_level.txt +1 -0
mrsuite/__init__.py ADDED
@@ -0,0 +1,54 @@
1
+ ####################################################################################################
2
+ # __init__.py #
3
+ ####################################################################################################
4
+ # #
5
+ # Authors: J. P. Merkofer (j.p.merkofer@tue.nl) #
6
+ # #
7
+ # Created: 2026-08-08 #
8
+ # #
9
+ # Purpose: Package entry point. Exposes the toolbox bridges directly (mrsuite.fid_a, #
10
+ # mrsuite.fsl_mrs, mrsuite.osprey, mrsuite.lcmodel) and mrsuite.method, which makes #
11
+ # a function of your own a method, and stamps the shared NIfTI-MRS+ provenance. #
12
+ # Bridges are imported lazily: resolving an Octave-backed one would start a #
13
+ # session, and the FSL-MRS one pulls in the whole FSL-MRS stack, neither of which #
14
+ # a caller who only wants the pipeline should pay for. #
15
+ # #
16
+ ####################################################################################################
17
+
18
+
19
+ #*************#
20
+ # imports #
21
+ #*************#
22
+ import importlib
23
+ from typing import Any
24
+
25
+ import nifti_mrs_plus
26
+
27
+ from mrsuite.core.method import Method, method
28
+
29
+ __version__ = "0.0.1"
30
+
31
+ # Same pattern as Augmentrum: stamp the shared wrapper's provenance metadata so
32
+ # processed data records which toolbox touched it.
33
+ nifti_mrs_plus.set_provenance("MRSuite", __version__)
34
+
35
+ _BRIDGES = {"augmentrum", "fid_a", "fsl_mrs", "lcmodel", "mrspeclab", "nifti_mrs", "osprey",
36
+ "pyamares", "spant", "suspect", "tarquin", "vespa"}
37
+
38
+ __all__ = ["__version__", "Method", "method", *sorted(_BRIDGES)]
39
+
40
+
41
+ #*************#
42
+ # getattr #
43
+ #*************#
44
+ def __getattr__(name: str) -> Any:
45
+ if name in _BRIDGES:
46
+ return importlib.import_module(f"mrsuite.bridges.{name}")
47
+ raise AttributeError(f"module 'mrsuite' has no attribute '{name}'")
48
+
49
+
50
+ #*********#
51
+ # dir #
52
+ #*********#
53
+ def __dir__():
54
+ return sorted(set(globals()) | _BRIDGES)
@@ -0,0 +1,112 @@
1
+ ####################################################################################################
2
+ # basic_driver.py #
3
+ ####################################################################################################
4
+ # #
5
+ # Authors: J. P. Merkofer (j.p.merkofer@tue.nl) #
6
+ # #
7
+ # Created: 2026-10-09 #
8
+ # #
9
+ # Purpose: Reads and writes basis sets with BasiC, for mrsuite.io. Not imported by MRSuite: BasiC #
10
+ # is a folder of scripts importing each other as top-level modules (core, readers, #
11
+ # writers), so it runs in a process of its own. FIDs come and go as .npz; what BasiC #
12
+ # prints goes to stderr; the job's answer goes to stdout, as JSON; a failure ends it #
13
+ # with one line saying why. #
14
+ # #
15
+ # Usage: python basic_driver.py '<job as JSON>' #
16
+ # #
17
+ ####################################################################################################
18
+
19
+
20
+ #*************#
21
+ # imports #
22
+ #*************#
23
+ import contextlib
24
+ import json
25
+ import sys
26
+
27
+ import numpy as np
28
+
29
+
30
+ #**********#
31
+ # fail #
32
+ #**********#
33
+ def fail(message):
34
+ sys.exit(" ".join(str(message).split()))
35
+
36
+
37
+ #**********#
38
+ # read #
39
+ #**********#
40
+ def read(job):
41
+ """The files in the format given or detected, with the parameters given where they state
42
+ none, as one array of FIDs (to ``result``) and what is said of them; the echo time and
43
+ sequence as the basis functions hold them (a reader gives 0 for an echo time not
44
+ stated), else as given."""
45
+ from core.detect import detect_format
46
+ from core.load import fill_missing, load_basis
47
+
48
+ found = detect_format(job["files"])
49
+ fmt = job["format"]
50
+ if fmt == "auto":
51
+ if found["status"] == "ambiguous":
52
+ fail(f"{found['message']} Choose one as format: "
53
+ + ", ".join(map(repr, found["ambiguous_options"])))
54
+ if found["status"] == "failed":
55
+ fail(found["message"])
56
+ fmt = found["format"]
57
+ stated = found["data"]
58
+ given = {k: job[k] for k in ("sw", "sf", "te")}
59
+ basis = load_basis(job["files"], fmt, sw=stated.get("sw") or given["sw"],
60
+ sf=stated.get("sf") or given["sf"])
61
+ if not basis:
62
+ fail("it found no basis functions in the files")
63
+ fill_missing(basis, given)
64
+ lacking = [k for k in ("sw", "sf") if not all(b.get(k) for b in basis)]
65
+ if lacking:
66
+ fail(f"the files do not state {' or '.join(lacking)}: give "
67
+ + ", ".join(f"{k}=" for k in lacking))
68
+ if len({len(np.ravel(b["fid"])) for b in basis}) > 1:
69
+ fail("its basis functions differ in length")
70
+ np.savez(job["result"], fids=np.array([np.ravel(b["fid"]) for b in basis]))
71
+ first = basis[0]
72
+ return {"names": [b["name"] for b in basis], "bandwidth": float(first["sw"]),
73
+ "frequency": float(first["sf"]), "centre": first.get("centerFreq"),
74
+ "te": first.get("te") or given["te"], "seq": first.get("seq"), "format": fmt}
75
+
76
+
77
+ #***********#
78
+ # write #
79
+ #***********#
80
+ def write(job):
81
+ """The job's basis set (FIDs from ``fids``) written for a tool BasiC writes for; the
82
+ files written."""
83
+ from core.formats import ALL_TOOLS
84
+ from core.write import write_basis
85
+
86
+ tools = {t["id"]: [f["id"] for f in t["formats"]] for t in ALL_TOOLS if not t["dimmed"]}
87
+ if job["tool"] not in tools:
88
+ fail(f"no tool {job['tool']!r}; one of: {', '.join(tools)}")
89
+ outputs = tools[job["tool"]]
90
+ if job["output"] != "default" and job["output"] not in outputs:
91
+ fail(f"{job['tool']} writes output 'default'"
92
+ + "".join(f", {o!r}" for o in outputs) + f", not {job['output']!r}")
93
+ fids = np.load(job["fids"])["fids"]
94
+ basis = [{"fid": fid, "name": name, "n": len(fid), "sw": job["bandwidth"],
95
+ "sf": job["frequency"], "centerFreq": job["centre"], "te": job["te"],
96
+ "seq": job["seq"]} for name, fid in zip(job["names"], fids)]
97
+ written = write_basis(basis, job["tool"], job["output"], job["folder"], te=job["te"],
98
+ seq=job["seq"])
99
+ if not written: # its writers go on without what they fail
100
+ fail("it wrote nothing")
101
+ return written
102
+
103
+
104
+ if __name__ == "__main__":
105
+ try:
106
+ with contextlib.redirect_stdout(sys.stderr):
107
+ job = json.loads(sys.argv[1])
108
+ sys.path.insert(0, job["root"])
109
+ answer = {"read": read, "write": write}[job["task"]](job)
110
+ except Exception as err: # noqa: BLE001 - said, in one line
111
+ fail(f"{type(err).__name__}: {err}")
112
+ print(json.dumps(answer))
@@ -0,0 +1,24 @@
1
+ ####################################################################################################
2
+ # __init__.py #
3
+ ####################################################################################################
4
+ # #
5
+ # Authors: J. P. Merkofer (j.p.merkofer@tue.nl) #
6
+ # #
7
+ # Created: 2026-10-01 #
8
+ # #
9
+ # Purpose: Benchmarking on MRSValidate. Fits and ground truth become its ObservationSets on a #
10
+ # reference the caller names (water, tCr, the optimal scale or none), every metric runs #
11
+ # as a step of its analysis plan, and radar draws the resulting scores. #
12
+ # #
13
+ ####################################################################################################
14
+
15
+
16
+ #*************#
17
+ # imports #
18
+ #*************#
19
+ from mrsuite.benchmark.radar import plot_scores, radar
20
+ from mrsuite.benchmark.scoring import (REFERENCES, analyses, bias, coverage, figure, mae,
21
+ observations, optimal_scale, score)
22
+
23
+ __all__ = ["REFERENCES", "observations", "optimal_scale", "score", "analyses", "figure",
24
+ "coverage", "mae", "bias", "radar", "plot_scores"]
@@ -0,0 +1,247 @@
1
+ ####################################################################################################
2
+ # radar.py #
3
+ ####################################################################################################
4
+ # #
5
+ # Authors: J. P. Merkofer (j.p.merkofer@tue.nl) #
6
+ # #
7
+ # Created: 2026-10-01 #
8
+ # #
9
+ # Purpose: A radar plot of benchmark scores. Like MRSValidate's own plots it only draws values #
10
+ # computed before: one polygon per method, one spoke per metric. #
11
+ # #
12
+ ####################################################################################################
13
+
14
+
15
+ #*************#
16
+ # imports #
17
+ #*************#
18
+ from typing import Any, Dict, Optional, Tuple, Union
19
+
20
+ import numpy as np
21
+ import pandas as pd
22
+
23
+
24
+ # how a metric reads, by its name in the score tables: higher is better for agreement and
25
+ # correlation, and for Shapiro-Wilk's W; a signed error (bias, a mean or median error,
26
+ # Bland-Altman's, whose score is its bias, the equivalence tests', whose score is their
27
+ # intercept) is best at 0; a slope (and the original concise score, slope times R²) is best
28
+ # at 1; the rest are errors and spreads, lower is better
29
+ HIGHER = {"lin_ccc", "robust_ccc", "pearson_r", "r_squared_direct", "adjusted_r_squared",
30
+ "median_centered_correlation", "concise_score_manuscript", "shapiro_wilk"}
31
+ ONE = {"passing_bablok", "deming", "theil_sen", "regression_metrics", "concise_score_original"}
32
+ SIGNED = {"bias", "mpe", "mepe", "mean", "median", "bland_altman", "equivalence_test_1",
33
+ "equivalence_test_2", "equivalence_test_3"}
34
+ OWN = {"mae", "bias", "mosae"} # MRSuite's own error measures (scoring.py)
35
+ # the one analysis MRSValidate runs under a name other than its function's (meta.method)
36
+ RUN_AS = {"manuscript_robust_ccc": "robust_ccc"}
37
+ # how a metric is labelled on the radar, by its name in the score tables; others by that name
38
+ LABELS = {"mae": "MAE", "mosae": "MOSAE", "bias": "bias", "rmse": "RMSE", "mape": "MAPE",
39
+ "lin_ccc": "Lin's CCC", "robust_ccc": "robust CCC", "pearson_r": "Pearson r",
40
+ "r_squared_direct": "R²", "bland_altman": "Bland-Altman bias", "robust_sd": "robust SD",
41
+ "iqr": "IQR"}
42
+ # an aspect by the module its analyses sit in, where MRSValidate's name says less
43
+ ASPECTS = {"descriptive": "Error distribution", "normality": "Normality of errors"}
44
+
45
+
46
+ #************#
47
+ # metric #
48
+ #************#
49
+ def _metric(measure: str) -> str:
50
+ """The metric a measure is: its name before any reference or metabolite."""
51
+ return measure.split(" ")[0]
52
+
53
+
54
+ #***********#
55
+ # radar #
56
+ #***********#
57
+ def radar(values: pd.DataFrame, limits: Optional[Dict[str, Tuple[float, float]]] = None,
58
+ ax: Any = None) -> Any:
59
+ """
60
+ Draw scores on a radar plot: one polygon per row (a method), one spoke per column
61
+ (a metric), the best method at the rim of each spoke and the worst at its centre, on a
62
+ web of polygon rings, a point at each corner, no fill.
63
+
64
+ It computes no statistic, as MRSValidate's plots compute none; score's table becomes
65
+ its input by picking one reference and each metric's own field, e.g.
66
+ ``table.query("reference == 'optimal' and metabolite == 'all'").pivot(index="method",
67
+ columns="metric", values="value")`` for metrics filed under 'value' (Lin's CCC is
68
+ under 'rho_c'). Each spoke runs from the worst value among the methods to the best,
69
+ which way round the metric says (``HIGHER``, else lower is better); a signed metric
70
+ such as bias is best at 0, so it is drawn by its magnitude (``SIGNED``), and a slope
71
+ is best at 1, so by its distance from 1 (``ONE``). So every
72
+ metric sits on 0-1, comparable across spokes, which is axis geometry only. Where all
73
+ methods score the same, all sit at the rim. A column given limits (0, 1) is drawn as
74
+ it is, labelled by its name alone.
75
+
76
+ Args:
77
+ values (pd.DataFrame): Methods by metrics.
78
+ limits (Optional[Dict[str, Tuple[float, float]]]): Per metric, the values at the
79
+ centre and at the rim instead, e.g. a lower-is-better metric's (worst, best).
80
+ ax (Any): A polar axes to draw on; a new figure's when None.
81
+
82
+ Returns:
83
+ Any: The axes.
84
+ """
85
+ import matplotlib.pyplot as plt
86
+
87
+ values = values.copy()
88
+ for c in values.columns:
89
+ if _metric(c) in SIGNED:
90
+ values[c] = values[c].abs()
91
+ elif _metric(c) in ONE:
92
+ values[c] = (values[c] - 1).abs()
93
+ limits = {c: (limits or {}).get(c, (values[c].min(), values[c].max()) if _metric(c) in HIGHER
94
+ else (values[c].max(), values[c].min()))
95
+ for c in values.columns}
96
+ angles = np.linspace(0, 2 * np.pi, len(limits), endpoint=False)
97
+ if ax is None:
98
+ _, ax = plt.subplots(subplot_kw={"projection": "polar"})
99
+ ax.set_facecolor("none")
100
+ ax.figure.set_facecolor("none")
101
+ closed = np.append(angles, angles[0])
102
+
103
+ for r in (0.2, 0.4, 0.6, 0.8, 1.0): # a web: rings as polygons
104
+ ax.plot(closed, [r] * len(closed), color="grey", alpha=0.55 if r == 1.0 else 0.35,
105
+ linewidth=1.6 if r == 1.0 else 1.0, zorder=0)
106
+ _scale(ax)
107
+ for method, row in values.iterrows():
108
+ radius = [(row[c] - lo) / (hi - lo) if hi != lo else 1.0 for c, (lo, hi) in limits.items()]
109
+ radius = [max(x, 0.05) for x in radius] # never a point at the centre
110
+ ax.plot(closed, radius + radius[:1], label=method, linewidth=2.5, marker="o",
111
+ markersize=7, zorder=2)
112
+
113
+ ax.set_xticks(angles)
114
+ ax.set_xticklabels([c if (lo, hi) == (0, 1) else f"{c}\n{lo:.3g} to {hi:.3g}"
115
+ for c, (lo, hi) in limits.items()], fontsize=13, color="#9aa4b5")
116
+ ax.tick_params(axis="x", pad=28) # the labels clear of the rim
117
+ for angle, label in zip(np.degrees(angles), ax.get_xticklabels()):
118
+ shown = (90 - angle) % 360 # the first corner on top, clockwise
119
+ side = np.isclose(shown, 90) or np.isclose(shown, 270)
120
+ label.set_horizontalalignment("center" if side else
121
+ "right" if 90 < shown < 270 else "left")
122
+ label.set_verticalalignment("bottom" if np.isclose(shown, 90) else
123
+ "top" if np.isclose(shown, 270) else "center")
124
+ ax.set_yticks([])
125
+ ax.set_ylim(0, 1)
126
+ ax.xaxis.grid(True, color="grey", alpha=0.45, linewidth=1.0)
127
+ ax.yaxis.grid(False)
128
+ ax.spines["polar"].set_visible(False)
129
+ ax.set_theta_offset(np.pi / 2) # the first corner at the top
130
+ ax.set_theta_direction(-1)
131
+ if len(values):
132
+ ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.2), frameon=False,
133
+ labelcolor="#9aa4b5", fontsize=11, ncol=min(len(values), 3))
134
+ return ax
135
+
136
+
137
+ #***********#
138
+ # scale #
139
+ #***********#
140
+ def _scale(ax: Any) -> None:
141
+ """The web's scale, 0 at the centre to 1 at the rim, beside the first spoke."""
142
+ for r in (0.0, 0.2, 0.4, 0.6, 0.8, 1.0):
143
+ ax.text(0, r, f" {r:g}", fontsize=9, color="grey", ha="left", va="bottom", zorder=1)
144
+
145
+
146
+ #*****************#
147
+ # plot scores #
148
+ #*****************#
149
+ def plot_scores(scores: Union[pd.DataFrame, Dict[str, pd.DataFrame]], ax: Any = None,
150
+ by: str = "aspect") -> Any:
151
+ """
152
+ Score tables on a radar plot: one corner per aspect of MRSValidate (errors, agreement,
153
+ regression, method comparison, ...), one polygon per method, as bsuite draws its agents
154
+ over its capabilities. Every measure in the tables is scaled from the worst method to the
155
+ best (see ``radar``), and a corner is the mean of its aspect's measures, so a corner
156
+ fills as more of its measures are wired in; each corner names its measures. Or, by
157
+ measure, a spoke per measure. Every spoke runs 0 to 1 alike. The pooled rows
158
+ (metabolite 'all') count where a table has them, else each metabolite's; a measure on
159
+ several references counts once per reference, and is labelled with it.
160
+
161
+ Args:
162
+ scores (Union[pd.DataFrame, Dict[str, pd.DataFrame]]): One table of score's, or
163
+ several by name, e.g. one per measure.
164
+ ax (Any): A polar axes to draw on; else a new figure's, made without pyplot, so
165
+ drawing it in a thread is safe and nothing is left open.
166
+ by (str): 'aspect', a corner per aspect, or 'measure', a spoke per measure.
167
+
168
+ Returns:
169
+ Any: The axes.
170
+
171
+ Raises:
172
+ ValueError: For a method without a score for a measure (one that failed, or an
173
+ interval's), or one measure given twice. No tables at all draw an empty radar,
174
+ to be filled.
175
+ """
176
+ from matplotlib.figure import Figure
177
+
178
+ tables = list(scores.values()) if isinstance(scores, dict) else [scores]
179
+ if ax is None:
180
+ ax = Figure(figsize=(7, 7), layout="constrained").add_subplot(projection="polar")
181
+ if not tables: # the web, waiting for corners
182
+ ax.set_facecolor("none")
183
+ ax.figure.set_facecolor("none")
184
+ for r in (0.2, 0.4, 0.6, 0.8, 1.0):
185
+ ax.plot(np.linspace(0, 2 * np.pi, 100), [r] * 100, color="grey",
186
+ alpha=0.55 if r == 1.0 else 0.35, linewidth=1.6 if r == 1.0 else 1.0)
187
+ ax.set_theta_offset(np.pi / 2) # its scale on top, as a radar's
188
+ _scale(ax)
189
+ ax.set_xticks([])
190
+ ax.set_yticks([])
191
+ ax.set_ylim(0, 1)
192
+ ax.spines["polar"].set_visible(False)
193
+ ax.set_title("No scores yet: wire score tables in, a corner per aspect",
194
+ color="#9aa4b5", fontsize=12)
195
+ return ax
196
+ picked = pd.concat([t[t["metabolite"] == "all"] if (t["metabolite"] == "all").any() else t
197
+ for t in tables])
198
+ picked = picked.assign(measure=picked["metric"] + " (" + picked["reference"] + ")"
199
+ + np.where(picked["metabolite"] == "all", "",
200
+ " " + picked["metabolite"]))
201
+ twice = picked[picked.duplicated(["method", "measure"])]["measure"].unique()
202
+ if len(twice):
203
+ raise ValueError(f"given twice: {', '.join(twice)}")
204
+ values = picked.pivot(index="method", columns="measure", values="score")
205
+ missing = [f"{measure} ({method})" for measure in values.columns
206
+ for method in values.index[~np.isfinite(values[measure])]]
207
+ if missing:
208
+ raise ValueError(f"no score for {', '.join(missing)}")
209
+
210
+ if by not in ("aspect", "measure"):
211
+ raise ValueError(f"a radar is by 'aspect' or by 'measure', not {by!r}")
212
+ analysis = dict(zip(picked["measure"], picked["analysis"].replace(RUN_AS)))
213
+ several = picked["reference"].nunique() > 1
214
+ scaled, labels = pd.DataFrame(index=values.index), {}
215
+ for c in values.columns: # each measure 0 (worst) to 1
216
+ names = ({_metric(c)} if _metric(c) in OWN else # MRSuite's own by its name,
217
+ {_metric(c), analysis[c]}) # others as named, or as run
218
+ v = (values[c].abs() if names & SIGNED else
219
+ (values[c] - 1).abs() if names & ONE else values[c])
220
+ lo, hi = (v.min(), v.max()) if names & HIGHER else (v.max(), v.min())
221
+ scaled[c] = (v - lo) / (hi - lo) if hi != lo else 1.0
222
+ label = LABELS.get(_metric(c), _metric(c))
223
+ label = f"|{label}|" if names & SIGNED else f"|{label} - 1|" if names & ONE else label
224
+ reference, _, metabolite = c.split(" ", 1)[1].partition(" ")
225
+ labels[c] = " ".join(filter(None, [label, reference if several else "", metabolite]))
226
+ if by == "measure":
227
+ corners = scaled.rename(columns=labels)
228
+ else:
229
+ group = {c: aspect(_metric(c), analysis[c]) for c in scaled.columns}
230
+ corners = scaled.T.groupby(group).mean().T
231
+ corners = corners.rename(columns={a: a + "\n" + ", ".join(
232
+ labels[c] for c in scaled.columns if group[c] == a) for a in corners.columns})
233
+ return radar(corners, {c: (0, 1) for c in corners.columns}, ax)
234
+
235
+
236
+ #************#
237
+ # aspect #
238
+ #************#
239
+ def aspect(metric: str, analysis: str) -> str:
240
+ """The aspect a measure judges, as MRSValidate groups its analyses: the module of the
241
+ analysis that ran, or of the metric's own name; MRSuite's own errors (MAE, bias, MOSAE)
242
+ are errors."""
243
+ from mrsuite.benchmark.scoring import analyses
244
+
245
+ modules = {name.split(".")[1]: name.split(".")[0] for name in analyses()}
246
+ module = "errors" if metric in OWN else modules.get(analysis) or modules.get(metric, "errors")
247
+ return ASPECTS.get(module, module.replace("_", " ").capitalize())