Skip to content

Fix #11867 - remove boost::bisect and correct calculation of Standard Effective Temperature - #11877

Open
jmarrec wants to merge 7 commits into
developfrom
11867-remove_boost_bisect
Open

jmarrec wants to merge 7 commits into
developfrom
11867-remove_boost_bisect

Conversation

@jmarrec

@jmarrec jmarrec commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Pull request overview

REVIEW WITH https://github.com/NatLabRockies/EnergyPlus/pull/11877/changes/29ec00e6e1e616328f7fc6eb554b21d2db6f1dac..7075e054ccb57652ac8d2eb635e9f406bb86fc6f . The first commit just takes an alias for readability.

Description of the purpose of this PR

  • Initially I changed boost::bisect to SolveRoot and found that SET is discontinuous.
  • Fix SET discontinuity: correct SkinWetSweat when skin wettedness is capped at EvapEff
    • We now deliberately differ from the ASHRAE 55 reference code

TL;DR is that the reference implementation and tables from ASRHAE 55 Appendices have an error (that the original Gagge et al. (1986) didn't have). Wasn't sure how to approach this, so I reported it to, and then fixed myself two open source implementations that are probably much closer to ASHRAE 55 (I suspect at least of the two is actually the reference...):

Should watch:

Pull Request Author

  • Title of PR should be user-synopsis style (clearly understandable in a standalone changelog context)
  • Label the PR with at least one of: Defect, Refactoring, NewFeature, Performance, and/or DoNoPublish
  • Pull requests that impact EnergyPlus code must also include unit tests to cover enhancement or defect repair
  • Author should provide a "walkthrough" of relevant code changes using a GitHub code review comment process
  • If any diffs are expected, author must demonstrate they are justified using plots and descriptions
  • If changes fix a defect, the fix should be demonstrated in plots and descriptions
  • If any defect files are updated to a more recent version, upload new versions here or on DevSupport
  • If IDD requires transition, transition source, rules, ExpandObjects, and IDFs must be updated, and add IDDChange label
  • If structural output changes, add to output rules file and add OutputChange label
  • If adding/removing any LaTeX docs or figures, update that document's CMakeLists file dependencies
  • If adding/removing any output files (e.g., eplustbl.*)
    • Update ..\scripts\Epl-run.bat
    • Update ..\scripts\RunEPlus.bat
    • Update ..\src\EPLaunch\ MainModule.bas, epl-ui.frm, and epl.vbp (VersionComments)
    • Update ...github\workflows\energyplus.py

Reviewer

  • Perform a Code Review on GitHub
  • If branch is behind develop, merge develop and build locally to check for side effects of the merge
  • If defect, verify by running develop branch and reproducing defect, then running PR and reproducing fix
  • If feature, test running new feature, try creative ways to break it
  • CI status: all green or justified
  • Check that performance is not impacted (CI Linux results include performance check)
  • Run Unit Test(s) locally
  • Check any new function arguments for performance impacts
  • Verify IDF naming conventions and styles, memos and notes and defaults
  • If new idf included, locally check the err file and other outputs

…ffect

SET is discontinuous at the critical skin wettedness cap, so SolveRoot, which converges on |f(x)|, failed on 42 timesteps of 1ZoneUncontrolled_Win_ASH55_Thermal_Comfort. Bisecting on the bracket width matches boost::math::tools::bisect, and the results are identical to develop.

cf:
- #11867 (comment)
…mperature (SET)

```
[ RUN      ] EnergyPlusFixture.ThermalComfort_CalcStandardEffectiveTemp_ContinuousAtCriticalWettedness
/media/DataExt4/Software/Others/EnergyPlus/tst/EnergyPlus/unit/ThermalComfort.unit.cc:1133: Failure
Expected: (maxStep) < (0.01), actual: 0.486617 vs 0.01
/media/DataExt4/Software/Others/EnergyPlus/tst/EnergyPlus/unit/ThermalComfort.unit.cc:1134: Failure
Expected: (minStep) > (0.0), actual: -0.486617 vs 0
[  FAILED  ] EnergyPlusFixture.ThermalComfort_CalcStandardEffectiveTemp_ContinuousAtCriticalWettedness (607 ms)
```
…ness is capped at EvapEff

SkinWetSweat = EvapEff / 0.94 is not the inverse of SkinWetTot = 0.06 + 0.94 * SkinWetSweat, so at the cap the skin evaporated as if its wettedness were EvapEff + 0.06. The cap then switched on and off between the minutes of the simulation, making SET jump by up to 0.5 C. Use (SkinWetTot - 0.06) / 0.94, as in Gagge et al. (1986): prsw=(eveff-.06)/.94.

Same fix as:
- CenterForTheBuiltEnvironment/comfort_tool#103
- pythermalcomfort/pythermalcomfort#490
SolveRoot bounds the SET mismatch, not the cooling effect itself, so Acc = 0.01 allowed errors up to 0.017 C where SET rises slowly with temperature (vs 0.003 C for the previous bisection). With 0.001, the max error on the annual ASH55 inputs is 0.0017 C for 4.4 root function evaluations per call on average, vs 15 with bisection.
@jmarrec jmarrec self-assigned this Oct 23, 2026
@jmarrec jmarrec added the Defect Includes code to repair a defect in EnergyPlus label Oct 23, 2026
Comment on lines 1067 to +1071
if (s_tc->SkinWetTot >= EvapEff) {
s_tc->SkinWetTot = EvapEff;
s_tc->SkinWetSweat = EvapEff / 0.94;
// Invert SkinWetTot = SkinWetSweat + SkinWetDiff = SkinWetSweat + (1 - SkinWetSweat) * 0.06 = 0.06 + 0.94 * SkinWetSweat, so
// that EvapHeatLoss below equals SkinWetTot * EvapHeatLossMax (Gagge et al. 1986: prsw=(eveff-.06)/.94)
s_tc->SkinWetSweat = (s_tc->SkinWetTot - 0.06) / 0.94;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's the discontinuity fix.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Comment on lines +1300 to +1307
Real64 constexpr tempAccuracy = 0.001; // Accuracy of the SET match [deltaC]
int constexpr MaxIte = 100;
Real64 constexpr lowerBound = 0.0;
Real64 constexpr upperBound = 50.0;
int SolFla = 0;

// We have yet another solver?
try {
std::pair<Real64, Real64> solverResult = boost::math::tools::bisect(ce_root_function, lowerBound, upperBound, ce_root_termination);
CoolingEffect = (solverResult.first + solverResult.second) / 2;
} catch (const std::exception &e) {
General::SolveRoot(state, tempAccuracy, MaxIte, SolFla, CoolingEffect, ce_root_function, lowerBound, upperBound);
if (SolFla < 0) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that SET is continuous, we can go back to General::SolveRoot instead of the bisection.

I first used tempAccuracy = 0.01 °C, but then lowered it to 0.001. SolveRoot stops when the SET mismatch |f(x)| is below the tolerance, not when the bracket on the cooling effect is small. Where SET rises slowly with temperature, a 0.01 tolerance on SET can therefore allow a larger error on the cooling effect.

I instrumented a standalone copy of the solver and ran it on the 38016 (air temperature, MRT, RH, air velocity, met, clo) inputs from the annual ASH55 run. I compared each result with the exact root:

Solver Max error [°C] Mean error [°C] Root function evaluations per call
Bisection (previous) 0.0031 0.0015 15 (always)
SolveRoot, Acc = 0.01 0.0169 0.0012 3.98
SolveRoot, Acc = 0.001 0.0017 0.0001 4.39

With 0.001, SolveRoot is more accurate than the bisection. It still needs about 3.4x fewer evaluations, and each evaluation is a full SET calculation. The bisection always does 15, because it stops on bracket width: two endpoints, then halvings until the [0, 50] bracket is under 0.01.

The annual E+ run (fix + bisection against fix + SolveRoot) shows the same thing. The max Cooling Effect diff is 0.018 °C with Acc = 0.01 and 0.004 °C with Acc = 0.001.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Improved accuracy with fewer iterations = winning

(I believe the note above where you state "3.4x" should probably be 4.3x based on the table.)

Comment on lines +1112 to +1135
TEST_F(EnergyPlusFixture, ThermalComfort_CalcStandardEffectiveTemp_ContinuousAtCriticalWettedness)
{
// When the skin wettedness reaches EvapEff, SkinWetSweat must invert SkinWetTot = 0.06 + 0.94 * SkinWetSweat, i.e.
// (SkinWetTot - 0.06) / 0.94 as in Gagge et al. (1986). SET should then change smoothly and monotonically with temperature.
state->dataEnvrn->OutBaroPress = 101325.0;
Real64 constexpr RelHum = 0.7;
Real64 constexpr AirVel = 0.1;
Real64 constexpr ActMet = 1.1;
Real64 constexpr CloUnit = 0.5;
Real64 constexpr WorkEff = 0.0;

Real64 maxStep = 0.0;
Real64 minStep = std::numeric_limits<Real64>::max();
Real64 previous = CalcStandardEffectiveTemp(*state, 32.5, 32.5, RelHum, AirVel, ActMet, CloUnit, WorkEff);
for (int i = 1; i <= 1500; ++i) {
Real64 const temp = 32.5 + i * 0.001;
Real64 const current = CalcStandardEffectiveTemp(*state, temp, temp, RelHum, AirVel, ActMet, CloUnit, WorkEff);
maxStep = std::max(maxStep, std::abs(current - previous));
minStep = std::min(minStep, current - previous);
previous = current;
}
EXPECT_LT(maxStep, 0.01);
EXPECT_GT(minStep, 0.0); // monotonic, so the cooling effect has a single root
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before fix

[ RUN      ] EnergyPlusFixture.ThermalComfort_CalcStandardEffectiveTemp_ContinuousAtCriticalWettedness
/media/DataExt4/Software/Others/EnergyPlus/tst/EnergyPlus/unit/ThermalComfort.unit.cc:1133: Failure
Expected: (maxStep) < (0.01), actual: 0.486617 vs 0.01
/media/DataExt4/Software/Others/EnergyPlus/tst/EnergyPlus/unit/ThermalComfort.unit.cc:1134: Failure
Expected: (minStep) > (0.0), actual: -0.486617 vs 0
[  FAILED  ] EnergyPlusFixture.ThermalComfort_CalcStandardEffectiveTemp_ContinuousAtCriticalWettedness (607 ms)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. There's a few lingering test values in here where @Myoldmopar was scratching his head previously.

https://github.com/NatLabRockies/EnergyPlus/blame/7075e054ccb57652ac8d2eb635e9f406bb86fc6f/tst/EnergyPlus/unit/ThermalComfort.unit.cc#L1085

Might be worth cleaning these up while we're in here.

@github-actions

Copy link
Copy Markdown

⚠️ Regressions detected on macos-15 for commit 62778fa

Regression Summary
  • ESO Big Diffs: 4

@jmarrec

jmarrec commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

Diffs

Expected ESO diffs in 4 files, and no table diffs:

File Max SET diff [°C] Max Cooling Effect diff [°C]
1ZoneUncontrolled_Win_ASH55_Thermal_Comfort +0.270 0.006
1ZoneUncontrolled_Win_ResilienceReports +0.283 —
1ZoneUncontrolled_Win_ResilienceReports_ReportingPeriod +0.281 —
1ZoneUncontrolled_Win_ResilienceReports_ReportingPeriod_AllTable +0.281 —

These are the only files that report SET (and, for ASH55, the Cooling Effect). All the max diffs are at 07/21 06:00 on the summer design day. The Cooling Effect Adjusted PMV/PPD diffs in ASH55 are tiny (max 0.0023 and 0.013).

Annual run

To check for a pattern, I ran 1ZoneUncontrolled_Win_ASH55_Thermal_Comfort for a full year (Golden, CO TMY3) at timestep resolution (35040 timesteps), on develop and on this branch.
I changed the variables from Hourly to Timestep.

Only the SET, Cooling Effect and Cooling Effect Adjusted PMV/PPD outputs change.

SET changes at 7550 timesteps, all with SET above 36.6 °C, which is where skin wettedness reaches the cap. Below that, the two builds are bit-identical. The change is a systematic upward correction. With the bug, the skin evaporated an extra 0.06 * EvapHeatLossMax, so SET came out too low.

SET (develop) [°C] Mean change [°C] Max change [°C]
37–39 +0.31 +0.49
41–42 +0.24 +0.34
43–44 +0.16 +0.24
45–46 +0.09 +0.15
47–48 +0.02 +0.05

The correction shrinks as conditions get hotter and more humid, because EvapHeatLossMax, and with it the extra 0.06 * EvapHeatLossMax, gets smaller. There are 14 timesteps around 37–38 °C where SET decreases slightly, by at most 0.003 °C. At those timesteps the cap was toggling on and off.

image

Cooling Effect:

  • At timesteps where SET changed, the Cooling Effect follows. At 678 timesteps (1.9 % of the year, about 4.6 % from June to September), it changes by 0.15 to 0.47 °C, nearly always downward (672 of 678). On develop, the SET discontinuity gave f(x) = SET(still air, T - x) - SET(v, T) several roots, and the solver landed on a wrong one. SET is now continuous and monotonic, so there is a single root.
  • At timesteps where SET is unchanged, the only differences come from switching the bisection to SolveRoot: max 0.0076 °C, mean 0.0015 °C, p99 0.0032 °C.
image

The largest Cooling Effect Adjusted PMV and PPD changes are 0.13 and 4.7, at the wrong-root timesteps.

No "The cooling effect could not be solved" warnings in either run.

@github-actions

Copy link
Copy Markdown

⚠️ Regressions detected on ubuntu-24.04 for commit 62778fa

Regression Summary
  • ESO Big Diffs: 4

@jmarrec
jmarrec requested a review from mitchute October 26, 2026 18:42

@mitchute mitchute left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for all the background work to figure this out @jmarrec. Looks good. I'll let you decide if you want to address those minor cleanups in the unit tests.

Comment on lines -57 to -58
// Third Party Headers
#include <boost/math/tools/roots.hpp>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's great.

Real64 CalcStandardEffectiveTemp(
EnergyPlusData &state, Real64 AirTemp, Real64 RadTemp, Real64 RelHum, Real64 AirVel, Real64 ActMet, Real64 CloUnit, Real64 WorkEff)
{
auto &s_tc = state.dataThermalComforts;

@mitchute mitchute Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More comfortable to read.

Comment on lines 1067 to +1071
if (s_tc->SkinWetTot >= EvapEff) {
s_tc->SkinWetTot = EvapEff;
s_tc->SkinWetSweat = EvapEff / 0.94;
// Invert SkinWetTot = SkinWetSweat + SkinWetDiff = SkinWetSweat + (1 - SkinWetSweat) * 0.06 = 0.06 + 0.94 * SkinWetSweat, so
// that EvapHeatLoss below equals SkinWetTot * EvapHeatLossMax (Gagge et al. 1986: prsw=(eveff-.06)/.94)
s_tc->SkinWetSweat = (s_tc->SkinWetTot - 0.06) / 0.94;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Comment on lines +1300 to +1307
Real64 constexpr tempAccuracy = 0.001; // Accuracy of the SET match [deltaC]
int constexpr MaxIte = 100;
Real64 constexpr lowerBound = 0.0;
Real64 constexpr upperBound = 50.0;
int SolFla = 0;

// We have yet another solver?
try {
std::pair<Real64, Real64> solverResult = boost::math::tools::bisect(ce_root_function, lowerBound, upperBound, ce_root_termination);
CoolingEffect = (solverResult.first + solverResult.second) / 2;
} catch (const std::exception &e) {
General::SolveRoot(state, tempAccuracy, MaxIte, SolFla, CoolingEffect, ce_root_function, lowerBound, upperBound);
if (SolFla < 0) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Improved accuracy with fewer iterations = winning

(I believe the note above where you state "3.4x" should probably be 4.3x based on the table.)

Comment on lines +1112 to +1135
TEST_F(EnergyPlusFixture, ThermalComfort_CalcStandardEffectiveTemp_ContinuousAtCriticalWettedness)
{
// When the skin wettedness reaches EvapEff, SkinWetSweat must invert SkinWetTot = 0.06 + 0.94 * SkinWetSweat, i.e.
// (SkinWetTot - 0.06) / 0.94 as in Gagge et al. (1986). SET should then change smoothly and monotonically with temperature.
state->dataEnvrn->OutBaroPress = 101325.0;
Real64 constexpr RelHum = 0.7;
Real64 constexpr AirVel = 0.1;
Real64 constexpr ActMet = 1.1;
Real64 constexpr CloUnit = 0.5;
Real64 constexpr WorkEff = 0.0;

Real64 maxStep = 0.0;
Real64 minStep = std::numeric_limits<Real64>::max();
Real64 previous = CalcStandardEffectiveTemp(*state, 32.5, 32.5, RelHum, AirVel, ActMet, CloUnit, WorkEff);
for (int i = 1; i <= 1500; ++i) {
Real64 const temp = 32.5 + i * 0.001;
Real64 const current = CalcStandardEffectiveTemp(*state, temp, temp, RelHum, AirVel, ActMet, CloUnit, WorkEff);
maxStep = std::max(maxStep, std::abs(current - previous));
minStep = std::min(minStep, current - previous);
previous = current;
}
EXPECT_LT(maxStep, 0.01);
EXPECT_GT(minStep, 0.0); // monotonic, so the cooling effect has a single root
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. There's a few lingering test values in here where @Myoldmopar was scratching his head previously.

https://github.com/NatLabRockies/EnergyPlus/blame/7075e054ccb57652ac8d2eb635e9f406bb86fc6f/tst/EnergyPlus/unit/ThermalComfort.unit.cc#L1085

Might be worth cleaning these up while we're in here.

@mitchute mitchute added the Staged By adding the "Staged" label, some expensive checks needed before merge are run. label Oct 29, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Defect Includes code to repair a defect in EnergyPlus Staged By adding the "Staged" label, some expensive checks needed before merge are run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

boost::math::tools::bisect is used in E+ own code

3 participants