Skip to content

Commit 0fdf948

Browse files
committed
loop roll update
1 parent f7e7069 commit 0fdf948

3 files changed

Lines changed: 215 additions & 11 deletions

File tree

examples/mcp/fit_beps_dataset_workflow.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ This is the small-model-friendly workflow for the common BEPS fitting task.
66

77
1. Read the file with the SciFiReaders MCP server.
88
2. Pass the original file path to the sidpy workflow tool.
9-
3. Fit SHO over the full DC sweep for one cycle.
9+
3. Fit SHO over the full DC sweep for one selected cycle only.
1010
4. Derive the loop input by projecting the fitted SHO amplitude and phase
1111
with BGlib `projectLoop`.
12-
5. Save the SHO and BEPS fit-parameter datasets.
12+
5. Rotate the projected loop and DC axis together so the single cycle starts
13+
at a consistent DC point, then scale the projected loop by `1e3` before
14+
BEPS fitting.
15+
6. Save the SHO and BEPS fit-parameter datasets.
1316

1417
There are two valid input styles:
1518

@@ -82,8 +85,10 @@ This one sidpy tool performs the full sequence internally:
8285
3. Save the SHO fit-parameter map as a `sidpy.Dataset` with a DC axis.
8386
4. Project the fitted SHO amplitude and phase into a piezoresponse loop with
8487
BGlib `projectLoop`.
85-
5. Fit the BEPS loop slice from that projected piezoresponse.
86-
6. Save the BEPS fit-parameter map as a `sidpy.Dataset`.
88+
5. Rotate the projected loop and the DC axis together so the two branches are
89+
contiguous, then scale the projected loop by `1e3`.
90+
6. Fit the BEPS loop slice from that normalized projected piezoresponse.
91+
7. Save the BEPS fit-parameter map as a `sidpy.Dataset`.
8792

8893
## Expected Result
8994

@@ -100,5 +105,10 @@ The tool returns:
100105
- Keep the workflow in this exact order: optionally read with SciFiReaders, then one sidpy workflow call.
101106
- The SHO metadata stores the loop slice indices used by the derive step.
102107
- The SHO metadata stores the selected cycle index used by the derive step.
103-
- The loop fit uses the projected piezoresponse, not the raw loop waveform.
108+
- The loop fit uses the normalized projected piezoresponse, not the raw loop
109+
waveform.
110+
- The loop fitter only accepts one cycle at a time. If the source file
111+
contains multiple cycles, select one cycle first.
112+
- The derived loop metadata stores the roll used to align the cycle. Reuse
113+
that roll when plotting or recomputing R² so the points stay aligned.
104114
- The saved fit datasets preserve the `X` and `Y` axes from the source file.

