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
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
@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(np.float64(_range.minimum), r0, a, b, c, d)), 3),
            maximum=round(float(_cvd_resistance(np.float64(_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
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
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
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
1269
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
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
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
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