dac_fix and review #52

Merged
sb10q merged 4 commits from dac_fix into master 2021-01-25 16:40:38 +08:00
9 changed files with 94 additions and 70 deletions

View File

@ -101,7 +101,7 @@ formatted as line-delimited JSON.
| `report mode <off/on>` | Set report mode |
| `pwm` | Show current PWM settings |
| `pwm <0/1> max_i_pos <amp>` | Set PWM duty cycle for **max_i_pos** to *ampere* |
| `pwm <0/1> max_i_neg <amp>` | Set PWM duty cycle for **max_i_neg** to *ampere* |
| `pwm <0/1> max_i_neg <amp>` | Set PWM duty cycle for **max_i_neg** to *- ampere* |
| `pwm <0/1> max_v <volts>` | Set PWM duty cycle for **max_v** to *volt* |
| `pwm <0/1> i_set <amp>` | Disengage PID, set **i_set** DAC to *ampere* |
| `pwm <0/1> pid` | Set PWM to be controlled by PID |
@ -176,9 +176,13 @@ postfilter rate can be tuned with the `postfilter` command.
## Thermo-Electric Cooling (TEC)
- Connect Peltier device 0 to TEC0- and TEC0+.
- Connect Peliter device 1 to TEC1- and TEC1+.
- The GND pin is for shielding not for sinking Peltier currents.
- Connect TEC module device 0 to TEC0- and TEC0+.
- Connect TEC module device 1 to TEC1- and TEC1+.
- The GND pin is for shielding not for sinking TEC module currents.
When using a TEC module with the Thermostat, the Thermostat expects the thermal load (where the thermistor is located) to heat up with a positive software current set point, and cool down with a negative current set point.

The doc sometimes says "TEC module" and sometimes "Peltier device"; we should use consistent terminology.