sidpy/proc/mcp_server_beps.py

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,9 @@
5252
"goal": (
5353
"Read a BEPS HDF5/NSID file with the SciFiReaders MCP server, "
5454
"then run one sidpy workflow tool that fits SHO over the full DC "
55-
"sweep for one cycle, derives the loop input by projecting the "
56-
"fitted SHO amplitude and phase with BGlib projectLoop, and "
57-
"saves both fit-parameter maps as sidpy.Datasets."
55+
"sweep for one selected cycle only, derives the loop input by "
56+
"projecting the fitted SHO amplitude and phase with BGlib "
57+
"projectLoop, and saves both fit-parameter maps as sidpy.Datasets."
5858
),
5959
"inputs": {
6060
"file_path": "/path/to/PTO_5x5.h5",
@@ -737,6 +737,24 @@ def _project_piezoresponse_loop(
737737
return dict(results)
738738

739739

740+
def _align_loop_trace(
741+
dc_voltage: Sequence[float],
742+
loop_trace: Sequence[Any],
743+
roll_steps: Optional[int] = None,
744+
) -> tuple[np.ndarray, np.ndarray, int]:
745+
"""Rotate a loop trace so the cycle starts at the minimum DC voltage."""
746+
dc = np.asarray(dc_voltage, dtype=float).reshape(-1)
747+
trace = np.asarray(loop_trace, dtype=float)
748+
if dc.size != trace.shape[-1]:
749+
raise ValueError("dc_voltage and loop_trace must have matching last-axis lengths.")
750+
751+
if roll_steps is None:
752+
roll_steps = -int(np.argmin(dc))
753+
else:
754+
roll_steps = int(roll_steps)
755+
return np.roll(dc, roll_steps), np.roll(trace, roll_steps, axis=-1), roll_steps
756+
757+
740758
def get_dataset(dataset_id: str) -> Dict[str, Any]:
741759
"""Return a JSON-friendly summary of a registered dataset."""
742760
dataset = _get_dataset(dataset_id)
@@ -768,8 +786,9 @@ def derive_loop_input_from_sho_result(sho_dataset_id: str) -> Dict[str, Any]:
768786
raise ValueError("The SHO result dataset must include amplitude and phase fit parameters.")
769787

770788
dc_voltage = np.asarray(source_dataset._axes[3].values)
771-
sho_amplitude = np.asarray(sho_dataset[..., 0], dtype=float)
772-
sho_phase = np.asarray(sho_dataset[..., 3], dtype=float)
789+
sho_array = np.asarray(sho_dataset)
790+
sho_amplitude = np.asarray(sho_array[..., 0], dtype=float)
791+
sho_phase = np.asarray(sho_array[..., 3], dtype=float)
773792

774793
projected = np.zeros(sho_amplitude.shape[:2] + (dc_voltage.size,), dtype=float)
775794
rotation = np.zeros(sho_amplitude.shape[:2] + (2,), dtype=float)
@@ -787,12 +806,16 @@ def derive_loop_input_from_sho_result(sho_dataset_id: str) -> Dict[str, Any]:
787806
centroid[row, col] = np.asarray(projection["Centroid"], dtype=float)
788807
geometric_area[row, col] = float(projection["Geometric Area"])
789808

790-
dc_voltage = np.asarray(source_dataset._axes[3].values)
809+
# Rotate the loop so it starts at the minimum DC voltage. That makes the
810+
# first half the increasing branch and the second half the decreasing branch.
811+
dc_voltage, projected, roll_steps = _align_loop_trace(dc_voltage, projected)
812+
projected = projected * 1e3
791813

792814
return {
793815
"source_dataset_id": source_dataset_id,
794816
"source_slice": {
795817
"cycle_index": cycle_index,
818+
"loop_roll_steps": roll_steps,
796819
"signal": "projected_piezoresponse",
797820
"projection_tool": "BGlib.projectLoop",
798821
},
@@ -919,6 +942,7 @@ def fit_beps_loops(
919942
dataset_name: str = "beps_loop_data",
920943
) -> Dict[str, Any]:
921944
"""Fit BEPS loop data where the last axis contains the loop trace."""
945+
_validate_single_cycle_dc_voltage(dc_voltage)
922946
dataset = _build_dataset(
923947
data,
924948
dc_voltage,
@@ -1127,6 +1151,63 @@ def _r2_score(y_true: Sequence[Any], y_pred: Sequence[Any]) -> float:
11271151
return 1.0 - (ss_res / ss_tot)
11281152

11291153

1154+
def _branch_r2_scores(dc_voltage: Sequence[Any], y_true: Sequence[Any], y_pred: Sequence[Any]) -> Dict[str, float]:
1155+
"""Compute R^2 scores for the increasing and decreasing halves of a loop."""
1156+
dc = np.asarray(dc_voltage, dtype=float).reshape(-1)
1157+
true = np.asarray(y_true, dtype=float)
1158+
pred = np.asarray(y_pred, dtype=float)
1159+
if dc.size != true.shape[-1] or dc.size != pred.shape[-1]:
1160+
raise ValueError("dc_voltage, y_true, and y_pred must have matching last-axis lengths.")
1161+
if dc.size < 2:
1162+
return {
1163+
"increasing_branch_r2": float("nan"),
1164+
"decreasing_branch_r2": float("nan"),
1165+
}
1166+
1167+
half = dc.size // 2
1168+
first_dc = dc[:half]
1169+
second_dc = dc[half:]
1170+
first_true = true[..., :half]
1171+
first_pred = pred[..., :half]
1172+
second_true = true[..., half:]
1173+
second_pred = pred[..., half:]
1174+
1175+
first_score = _r2_score(first_true, first_pred)
1176+
second_score = _r2_score(second_true, second_pred)
1177+
1178+
first_slope = float(np.mean(np.diff(first_dc))) if first_dc.size > 1 else 0.0
1179+
second_slope = float(np.mean(np.diff(second_dc))) if second_dc.size > 1 else 0.0
1180+
if first_slope >= second_slope:
1181+
increasing_score, decreasing_score = first_score, second_score
1182+
else:
1183+
increasing_score, decreasing_score = second_score, first_score
1184+
1185+
return {
1186+
"increasing_branch_r2": float(increasing_score),
1187+
"decreasing_branch_r2": float(decreasing_score),
1188+
}
1189+
1190+
1191+
def _validate_single_cycle_dc_voltage(dc_voltage: Sequence[Any]) -> None:
1192+
"""Ensure the loop input contains one forward branch and one reverse branch."""
1193+
dc = np.asarray(dc_voltage, dtype=float).reshape(-1)
1194+
if dc.size < 3:
1195+
return
1196+
1197+
diffs = np.diff(dc)
1198+
diffs = diffs[np.abs(diffs) > np.finfo(float).eps]
1199+
if diffs.size == 0:
1200+
return
1201+
1202+
signs = np.sign(diffs)
1203+
sign_changes = np.count_nonzero(signs[1:] * signs[:-1] < 0)
1204+
if sign_changes > 2:
1205+
raise ValueError(
1206+
"BEPS loop fitting expects one cycle with a single forward branch and a single "
1207+
"reverse branch. Provide one cycle at a time."
1208+
)
1209+
1210+
11301211
def _fit_parameter_dataset(
11311212
parameters: Sequence[Any],
11321213
*,
@@ -1326,6 +1407,7 @@ def fit_beps_dataset_from_reader_payload(
13261407
]).reshape(beps_data.shape)
13271408
beps_quality = {
13281409
"overall_r2": _r2_score(beps_data, beps_pred),
1410+
**_branch_r2_scores(dc_voltage, beps_data, beps_pred),
13291411
}
13301412
beps_dataset = _fit_parameter_dataset(
13311413
beps_params,
@@ -1491,6 +1573,7 @@ def fit_beps_dataset_from_scifireaders_file(
14911573
]).reshape(beps_data.shape)
14921574
beps_quality = {
14931575
"overall_r2": _r2_score(beps_data, beps_pred),
1576+
**_branch_r2_scores(dc_voltage, beps_data, beps_pred),
14941577
}
14951578
beps_dataset = _fit_parameter_dataset(
14961579
beps_params,

tests/proc/test_mcp_server_beps.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,88 @@ def test_fit_parameter_dataset_preserves_dc_axis_for_4d_sho_maps(self):
232232
self.assertEqual(result["dimensions"][3]["name"], "fit_parameter")
233233
self.assertEqual(result["dimensions"][3]["values"], [0, 1, 2, 3, 4])
234234

235+
def test_align_loop_trace_honors_explicit_roll_steps(self):
236+
dc_voltage = np.array([1.0, 2.0, -2.0, -1.0, 0.0])
237+
loop_trace = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
238+
239+
aligned_dc, aligned_trace, applied_roll = mcp_mod._align_loop_trace(
240+
dc_voltage,
241+
loop_trace,
242+
roll_steps=-2,
243+
)
244+
245+
np.testing.assert_array_equal(aligned_dc, np.array([-2.0, -1.0, 0.0, 1.0, 2.0]))
246+
np.testing.assert_array_equal(aligned_trace, np.array([12.0, 13.0, 14.0, 10.0, 11.0]))
247+
self.assertEqual(applied_roll, -2)
248+
249+
def test_align_loop_trace_infers_minimum_dc_roll(self):
250+
dc_voltage = np.array([1.0, 2.0, -2.0, -1.0, 0.0])
251+
loop_trace = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
252+
253+
aligned_dc, aligned_trace, applied_roll = mcp_mod._align_loop_trace(dc_voltage, loop_trace)
254+
255+
np.testing.assert_array_equal(aligned_dc, np.array([-2.0, -1.0, 0.0, 1.0, 2.0]))
256+
np.testing.assert_array_equal(aligned_trace, np.array([12.0, 13.0, 14.0, 10.0, 11.0]))
257+
self.assertEqual(applied_roll, -2)
258+
259+
def test_real_file_path_workflow_returns_sho_and_beps_datasets(self):
260+
from pathlib import Path
261+
import shutil
262+
import os
263+
import tempfile
264+
265+
os.environ.setdefault("NUMBA_DISABLE_JIT", "1")
266+
try:
267+
import SciFiReaders as sr
268+
except Exception as exc:
269+
self.skipTest(f"SciFiReaders is required for the workflow file-path regression test: {exc}")
270+
271+
file_path = Path("/Users/rvv/Downloads/PTO_5x5.h5")
272+
if not file_path.exists():
273+
self.skipTest(f"Real BEPS fixture not found: {file_path}")
274+
275+
fd, tmp_name = tempfile.mkstemp(prefix="pto_5x5_sidpy_", suffix=".h5")
276+
os.close(fd)
277+
workflow_path = Path(tmp_name)
278+
shutil.copy2(file_path, workflow_path)
279+
280+
reader = sr.NSIDReader(str(workflow_path))
281+
data = reader.read()["Channel_000"]
282+
array = np.asarray(data)
283+
284+
result = mcp_mod.fit_beps_dataset_from_scifireaders_file(
285+
str(workflow_path),
286+
channel_name="Channel_000",
287+
dataset_name="PTO_5x5.h5",
288+
sho_cycle_index=1,
289+
use_kmeans=False,
290+
n_clusters=4,
291+
return_cov=False,
292+
loss="linear",
293+
sho_dataset_name="pto_5x5_sho_workflow",
294+
beps_dataset_name="pto_5x5_beps_workflow",
295+
)
296+
297+
self.assertIn("sho_dataset_id", result)
298+
self.assertIn("beps_dataset_id", result)
299+
self.assertEqual(result["sho_dataset"]["shape"], [5, 5, array.shape[3], 4])
300+
self.assertEqual(result["beps_dataset"]["shape"], [5, 5, 9])
301+
self.assertEqual(
302+
result["loop_input"]["source_slice"],
303+
{
304+
"cycle_index": 1,
305+
"signal": "projected_piezoresponse",
306+
"projection_tool": "BGlib.projectLoop",
307+
},
308+
)
309+
self.assertEqual(result["sho_dataset"]["metadata"]["fit_kind"], "sho")
310+
self.assertEqual(result["beps_dataset"]["metadata"]["fit_kind"], "beps_loop")
311+
self.assertGreater(result["sho_dataset"]["metadata"]["fit_quality"]["overall_r2"], 0.8)
312+
self.assertGreater(result["sho_dataset"]["metadata"]["fit_quality"]["amplitude_overall_r2"], 0.9)
313+
self.assertGreater(result["beps_dataset"]["metadata"]["fit_quality"]["overall_r2"], 0.75)
314+
self.assertIn("increasing_branch_r2", result["beps_dataset"]["metadata"]["fit_quality"])
315+
self.assertIn("decreasing_branch_r2", result["beps_dataset"]["metadata"]["fit_quality"])
316+
235317
def test_real_file_scifireaders_payload_can_drive_the_executable_workflow(self):
236318
from pathlib import Path
237319
import shutil
@@ -348,10 +430,13 @@ def test_real_file_scifireaders_payload_can_drive_the_executable_workflow(self):
348430
loop_input = mcp_mod.derive_loop_input_from_sho_result(sho_dataset["dataset_id"])
349431
self.assertIn("beps_data", loop_input)
350432
self.assertIn("dc_voltage", loop_input)
433+
expected_roll_steps = -int(np.argmin(beps_axis))
434+
expected_dc_voltage = np.roll(beps_axis, expected_roll_steps)
351435
self.assertEqual(
352436
loop_input["source_slice"],
353437
{
354438
"cycle_index": sho_cycle_idx,
439+
"loop_roll_steps": expected_roll_steps,
355440
"signal": "projected_piezoresponse",
356441
"projection_tool": "BGlib.projectLoop",
357442
},
@@ -367,7 +452,10 @@ def test_real_file_scifireaders_payload_can_drive_the_executable_workflow(self):
367452
for col in range(sho_params.shape[1])
368453
]
369454
).reshape(beps_data.shape)
455+
expected_projected = np.roll(expected_projected, expected_roll_steps, axis=-1)
456+
expected_projected = expected_projected * 1e3
370457
np.testing.assert_allclose(np.asarray(loop_input["beps_data"]), expected_projected)
458+
np.testing.assert_allclose(np.asarray(loop_input["dc_voltage"]), expected_dc_voltage)
371459

372460
beps_result = mcp_mod.fit_beps_loops(
373461
loop_input["beps_data"],
@@ -388,6 +476,25 @@ def test_real_file_scifireaders_payload_can_drive_the_executable_workflow(self):
388476
]).reshape(np.asarray(loop_input["beps_data"]).shape)
389477
beps_r2 = r2_score(np.asarray(loop_input["beps_data"]).reshape(-1), beps_pred.reshape(-1))
390478
self.assertGreater(beps_r2, 0.75)
479+
branch_quality = mcp_mod._branch_r2_scores(
480+
np.asarray(loop_input["dc_voltage"]),
481+
np.asarray(loop_input["beps_data"]),
482+
beps_pred,
483+
)
484+
self.assertIn("increasing_branch_r2", branch_quality)
485+
self.assertIn("decreasing_branch_r2", branch_quality)
486+
487+
def test_fit_beps_loops_rejects_multi_cycle_dc_voltage(self):
488+
dc_voltage = np.array([
489+
-1.0, -0.5, 0.0, 0.5, 1.0,
490+
0.5, 0.0, -0.5, -1.0,
491+
-0.5, 0.0, 0.5, 1.0,
492+
0.5, 0.0, -0.5, -1.0,
493+
])
494+
data = np.zeros((1, 1, dc_voltage.size))
495+
496+
with self.assertRaises(ValueError):
497+
mcp_mod.fit_beps_loops(data, dc_voltage)
391498

392499
def test_real_file_fits_round_trip_to_sidpy_datasets(self):
393500
from pathlib import Path
@@ -942,6 +1049,7 @@ def test_stdio_mcp_client_can_run_full_beps_dataset_workflow(self):
9421049
payload["loop_input"]["source_slice"],
9431050
{
9441051
"cycle_index": 1,
1052+
"loop_roll_steps": expected_roll_steps,
9451053
"signal": "projected_piezoresponse",
9461054
"projection_tool": "BGlib.projectLoop",
9471055
},
@@ -961,6 +1069,9 @@ def test_stdio_mcp_client_can_run_full_beps_dataset_workflow(self):
9611069
for col in range(sho_params.shape[1])
9621070
]
9631071
).reshape(beps_data.shape)
1072+
expected_roll_steps = -int(np.argmin(beps_axis))
1073+
expected_projected = np.roll(expected_projected, expected_roll_steps, axis=-1)
1074+
expected_projected = expected_projected * 1e3
9641075
np.testing.assert_allclose(np.asarray(payload["loop_input"]["beps_data"]), expected_projected)
9651076

9661077

0 commit comments

Comments
 (0)