diff --git a/README.md b/README.md index 89dc63a..3c82e70 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ formatted as line-delimited JSON. | `report mode ` | Set report mode | | `pwm` | Show current PWM settings | | `pwm <0/1> max_i_pos ` | Set PWM duty cycle for **max_i_pos** to *ampere* | -| `pwm <0/1> max_i_neg ` | Set PWM duty cycle for **max_i_neg** to *ampere* | +| `pwm <0/1> max_i_neg ` | Set PWM duty cycle for **max_i_neg** to *- ampere* | | `pwm <0/1> max_v ` | Set PWM duty cycle for **max_v** to *volt* | | `pwm <0/1> i_set ` | 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. + +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 diff --git a/doc/PID tuning.md b/doc/PID tuning.md index fbc15bd..51abc74 100644 --- a/doc/PID tuning.md +++ b/doc/PID tuning.md @@ -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) diff --git a/doc/assets/tec3pessen.png b/doc/assets/tec3pessen.png deleted file mode 100644 index bddb90c..0000000 Binary files a/doc/assets/tec3pessen.png and /dev/null differ diff --git a/doc/assets/twelve_hours.png b/doc/assets/twelve_hours.png new file mode 100644 index 0000000..f9bdba0 Binary files /dev/null and b/doc/assets/twelve_hours.png differ diff --git a/pytec/plot.py b/pytec/plot.py index 77fd048..4a1e6da 100644 --- a/pytec/plot.py +++ b/pytec/plot.py @@ -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() diff --git a/src/channel.rs b/src/channel.rs index 035d5c1..c867894 100644 --- a/src/channel.rs +++ b/src/channel.rs @@ -1,4 +1,8 @@ use stm32f4xx_hal::hal::digital::v2::OutputPin; +use uom::si::{ + f64::ElectricPotential, + 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 { pub state: ChannelState, /// for `i_set` pub dac: ad5680::Dac, - /// 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 Channel { 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::(1.5); Channel { state, - dac, dac_factor, + dac, vref_meas, shdn: pins.shdn, vref_pin: pins.vref_pin, itec_pin: pins.itec_pin, diff --git a/src/channels.rs b/src/channels.rs index 2ddf7d8..7103034 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -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; // 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::(ad5680::MAX_VALUE as f64 / dac_factor); - (voltage, max) + voltage } - 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::(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::() * dac_factor) as u32; - let value = match channel { + fn set_dac(&mut self, channel: usize, voltage: ElectricPotential) -> ElectricPotential { + let value = ((voltage / ElectricPotential::new::(DAC_OUT_V_MAX)).get::() * (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::(value as f64 / dac_factor); self.channel_state(channel).dac_value = voltage; - let max = ElectricPotential::new::(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::(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 { @@ -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 + /// be stored and used in subsequent i_set routines to bias the current control signal to the measured VREF, reducing + /// the offset error of the current control signal. + /// + /// 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. + /// + /// 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::(2.5); + let samples = 50; + let mut target_voltage = ElectricPotential::new::(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::(100.0); @@ -285,10 +294,10 @@ impl Channels { best_error = error; start_value = prev_value; - let dac_factor = value as f64 / dac_feedback.get::(); + let vref = (value as f64 / ad5680::MAX_VALUE as f64) * ElectricPotential::new::(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::(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::(3.3); let duty = (max_v / max).get::(); 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::(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::(3.0)).into(), + max_v: (self.get_max_v(channel), ElectricPotential::new::(5.0)).into(), max_i_pos: self.get_max_i_pos(channel).into(), max_i_neg: self.get_max_i_neg(channel).into(), } diff --git a/src/config.rs b/src/config.rs index 71db656..e7286fa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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 { diff --git a/src/main.rs b/src/main.rs index 7f29472..461f3fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 {