-
Notifications
You must be signed in to change notification settings - Fork 27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Lock-in #177
Lock-in #177
Conversation
315b542
to
8c246d6
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some minor changes, but in general the code looks much better!
Thanks for the review @ryan-summers! I've made the changes. Let me know if you find anything else. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM from a rust perspective. Will need to defer to @jordens on the math aspect of things
dsp/src/lockin.rs
Outdated
/// # Arguments | ||
/// | ||
/// * `in_phase` - In-phase signal. | ||
/// * `quadrature` - Quadrature signal. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's not clear where magnitude and phase are stored. Which magnitude and which phase?
I think this would be simpler if you took ownership fo the in_phase
and quadrature
arrays and then returned them with the new values as (magnitude, phase) arrays. It would still be in-place updates, but ownership would make it clear that the data has transformed.
E.g.
let (i, q) = //...;
let (mag, phase) = dsp::lockin::magnitude_phase(i, q);
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The difficulty with this is that this could be performed on arrays of various sizes. For instance, magnitude_phase
might be called after decimate
. Alternatively decimate
might be omitted. At least, that's my understanding of how the API can be used, though I could be wrong.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Based on Robert's feedback, it looks like this will go away anyway.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd suggest a few changes:
- Use (
num-complex
) or create aComplex<f32>
type. Arrays/slices/iterators over it come automatically. - Use one IIR filter for both quadratures.
- Let's try boiling down the iterators further. Being generic over iterators is probably going to be a bit hairy. Let's not try it. Maybe with the changes above the functions become so small that just chaining iterators in user code is actually the easiest and most flexible way. Could you give something like this a try (just a suggestion, untested, maybe some of these don't work)?
// feed timestamps into pll
timestamps.map(|t| pll.update(&mut pll_state, t)).last();
let last_phase = adc_samples.iter()
.enumerate()
.map(|j, x| {
let phi = pll.get_sample_phase(j);
let (cos_phi, sin_phi) = cossinf(phi);
// demodulate
let (xcp, xsp) = (x*cos_phi, x*sin_phi);
// filter
let i = iir.update(&mut iir_state.0, xcp);
let q = iir.update(&mut iir_state.1, xsp);
let wraps = unwrap_phase((i, q), &mut wrap_state); // The processing should later also handle phase unwrapping (which needs two subsequent samples and a state). That unwrapping can and should be done before decimate, maybe even before filtering.
((i, q), wraps)
})
.step_by(n_k) // decimate
.map(|(i, q), wraps| atan2f(q, i) + wraps) // compute the phase
.last(); // just look at the last phase in this case
dac_samples.iter_mut()
.enumerate()
.for_each(|j, d| *d = last_phase + modulate_waveform[j]); // update the DAC samples with both the feedback phase and the modulation waveform
dsp/src/lockin.rs
Outdated
timestamps: [u16; TIMESTAMP_BUFFER_SIZE], | ||
valid_timestamps: u16, | ||
) -> Result< | ||
([f32; ADC_SAMPLE_BUFFER_SIZE], [f32; ADC_SAMPLE_BUFFER_SIZE]), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should just merge in-phase and quadrature into one type. I.e. [(f32, f32); ADC_SAMPLE_BUFFER_SIZE]
. And better yet, try returning the iterator (ready to be consumed) instead of storing.
dsp/src/lockin.rs
Outdated
pub fn magnitude_phase(in_phase: &mut [f32], quadrature: &mut [f32]) { | ||
in_phase | ||
.iter_mut() | ||
.zip(quadrature.iter_mut()) | ||
.for_each(|(i, q)| { | ||
let new_i = libm::sqrtf([*i, *q].iter().map(|i| i * i).sum()); | ||
let new_q = libm::atan2f(*q, *i); | ||
*i = new_i; | ||
*q = new_q; | ||
}); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's probably a bit rare that both magnitude and phase are needed in this way. Let's split this and look for the most useful abstraction.
Let's do a crate-wide:
type Complex<T> = (T, T);
And then (wit data: &[Complex<f32>]
: data.iter().map(|(i, q)| sqrtf(i*i + q*q))
and data.iter().map(|(i, q)| atan2f(q, i))
in the user code when needed. It's a one-liner and doesn't really need a wrapper to iterate over a slice.
(Or maybe even num-complex
).
In these closures it's fine and very readable to use single-letter variables.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is the goal to hold onto/pass around the iterator? I don't believe it's possible to collect()
into an array.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As mentioned above, passing iterators around might be tricky because the types become a bit complicated.
Let's just provide the building blocks for single-sample processing and then have the user use iterators in process()
(the ISR in main.rs
) where and when convenient. Like #177 (review)
dsp/src/lockin.rs
Outdated
pub fn decimate( | ||
in_phase: [f32; ADC_SAMPLE_BUFFER_SIZE], | ||
quadrature: [f32; ADC_SAMPLE_BUFFER_SIZE], | ||
) -> ([f32; DECIMATED_BUFFER_SIZE], [f32; DECIMATED_BUFFER_SIZE]) { | ||
let n_k = ADC_SAMPLE_BUFFER_SIZE / DECIMATED_BUFFER_SIZE; | ||
debug_assert!( | ||
ADC_SAMPLE_BUFFER_SIZE == DECIMATED_BUFFER_SIZE || n_k % 2 == 0 | ||
); | ||
|
||
let mut in_phase_decimated = [0f32; DECIMATED_BUFFER_SIZE]; | ||
let mut quadrature_decimated = [0f32; DECIMATED_BUFFER_SIZE]; | ||
|
||
in_phase_decimated | ||
.iter_mut() | ||
.zip(quadrature_decimated.iter_mut()) | ||
.zip( | ||
in_phase | ||
.iter() | ||
.step_by(n_k) | ||
.zip(quadrature.iter().step_by(n_k)), | ||
) | ||
.for_each(|((i_d, q_d), (i, q))| { | ||
*i_d = *i; | ||
*q_d = *q; | ||
}); | ||
|
||
(in_phase_decimated, quadrature_decimated) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we simplify this further, we can just do data.step_by(n_k)
in user code (data: &[Complex<f32>]
or an Iter
over it, see below).
@jordens I've implemented most of your suggested revisions. Currently, however, I'm still using arrays rather than iterators as function arguments and return values. Moreover, I haven't provided a user code solution that chains iterators. The reason for this is that computing the demodulation phase for each ADC sample by using the timestamps closest to the ADC sample complicates the demodulation logic somewhat, and the required user code would be somewhat lengthy. In other words, although some of the changes did decrease the amount of code in each function (e.g., using arrays of complex values instead of individual I expect that it will be worth revisiting the idea of chaining iterators in user code when the PLL is implemented. I've used a custom |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On top of the previous changes and the ones pending with the PLL code, a couple things.
Ack. It's typically very good to reduce the amount of code. Remove trivial functions and reduce the interface of the non-trivial ones to make the universal. I think we will also want to use the existing
Yes.
Ack. Sounds good! |
This clarifies what it means to "push" to an array.
bors r+ |
1 similar comment
bors r+ |
Bors r+ |
Bors retry |
Already running a review |
bors cancel |
bors retry |
I'm closing #173 and opening this PR, since I'm not keeping any commits from that PR.
Usage
The first step is to initialize a
Lockin
instance withLockin::new()
. This provides the lock-in algorithms with necessary information about the demodulation and filtering steps, such as whether to demodulate with a harmonic of the reference signal and the IIR biquad filter to use. There are then 4 different processing blocks that can be used (these blocks are mutually independent):demodulate
- Computes the phase of the demodulation signal corresponding to each ADC sample, uses this phase to compute the in-phase and quadrature demodulation signals, and multiplies these demodulation signals by the ADC-sampled signal. This is a method ofLockin
since it requires information about how to modify the reference signal for demodulation.filter
- Performs IIR biquad filtering of in-phase and quadrature signals. This is commonly performed on the in-phase and quadrature components provided by the demodulation step, but can be performed at any other point in the processing chain or omitted entirely.filter
is a method ofLockin
since it must hold onto the filter configuration and state.decimate
- This decimates the in-phase and quadrature signals to reduce the load on the DAC output. It does not require any state information and is therefore a normal function (i.e., non-method).magnitude_phase
- Computes the magnitude and phase of the component of the ADC-sampled signal whose frequency is equal to the demodulation frequency. This does not require any state information and is therefore a normal function.Considerations
sincosf
usesf64
, it is not currently usable on stabilizer, which only supportsf32
. I will open a separate issue investigating alternatives.