ImmunityConfigGroup.java
package org.matsim.episim;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.matsim.core.config.Config;
import org.matsim.core.config.ConfigGroup;
import org.matsim.core.config.ConfigUtils;
import org.matsim.core.config.ReflectiveConfigGroup;
import org.matsim.episim.EpisimPerson.DiseaseStatus;
import org.matsim.episim.model.ConfigurableProgressionModel;
import org.matsim.episim.model.ExplicitImmunityModel;
import org.matsim.episim.model.ImmunityModel;
import org.matsim.episim.model.ImmunityEvent;
import org.matsim.episim.model.Pathogen;
import org.matsim.episim.model.VaccinationType;
import org.matsim.episim.model.VirusStrain;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.time.LocalDate;
import java.util.NavigableMap;
import java.util.TreeSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.OptionalDouble;
import java.util.Set;
/**
* How much protection an immunity event gives, over time, against which disease status and which strain.
*
* <p>One {@link SourceParams} per source of immunity. A source is an {@link ImmunityEvent}: an infection is
* described by the {@link VirusStrain} that caused it, a product — a vaccine, a monoclonal antibody such as
* nirsevimab, a maternal vaccination — by its {@link VaccinationType}. Which of the two is stated explicitly
* with {@code sourceKind}, because strains and vaccination types live in separate registries and a name may exist
* in both. The Java class of a product is still called {@code VaccinationType}, although not every product is a
* vaccine.</p>
*
* <p>Each source holds one {@link ProtectionParams} per target disease status ({@code against}) and target strain:
* a {@link ProtectionCurve} of protection over the days since the event. Protection against strains of
* <i>other</i> pathogens is not listed pair by pair but given once by {@code otherPathogensProtection}.</p>
*
* <p>{@code model} decides whether this group is read at all. With {@link Model#legacyCovid}, the default, the
* immunity model is the legacy implementation the scenario module binds, which reads the vaccination curves and
* antibody levels calibrated for SARS-CoV-2; this group is then ignored. With {@link Model#explicit} the new
* immunity model reads this group, and it is checked strictly before the simulation starts.</p>
*
* <p>This group only describes protection. Who receives a product and when stays in {@link VaccinationConfigGroup}
* and the vaccination model; the protection curves of {@link VaccinationConfigGroup} are not read by the immunity
* model that uses this group.</p>
*/
public final class ImmunityConfigGroup extends ReflectiveConfigGroup {
public static final String GROUPNAME = "immunity";
private static final Logger log = LogManager.getLogger(ImmunityConfigGroup.class);
/**
* Disease statuses a person can be protected against, in the order of the course of a disease.
*/
public static final Set<DiseaseStatus> SUPPORTED_TARGETS = Collections.unmodifiableSet(EnumSet.of(
DiseaseStatus.infectedButNotContagious, DiseaseStatus.showingSymptoms,
DiseaseStatus.seriouslySick, DiseaseStatus.critical));
private static final String MODEL = "model";
private static final String OTHER_PATHOGENS_PROTECTION = "otherPathogensProtection";
private static final String COMBINATOR = "combinator";
/**
* Which immunity model a run uses.
*/
public enum Model {
/**
* The legacy implementation bound by the scenario module, calibrated for SARS-CoV-2. This group is not read.
*/
legacyCovid,
/**
* The immunity model that reads this group; the group is checked strictly.
*/
explicit
}
/**
* How the protection of several immunity events of one person is reduced to one.
*/
public enum Combinator {
/**
* The strongest single event wins; repeated events do not add up.
*/
min,
/**
* Remaining risks multiply, so repeated events add up.
*/
product
}
private Model model = Model.legacyCovid;
/**
* Protection against strains of pathogens a source does not list; {@code null} while not set.
*/
private Double otherPathogensProtection;
private Combinator combinator = Combinator.min;
private final Map<ImmunityEvent, SourceParams> sources = new LinkedHashMap<>();
/**
* An empty group: no source of immunity and {@code otherPathogensProtection} not set.
*/
public ImmunityConfigGroup() {
super(GROUPNAME);
}
public Model getModel() {
return model;
}
public void setModel(Model model) {
this.model = Objects.requireNonNull(model, "Immunity model must not be null");
}
@StringGetter(MODEL)
String getModelString() {
return model.name();
}
@StringSetter(MODEL)
void setModelString(String value) {
setModel(parseEnum(Model.class, MODEL, value));
}
/**
* Protection against strains of pathogens a source does not list, if set.
*/
public OptionalDouble getOtherPathogensProtection() {
return otherPathogensProtection == null ? OptionalDouble.empty() : OptionalDouble.of(otherPathogensProtection);
}
public void setOtherPathogensProtection(double protection) {
if (!(protection >= 0 && protection <= 1))
throw new IllegalArgumentException("'" + OTHER_PATHOGENS_PROTECTION + "' has to be between 0 and 1, got " + protection);
this.otherPathogensProtection = protection;
}
@StringGetter(OTHER_PATHOGENS_PROTECTION)
String getOtherPathogensProtectionString() {
return otherPathogensProtection == null ? null : otherPathogensProtection.toString();
}
@StringSetter(OTHER_PATHOGENS_PROTECTION)
void setOtherPathogensProtectionString(String value) {
if (value == null || value.isBlank())
otherPathogensProtection = null;
else
setOtherPathogensProtection(Double.parseDouble(value.trim()));
}
public Combinator getCombinator() {
return combinator;
}
public void setCombinator(Combinator combinator) {
this.combinator = Objects.requireNonNull(combinator, "Combinator must not be null");
}
@StringGetter(COMBINATOR)
String getCombinatorString() {
return combinator.name();
}
@StringSetter(COMBINATOR)
void setCombinatorString(String value) {
setCombinator(parseEnum(Combinator.class, COMBINATOR, value));
}
/**
* Parameters of the immunity gained from an infection with this strain, added if missing.
*/
public SourceParams getOrAddSource(VirusStrain strain) {
return getOrAddSource(strain, SourceKind.infection);
}
/**
* Parameters of the immunity given by this product, added if missing.
*/
public SourceParams getOrAddSource(VaccinationType product) {
return getOrAddSource(product, SourceKind.product);
}
private SourceParams getOrAddSource(ImmunityEvent event, SourceKind kind) {
SourceParams existing = sources.get(event);
if (existing != null)
return existing;
SourceParams p = new SourceParams();
p.setSource(event.toString(), kind);
addParameterSet(p);
return p;
}
/**
* Parameters of a source, or {@code null} if it is not configured.
*/
public SourceParams getSource(ImmunityEvent event) {
return sources.get(event);
}
public Collection<SourceParams> getSources() {
return Collections.unmodifiableCollection(sources.values());
}
@Override
public ConfigGroup createParameterSet(String type) {
if (SourceParams.SET_TYPE.equals(type))
return new SourceParams();
throw new IllegalArgumentException("Unknown parameter set type: " + type);
}
@Override
public void addParameterSet(final ConfigGroup set) {
if (!SourceParams.SET_TYPE.equals(set.getName()))
throw new IllegalStateException("Unknown parameter set type: " + set.getName());
SourceParams p = (SourceParams) set;
ImmunityEvent event = p.getSource();
if (sources.containsKey(event))
throw new IllegalArgumentException("Source of immunity '" + event + "' is configured twice in config group '"
+ GROUPNAME + "'");
sources.put(event, p);
super.addParameterSet(set);
}
/**
* Refuses a run whose bound {@link ImmunityModel} is not the one this group asks for.
*
* <p>A module that binds {@code ImmunityModel} directly wins over the provider that reads {@link #MODEL}, so the
* setting would be ignored without a word. That is how every scenario in this repository used to bind its legacy
* implementation; they now bind it as {@code @Legacy}, but modules outside this repository do not know that yet.</p>
*
* @param bound the implementation the run actually got
*/
public void checkBoundModel(Object bound) {
boolean isExplicit = bound instanceof ExplicitImmunityModel;
if (isExplicit == (model == Model.explicit)) {
log.info("Immunity model: {} ('{}.{}' is {})", bound.getClass().getSimpleName(), GROUPNAME, MODEL, model);
return;
}
throw new IllegalStateException("'" + GROUPNAME + "." + MODEL + "' is " + model + ", but the run is using "
+ bound.getClass().getSimpleName() + ". A module binds " + ImmunityModel.class.getSimpleName()
+ " directly and overrules the setting; bind it with the @Legacy qualifier instead, so that the "
+ "configuration decides which model a run uses.");
}
/**
* Refuses a run in which a component takes immunity from somewhere other than {@link org.matsim.episim.model.ImmunityModel}
* while this group configures immunity.
*
* <p>Some infection models read antibody levels directly, or apply no immunity at all. They are consistent with
* {@link Model#legacyCovid}, but under {@link Model#explicit} the configured curves would never reach the
* infection probabilities: antibodies are not even evolved. The run would look normal and quietly give agents no
* protection against infection, which is worse than not starting.</p>
*
* @param component the class that does not ask the immunity model, named in the message
* @param what what it does instead, e.g. "takes susceptibility from antibody levels"
*/
public static void requireLegacyImmunity(Config config, Class<?> component, String what) {
ImmunityConfigGroup immunity = ConfigUtils.addOrGetModule(config, ImmunityConfigGroup.class);
if (immunity.getModel() == Model.legacyCovid)
return;
throw new IllegalStateException(component.getSimpleName() + " " + what + " instead of asking the immunity model, "
+ "but '" + GROUPNAME + "." + MODEL + "' is " + immunity.getModel() + ". The configured curves would never be "
+ "applied and agents would have no protection against infection. Bind an infection model that asks the "
+ "immunity model, such as AgeAndProgressionDependentInfectionModelWithSeasonality, or set " + MODEL + " to "
+ Model.legacyCovid + ".");
}
/**
* Checks the group before the simulation starts. Only with {@link Model#explicit}: with {@link Model#legacyCovid}
* the group is not read, and settings that would be ignored are reported as a warning.
*/
@Override
protected void checkConsistency(Config config) {
super.checkConsistency(config);
if (model == Model.legacyCovid) {
if (!sources.isEmpty() || otherPathogensProtection != null)
log.warn("Config group '{}' configures immunity, but '{}' is {}: the legacy immunity model does not read "
+ "this group, so these settings have no effect.", GROUPNAME, MODEL, model);
return;
}
List<String> problems = new ArrayList<>();
for (SourceParams source : sources.values())
source.collectProblems(problems);
// coverage needs complete sets; report incomplete ones first
if (problems.isEmpty()) {
checkCoverage(config, problems);
checkRefractoryPeriod(config, problems);
}
if (!problems.isEmpty())
throw new IllegalStateException(String.join("\n", problems));
}
/**
* Checks that immunity left by an infection can reach anybody.
*
* <p>An agent in {@code recovered} is not a candidate for infection at all, whatever the curves say: the contact
* model skips them. They can only be infected again after the progression config takes them back to
* {@code susceptible}, so without that transition every curve of every infection source is dead, silently. This
* holds across pathogens as well, because an agent has one disease status for all of them.</p>
*
* <p>Curves of products are not affected: they protect agents who have not been infected yet. A run whose
* infection sources all say {@code "0>0.0"} is therefore fine without the transition — unless
* {@link #OTHER_PATHOGENS_PROTECTION} is positive, because that protection comes from the same events.</p>
*
* <p>How <i>long</i> the state lasts is a modelling decision and not checked here; the point of the new model is
* that protection is described by curves, so this transition should be a short refractory period.</p>
*/
private void checkRefractoryPeriod(Config config, List<String> problems) {
EpisimConfigGroup episimConfig = VirusStrainConfigGroup.moduleOrDefault(config, "episim",
EpisimConfigGroup.class, EpisimConfigGroup::new);
if (episimConfig == null)
return;
com.typesafe.config.Config progression = episimConfig.getProgressionConfig();
// an empty config is not "no transitions": the progression model falls back to its own default, which does
// return agents to susceptible
if (progression.isEmpty())
progression = ConfigurableProgressionModel.DEFAULT_CONFIG;
String path = DiseaseStatus.recovered.name() + "." + DiseaseStatus.susceptible.name();
if (progression.hasPath(path))
return;
// an infection protects through its own curves and, for strains it does not list, through the default
boolean crossProtection = otherPathogensProtection != null && otherPathogensProtection > 0;
List<String> withProtection = new ArrayList<>();
for (SourceParams source : sources.values()) {
if (source.getSourceKind() != SourceKind.infection)
continue;
boolean protects = crossProtection;
for (ProtectionParams p : source.getProtections())
protects |= !p.getCurve().isZero();
if (protects)
withProtection.add(source.getSource().toString());
}
if (!withProtection.isEmpty())
problems.add("Infections with " + withProtection + " leave protection behind"
+ (crossProtection ? " (through '" + OTHER_PATHOGENS_PROTECTION + "' if not through their curves)" : "")
+ ", but 'progressionConfig' has no transition from " + DiseaseStatus.recovered + " to "
+ DiseaseStatus.susceptible + ". Agents never become susceptible again, so this protection would never be "
+ "applied. Configure a short refractory period, or write curves '0>0.0' and set '"
+ OTHER_PATHOGENS_PROTECTION + "' to 0.0 if immunity is meant to be the " + DiseaseStatus.recovered
+ " state itself.");
}
/**
* Checks that the sources cover what can happen in the run.
*
* <p>Only <i>circulating</i> strains are required: configured in {@code virusStrains} and imported with a
* positive number of infections in {@code episim}. The constructor of {@link VirusStrainConfigGroup} always adds
* SARS-CoV-2, so requiring every configured strain would force a placeholder for it into every other run. A strain
* that enters a run without being imported, through a custom initial infection handler, is not seen here; the
* immunity model fails on its first infection instead.</p>
*
* <p>Products handed out by the vaccination programme are not checked here: which vaccination model is bound is not
* part of the configuration. The immunity model fails on the first vaccination with a product that has no source.</p>
*/
private void checkCoverage(Config config, List<String> problems) {
VirusStrainConfigGroup strainConfig = VirusStrainConfigGroup.moduleOrDefault(config, "virusStrains",
VirusStrainConfigGroup.class, VirusStrainConfigGroup::new);
EpisimConfigGroup episimConfig = VirusStrainConfigGroup.moduleOrDefault(config, "episim",
EpisimConfigGroup.class, EpisimConfigGroup::new);
if (strainConfig == null || episimConfig == null)
return;
Set<VirusStrain> configured = strainConfig.getConfiguredStrains();
Set<VirusStrain> circulating = new LinkedHashSet<>();
for (Map.Entry<VirusStrain, NavigableMap<LocalDate, Integer>> e : episimConfig.getInfections_pers_per_day().entrySet()) {
if (configured.contains(e.getKey()) && e.getValue().values().stream().anyMatch(n -> n > 0))
circulating.add(e.getKey());
}
checkStrainNames(configured, problems);
checkInfectionSources(circulating, problems);
checkTargets(circulating, problems);
}
/**
* Names that do not resolve to a configured strain are most likely typos.
*/
private void checkStrainNames(Set<VirusStrain> configured, List<String> problems) {
for (SourceParams source : sources.values()) {
if (source.getSourceKind() == SourceKind.infection && !configured.contains((VirusStrain) source.getSource()))
problems.add("Source of immunity '" + source.getSource() + "' is an infection with a strain that has no '"
+ "strainParams' in config group 'virusStrains'. Check the name or add the strain.");
for (ProtectionParams p : source.getProtections()) {
if (!configured.contains(p.getStrain()))
problems.add("Source of immunity '" + source.getSource() + "' lists protection for strain '" + p.getStrain()
+ "', which has no 'strainParams' in config group 'virusStrains'. Check the name or add the strain.");
}
}
}
/**
* Every circulating strain leaves immunity behind, which has to be described, even if it is none.
*/
private void checkInfectionSources(Set<VirusStrain> circulating, List<String> problems) {
for (VirusStrain strain : circulating) {
SourceParams source = sources.get(strain);
if (source == null || source.getSourceKind() != SourceKind.infection)
problems.add("Strain '" + strain + "' circulates, but there is no '" + SourceParams.SET_TYPE + "' for an infection "
+ "with it. Describe the immunity it leaves behind, with curves '0>0.0' if there is none.");
}
}
/**
* A source covers its own pathogen (for an infection) and every pathogen it lists curves for. For circulating strains
* of a covered pathogen every target has to be listed; other strains need {@link #OTHER_PATHOGENS_PROTECTION}.
*/
private void checkTargets(Set<VirusStrain> circulating, List<String> problems) {
Set<String> uncovered = new TreeSet<>();
for (SourceParams source : sources.values()) {
Set<Pathogen> covered = new HashSet<>();
if (source.getSourceKind() == SourceKind.infection)
covered.add(((VirusStrain) source.getSource()).getPathogen());
for (ProtectionParams p : source.getProtections())
covered.add(p.getStrain().getPathogen());
for (VirusStrain target : circulating) {
if (!covered.contains(target.getPathogen())) {
uncovered.add(source.getSource() + " -> " + target);
continue;
}
for (DiseaseStatus against : SUPPORTED_TARGETS) {
if (source.getProtection(against, target) == null)
problems.add("Source of immunity '" + source.getSource() + "' covers pathogen '" + target.getPathogen()
+ "' but has no protection against " + against + " for its strain '" + target
+ "'. Every target has to be listed; write curve '0>0.0' for no protection.");
}
}
}
if (!uncovered.isEmpty() && otherPathogensProtection == null)
problems.add("Sources give no curves for strains of other pathogens " + uncovered + ", and '"
+ OTHER_PATHOGENS_PROTECTION + "' is not set. Set it explicitly, e.g. to 0.0 for no cross-protection.");
}
/**
* Reads an enum value and names the allowed values if it is unknown, instead of the bare "No enum constant".
*/
private static <E extends Enum<E>> E parseEnum(Class<E> type, String param, String value) {
try {
return Enum.valueOf(type, value.trim());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("'" + param + "' has no value '" + value.trim() + "'; allowed are "
+ java.util.Arrays.toString(type.getEnumConstants()), e);
}
}
/**
* Whether a source of immunity is an infection or a product.
*/
public enum SourceKind {
infection, product
}
/**
* Protection given by one source of immunity.
*/
public static final class SourceParams extends ReflectiveConfigGroup {
public static final String SET_TYPE = "immunitySource";
private static final String SOURCE = "source";
private static final String SOURCE_KIND = "sourceKind";
private String sourceName;
private SourceKind sourceKind;
/**
* One set per target disease status and target strain.
*/
private final Map<DiseaseStatus, Map<VirusStrain, ProtectionParams>> protections = new LinkedHashMap<>();
SourceParams() {
super(SET_TYPE);
}
void setSource(String name, SourceKind kind) {
this.sourceName = name;
this.sourceKind = kind;
}
/**
* The immunity event this source describes.
*
* @throws IllegalStateException if {@code source} or {@code sourceKind} is not set
*/
public ImmunityEvent getSource() {
if (sourceName == null || sourceKind == null)
throw new IllegalStateException("A '" + SET_TYPE + "' in config group '" + GROUPNAME + "' needs both '"
+ SOURCE + "' and '" + SOURCE_KIND + "', got source=" + sourceName + ", sourceKind=" + sourceKind);
return sourceKind == SourceKind.infection ? VirusStrain.of(sourceName) : VaccinationType.of(sourceName);
}
public SourceKind getSourceKind() {
return sourceKind;
}
@StringGetter(SOURCE)
String getSourceName() {
return sourceName;
}
@StringSetter(SOURCE)
void setSourceName(String name) {
this.sourceName = name == null || name.isBlank() ? null : name.trim();
}
@StringGetter(SOURCE_KIND)
String getSourceKindString() {
return sourceKind == null ? null : sourceKind.name();
}
@StringSetter(SOURCE_KIND)
void setSourceKindString(String kind) {
this.sourceKind = kind == null || kind.isBlank() ? null : parseEnum(SourceKind.class, SOURCE_KIND, kind);
}
/**
* Protection against {@code against} for {@code strain}, added with the given curve or replacing its curve.
*/
public ProtectionParams setProtection(DiseaseStatus against, VirusStrain strain, ProtectionCurve curve) {
ProtectionParams existing = find(against, strain);
if (existing != null) {
existing.setCurve(curve);
return existing;
}
ProtectionParams p = new ProtectionParams();
p.setAgainst(against);
p.setStrain(strain);
p.setCurve(curve);
addParameterSet(p);
return p;
}
/**
* Curve for the given target, or {@code null} if this source does not list it.
*/
public ProtectionCurve getProtection(DiseaseStatus against, VirusStrain strain) {
ProtectionParams p = find(against, strain);
return p == null ? null : p.getCurve();
}
/**
* All protections of this source, in the order they were added.
*/
public List<ProtectionParams> getProtections() {
List<ProtectionParams> all = new ArrayList<>();
protections.values().forEach(byStrain -> all.addAll(byStrain.values()));
return Collections.unmodifiableList(all);
}
private ProtectionParams find(DiseaseStatus against, VirusStrain strain) {
Map<VirusStrain, ProtectionParams> byStrain = protections.get(against);
return byStrain == null ? null : byStrain.get(strain);
}
@Override
public ConfigGroup createParameterSet(String type) {
if (ProtectionParams.SET_TYPE.equals(type))
return new ProtectionParams();
throw new IllegalArgumentException("Unknown parameter set type: " + type);
}
@Override
public void addParameterSet(final ConfigGroup set) {
if (!ProtectionParams.SET_TYPE.equals(set.getName()))
throw new IllegalStateException("Unknown parameter set type: " + set.getName());
ProtectionParams p = (ProtectionParams) set;
p.requireTarget();
if (find(p.getAgainst(), p.getStrain()) != null)
throw new IllegalArgumentException("Source of immunity '" + sourceName + "' lists protection against "
+ p.getAgainst() + " for strain '" + p.getStrain() + "' twice");
protections.computeIfAbsent(p.getAgainst(), k -> new LinkedHashMap<>()).put(p.getStrain(), p);
super.addParameterSet(set);
}
private void collectProblems(List<String> problems) {
if (sourceName == null || sourceKind == null) {
problems.add("A '" + SET_TYPE + "' needs both '" + SOURCE + "' and '" + SOURCE_KIND + "', got source="
+ sourceName + ", sourceKind=" + sourceKind);
return;
}
for (ProtectionParams p : getProtections()) {
if (p.getCurve() == null)
problems.add("Source of immunity '" + sourceName + "' has no '" + ProtectionParams.CURVE
+ "' for protection against " + p.getAgainst() + " for strain '" + p.getStrain() + "'");
}
}
}
/**
* Protection a source gives against one target disease status for one target strain.
*/
public static final class ProtectionParams extends ReflectiveConfigGroup {
static final String SET_TYPE = "protection";
private static final String AGAINST = "against";
private static final String STRAIN = "strain";
private static final String CURVE = "curve";
private DiseaseStatus against;
private String strainName;
private ProtectionCurve curve;
ProtectionParams() {
super(SET_TYPE);
}
public DiseaseStatus getAgainst() {
return against;
}
/**
* Target disease status of this protection.
*
* @throws IllegalArgumentException if the status is not one a person can be protected against
*/
public void setAgainst(DiseaseStatus against) {
if (!SUPPORTED_TARGETS.contains(against))
throw new IllegalArgumentException("Protection against '" + against + "' is not supported; supported are "
+ SUPPORTED_TARGETS);
this.against = against;
}
@StringGetter(AGAINST)
String getAgainstString() {
return against == null ? null : against.name();
}
@StringSetter(AGAINST)
void setAgainstString(String value) {
setAgainst(parseEnum(DiseaseStatus.class, AGAINST, value));
}
/**
* Strain the protection is against. Resolved by name on access, so that a strain whose pathogen is declared
* by a config group read later still ends up with the right pathogen.
*/
public VirusStrain getStrain() {
return strainName == null ? null : VirusStrain.of(strainName);
}
public void setStrain(VirusStrain strain) {
this.strainName = strain.getVirusStrainName();
}
@StringGetter(STRAIN)
String getStrainName() {
return strainName;
}
@StringSetter(STRAIN)
void setStrainName(String name) {
this.strainName = name == null || name.isBlank() ? null : name.trim();
}
public ProtectionCurve getCurve() {
return curve;
}
public void setCurve(ProtectionCurve curve) {
this.curve = Objects.requireNonNull(curve, "Protection curve must not be null");
}
@StringGetter(CURVE)
String getCurveString() {
return curve == null ? null : curve.toString();
}
@StringSetter(CURVE)
void setCurveString(String value) {
try {
setCurve(ProtectionCurve.parse(value));
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Protection against " + against + " for strain '" + strainName
+ "': " + e.getMessage(), e);
}
}
private void requireTarget() {
if (against == null || strainName == null)
throw new IllegalStateException("A '" + SET_TYPE + "' needs both '" + AGAINST + "' and '" + STRAIN
+ "', got against=" + against + ", strain=" + strainName);
}
}
}