Lock-in#177
Conversation
315b542 to
8c246d6
Compare
ryan-summers
left a comment
There was a problem hiding this comment.
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. |
ryan-summers
left a comment
There was a problem hiding this comment.
LGTM from a rust perspective. Will need to defer to @jordens on the math aspect of things
| /// # Arguments | ||
| /// | ||
| /// * `in_phase` - In-phase signal. | ||
| /// * `quadrature` - Quadrature signal. |
There was a problem hiding this comment.
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.
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.
Based on Robert's feedback, it looks like this will go away anyway.
There was a problem hiding this comment.
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| 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.
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.
| 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.
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.
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.
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)
| 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.
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 |
jordens
left a comment
There was a problem hiding this comment.
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
Lockininstance 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 ofLockinsince 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.filteris a method ofLockinsince 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
sincosfusesf64, it is not currently usable on stabilizer, which only supportsf32. I will open a separate issue investigating alternatives.