Skip to content

CVDEquation¤

The Callendar-Van Dusen (CVD) equation describes the relationship between resistance, \(R\), and temperature, \(t\), of platinum resistance thermometers (PRT). It is defined in two temperature ranges

\[ \frac{R(t)}{R_0} = \begin{cases} 1 + A \cdot t + B \cdot t^2 + D \cdot t^3 & R(t) \geq R_0 \\ 1 + A \cdot t + B \cdot t^2 + C \cdot t^3 \cdot (t-100) & R(t) \lt R_0 \\ \end{cases} \]

where, \(R_0 = R(0~^{\circ}\text{C})\) is the resistance at \(t=0~^{\circ}\text{C}\) and \(A\), \(B\), \(C\) and \(D\) are the CVD coefficients. The \(D\) coefficient is typically zero but may be non-zero if \(t \gtrsim 200~^{\circ}\text{C}\).

Suppose you have a variable named cvd (which is an instance of CVDEquation) that represents the following information in an equipment register for a PRT

<cvdCoefficients>
  <R0>100.0189</R0>
  <A>3.913e-3</A>
  <B>-6.056e-7</B>
  <C>1.372e-12</C>
  <D>0</D>
  <uncertainty variables="">0.0056/2</uncertainty>
  <range>
    <minimum>-10</minimum>
    <maximum>70</maximum>
  </range>
</cvdCoefficients>

You can access the CVD coefficients, degrees of freedom and comment as attributes of cvd,

>>> cvd.R0
100.0189
>>> cvd.A
0.003913
>>> cvd.B
-6.056e-07
>>> cvd.C
1.372e-12
>>> cvd.D
0.0
>>> cvd.degree_freedom
inf
>>> cvd.comment
''

evaluate the uncertainty,

>>> print(cvd.uncertainty())
0.0026

calculate resistance from temperature,

>>> print(cvd.resistance(12.4))
104.86262358516764
>>> cvd.resistance([-5, 0, 5, 10, 15, 20, 25])
array([ 98.06051774, 100.0189    , 101.97425549, 103.92658241,
       105.87588076, 107.82215054, 109.76539174])

and calculate temperature from resistance

>>> print(cvd.temperature(109.1))
23.287055698724505
>>> cvd.temperature([98.7, 99.2, 100.4, 101.7, 103.8])
array([-3.36816839, -2.09169544,  0.9738958 ,  4.29823964,  9.67558125])

A number or any sequence of numbers, i.e., a list, tuple or ndarray may be used to calculate the temperature or resistance (tip: using ndarray will improve performance since a copy of the values is not required).

When calculating resistance or temperature, the values of the inputs are checked to ensure that the values are within the range that the CVD coefficients are valid for. The XML data above shows that the temperature must be in the range \(-10~^\circ\text{C}\) to \(70~^\circ\text{C}\), which has a corresponding resistance range of \(96.099~\Omega\) to \(127.118~\Omega\) from the equation above. If you calculate resistance from \(t=-10.2~^\circ\text{C}\) or temperature from \(R=96.0~\Omega\) a ValueError is raised, since the value is outside the range.

>>> cvd.ranges
{'t': Range(minimum=-10, maximum=70), 'r': Range(minimum=96.099, maximum=127.118)}

>>> cvd.resistance(-10.2)
Traceback (most recent call last):
...
ValueError: The value -10.2 is not within the range [-10, 70]

>>> cvd.temperature(96)
Traceback (most recent call last):
...
ValueError: The value 96.0 is not within the range [96.099, 127.118]

You can bypass range checking by including a check_range=False keyword argument

>>> print(cvd.resistance(-10.2, check_range=False))
96.02059984653798
>>> print(cvd.temperature(96, check_range=False))
-10.252469261526016

CVDEquation dataclass ¤

CVDEquation(
    R0: float,
    A: float,
    B: float,
    C: float,
    D: float,
    uncertainty: Evaluable,
    ranges: dict[str, Range] = dict(),
    degree_freedom: float = float("inf"),
    comment: str = "",
)

The Callendar-Van Dusen (CVD) equation based on the cvdCoefficients element in an equipment register.

Parameters:

Name Type Description Default
R0 float

The value, in \(\Omega\), of the resistance at \(0~^\circ\text{C}\), \(R_0\).

required
A float

The value, in \((^\circ\text{C})^{-1}\), of the A coefficient, \(A \cdot t\).

required
B float

The value, in \((^\circ\text{C})^{-2}\), of the B coefficient, \(B \cdot t^2\).

required
C float

The value, in \((^\circ\text{C})^{-4}\), of the C coefficient, \(C \cdot t^3 \cdot (t-100)\).

required
D float

The value, in \((^\circ\text{C})^{-3}\), of the D coefficient, \(D \cdot t^3\). The \(D\) coefficient is typically zero but may be non-zero if \(t \gtrsim 200~^{\circ}\text{C}\). If a calibration report does not specify the \(D\) coefficient, set the value to be 0.

required
uncertainty Evaluable

The equation to evaluate to calculate the standard uncertainty.

required
ranges dict[str, Range]

