visualdynamics.core.modal_fit¶
modal_fit
¶
Fit real normal modes to measured FRFs, one mode at a time.
The session holds the measured FRF matrix and the modes confirmed so far. Everything it shows is computed on the residual — measurement minus the synthesis of the confirmed modes — so each confirmed mode collapses its own CMIF peak and the largest remaining peak is the natural next suggestion.
One mode is fitted from the residual alone:
- the peak line's SVD gives the shape direction (its first left singular vector, rotated to the nearest real vector — these are real normal modes);
- half-power points on the residual CMIF give the damping estimate;
- a least-squares fit of the SDOF term over a band around the peak gives one real residue per reference, which scales the unit shape into a mass-normalized one. A drive point ties the scale down exactly; without one the scale comes from the residue magnitudes, which still resynthesizes the measured records correctly.
Classes:
| Name | Description |
|---|---|
ModalFitSession |
An interactive fit of real normal modes to one FRF set. |
Classes¶
ModalFitSession
¶
ModalFitSession(
frf: Frf,
records: Sequence[int] | None = None,
coherence: Any | None = None,
)
An interactive fit of real normal modes to one FRF set.
Methods:
| Name | Description |
|---|---|
adopt |
Seed the fit with an existing shape set's modes. |
residual_matrix |
The matrix with every confirmed mode taken out of it. |
synthesis_singular_values |
The synthesis CMIF's singular values, (k, freqs), exactly — |
synthesis_records |
The confirmed (and previewed) modes' synthesis, in the |
searched |
(low, high) a suggestion may land in — the view, or all of it. |
suggest |
Move the pending mode to the most prominent residual CMIF |
move_to |
The cursor moved. With |
override_damping |
Hold the damping by hand: the pending mode keeps this value |
pending_curve |
The SDOF magnitude the pending (frequency, damping) claims, |
fit_pending |
Fit the pending mode where the cursor is, without adding it. |
confirm |
Add the pending mode, fitting it first if Fit Mode has not. |
refine_residues |
Re-fit every confirmed mode's residues together, poles held — |
residual_records |
The residual back in the source's record layout, |
search_damping |
The damping that leaves the least behind at this frequency. |
search |
The (frequency, damping) that leave the least behind. |
shape_set |
The confirmed modes as a ShapeSet, ready for the project. |
Source code in src/visualdynamics/core/modal_fit.py
Methods:¶
adopt
¶
adopt(shapes: ShapeSet) -> None
Seed the fit with an existing shape set's modes.
Editing a published fit means starting from it, not from nothing — and a ShapeSet already carries the whole fit state: frequency, damping, shape and description per mode. Shapes map onto this session's DOFs by name; DOFs the set lacks contribute nothing, and DOFs beyond this FRF's records are left behind.
Source code in src/visualdynamics/core/modal_fit.py
residual_matrix
¶
The matrix with every confirmed mode taken out of it.
Cached against the modes, because it does not depend on where the cursor is and dragging asks for it constantly. The SVD over it is 4.7 ms on the airplane's FRFs — a third of a frame at 60 Hz — which is what made dragging have to stay free of it.
Source code in src/visualdynamics/core/modal_fit.py
synthesis_singular_values
¶
The synthesis CMIF's singular values, (k, freqs), exactly — without ever materialising the synthesis.
The modal synthesis is rank-m by construction: every mode
contributes q_k(w) * outer(shape, participation), so the
whole matrix is U diag(q(w)) V^T with constant U and V.
Singular values are invariant under matrices with orthonormal
columns on the left and orthonormal rows on the right, so QR
both factors once and every line's SVD collapses from
(responses x references) to (m x references) on the tiny R
factors. On the hard drone survey that is the difference
between 6.5 seconds — building a two-gigabyte record array and
decomposing 1197x12 at every line — and a few milliseconds,
for bit-identical curves.
Padded with zero rows up to min(responses, references), which is what the materialised SVD reports: the extra singular values of a rank-m matrix are zero, and the plot's floor test counts rows.
None with nothing to synthesise.
Source code in src/visualdynamics/core/modal_fit.py
synthesis_records
¶
The confirmed (and previewed) modes' synthesis, in the source's record layout — what the dashed CMIF is drawn from.
Source code in src/visualdynamics/core/modal_fit.py
searched
¶
(low, high) a suggestion may land in — the view, or all of it.
Zooming to a region is the plainest way to say "the next mode is
in here", and it is now the only way. There used to be a pair
of draggable bounds as well, shading the rest of the plot out,
and searched intersected the two. They earned nothing: the
answer was almost always the view, and saying the same thing
twice meant zooming and then dragging two bars to match.
Clamped to the measured range, so a view scrolled past the end of the data still searches data.
Source code in src/visualdynamics/core/modal_fit.py
suggest
¶
Move the pending mode to the most prominent residual CMIF
peak inside within — the frequencies on screen — or anywhere.
Prominence, not height, and the difference is the difference between the eye and a ruler. The CMIF is accelerance in most tests, so its raw height grows as frequency squared, and the tallest residual line is nearly always a shoulder of the highest dense cluster — ranked by height, ten Confirms on a real survey spent every one of them inside a single 1340 Hz cluster and never visited the 64 Hz fundamental any engineer would fit first. Ranked by how far a peak stands above its own local floor, in log height — which is what a peak looks like on the plot — the same ten confirms landed on the very set of modes the survey's engineer had picked by hand.
A confirmed mode's half-power width is also spoken for — unless
the shape standing there is somebody else's. The subtraction of
a fitted mode is exact only when the mode is alone; in a cluster
it leaves ridge enough that the same line stays the tallest, and
the loop confirmed fourteen modes at one frequency without ever
moving on. But frequency alone cannot tell that ridge from a
genuine neighbour — a repeated pair's second mode stands at the
confirmed frequency and was shadowed for exactly that reason —
and shape can: ridge keeps the shape of the mode that left it,
a real neighbour has its own (_another_mode_at). The cursor
can still be dragged anywhere by hand.
Source code in src/visualdynamics/core/modal_fit.py
move_to
¶
The cursor moved. With damping, say what damping that
frequency implies.
This used to be free by necessity: the damping estimate meant a full SVD of the residual every mouse tick. The residual does not depend on the cursor, so it is cached against the modes instead, and what a tick actually costs is a one-dimensional search over a couple of dozen lines.
A caller that cannot afford even that — a drag already running behind — passes False and gets the old behaviour.
Source code in src/visualdynamics/core/modal_fit.py
override_damping
¶
Hold the damping by hand: the pending mode keeps this value until Find Mode suggests afresh or a confirm resets it.
This is the second axis of the search put in the user's hand. Where two solutions sit at nearly the same frequency they are told apart by their damping, and the automatic estimate can only pick one; holding the damping says which neighbourhood the fit is meant to land in.
Source code in src/visualdynamics/core/modal_fit.py
pending_curve
¶
The SDOF magnitude the pending (frequency, damping) claims, scaled to the residual CMIF at the cursor — the parabola under the cursor that shows how sharp a mode is about to be fitted.
Closed form: no SVD, no least squares, safe to redraw on every tick of a drag. The crown touches the residual CMIF at the cursor's line, so the curve reads as "a mode this tall and this wide, here"; the width is the damping made visible, which is what lets a vertical drag be aimed.
unit_system converts the heights the way the plotted CMIF is
converted. The session works in SI; the plot draws display
units, and on in-lbf-s data the two are a couple of orders of
magnitude apart — the SI-valued parabola was drawn that far
below the curves it was meant to hug, which on screen is
indistinguishable from not being drawn at all. It passed every
test, because the test fixture's units were undefined and
undefined values display as they stand.
Source code in src/visualdynamics/core/modal_fit.py
fit_pending
¶
Fit the pending mode where the cursor is, without adding it.
Estimates the damping (unless the user typed one), computes the shape, and holds the result as a preview — the synthesis and MAC show it, and Confirm adopts it.
Source code in src/visualdynamics/core/modal_fit.py
confirm
¶
confirm(
frequency: float | None = None,
damping: float | None = None,
description: str | None = None,
) -> dict[str, Any]
Add the pending mode, fitting it first if Fit Mode has not.
Source code in src/visualdynamics/core/modal_fit.py
refine_residues
¶
Re-fit every confirmed mode's residues together, poles held — and let the CMIF choose how far to trust the joint solve.
Fitting is sequential peeling: each mode was fit on the residual as it stood, so the first of a close pair was fit on data that still contained the second's tail, and its residues absorbed a piece of it. The sum of the pair tracks the measurement — the error is in the decomposition — and nothing in the loop ever went back. This goes back: the poles stay exactly where the user put them, and the residues are re-estimated with all the SDOF terms present at once, over the union of the modes' own bands — lines far from every fitted mode hold only what was never fitted, and a full-band solve would smear that into the shapes.
The pure joint solve has a failure mode that shipped and was caught by eye: when close poles come with similar shapes the basis is ill-conditioned, and plain least squares amplifies measurement noise into amplitude swaps between the pair — the shapes' directions stay right while the synthesized CMIF walks visibly away from the measured one. Regularizing toward the sequential answer damps the swap, but no fixed weight suits both a parallel pair and a well-separated one.
So the weight is not fixed. A short ladder of candidates is solved — the pure joint answer, three ridge strengths pulled toward the sequential residues, and the sequential answer itself — and each is scored by how far its synthesized CMIF sits from the measured CMIF over the band. The winner is adopted. The residues are optimized on the FRFs; the model among them is selected on the CMIF, which is the curve the fit is judged by — and with the sequential answer in the ladder, refining can never worsen that curve.
Returns the number of modes refined; the shapes are unchanged when the sequential answer won.
Source code in src/visualdynamics/core/modal_fit.py
691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 | |
residual_records
¶
The residual back in the source's record layout, (records, freqs) — what a plot of the residual draws.
Source code in src/visualdynamics/core/modal_fit.py
search_damping
¶
search_damping(
frequency: float,
seed: float | None = None,
refine: int = 2,
steps: int = 5,
) -> float
The damping that leaves the least behind at this frequency.
The one-dimensional half of search, for a cursor being
dragged: the user is choosing the frequency by hand, so there
is nothing to search along it, and what is worth showing live
is what damping that frequency implies and how well it fits.
Cheap enough to run on a drag — the residual and its SVD are cached against the modes rather than the cursor, so what is left is a couple of dozen SDOF terms.
Source code in src/visualdynamics/core/modal_fit.py
search
¶
search(
index: int,
damping: float | None = None,
refine: int = 2,
steps: int = 5,
) -> tuple[float, float]
The (frequency, damping) that leave the least behind.
Coarse to fine: a small grid over half a line either side and a wide span of damping, then the same grid again around the winner at a fifth the span, then a parabola through the best and its neighbours in each direction. Fifty cheap evaluations reach what a twenty-five-by-twenty-five grid would, and the parabola lands between grid points, where the surface really is quadratic and interpolating it is unbiased.
This replaces reading the damping off two half-power crossings. Those are two points on a curve, they assume the peak is one isolated mode, and where two modes sit close the width they measure is the pair's — so the damping comes back too high and nothing says so. A residual minimum assumes none of that.
damping seeds the span when the user has typed one; otherwise
the half-power estimate seeds it, which is a fine starting
guess even where it is a poor answer.
Source code in src/visualdynamics/core/modal_fit.py
1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 | |
shape_set
¶
shape_set() -> ShapeSet
The confirmed modes as a ShapeSet, ready for the project.