LegacyAntibodyImmunityModel.java

package org.matsim.episim.model;

import com.google.inject.Inject;
import org.matsim.episim.EpisimPerson;
import org.matsim.episim.EpisimPerson.DiseaseStatus;
import org.matsim.episim.Immunizable;
import org.matsim.episim.VaccinationConfigGroup;

/**
 * Immunity from the antibody level a person carries.
 *
 * <p>Extraction of the code that computed immunity from antibodies before {@link ImmunityModel} existed: the
 * three overrides of {@code AntibodyDependentTransitionModel} for the severity side and the immunity factors of
 * {@code InfectionModelWithAntibodies} for the infection side. The formulas are unchanged, including the
 * peculiarities that are almost certainly unintended but are current behaviour:</p>
 * <ul>
 *     <li>severity reads {@code getMaxAntibodies}, the highest level ever reached, which never wanes, and which
 *     already includes the boost of the very infection whose severity is being decided;</li>
 *     <li>the "is this omicron" list is long for {@code seriouslySick} but only BA.1/BA.2 for {@code critical};</li>
 *     <li>the guard that spares a person without previous immunity is active for {@code critical} and commented
 *     out for {@code seriouslySick};</li>
 *     <li>{@code showingSymptoms} is not modified at all.</li>
 * </ul>
 *
 * <p><b>One deliberate omission.</b> The IgA term of {@code InfectionModelWithAntibodies} is not carried over. It
 * needs the per-infection history, which {@link Immunizable} does not expose, and it is built on hard-coded
 * SARS-CoV-2 lineage lists. No scenario in this repository binds that infection model, and the transitional
 * composite routes protection against infection to the curves, so this path is unreachable today. Anyone binding
 * this model for the infection side has to decide what the IgA term should become first.</p>
 */
public class LegacyAntibodyImmunityModel implements ImmunityModel {

	private final VaccinationConfigGroup vaccinationConfig;

	@Inject
	public LegacyAntibodyImmunityModel(VaccinationConfigGroup vaccinationConfig) {
		this.vaccinationConfig = vaccinationConfig;
	}

	@Override
	public double getFactor(Immunizable person, VirusStrain strain, DiseaseStatus target, int day) {

		switch (target) {
			case infectedButNotContagious:
				return remainingRisk(currentAntibodies(person, strain));

			case showingSymptoms:
				return 1.0;

			case seriouslySick:
				return remainingRisk(boosted(person, strain, omicronFactorForSeriouslySick(strain)));

			case critical:
				if (person.getNumVaccinations() == 0 && person.getNumInfections() - 1 == 0)
					return 1.0;

				return remainingRisk(boosted(person, strain, omicronFactorForCritical(strain)));

			default:
				throw new IllegalArgumentException("No protection is configured against disease status '" + target
					+ "'. Supported are infectedButNotContagious, showingSymptoms, seriouslySick and critical.");
		}
	}

	@Override
	public double getInfectivityFactor(Immunizable infector, VirusStrain strain, int day) {
		// an infector with antibodies sheds less, but only a quarter of the effect is applied
		return 1.0 - 0.25 * (1.0 - remainingRisk(infector.getAntibodyLevelAtInfection()));
	}

	/**
	 * Remaining risk for an antibody level, the shape shared by every use: {@code 1} at level zero, falling
	 * towards {@code 0} as the level grows.
	 */
	private double remainingRisk(double antibodies) {
		return 1.0 / (1.0 + Math.pow(antibodies, vaccinationConfig.getBeta()));
	}

	/**
	 * Highest antibody level ever reached, with the two modifications the severity code applies: four times as
	 * much for a boostered person, and an extra factor for omicron strains. Note the direction: the factor
	 * multiplies the antibody level, so it <b>increases</b> protection rather than describing immune escape.
	 */
	private static double boosted(Immunizable person, VirusStrain strain, double omicronFactor) {
		double antibodies = person.getMaxAntibodies(strain);

		if (person.getNumVaccinations() > 1)
			antibodies *= 4;

		return antibodies * omicronFactor;
	}

	private static double omicronFactorForSeriouslySick(VirusStrain strain) {
		boolean isOmicron = strain.equals(VirusStrain.OMICRON_BA1)
			|| strain.equals(VirusStrain.OMICRON_BA2)
			|| strain.equals(VirusStrain.OMICRON_BA5)
			|| strain.equals(VirusStrain.BQ)
			|| strain.equals(VirusStrain.EG)
			|| strain.equals(VirusStrain.XBB_15)
			|| strain.equals(VirusStrain.XBB_19)
			|| strain.equals(VirusStrain.STRAIN_A)
			|| strain.equals(VirusStrain.STRAIN_B)
			|| strain.toString().startsWith("A_")
			|| strain.toString().startsWith("B_");

		return isOmicron ? 3.7 : 1.0;
	}

	/**
	 * Deliberately shorter than {@link #omicronFactorForSeriouslySick}: the original code only listed BA.1 and BA.2 here.
	 */
	private static double omicronFactorForCritical(VirusStrain strain) {
		return strain.equals(VirusStrain.OMICRON_BA1) || strain.equals(VirusStrain.OMICRON_BA2) ? 3.7 : 1.0;
	}

	/**
	 * The current, waning antibody level, as opposed to the lifetime maximum used for severity. It is not part of
	 * {@link Immunizable}, so this needs the concrete person; see {@code LegacyCurveImmunityModel.asPerson} for the same
	 * problem on the other side.
	 */
	private static double currentAntibodies(Immunizable person, VirusStrain strain) {
		if (person instanceof EpisimPerson)
			return ((EpisimPerson) person).getAntibodies(strain);

		throw new IllegalArgumentException("Protection against infection reads the current antibody level, which "
			+ "only " + EpisimPerson.class.getSimpleName() + " provides, but got " + person.getClass().getName());
	}
}