The temperature range, in \((^\circ)\text{C}\), and the resistance range, in \(\Omega\), that the CVD coefficients are valid. The temperature key must be "t" and the resistance key "r".

dict()
degree_freedom float

The degrees of freedom.

float('inf')
comment str

A comment to associate with the CVD equation.

''

A instance-attribute ¤

A: float

The value, in \((^\circ\text{C})^{-1}\), of the A coefficient, \(A \cdot t\).

B instance-attribute ¤

B: float

The value, in \((^\circ\text{C})^{-2}\), of the B coefficient, \(B \cdot t^2\).

C instance-attribute ¤

C: float

The value, in \((^\circ\text{C})^{-4}\), of the C coefficient, \(C \cdot t^3 \cdot (t-100)\).

D instance-attribute ¤

D: float

The value, in \((^\circ\text{C})^{-3}\), of the D coefficient, \(D \cdot t^3\).

R0 instance-attribute ¤

R0: float

The value, in \(\Omega\), of the resistance at \(0~^\circ\text{C}\), \(R_0\).

comment class-attribute instance-attribute ¤

comment: str = ''

A comment associated with the Callendar-Van Dusen equation.

degree_freedom class-attribute instance-attribute ¤

degree_freedom: float = float('inf')

The degrees of freedom.

ranges class-attribute instance-attribute ¤

ranges: dict[str, Range] = field(default_factory=dict)

The temperature range, in \(^\circ\text{C}\), and the resistance range, in \(\Omega\), that the Callendar-Van Dusen coefficients are valid.

uncertainty instance-attribute ¤

uncertainty: Evaluable

The equation to evaluate to calculate the standard uncertainty.

from_xml classmethod ¤

from_xml(element: Element[str]) -> CVDEquation

Convert an XML element into a CVDEquation instance.

Parameters:

Name Type Description Default
element Element[str]

A cvdCoefficients XML element from an equipment register.

required

Returns:

Type Description
CVDEquation

The CVDEquation instance.

Source code in src/msl/equipment/schema.py
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
@classmethod
def from_xml(cls, element: Element[str]) -> CVDEquation:
    """Convert an XML element into a [CVDEquation][msl.equipment.schema.CVDEquation] instance.

    Args:
        element: A [cvdCoefficients][type_cvdCoefficients] XML element
            from an equipment register.

    Returns:
        The [CVDEquation][msl.equipment.schema.CVDEquation] instance.
    """
    # Schema forces order
    r0 = float(element[0].text or 0)
    a = float(element[1].text or 0)
    b = float(element[2].text or 0)
    c = float(element[3].text or 0)
    d = float(element[4].text or 0)

    r = element[6]
    _range = Range(float(r[0].text or -200), float(r[1].text or 661))
    ranges = {
        "t": _range,
        "r": Range(
            minimum=round(float(_cvd_resistance(_range.minimum, r0, a, b, c, d)), 3),
            maximum=round(float(_cvd_resistance(_range.maximum, r0, a, b, c, d)), 3),
        ),
    }

    u = element[5]
    uncertainty = Evaluable(
        equation=u.text or "",
        variables=tuple(u.attrib["variables"].split()),
        ranges=ranges,
    )

    return cls(
        R0=r0,
        A=a,
        B=b,
        C=c,
        D=d,
        uncertainty=uncertainty,
        ranges=ranges,
        degree_freedom=float(element[7].text or np.inf) if len(element) > 7 else np.inf,  # noqa: PLR2004
        comment=element.attrib.get("comment", ""),
    )

resistance ¤

resistance(
    temperature: ArrayLike, *, check_range: bool = True
) -> NDArray[float64]

Calculate resistance from temperature.

Parameters:

Name Type Description Default
temperature ArrayLike

The temperature values, in \(^\circ\text{C}\).

required
check_range bool

Whether to check that the temperature values are within the allowed range.

True

Returns:

Type Description
NDArray[float64]

The resistance values.

Source code in src/msl/equipment/schema.py
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
def resistance(self, temperature: ArrayLike, *, check_range: bool = True) -> NDArray[np.float64]:
    r"""Calculate resistance from temperature.

    Args:
        temperature: The temperature values, in $^\circ\text{C}$.
        check_range: Whether to check that the temperature values are within the allowed range.

    Returns:
        The resistance values.
    """
    array = np.asarray(temperature, dtype=float)
    if check_range and self.ranges["t"].check_within_range(array):
        pass  # check_within_range() will raise an error, if one occurred

    return _cvd_resistance(array, self.R0, self.A, self.B, self.C, self.D)

temperature ¤

temperature(
    resistance: ArrayLike, *, check_range: bool = True
) -> NDArray[float64]

Calculate temperature from resistance.

Parameters:

Name Type Description Default
resistance ArrayLike

The resistance values, in \(\Omega\).

required
check_range bool

Whether to check that the resistance values are within the allowed range.

True

Returns:

Type Description
NDArray[float64]

The temperature values.

