LegacyCurveImmunityModel.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;
import org.matsim.episim.VaccinationFactorFunction;

/**
 * Immunity from the effectiveness curves of {@link VaccinationConfigGroup}.
 *
 * <p>This is a faithful extraction of the code that computed immunity before {@link ImmunityModel} existed: the
 * default methods of {@code DiseaseStatusTransitionModel} for the severity side, and
 * {@code DefaultInfectionModel.getVaccinationEffectiveness} / {@code getImmunityEffectiveness} /
 * {@code getInfectivity} for the infection side. Nothing about the computation was changed, so the known defects of that
 * code are still here on purpose and are removed in separate steps:</p>
 * <ul>
 *     <li>natural immunity is the single {@code VaccinationType.natural}, not one type per source strain;</li>
 *     <li>it is timed by {@code daysSince(recovered)}, which is one global timestamp across all pathogens;</li>
 *     <li>a strain without a curve silently falls back to the SARS-CoV-2 curve;</li>
 *     <li>{@code getImmunityEffectiveness} contains a hard-coded 180 days of sterilising immunity.</li>
 * </ul>
 */
public class LegacyCurveImmunityModel implements ImmunityModel {

	private final VaccinationConfigGroup vaccinationConfig;

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

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

		if (target == DiseaseStatus.infectedButNotContagious)
			return Math.min(vaccinationEffectiveness(person, strain, day), naturalEffectiveness(person, strain, day));

		return severityFactor(person, strain, curveFor(target), day);
	}

	@Override
	public double getInfectivityFactor(Immunizable infector, VirusStrain strain, int day) {

		double naturalInfectivity = 1;

		if (vaccinationConfig.hasParams(VaccinationType.natural) && infector.hadDiseaseStatus(DiseaseStatus.recovered)) {
			VaccinationConfigGroup.VaccinationParams params = vaccinationConfig.getParams(VaccinationType.natural);
			naturalInfectivity = params.getInfectivity(strain, infector.daysSince(DiseaseStatus.recovered, day));
		}

		return Math.min(vaccinationInfectivity(infector, strain, day), naturalInfectivity);
	}

	/**
	 * Which of the per-strain curves describes protection against this target. The three severity curves differ
	 * only in which one is read, which is why the three near-identical methods of the old interface collapse here.
	 */
	private static VaccinationFactorFunction curveFor(DiseaseStatus target) {
		switch (target) {
			case showingSymptoms:
				return VaccinationConfigGroup.VaccinationParams::getFactorShowingSymptoms;
			case seriouslySick:
				return VaccinationConfigGroup.VaccinationParams::getFactorSeriouslySick;
			case critical:
				return VaccinationConfigGroup.VaccinationParams::getFactorCritical;
			default:
				throw new IllegalArgumentException("No protection is configured against disease status '" + target
					+ "'. Supported are infectedButNotContagious, showingSymptoms, seriouslySick and critical.");
		}
	}

	private double severityFactor(Immunizable person, VirusStrain strain, VaccinationFactorFunction curve, int day) {

		double fromVaccination = vaccinationConfig.getMinFactor(asPerson(person), day, curve);

		double fromInfection = (person.getNumInfections() > 0
			&& person.hadDiseaseStatus(DiseaseStatus.recovered)
			&& vaccinationConfig.hasParams(VaccinationType.natural))
			? curve.getFactor(vaccinationConfig.getParams(VaccinationType.natural), strain,
				person.daysSince(DiseaseStatus.recovered, day))
			: 1d;

		return Math.min(fromVaccination, fromInfection);
	}

	private double vaccinationEffectiveness(Immunizable person, VirusStrain strain, int day) {

		EpisimPerson target = asPerson(person);

		if (target.getVaccinationStatus() == EpisimPerson.VaccinationStatus.no)
			return 1;

		int daysVaccinated = target.daysSince(EpisimPerson.VaccinationStatus.yes, day);
		VaccinationConfigGroup.VaccinationParams params = vaccinationConfig.getParams(target.getVaccinationType());

		double vaccineEffectiveness;
		// a person with a previous infection also gets the boost effectiveness
		if (target.getReVaccinationStatus() == EpisimPerson.VaccinationStatus.yes || target.getNumInfections() >= 1) {
			vaccineEffectiveness = params.getBoostEffectiveness(strain,
				Math.min(daysVaccinated, target.daysSinceOrElse(DiseaseStatus.recovered, day, Integer.MAX_VALUE)));
		} else {
			vaccineEffectiveness = params.getEffectiveness(strain, daysVaccinated);
		}

		return 1 - vaccineEffectiveness;
	}

	private double naturalEffectiveness(Immunizable person, VirusStrain strain, int day) {

		if (person.getNumInfections() < 1 || !person.hadDiseaseStatus(DiseaseStatus.recovered))
			return 1;

		if (!vaccinationConfig.hasParams(VaccinationType.natural))
			return 1;

		int daysSince = person.daysSince(DiseaseStatus.recovered, day);

		// persons can not get infected for 180 days when they had 2 infections, or 1 infection and vaccinations
		// TODO: only here a quick fix and needs to be remodelled
		if (daysSince < 180 && (person.getNumInfections() >= 2
			|| (person.getNumInfections() >= 1 && asPerson(person).getVaccinationStatus() == EpisimPerson.VaccinationStatus.yes)))
			return 0;

		return 1 - vaccinationConfig.getParams(VaccinationType.natural).getEffectiveness(strain, daysSince);
	}

	private double vaccinationInfectivity(Immunizable person, VirusStrain strain, int day) {

		EpisimPerson infector = asPerson(person);

		if (infector.getVaccinationStatus() == EpisimPerson.VaccinationStatus.no)
			return 1;

		int daysVaccinated = infector.daysSince(EpisimPerson.VaccinationStatus.yes, day);
		VaccinationConfigGroup.VaccinationParams params = vaccinationConfig.getParams(infector.getVaccinationType());

		if (infector.getReVaccinationStatus() == EpisimPerson.VaccinationStatus.yes || infector.getNumInfections() >= 1) {
			return params.getBoostInfectivity(strain,
				Math.min(daysVaccinated, infector.daysSinceOrElse(DiseaseStatus.recovered, day, Integer.MAX_VALUE)));
		}

		return params.getInfectivity(strain, daysVaccinated);
	}

	/**
	 * The vaccination history is not part of {@link Immunizable}, so everything that reads it needs the concrete
	 * person. The old code hid the same cast inside the default methods of {@code DiseaseStatusTransitionModel};
	 * keeping it here does not make it safe, it only makes it visible and removable in one place. It fails for the
	 * replaying {@code Immunizable} of {@code HospitalNumbersFromEvents}, which is why that caller must not be
	 * routed here until {@link Immunizable} exposes the vaccination history.
	 */
	private static EpisimPerson asPerson(Immunizable person) {
		if (person instanceof EpisimPerson)
			return (EpisimPerson) person;

		throw new IllegalArgumentException("This immunity model reads the vaccination history, which only "
			+ EpisimPerson.class.getSimpleName() + " provides, but got " + person.getClass().getName());
	}
}