$raw */ public static function fromArray(array $raw): self { $pop = is_array($raw['population'] ?? null) ? $raw['population'] : []; $luteal = is_array($raw['luteal'] ?? null) ? $raw['luteal'] : []; $pred = is_array($raw['predictive'] ?? null) ? $raw['predictive'] : []; $cal = is_array($raw['calibration'] ?? null) ? $raw['calibration'] : []; $plaus = is_array($raw['plausible'] ?? null) ? $raw['plausible'] : []; $ov = is_array($raw['ovulation'] ?? null) ? $raw['ovulation'] : []; $ab = is_array($raw['abstain'] ?? null) ? $raw['abstain'] : []; $haz = is_array($raw['hazard'] ?? null) ? $raw['hazard'] : []; return new self( version: (string) ($raw['version'] ?? 'dochia-v1-literature'), popMean: (float) ($pop['mean'] ?? 29.0), popBetweenSd: (float) ($pop['between_sd'] ?? 3.5), withinSdPrior: (float) ($pop['within_sd_prior'] ?? 2.0), priorWeight: (float) ($pop['prior_weight'] ?? 4.0), minMean: (float) ($pop['min_mean'] ?? 18.0), maxMean: (float) ($pop['max_mean'] ?? 90.0), minWithinVar: (float) ($pop['min_within_var'] ?? 1.44), maxWithinVar: (float) ($pop['max_within_var'] ?? 36.0), lutealMean: (float) ($luteal['mean'] ?? 14.13), lutealSd: (float) ($luteal['sd'] ?? 1.41), fertileWindowDays: (int) ($raw['fertile_window_days'] ?? 6), ovulationSdFloor: (float) ($ov['sd_floor'] ?? 0.35), ovulationGridStep: (float) ($ov['grid_step'] ?? 0.25), varianceBlend: (float) ($pred['variance_blend'] ?? 1.0), momentMatch: (bool) ($pred['moment_match'] ?? false), tailProbBase: (float) ($pred['tail_prob_base'] ?? 0.10), tailProbMax: (float) ($pred['tail_prob_max'] ?? 0.40), tailProbSdGain: (float) ($pred['tail_prob_sd_gain'] ?? 0.06), tailProbRangeGain: (float) ($pred['tail_prob_range_gain'] ?? 0.02), tailProbGapBonus: (float) ($pred['tail_prob_gap_bonus'] ?? 0.20), tailShiftDays: (float) ($pred['tail_shift_days'] ?? 3.0), tailSdMultiplier: (float) ($pred['tail_sd_multiplier'] ?? 2.2), trendEwma: (float) ($pred['trend_ewma'] ?? 0.4), trendThreshold: (float) ($pred['trend_threshold'] ?? 3.0), trendMaxShift: (float) ($pred['trend_max_shift'] ?? 3.0), bodyScale: (float) ($cal['body_scale'] ?? 1.0), tailScaleLow: (float) ($cal['tail_scale_low'] ?? 1.0), tailScaleHigh: (float) ($cal['tail_scale_high'] ?? 1.0), tailAnchorLow: (float) ($cal['tail_anchor_low'] ?? 0.10), tailAnchorHigh: (float) ($cal['tail_anchor_high'] ?? 0.90), calibrationMethod: (string) ($cal['method'] ?? 'none'), minLength: (int) ($plaus['min'] ?? 15), maxLength: (int) ($plaus['max'] ?? 120), hazardMaxDay: (int) ($haz['max_day'] ?? 120), abstainGapDays: (int) ($ab['gap_days'] ?? 90), abstainRecentSd: (float) ($ab['recent_sd'] ?? 6.0), abstainMinCycles: (int) ($ab['min_cycles'] ?? 0), ); } public static function fromJson(string $path): self { $raw = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); if (! is_array($raw)) { throw new \RuntimeException("Invalid model params JSON: {$path}"); } return self::fromArray($raw); } }