Source code in src/msl/equipment/schema.py
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
def temperature(self, resistance: ArrayLike, *, check_range: bool = True) -> NDArray[np.float64]:
    r"""Calculate temperature from resistance.

    Args:
        resistance: The resistance values, in $\Omega$.
        check_range: Whether to check that the resistance values are within the allowed range.

    Returns:
        The temperature values.
    """
    array: NDArray[np.float64] = np.asarray(resistance, dtype=float)
    if check_range and self.ranges["r"].check_within_range(array):
        pass  # check_within_range raised an error, if one occurred

    def positive_quadratic(r: NDArray[np.float64]) -> NDArray[np.float64]:
        # rearrange CVD equation to be: a*x^2 + b*x + c = 0
        #   a -> B, b -> A, c -> 1 - R/R0
        # then use the quadratic formula
        return (-self.A + np.sqrt(self.A**2 - 4.0 * self.B * (1.0 - r / self.R0))) / (2.0 * self.B)

    def positive_cubic(r: NDArray[np.float64]) -> NDArray[np.float64]:
        # rearrange CVD equation to be: a*x^3 + b*x^2 + c*x + d = 0
        a = self.D
        b = self.B
        c = self.A
        d = 1.0 - (r / self.R0)

        # then use Cardano's Formula
        # https://proofwiki.org/wiki/Cardano's_Formula#Real_Coefficients
        Q: float = (3.0 * a * c - b**2) / (9.0 * a**2)  # noqa: N806
        R: NDArray[np.float64] = (9.0 * a * b * c - 27.0 * a**2 * d - 2.0 * b**3) / (54.0 * a**3)  # noqa: N806
        sqrt: NDArray[np.float64] = np.sqrt(Q**3 + R**2)
        S: NDArray[np.float64] = np.cbrt(R + sqrt)  # noqa: N806
        T: NDArray[np.float64] = np.cbrt(R - sqrt)  # noqa: N806
        return S + T - (b / (3.0 * a))  # x1 equation

    def negative(r: NDArray[np.float64]) -> NDArray[np.float64]:
        # rearrange CVD equation to be: a*x^4 + b*x^3 + c*x^2 + d*x + e = 0
        a = self.C
        b = -100.0 * self.C
        c = self.B
        d = self.A
        e = 1.0 - (r / self.R0)

        # https://en.wikipedia.org/wiki/Quartic_function#Solving_a_quartic_equation]
        # See Section "General formula for roots" for the definitions of these variables
        p = (8 * a * c - 3 * b**2) / (8 * a**2)
        q = (b**3 - 4 * a * b * c + 8 * a**2 * d) / (8 * a**3)
        delta_0 = c**2 - 3 * b * d + 12 * a * e
        delta_1 = 2 * c**3 - 9 * b * c * d + 27 * b**2 * e + 27 * a * d**2 - 72 * a * c * e
        Q = np.cbrt((delta_1 + np.sqrt(delta_1**2 - 4 * delta_0**3)) / 2)  # noqa: N806
        S = 0.5 * np.sqrt(-2 * p / 3 + 1 / (3 * a) * (Q + delta_0 / Q))  # noqa: N806

        # decide which root of the quartic to use by looking at the value under the
        # square root in the x1,2 and x3,4 equations
        t1 = -4 * S**2 - 2 * p
        t2 = q / S
        t3 = t1 - t2
        return np.piecewise(
            t3,
            [t3 >= 0, t3 < 0],
            [
                lambda x: -b / (4.0 * a) + S - 0.5 * np.sqrt(x),  # x4 equation
                lambda x: -b / (4.0 * a) - S + 0.5 * np.sqrt(x + 2.0 * t2),  # x1 equation
            ],
        )

    positive = positive_quadratic if self.D == 0 else positive_cubic
    return np.piecewise(array, [array < self.R0, array >= self.R0], [negative, positive])

to_xml ¤

to_xml() -> Element[str]

Convert the CVDEquation class into an XML element.

Returns:

Type Description
Element[str]

The CVDEquation as an XML element.

Source code in src/msl/equipment/schema.py
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
def to_xml(self) -> Element[str]:
    """Convert the [CVDEquation][msl.equipment.schema.CVDEquation] class into an XML element.

    Returns:
        The [CVDEquation][msl.equipment.schema.CVDEquation] as an XML element.
    """
    attrib = {"comment": self.comment} if self.comment else {}
    e = Element("cvdCoefficients", attrib=attrib)

    r0 = SubElement(e, "R0")
    r0.text = str(self.R0)

    a = SubElement(e, "A")
    a.text = str(self.A)

    b = SubElement(e, "B")
    b.text = str(self.B)

    c = SubElement(e, "C")
    c.text = str(self.C)

    d = SubElement(e, "D")
    d.text = str(self.D)

    u = SubElement(e, "uncertainty", attrib={"variables": " ".join(self.uncertainty.variables)})
    u.text = str(self.uncertainty.equation)

    rng = SubElement(e, "range")
    mn = SubElement(rng, "minimum")
    mn.text = str(self.ranges["t"].minimum)
    mx = SubElement(rng, "maximum")
    mx.text = str(self.ranges["t"].maximum)

    if not isinf(self.degree_freedom):
        dof = SubElement(e, "degreeFreedom")
        dof.text = str(self.degree_freedom)

    return e