The doc sometimes says "TEC module" and sometimes "Peltier device"; we should use consistent terminology.
Testing heat flow direction with a low set current is recommended before installation of the TEC module.
### Limits
@ -199,6 +203,16 @@ Example: set the maximum voltage of channel 0 to 1.5 V.
pwm 0 max_v 1.5
```
Example: set the maximum negative current of channel 0 to -3 A.
```
pwm 0 max_i_neg 3
```
Example: set the maximum positive current of channel 1 to 3 A.
```
pwm 0 max_i_pos 3
```
### Open-loop mode
To manually control TEC output current, omit the limit parameter of

View File

@ -2,8 +2,6 @@
## Note on hardware setup
When using a TEC module with the Thermostat, the Thermostat expects the thermal load (where the thermistor is connected) to heat up when a positive current flow from the TEC + terminal, through the TEC, to the TEC - terminal, and cool down when the current flows in the reverse direction.
The heat sinking side of the TEC module should be thermally bonded to a large heat-sinking thermal mass to ensure maximum temperature stability, a large optical table had provided good results in tests.
The thermal load under control should be well insulated from the surrounding for maximum stability, closed cell foam had been tested showing good results.
@ -76,6 +74,8 @@ At the end of the test, the ultimate gain `Ku`, oscillation period `Pu` and a fe
Multiple suggested sets of PID parameters based on different calculation rules are displayed. While all sets are expected to work, the different sets trade off response time with overshoot differently, and testing is needed to see which set works best for the system on hand.
With a well designed and constructed setup, the PID parameters calculated by the auto tune utility can provide mK level control stability with no manual tweaking.
With a well designed and constructed setup, the PID parameters calculated by the auto tune utility together with some manual tweaking can yield sub-mK control stability.
![tec3pessen](./assets/tec3pessen.png)
Below shows data captured on an experiment setup, with 300uK stability over 12 hours.
![twelve_hours](./assets/twelve_hours.png)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

BIN
doc/assets/twelve_hours.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@ -28,18 +28,18 @@ class Series:
self.y_data = self.y_data[drop:]
series = {
'adc': Series(),
'sens': Series(lambda x: x * 0.0001),
'temperature': Series(lambda t: t - target_temperature),
'i_set': Series(),
# 'adc': Series(),
# 'sens': Series(lambda x: x * 0.0001),
'temperature': Series(),
# 'i_set': Series(),
'pid_output': Series(),
'vref': Series(),
'dac_value': Series(),
'dac_feedback': Series(),
'i_tec': Series(),
# 'vref': Series(),
# 'dac_value': Series(),
# 'dac_feedback': Series(),
# 'i_tec': Series(),
'tec_i': Series(),
'tec_u_meas': Series(),
'interval': Series(),
# 'interval': Series(),
}
series_lock = Lock()

View File

@ -1,4 +1,8 @@
use stm32f4xx_hal::hal::digital::v2::OutputPin;
use uom::si::{
f64::ElectricPotential,

Braces are not necessary here.

Braces are not necessary here.
electric_potential::volt,
};
use crate::{
ad5680,
ad7172,
@ -12,13 +16,12 @@ pub struct Channel0;
/// Marker type for the second channel
pub struct Channel1;
pub struct Channel<C: ChannelPins> {
pub state: ChannelState,
/// for `i_set`
pub dac: ad5680::Dac<C::DacSpi, C::DacSync>,
/// 1 / Volts
pub dac_factor: f64,
/// Measured vref of MAX driver chip
pub vref_meas: ElectricPotential,
pub shdn: C::Shdn,
pub vref_pin: C::VRefPin,
pub itec_pin: C::ItecPin,
@ -32,12 +35,12 @@ impl<C: ChannelPins> Channel<C> {
let state = ChannelState::new(adc_calibration);
let mut dac = ad5680::Dac::new(pins.dac_spi, pins.dac_sync);
let _ = dac.set(0);
// sensible dummy preset. calibrate_i_set() must be used.
let dac_factor = ad5680::MAX_VALUE as f64 / 5.0;
// sensible dummy preset taken from datasheet. calibrate_dac_value() should be used to override this value.
let vref_meas = ElectricPotential::new::<volt>(1.5);
Channel {
state,
dac, dac_factor,
dac, vref_meas,
shdn: pins.shdn,
vref_pin: pins.vref_pin,
itec_pin: pins.itec_pin,

View File

@ -22,6 +22,8 @@ use crate::{
pub const CHANNELS: usize = 2;
pub const R_SENSE: f64 = 0.05;
// DAC chip outputs 0-5v, which is then passed through a resistor dividor to provide 0-3v range
const DAC_OUT_V_MAX: f64 = 3.0;

Can't have ElectricPotential constants?

Can't have `ElectricPotential` constants?

One purpose of the uom crate is to have the type checker verify dimensional homogeneity; that only works if used consistently.

One purpose of the uom crate is to have the type checker verify dimensional homogeneity; that only works if used consistently.
// TODO: -pub
pub struct Channels {
@ -106,53 +108,43 @@ impl Channels {
}
/// i_set DAC
fn get_dac(&mut self, channel: usize) -> (ElectricPotential, ElectricPotential) {
let dac_factor = match channel.into() {
0 => self.channel0.dac_factor,
1 => self.channel1.dac_factor,
_ => unreachable!(),
};
fn get_dac(&mut self, channel: usize) -> ElectricPotential {
let voltage = self.channel_state(channel).dac_value;
let max = ElectricPotential::new::<volt>(ad5680::MAX_VALUE as f64 / dac_factor);
(voltage, max)
voltage

Why do we need to return this if it's constant? Check what is using get_dac and see if that abstraction is relevant.

Why do we need to return this if it's constant? Check what is using `get_dac` and see if that abstraction is relevant.
}
pub fn get_i(&mut self, channel: usize) -> (ElectricCurrent, ElectricCurrent) {
pub fn get_i(&mut self, channel: usize) -> ElectricCurrent {
let center_point = self.get_center(channel);
let r_sense = ElectricalResistance::new::<ohm>(R_SENSE);
let (voltage, max) = self.get_dac(channel);
let voltage = self.get_dac(channel);
let i_tec = (voltage - center_point) / (10.0 * r_sense);
let max = (max - center_point) / (10.0 * r_sense);
(i_tec, max)
i_tec
}
/// i_set DAC
fn set_dac(&mut self, channel: usize, voltage: ElectricPotential) -> (ElectricPotential, ElectricPotential) {
let dac_factor = match channel.into() {
0 => self.channel0.dac_factor,
1 => self.channel1.dac_factor,
_ => unreachable!(),
};
let value = (voltage.get::<volt>() * dac_factor) as u32;
let value = match channel {
fn set_dac(&mut self, channel: usize, voltage: ElectricPotential) -> ElectricPotential {
let value = ((voltage / ElectricPotential::new::<volt>(DAC_OUT_V_MAX)).get::<ratio>() * (ad5680::MAX_VALUE as f64)) as u32 ;
match channel {
0 => self.channel0.dac.set(value).unwrap(),
1 => self.channel1.dac.set(value).unwrap(),
_ => unreachable!(),
};
let voltage = ElectricPotential::new::<volt>(value as f64 / dac_factor);
self.channel_state(channel).dac_value = voltage;
let max = ElectricPotential::new::<volt>(ad5680::MAX_VALUE as f64 / dac_factor);
(voltage, max)
voltage
}
pub fn set_i(&mut self, channel: usize, i_tec: ElectricCurrent) -> (ElectricCurrent, ElectricCurrent) {
let center_point = self.get_center(channel);
pub fn set_i(&mut self, channel: usize, i_tec: ElectricCurrent) -> ElectricCurrent {
let vref_meas = match channel.into() {
0 => self.channel0.vref_meas,
1 => self.channel1.vref_meas,
_ => unreachable!(),
};
let center_point = vref_meas;
let r_sense = ElectricalResistance::new::<ohm>(R_SENSE);
let voltage = i_tec * 10.0 * r_sense + center_point;
let (voltage, max) = self.set_dac(channel, voltage);
let voltage = self.set_dac(channel, voltage);
let i_tec = (voltage - center_point) / (10.0 * r_sense);
let max = (max - center_point) / (10.0 * r_sense);
(i_tec, max)
i_tec
}
pub fn read_dac_feedback(&mut self, channel: usize) -> ElectricPotential {

Remove?

Remove?
@ -255,12 +247,29 @@ impl Channels {
}
}
/// Calibrate the I_SET DAC using the DAC_FB ADC pin.
/// Calibrates the DAC output to match vref of the MAX driver to reduce zero-current offset of the MAX driver output.
///
/// These loops perform a breadth-first search for the DAC setting
/// that will produce a `target_voltage`.
/// The thermostat DAC applies a control voltage signal to the CTLI pin of MAX driver chip to control its output current.
/// The CTLI input signal is centered around VREF of the MAX chip. Applying VREF to CTLI sets the output current to 0.
///
/// This calibration routine measures the VREF voltage and the DAC output with the STM32 ADC, and uses a breadth-first
/// search to find the DAC setting that will produce a DAC output voltage closest to VREF. This DAC output voltage will

Do we really need a breadth-first search, or is it sufficient (e.g. fast enough) to simply increment (or decrement depending on the sign of the error) the DAC value until the ADC reads back Vref?

Do we really need a breadth-first search, or is it sufficient (e.g. fast enough) to simply increment (or decrement depending on the sign of the error) the DAC value until the ADC reads back Vref?

The time it takes is not noticeable.

The time it takes is not noticeable.

OK, then a simple increment/decrement is better (simpler) and then the code comments can focus solely on the STM ADC offset.

OK, then a simple increment/decrement is better (simpler) and then the code comments can focus solely on the STM ADC offset.

Misunderstood what you said there. I think if the search works and is fast, then probably should leave it there.

Misunderstood what you said there. I think if the search works and is fast, then probably should leave it there.
/// be stored and used in subsequent i_set routines to bias the current control signal to the measured VREF, reducing

Comments should explain the STM ADC offset compensation technique.

Comments should explain the STM ADC offset compensation technique.
/// the offset error of the current control signal.
///
sb10q marked this conversation as resolved Outdated

...reads the VREF voltage and the DAC output by using the STM32 ADC...

...reads the VREF voltage and the DAC output by using the STM32 ADC...
/// The input offset of the STM32 ADC is eliminated by using the same ADC for the measurements, and by only using the
/// difference in VREF and DAC output for the calibration.

.rev()?

`.rev()`?
///
/// This routine should be called only once after boot, repeated reading of the vref signal and changing of the stored
/// VREF measurement can introduce significant noise at the current output, degrading the stabilily performance of the
/// thermostat.
pub fn calibrate_dac_value(&mut self, channel: usize) {
let target_voltage = ElectricPotential::new::<volt>(2.5);
let samples = 50;
let mut target_voltage = ElectricPotential::new::<volt>(0.0);
for _ in 0..samples {
target_voltage = target_voltage + self.get_center(channel);
}
target_voltage = target_voltage / samples as f64;
let mut start_value = 1;
let mut best_error = ElectricPotential::new::<volt>(100.0);
@ -285,10 +294,10 @@ impl Channels {
best_error = error;
start_value = prev_value;
let dac_factor = value as f64 / dac_feedback.get::<volt>();
let vref = (value as f64 / ad5680::MAX_VALUE as f64) * ElectricPotential::new::<volt>(DAC_OUT_V_MAX);
match channel {
0 => self.channel0.dac_factor = dac_factor,
1 => self.channel1.dac_factor = dac_factor,
0 => self.channel0.vref_meas = vref,
1 => self.channel1.vref_meas = vref,
_ => unreachable!(),
}
}
@ -345,11 +354,10 @@ impl Channels {
}
}
pub fn get_max_v(&mut self, channel: usize) -> (ElectricPotential, ElectricPotential) {
let vref = self.channel_state(channel).vref;
let max = 4.0 * vref;
pub fn get_max_v(&mut self, channel: usize) -> ElectricPotential {
let max = 4.0 * ElectricPotential::new::<volt>(3.3);
let duty = self.get_pwm(channel, PwmPin::MaxV);
(duty * max, max)
duty * max
}
pub fn get_max_i_pos(&mut self, channel: usize) -> (ElectricCurrent, ElectricCurrent) {
@ -402,8 +410,7 @@ impl Channels {
}
pub fn set_max_v(&mut self, channel: usize, max_v: ElectricPotential) -> (ElectricPotential, ElectricPotential) {
let vref = self.channel_state(channel).vref;
let max = 4.0 * vref;
let max = 4.0 * ElectricPotential::new::<volt>(3.3);
let duty = (max_v / max).get::<ratio>();
let duty = self.set_pwm(channel, PwmPin::MaxV, duty);
(duty * max, max)
@ -425,10 +432,10 @@ impl Channels {
fn report(&mut self, channel: usize) -> Report {
let vref = self.channel_state(channel).vref;
let (i_set, _) = self.get_i(channel);
let i_set = self.get_i(channel);
let i_tec = self.read_itec(channel);
let tec_i = self.get_tec_i(channel);
let (dac_value, _) = self.get_dac(channel);
let dac_value = self.get_dac(channel);
let state = self.channel_state(channel);
let pid_output = state.pid.last_output.map(|last_output|
ElectricCurrent::new::<ampere>(last_output)
@ -473,8 +480,8 @@ impl Channels {
PwmSummary {
channel,
center: CenterPointJson(self.channel_state(channel).center.clone()),
i_set: self.get_i(channel).into(),
max_v: self.get_max_v(channel).into(),
i_set: (self.get_i(channel), ElectricCurrent::new::<ampere>(3.0)).into(),
max_v: (self.get_max_v(channel), ElectricPotential::new::<volt>(5.0)).into(),
max_i_pos: self.get_max_i_pos(channel).into(),
max_i_neg: self.get_max_i_neg(channel).into(),
}

View File

@ -71,7 +71,7 @@ struct PwmLimits {
impl PwmLimits {
pub fn new(channels: &mut Channels, channel: usize) -> Self {
let (max_v, _) = channels.get_max_v(channel);
let max_v = channels.get_max_v(channel);
let (max_i_pos, _) = channels.get_max_i_pos(channel);
let (max_i_neg, _) = channels.get_max_i_neg(channel);
PwmLimits {

View File

@ -316,7 +316,7 @@ fn main() -> ! {
send_line(&mut socket, b"{}");
}
Command::CenterPoint { channel, center } => {
let (i_tec, _) = channels.get_i(channel);
let i_tec = channels.get_i(channel);
let state = channels.channel_state(channel);
state.center = center;
if !state.pid_engaged {