6, 'medium' => 3, 'low' => 1]; private const CENTRE_PENALTY_CAP = 15; private const MIRROR_NOTE_GAP = 25; // only mention the flipped view when it changes the score this much private EntrancePada $pada; private RoomSwaps $swaps; private Rules $rules; private MagneticModel $mag; public function __construct(Rules $rules, MagneticModel $mag) { $this->rules = $rules; $this->mag = $mag; $this->pada = new EntrancePada(); $this->swaps = new RoomSwaps($rules); } public function analyze(array $in): array { $notices = []; $ownership = (string)($in['property']['ownership'] ?? 'own'); if (!in_array($ownership, ['own', 'rent', 'hoa'], true)) $ownership = 'own'; $areaToSqft = (($in['units'] ?? 'sqft') === 'm2') ? 10.7639 : 1.0; // room areas are kept in sq ft $propType = (string)($in['property']['type'] ?? 'house'); if (!in_array($propType, ['house', 'townhouse', 'condo'], true)) $propType = 'house'; /* ── 1. location ── */ $loc = $this->location($in['location'] ?? []); $year = isset($in['year']) ? (float)$in['year'] : MagneticModel::decimalYear(); $decl = $this->mag->declination($loc['lat'], $loc['lon'], 0.0, $year); $sun = SunPath::summary($loc['lat'], $loc['lon'], (int)floor($year)); /* ── 2. floors ── */ $floors = []; $scored = []; // [score, weight] for the overall score $issues = []; $unmatched = []; $penalty = 0; $hasBasement = false; $entranceDone = false; $mirrorNotes = 0; $centreStairsSeen = false; $majors = []; // major defects found (they cap the overall score) $floorsIn = $in['floors'] ?? []; if (!$floorsIn) throw new \InvalidArgumentException('No floors given'); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- message is never printed. foreach (array_values($floorsIn) as $fi => $f) { $level = in_array($f['level'] ?? 'main', ['main', 'upper', 'basement', 'lower'], true) ? ($f['level'] ?? 'main') : 'main'; if ($level === 'basement') $hasBasement = true; $label = trim((string)($f['label'] ?? '')) ?: ['main' => 'Main floor', 'upper' => 'Upper floor', 'basement' => 'Basement', 'lower' => 'Lower floor'][$level]; $img = $f['image'] ?? $in['image'] ?? []; $aspect = (isset($img['width'], $img['height']) && $img['height'] > 0) ? $img['width'] / $img['height'] : 1.0; $north = $f['north'] ?? $in['north'] ?? null; if (!$north || !isset($north['image_bearing'])) throw new \InvalidArgumentException("North not confirmed for floor '$label'"); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- message is never printed. $ref = ($north['reference'] ?? 'true') === 'magnetic' ? 'magnetic' : 'true'; // A magnetic north on the image sits D degrees away from true north: true north image bearing = magnetic − D $trueNorthImg = Orientation::wrap((float)$north['image_bearing'] - ($ref === 'magnetic' ? $decl : 0.0)); $rooms = array_values(array_filter($f['rooms'] ?? [], fn($r) => isset($r['name'], $r['x'], $r['y']))); $outline = array_values(array_filter($f['outline'] ?? [], fn($p) => is_array($p) && count($p) >= 2)); $approxOutline = false; if (count($outline) < 3) { $pts = array_map(fn($r) => [(float)$r['x'], (float)$r['y']], $rooms); if (isset($f['entrance']['x'], $f['entrance']['y'])) $pts[] = [(float)$f['entrance']['x'], (float)$f['entrance']['y']]; if (count($pts) < 2) throw new \InvalidArgumentException("Floor '$label' needs an outline or at least two rooms"); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- message is never printed. [$x1, $y1, $x2, $y2] = Orientation::bbox($pts); $px = max(2.0, ($x2 - $x1) * 0.12); $py = max(2.0, ($y2 - $y1) * 0.12); $outline = [[$x1 - $px, $y1 - $py], [$x2 + $px, $y1 - $py], [$x2 + $px, $y2 + $py], [$x1 - $px, $y2 + $py]]; $approxOutline = true; } $centre = Orientation::centroid($outline, $aspect); $wallRotImg = Orientation::houseRotation($outline, $aspect, 0.0) ?? 0.0; $houseRot = Orientation::houseRotation($outline, $aspect, $trueNorthImg) ?? 0.0; $roomOut = []; foreach ($rooms as $r) { $type = $this->rules->match((string)$r['name']); if ($type === null) { $unmatched[] = ['floor' => $label, 'name' => (string)$r['name']]; continue; } $def = $this->rules->room($type); $pt = [(float)$r['x'], (float)$r['y']]; $bearing = Orientation::wrap(Orientation::imageBearing($centre, $pt, $aspect) - $trueNorthImg); $inC = Orientation::inCentre($pt, $centre, $outline, $aspect, $wallRotImg); $dir = $inC ? 'C' : Orientation::zone($bearing); $row = [ 'name' => (string)$r['name'], 'type' => $type, 'label' => $def['label'], 'basis' => $def['basis'], 'level' => $level, 'x' => $pt[0], 'y' => $pt[1], 'bearing' => round($bearing, 1), 'direction' => $dir, 'zone16' => $inC ? 'C' : Orientation::zone16($bearing), 'area' => (isset($r['area']) && is_numeric($r['area']) && $r['area'] > 0) ? round((float)$r['area'] * $areaToSqft) : null, 'placement' => $def['placement'] ?? '', 'direction_name' => Orientation::NAMES[$dir], 'ideal' => $def['ideal'], 'zone_meaning' => $this->rules->direction($dir)['theme'] ?? '', ]; if ($dir === 'C') { $sev = $this->rules->centre()['flag'][$type] ?? null; $row['score'] = null; $row['rating'] = $sev ? ['id' => $sev === 'high' ? 'priority' : 'attention', 'label' => $sev === 'high' ? 'Priority' : 'Needs attention'] : ['id' => 'strong', 'label' => 'Fits the open centre']; $row['centre_severity'] = $sev; $row['remedies'] = $sev ? Rules::remediesFor($def['remedies'], $ownership) : []; // a staircase that runs through several floors is one staircase: count it once $repeat = $type === 'staircase' && !empty($centreStairsSeen); if ($type === 'staircase') $centreStairsSeen = true; if ($repeat) $row['centre_repeat'] = true; $major = $repeat ? null : $this->rules->majorDefect($type, 'C'); if ($major) { $row['major_defect'] = true; $majors[] = ['type' => $type, 'room' => (string)$r['name'], 'floor' => $label, 'direction' => 'C', 'title' => $major['title']]; } if ($sev && !$repeat) { $penalty += self::CENTRE_PENALTY[$sev]; $issues[] = ['kind' => 'centre', 'severity' => $major ? 'high' : $sev, 'major' => (bool)$major, 'floor' => $label, 'room' => $def['label'], 'direction' => 'C', 'title' => $def['label'] . ' in the centre (Brahmasthan)', 'text' => 'Tradition keeps the centre of the home open; a ' . strtolower($def['label']) . ' here is one of the placements it discourages most.', 'rank' => self::CENTRE_PENALTY[$sev] * 10 + ($major ? 1000 : 0)]; } } else { $score = (int)$def['scores'][$dir]; $row['score'] = $score; $row['rating'] = Rules::rating($score); $row['remedies'] = $score < 60 ? Rules::remediesFor($def['remedies'], $ownership) : []; $scored[] = [$score, (float)$def['weight']]; $major = $this->rules->majorDefect($type, $dir); if ($major) { $row['major_defect'] = true; $majors[] = ['type' => $type, 'room' => (string)$r['name'], 'floor' => $label, 'direction' => $dir, 'title' => $major['title']]; } if ($score < 60 || $major) { $issues[] = ['kind' => 'room', 'severity' => ($score < 40 || $major) ? 'high' : 'medium', 'major' => (bool)$major, 'floor' => $label, 'room' => $def['label'], 'direction' => $dir, 'title' => $def['label'] . ' in the ' . Orientation::NAMES[$dir], 'text' => 'Ideal: ' . $def['ideal'], 'rank' => (60 - $score) * $def['weight'] + ($major ? 1000 : 0)]; } $d = Orientation::distanceToBoundary($bearing); if ($d < Orientation::BORDER_DEG) { $nb = Orientation::neighbourZone($bearing); $row['boundary'] = ['neighbour' => $nb, 'neighbour_name' => Orientation::NAMES[$nb], 'degrees' => round($d, 1), 'neighbour_score' => (int)$def['scores'][$nb]]; } if ($loc['lat'] < 0) { $m = Orientation::mirror($dir); $ms = (int)$def['scores'][$m]; $row['mirrored'] = ['direction' => $m, 'direction_name' => Orientation::NAMES[$m], 'score' => $ms, 'differs' => abs($ms - $score) >= self::MIRROR_NOTE_GAP]; if ($row['mirrored']['differs']) $mirrorNotes++; } } $row['sun'] = $this->sunNote($dir, $sun); $roomOut[] = $row; } /* entrance */ $entrance = null; if (isset($f['entrance']['x'], $f['entrance']['y']) && !$entranceDone) { $e = $this->rules->entrance(); $pt = [(float)$f['entrance']['x'], (float)$f['entrance']['y']]; $b = Orientation::wrap(Orientation::imageBearing($centre, $pt, $aspect) - $trueNorthImg); $dir = Orientation::zone($b); $score = (int)$e['scores'][$dir]; $entrance = ['floor' => $label, 'x' => $pt[0], 'y' => $pt[1], 'bearing' => round($b, 1), 'direction' => $dir, 'direction_name' => Orientation::NAMES[$dir], 'score' => $score, 'rating' => Rules::rating($score), 'ideal' => $e['ideal'], 'remedies' => $score < 60 ? Rules::remediesFor($e['remedies'], $ownership) : [], 'sun' => $this->sunNote($dir, $sun), 'placement' => $e['placement'] ?? '', // no pada without a real plan: the door's exact place on the wall is unknown 'pada' => ($in['source'] ?? '') === 'directions' ? null : $this->pada->fromPlan($centre, $outline, $pt, $aspect, $wallRotImg, $trueNorthImg)]; if ($loc['lat'] < 0) { $m = Orientation::mirror($dir); $entrance['mirrored'] = ['direction' => $m, 'direction_name' => Orientation::NAMES[$m], 'score' => (int)$e['scores'][$m], 'differs' => abs((int)$e['scores'][$m] - $score) >= self::MIRROR_NOTE_GAP]; } $scored[] = [$score, (float)$e['weight']]; $major = $this->rules->majorDefect('entrance', $dir); if ($major) { $entrance['major_defect'] = true; $majors[] = ['type' => 'entrance', 'room' => 'Main Entrance', 'floor' => $label, 'direction' => $dir, 'title' => $major['title']]; } if ($score < 60 || $major) { $issues[] = ['kind' => 'entrance', 'severity' => ($score < 40 || $major) ? 'high' : 'medium', 'major' => (bool)$major, 'floor' => $label, 'room' => 'Main Entrance', 'direction' => $dir, 'title' => 'Main entrance in the ' . Orientation::NAMES[$dir], 'text' => 'Ideal: ' . $e['ideal'], 'rank' => (60 - $score) * $e['weight'] + ($major ? 1000 : 0)]; } $entranceDone = true; } $grid = array_fill_keys(array_merge(Orientation::DIRS, ['C']), []); foreach ($roomOut as $r) $grid[$r['direction']][] = $r['label']; $grid16 = array_fill_keys(array_merge(Orientation::DIRS16, ['C']), []); foreach ($roomOut as $r) $grid16[$r['zone16']][] = $r['name']; $floors[] = [ 'label' => $label, 'level' => $level, 'centre' => array_map(fn($v) => round($v, 1), $centre), 'outline' => $outline, 'aspect' => $aspect, 'wall_rotation_image' => round($wallRotImg, 2), 'plan_image' => isset($f['plan_image']) ? basename((string)$f['plan_image']) : null, 'outline_estimated' => $approxOutline, 'true_north_image_bearing' => round($trueNorthImg, 1), 'house_rotation' => round($houseRot, 1), 'angled' => abs($houseRot) >= 10.0, 'rooms' => $roomOut, 'entrance' => $entrance, 'grid' => $grid, 'grid16' => $grid16, ]; if ($approxOutline) $notices[] = ['id' => 'outline_estimated', 'text' => "The outline of the $label wasn't marked, so its centre was estimated from the room positions. Marking the outline gives a more exact centre."]; } /* ── 3. score ── */ $sw = array_sum(array_column($scored, 1)); $base = $sw > 0 ? array_sum(array_map(fn($s) => $s[0] * $s[1], $scored)) / $sw : 0; $pen = min(self::CENTRE_PENALTY_CAP, $penalty); $uncapped = (int)max(0, min(100, round($base - $pen))); $cap = $this->rules->majorCap(); $overall = $majors ? min($uncapped, $cap) : $uncapped; $sw = $this->swaps->suggest($floors); // a swap can move the master bedroom out of the north-east; every other major defect stays after swaps $movedTypes = []; foreach ($sw['floors'] as $sf) foreach ($sf['moves'] as $mv) $movedTypes[$mv['new_type']] = true; $majorsAfter = array_filter($majors, fn($m) => !($m['type'] === 'primary_bedroom' && isset($movedTypes['primary_bedroom']))); $after = $sw['floors'] ? (int)max(0, min(100, round($base + $sw['gain_weighted'] / max(1e-9, array_sum(array_column($scored, 1))) - $pen))) : $uncapped; if ($majorsAfter) $after = min($after, $cap); $swapsOut = ['floors' => $sw['floors'], 'score_now' => $overall, 'score_after' => $sw['floors'] ? $after : $overall]; if ($swapsOut['score_after'] - $overall < 2) $swapsOut = ['floors' => [], 'score_now' => $overall, 'score_after' => $overall]; // only worth showing if the score rises 2+ points usort($issues, fn($a, $b) => $b['rank'] <=> $a['rank']); foreach ($issues as &$i) unset($i['rank']); unset($i); /* ── 4. notices ── */ if ($majors && $uncapped > $cap) { $notices[] = ['id' => 'major_defect', 'text' => 'Your rooms average ' . $uncapped . ', but the score is capped at ' . $cap . ' because of ' . (count($majors) > 1 ? count($majors) . ' major defects' : 'a major defect') . ': ' . implode('; ', array_unique(array_map(fn($m) => strtolower($m['title']), $majors))) . '. ' . $this->rules->majorNote()]; } if (!$entranceDone) $notices[] = ['id' => 'no_entrance', 'text' => 'The main entrance was not marked, so it is not part of the score.']; if ($hasBasement) $notices[] = ['id' => 'basement', 'text' => $this->rules->basement()['note']]; if ($unmatched) $notices[] = ['id' => 'unmatched', 'text' => count($unmatched) . ' space(s) could not be matched to a room type and were not scored: ' . implode(', ', array_map(fn($u) => $u['name'], $unmatched)) . '.']; $angled = array_values(array_filter($floors, fn($f) => $f['angled'])); if ($angled) { $rots = array_unique(array_map(fn($f) => abs((int)round($f['house_rotation'])), $angled)); $where = count($angled) === count($floors) ? 'Your home is' : 'Your ' . implode(' and ', array_map(fn($f) => $f['label'], $angled)) . (count($angled) > 1 ? ' are' : ' is'); $notices[] = ['id' => 'angled', 'text' => $where . ' set at about ' . implode('° / ', $rots) . '° to the compass. Directions are read from the true compass bearing of each room, so rooms near a zone boundary are marked.']; } if ($loc['lat'] < 0) { $notices[] = ['id' => 'southern_hemisphere', 'text' => 'Vastu practitioners disagree about homes south of the equator. Some keep the classical directions, which this report follows; others flip north and south. The classical texts give no separate rules for the Southern Hemisphere. Where the flipped view would read a room very differently, this report says so beside that room' . ($mirrorNotes ? " ($mirrorNotes room" . ($mirrorNotes > 1 ? 's' : '') . ')' : '') . '.']; if ($sun['tropical']) $notices[] = ['id' => 'tropical', 'text' => 'Your home is in the tropics. The midday sun is on the south side for about ' . $sun['pole_side_noon_days'] . ' days of the year and overhead around those dates, so a simple north–south flip does not match the sun here either.']; } if (($in['source'] ?? '') === 'directions') { $notices[] = ['id' => 'directions_only', 'text' => 'This score uses the direction you chose for each room, not a floor plan. A floor plan gives exact room centres, the true centre of the home and the pada of the front door.']; } return [ 'engine' => ['name' => 'VastuIQ Home Report Engine', 'version' => self::ENGINE_VERSION, 'rules_version' => $this->rules->version(), 'generated_at' => gmdate('c')], 'property' => ['type' => $propType, 'ownership' => $ownership], 'input_source' => ($in['source'] ?? '') === 'directions' ? 'directions' : 'plan', 'location' => $loc + ['hemisphere' => $sun['hemisphere'], 'tropical' => $sun['tropical']], 'true_north' => [ 'declination' => round($decl, 1), 'direction' => $decl >= 0 ? 'east' : 'west', 'reference_used' => array_values(array_unique(array_map(fn($f) => ($f['north']['reference'] ?? ($in['north']['reference'] ?? 'true')) === 'magnetic' ? 'magnetic' : 'true', $floorsIn))), 'model' => $this->mag->modelName() . ' (NOAA / BGS)', 'year' => round($year, 2), 'text' => sprintf('In %s a compass points %.1f° %s of true north (%s, %d). %s', $loc['name'], abs($decl), $decl >= 0 ? 'east' : 'west', $this->mag->modelName(), (int)$year, abs($decl) >= 3 ? 'That is enough to move a room into the next zone, so this report uses true north.' : 'That is small; true north is used either way.'), ], 'sun' => $sun + ['facts' => $this->sunFacts($sun, $loc)], 'score' => [ 'overall' => $overall, 'band' => $this->band($overall), 'rooms_scored' => count($scored), 'centre_penalty' => $pen, 'major_defects' => array_values($majors), 'capped' => $majors && $uncapped > $cap, 'uncapped' => $uncapped, 'cap' => $cap, 'method' => 'Weighted average of each scored room and the main entrance (VastuIQ classical tables), minus up to ' . self::CENTRE_PENALTY_CAP . ' points for rooms tradition keeps out of the centre. A major defect (such as a toilet in the north-east or a south-west front door) caps the score at ' . $cap . '.', ], 'floors' => $floors, 'room_swaps' => $swapsOut, 'zones16_note' => $this->rules->zones16()['note'], 'zones16_names' => $this->rules->zones16()['names'], 'issues' => $issues, 'unmatched' => $unmatched, 'notices' => $notices, 'disclaimer' => 'Vastu Shastra is a traditional and cultural practice. This report is not engineering, legal, financial or medical advice, and it does not promise any outcome.', 'credits' => ['Magnetic declination: World Magnetic Model WMM-2025 (NOAA NCEI / British Geological Survey).', 'Sun positions: NOAA solar position equations.'], ]; } /** Location from a city name or from coordinates. */ private function location(array $l): array { if (isset($l['lat'], $l['lon']) && is_numeric($l['lat']) && is_numeric($l['lon'])) { $lat = (float)$l['lat']; $lon = (float)$l['lon']; if (abs($lat) > 85 || abs($lon) > 180) throw new \InvalidArgumentException('Coordinates out of range'); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- message is never printed. $near = Geo::nearest($lat, $lon); $name = trim((string)($l['label'] ?? '')) ?: ($near ? $near['name'] : sprintf('%.3f, %.3f', $lat, $lon)); return ['name' => $name, 'label' => trim((string)($l['label'] ?? '')) ?: ($near['label'] ?? $name), 'lat' => $lat, 'lon' => $lon, 'country' => strtoupper((string)($l['country'] ?? ($near['country'] ?? ''))), 'source' => 'coordinates']; } $c = Geo::find((string)($l['city'] ?? ''), $l['state'] ?? null, $l['country'] ?? null); if (!$c) throw new \InvalidArgumentException('City not found'); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- message is never printed. return ['name' => $c['name'], 'label' => $c['label'], 'lat' => $c['lat'], 'lon' => $c['lon'], 'country' => $c['country'], 'source' => 'city']; } private function band(int $s): array { if ($s >= 80) return ['id' => 'strong', 'label' => 'Strong Vastu alignment']; if ($s >= 60) return ['id' => 'good', 'label' => 'Good alignment']; if ($s >= 40) return ['id' => 'attention', 'label' => 'Needs attention']; return ['id' => 'priority', 'label' => 'Priority fixes']; } /** One plain sentence on the sun for a side of the home (true direction). */ private function sunNote(string $dir, array $sun): string { if ($dir === 'C') return ''; $eq = $sun['equator_side']; $pole = $sun['pole_side']; $has = fn($c) => false !== strpos($dir, $c); if ($has($eq) && $has('E')) return 'Morning and midday sun; one of the warmest sides in winter.'; if ($has($eq) && $has('W')) return 'Midday and afternoon sun; the warmest corner on summer afternoons.'; if ($dir === $eq) return $sun['tropical'] ? 'Midday sun for most of the year; in the tropics the sun is close to overhead in summer.' : 'Faces the midday sun: the sunniest side in winter and the easiest to shade in summer.'; if ($dir === 'E') return 'Morning sun all year; cooler in the afternoon.'; if ($dir === 'W') return 'Afternoon sun; the hottest side on summer afternoons, so west glass benefits from shading.'; if ($has($pole) && $has('E')) return 'Early-morning sun in summer only; otherwise cool and shaded.'; if ($has($pole) && $has('W')) return 'Late-afternoon sun in summer only; otherwise cool and shaded.'; if ($dir === $pole) return 'Little direct sun in winter; the coolest side with the most even light.'; return ''; } /** Plain-language sun facts for the city (all measured, none Vastu claims). */ private function sunFacts(array $sun, array $loc): array { $w = $sun['winter_solstice']; $s = $sun['summer_solstice']; $eqName = $sun['equator_side'] === 'N' ? 'north' : 'south'; $facts = []; $polar = ($w['day_hours'] ?? 1) <= 0 || ($s['day_hours'] ?? 0) >= 24; // far north of Sweden: midnight sun and polar night if ($polar) { $facts[] = 'Around midsummer the sun does not set here, and around midwinter it does not rise: the midnight sun and the polar night.'; } else { $facts[] = sprintf('The sun rises between %d° and %d° through the year (East is 90°), so the east side gets morning sun in every season.', (int)round($sun['sunrise_range'][0]), (int)round($sun['sunrise_range'][1])); } $facts[] = $w['noon_elevation'] < 0 ? sprintf('In mid-winter (%s) the sun stays below the horizon all day; in mid-summer (%s) it is %d° up at midday.', gmdate('j M', strtotime($w['date'] . ' 12:00:00 UTC')), gmdate('j M', strtotime($s['date'] . ' 12:00:00 UTC')), (int)round($s['noon_elevation'])) : sprintf('At midday in mid-winter (%s) the sun is in the %s, %d° above the horizon; in mid-summer (%s) it is %d° up.', gmdate('j M', strtotime($w['date'] . ' 12:00:00 UTC')), $w['noon_side'] === 'N' ? 'north' : 'south', (int)round($w['noon_elevation']), gmdate('j M', strtotime($s['date'] . ' 12:00:00 UTC')), (int)round($s['noon_elevation'])); $facts[] = sprintf('Daylight runs from about %.1f hours in mid-winter to %.1f hours in mid-summer.', $w['day_hours'], $s['day_hours']); if ($sun['tropical']) { $facts[] = 'Because ' . $loc['name'] . ' is in the tropics, the midday sun is on the ' . ($sun['pole_side'] === 'S' ? 'south' : 'north') . ' side for about ' . $sun['pole_side_noon_days'] . ' days a year.'; } else { $facts[] = 'For most of the day the sun is on the ' . $eqName . ' side of your home; the ' . ($eqName === 'north' ? 'south' : 'north') . ' side gets little direct sun in winter.'; } if ($sun['hemisphere'] === 'southern') { $facts[] = 'This is the reverse of India, where the midday sun is in the south. East and west are the same everywhere.'; } return $facts; } }