+ Pick the gem kind, tick the clues you actually see under loupe, microscope, UV, or warming.
+ The wizard weighs each clue and ranks treatments by likelihood. A negative score means the
+ clue argues against that treatment.
+
+
+
+
+
+
+
+
+ Observed clues ({selected.size} ticked of {availableClues.length})
+
Note: These are estimates. Actual weight varies with exact proportions, girdle thickness, and cut quality.
-
Example (1ct diamond): 6.5 × 6.5 × 4.0 mm, SG 3.52, Round = ~1.0 ct
+
Note: These are estimates. Actual weight varies with exact proportions, symmetry, and cut quality. The girdle factor accounts for material carried in a thicker-than-medium girdle.
+
Example (1ct diamond): 6.5 × 6.5 × 4.0 mm, SG 3.52, Round, medium girdle = ~1.0 ct
+ For stones above the refractometer scale (RI {'>'} 1.81) or rough material with no
+ polished facet. Place the stone in a drop of each liquid and compare relief, then
+ report what you see.
+
+
+
+
+ {rows.map((row, i) => (
+
+
+
+
+
+
+ ))}
+
+
+ {loading && (
+
+ Loading mineral database…
+
+ )}
+ {dbError && (
+
+ Database unavailable: {dbError}
+
+ )}
+
+ {usable.length === 0 ? (
+
+ Select at least one observation above to infer an RI band.
+
+ ) : !band ? null : band.min > band.max ? (
+
+ Conflicting observations. {band.rationale} Re-test with the suspect liquid.
+
+ ) : (
+ <>
+
+
+ Inferred RI band: {band.min.toFixed(2)} – {band.max.toFixed(2)}
+
);
}
diff --git a/src/components/calculator/RICalculator.tsx b/src/components/calculator/RICalculator.tsx
index aab6cd6..5969d2c 100644
--- a/src/components/calculator/RICalculator.tsx
+++ b/src/components/calculator/RICalculator.tsx
@@ -1,10 +1,10 @@
/**
* RI Lookup Calculator component.
- * Look up gems by refractive index value.
- * Uses database with fallback to hardcoded reference data.
+ * Look up gems by refractive index value, with optional double-reading mode
+ * that infers birefringence and optic character (SR vs DR) on the fly.
*/
-import { useEffect } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { useCalculatorForm } from '../../hooks/useCalculatorForm';
import { useGemLookup, formatRI, formatSG } from '../../hooks/useGemLookup';
import { useCalculatorData } from '../../hooks/useCalculatorData';
@@ -12,6 +12,10 @@ import { validateRI } from './ValidationMessage';
import { FormField, NumberInput, Select } from '../form';
import { GemMatchList } from './results';
import { Table } from '../ui';
+import {
+ calculateBirefringence,
+ classifyBirefringence,
+} from '../../lib/calculator/conversions';
const TOLERANCE_OPTIONS = [
{ value: '0.005', label: '± 0.005 (Narrow)' },
@@ -20,9 +24,17 @@ const TOLERANCE_OPTIONS = [
{ value: '0.05', label: '± 0.05 (Very Wide)' },
];
+const MODE_OPTIONS = [
+ { value: 'single', label: 'Single reading' },
+ { value: 'double', label: 'Double reading (anisotropic)' },
+];
+
export function RICalculator() {
const { fallbackGems } = useCalculatorData();
+ const [mode, setMode] = useState<'single' | 'double'>('single');
+ const [ri2, setRi2] = useState('');
+
const { values, errors, result, setValue } = useCalculatorForm({
fields: {
ri: {
@@ -41,27 +53,62 @@ export function RICalculator() {
},
});
- // Gem lookup with debouncing
+ const ri2Error = mode === 'double' && ri2 ? validateRI(ri2) : null;
+
+ const doubleReadingResult = useMemo(() => {
+ if (mode !== 'double') return null;
+ const a = result?.ri;
+ const b = parseFloat(ri2);
+ if (a === undefined || isNaN(b) || b < 1) return null;
+
+ const birefringence = calculateBirefringence(a, b);
+ const character = birefringence > 0.005 ? 'DR' : 'SR';
+ const lookupRI = (a + b) / 2;
+
+ return {
+ ri1: Math.min(a, b),
+ ri2: Math.max(a, b),
+ birefringence,
+ classification: classifyBirefringence(birefringence),
+ character,
+ characterLabel:
+ character === 'DR'
+ ? 'Doubly refractive (anisotropic — uniaxial or biaxial)'
+ : 'Singly refractive within reading tolerance — likely cubic, amorphous, or read along an optic axis',
+ lookupRI,
+ };
+ }, [mode, result?.ri, ri2]);
+
+ // Use the average of the two readings when in double mode, otherwise the single reading.
+ const lookupTarget = doubleReadingResult?.lookupRI ?? result?.ri ?? null;
+
const { matches, lookup } = useGemLookup({
type: 'ri',
tolerance: parseFloat(values.tolerance) || 0.01,
});
- // Trigger lookup when RI result changes
useEffect(() => {
- lookup(result?.ri ?? null);
- }, [result?.ri, lookup]);
+ lookup(lookupTarget);
+ }, [lookupTarget, lookup]);
return (
-
Enter an RI reading to find matching gemstones.
+
Enter an RI reading to find matching gemstones. Toggle Double reading to enter both shadow-edge readings (ω/ε or α/γ) and infer birefringence + optic character automatically.
- No common gems found with RI {values.ri} (±{values.tolerance}). Try widening the tolerance or
- checking your reading.
+ No common gems found near RI {lookupTarget.toFixed(3)} (±{values.tolerance}). Try widening the tolerance or
+ checking your readings.
)}
diff --git a/src/components/calculator/SGCalculator.tsx b/src/components/calculator/SGCalculator.tsx
index 1e4f3b8..c3574aa 100644
--- a/src/components/calculator/SGCalculator.tsx
+++ b/src/components/calculator/SGCalculator.tsx
@@ -1,18 +1,73 @@
/**
* Specific Gravity Calculator component.
- * Uses hydrostatic weighing method: SG = Weight in air / (Weight in air - Weight in water)
- * Matches against minerals in the database with fallback to hardcoded reference data.
+ *
+ * Hydrostatic weighing: SG = W_air / ((W_air − W_water) / ρ_water(T))
+ * The water-temperature correction adjusts for the small density change
+ * of water with temperature, which matters for high-precision SG below ~3.0.
*/
-import { useEffect } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { useCalculatorForm } from '../../hooks/useCalculatorForm';
import { useGemLookup, formatSG } from '../../hooks/useGemLookup';
-import { calculateSG } from '../../lib/calculator/conversions';
import { validateNumber } from './ValidationMessage';
-import { FormField, NumberInput } from '../form';
+import { FormField, NumberInput, Select } from '../form';
import { NumberResult, GemMatchBadges } from './results';
+/**
+ * Density of pure water (g/cm³) at 1 atm, NIST table.
+ * Linear interpolation between samples is more than accurate enough for
+ * gem-room work.
+ */
+const WATER_DENSITY_TABLE: { tempC: number; density: number }[] = [
+ { tempC: 4, density: 1.00000 },
+ { tempC: 15, density: 0.99913 },
+ { tempC: 20, density: 0.99821 },
+ { tempC: 25, density: 0.99704 },
+ { tempC: 30, density: 0.99565 },
+];
+
+const TEMP_OPTIONS = [
+ { value: '20', label: '20 °C (room temperature, default)' },
+ { value: '4', label: '4 °C (max density — ideal but cold)' },
+ { value: '15', label: '15 °C (cool tap water)' },
+ { value: '25', label: '25 °C (warm room)' },
+ { value: '30', label: '30 °C (warm)' },
+];
+
+function waterDensityAt(tempC: number): number {
+ const table = WATER_DENSITY_TABLE;
+ if (tempC <= table[0].tempC) return table[0].density;
+ if (tempC >= table[table.length - 1].tempC) return table[table.length - 1].density;
+ for (let i = 1; i < table.length; i++) {
+ const lo = table[i - 1];
+ const hi = table[i];
+ if (tempC <= hi.tempC) {
+ const t = (tempC - lo.tempC) / (hi.tempC - lo.tempC);
+ return lo.density + t * (hi.density - lo.density);
+ }
+ }
+ return 1.0;
+}
+
+/**
+ * Pure SG formula with water-density correction.
+ * SG = W_air / ((W_air − W_water) / ρ_water(T))
+ */
+export function calculateSGCorrected(
+ weightInAir: number,
+ weightInWater: number,
+ waterDensity: number,
+): number {
+ if (weightInAir <= 0) return 0;
+ const denom = (weightInAir - weightInWater) / waterDensity;
+ if (denom <= 0) return 0;
+ return weightInAir / denom;
+}
+
export function SGCalculator() {
+ const [tempC, setTempC] = useState('20');
+ const waterDensity = useMemo(() => waterDensityAt(parseFloat(tempC) || 20), [tempC]);
+
const { values, errors, result, setValue } = useCalculatorForm({
fields: {
weightInAir: {
@@ -35,7 +90,7 @@ export function SGCalculator() {
compute: ({ weightInAir, weightInWater }) => {
if (weightInAir === undefined || weightInWater === undefined) return null;
if (weightInAir <= 0 || weightInWater >= weightInAir) return null;
- return calculateSG(weightInAir, weightInWater);
+ return calculateSGCorrected(weightInAir, weightInWater, waterDensity);
},
});
@@ -55,11 +110,11 @@ export function SGCalculator() {
Enter the weight of your stone in air and water to calculate its specific gravity.
- Formula: SG = Weight in air ÷ (Weight in air − Weight in water)
+ Formula: SG = Wair ÷ ((Wair − Wwater) ÷ ρwater(T))
-
+
+
+
+
+
+
+
+
+ ρwater at {tempC} °C = {waterDensity.toFixed(5)} g/cm³
+ Tick every absorption line you can see in the spectroscope. The reasoner ranks species
+ whose stored band patterns match. Selective (diagnostic) bands are weighted more heavily.
+
+ Observe the stone under both long-wave (365 nm) and short-wave (254 nm) UV in a darkened
+ cabinet and report what you see. The reasoner ranks species whose stored fluorescence text
+ matches your observation.
+
+ );
+}
diff --git a/src/components/optical/OpticSignReasoner.tsx b/src/components/optical/OpticSignReasoner.tsx
new file mode 100644
index 0000000..8cf08b3
--- /dev/null
+++ b/src/components/optical/OpticSignReasoner.tsx
@@ -0,0 +1,311 @@
+/**
+ * Optic-sign / 2V reasoner.
+ *
+ * The user reports the optic character seen in the polariscope plus the
+ * relevant refractive indices (ω/ε for uniaxial, α/β/γ for biaxial). The
+ * widget computes the optic sign, birefringence, and 2V (when applicable),
+ * then ranks candidate species from the database whose stored
+ * `optical_character` matches the observation.
+ *
+ * The mineral DB stores optic character as freeform text — we parse it via
+ * `parseOpticalCharacter()` so no SQL schema migration is needed.
+ */
+
+import { useEffect, useMemo, useState } from 'react';
+import { getAllMinerals, type Mineral } from '../../lib/db';
+import {
+ parseOpticalCharacter,
+ uniaxialSign,
+ biaxialSign,
+ biaxial2V,
+ characterMatches,
+ type OpticCharacterKind,
+ type OpticSign,
+} from '../../lib/optic-sign/optic-character';
+import { FormField, NumberInput, Select } from '../form';
+import { Pagination } from '../ui';
+import { usePagination } from '../../hooks/usePagination';
+
+const CHARACTER_OPTIONS: { value: OpticCharacterKind; label: string }[] = [
+ { value: 'isotropic', label: 'Isotropic (single dark cross or dark all rotations)' },
+ { value: 'uniaxial', label: 'Uniaxial (one optic axis)' },
+ { value: 'biaxial', label: 'Biaxial (two optic axes)' },
+ { value: 'aggregate', label: 'Aggregate (snake-like flickering = AGG)' },
+];
+
+interface MatchRow {
+ mineral: Mineral;
+ riOverlap: boolean;
+ birefringenceOverlap: boolean;
+}
+
+const SIGN_LABEL: Record = {
+ '+': 'positive',
+ '-': 'negative',
+ '+/-': 'either sign (variable)',
+ 'n/a': '— not applicable —',
+};
+
+export function OpticSignReasoner() {
+ const [minerals, setMinerals] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [dbError, setDbError] = useState(null);
+
+ const [character, setCharacter] = useState('uniaxial');
+ const [omega, setOmega] = useState('');
+ const [epsilon, setEpsilon] = useState('');
+ const [alpha, setAlpha] = useState('');
+ const [beta, setBeta] = useState('');
+ const [gamma, setGamma] = useState('');
+ const tolerance = 0.005;
+
+ useEffect(() => {
+ let mounted = true;
+ (async () => {
+ try {
+ const all = await getAllMinerals();
+ if (!mounted) return;
+ setMinerals(all);
+ } catch (err) {
+ if (!mounted) return;
+ setDbError(err instanceof Error ? err.message : 'Database load failed');
+ } finally {
+ if (mounted) setLoading(false);
+ }
+ })();
+ return () => {
+ mounted = false;
+ };
+ }, []);
+
+ const computed = useMemo(() => {
+ if (character === 'isotropic') {
+ return {
+ sign: 'n/a' as OpticSign,
+ birefringence: 0,
+ twoV: null as number | null,
+ riCentre: null as number | null,
+ };
+ }
+ if (character === 'aggregate') {
+ return {
+ sign: 'n/a' as OpticSign,
+ birefringence: null,
+ twoV: null,
+ riCentre: null,
+ };
+ }
+ if (character === 'uniaxial') {
+ const o = parseFloat(omega);
+ const e = parseFloat(epsilon);
+ if (Number.isNaN(o) || Number.isNaN(e)) {
+ return { sign: 'n/a' as OpticSign, birefringence: null, twoV: null, riCentre: null };
+ }
+ return {
+ sign: uniaxialSign(o, e),
+ birefringence: Math.abs(e - o),
+ twoV: null,
+ riCentre: (o + e) / 2,
+ };
+ }
+ // biaxial
+ const a = parseFloat(alpha);
+ const b = parseFloat(beta);
+ const g = parseFloat(gamma);
+ if (Number.isNaN(a) || Number.isNaN(g)) {
+ return { sign: 'n/a' as OpticSign, birefringence: null, twoV: null, riCentre: null };
+ }
+ const aSorted = Math.min(a, g);
+ const gSorted = Math.max(a, g);
+ const bUsed = Number.isNaN(b) ? (aSorted + gSorted) / 2 : b;
+ return {
+ sign: biaxialSign(aSorted, bUsed, gSorted),
+ birefringence: gSorted - aSorted,
+ twoV: biaxial2V(aSorted, bUsed, gSorted),
+ riCentre: (aSorted + gSorted) / 2,
+ };
+ }, [character, omega, epsilon, alpha, beta, gamma]);
+
+ const matches = useMemo(() => {
+ if (minerals.length === 0) return [];
+ const out: MatchRow[] = [];
+ const obsSign: OpticSign = computed.sign;
+ const ri = computed.riCentre;
+ const br = computed.birefringence;
+
+ for (const m of minerals) {
+ const ref = parseOpticalCharacter(m.optical_character);
+ if (!characterMatches(character, obsSign, ref)) continue;
+
+ let riOk = true;
+ if (ri !== null && m.ri_min !== undefined && m.ri_max !== undefined) {
+ riOk =
+ ri >= m.ri_min - tolerance && ri <= m.ri_max + tolerance;
+ }
+
+ let brOk = true;
+ if (br !== null && br > 0 && m.birefringence !== undefined) {
+ brOk = Math.abs(m.birefringence - br) <= 0.01;
+ }
+
+ if (!riOk) continue;
+ out.push({
+ mineral: m,
+ riOverlap:
+ ri !== null && m.ri_min !== undefined && m.ri_max !== undefined && riOk,
+ birefringenceOverlap: brOk && br !== null && br > 0,
+ });
+ }
+
+ out.sort((x, y) => {
+ // Items with both RI and birefringence overlap rank highest.
+ const xs = (x.riOverlap ? 2 : 0) + (x.birefringenceOverlap ? 1 : 0);
+ const ys = (y.riOverlap ? 2 : 0) + (y.birefringenceOverlap ? 1 : 0);
+ if (ys !== xs) return ys - xs;
+ return x.mineral.name.localeCompare(y.mineral.name);
+ });
+ return out;
+ }, [minerals, character, computed]);
+
+ const { paged, page, setPage, totalPages } = usePagination(matches, 10);
+
+ return (
+
+
+ Pick what you saw in the polariscope. For uniaxial gems, enter ω and ε from the
+ refractometer; for biaxial, enter α and γ (β is optional). The reasoner derives optic
+ sign, birefringence, and 2V where defined, then ranks candidate species.
+
+ Report what you saw through the dichroscope. The reasoner explains what your observation implies and ranks
+ candidate species from the mineral database.
+
+
+ {/* Step 1 — colour count */}
+
+
+
+
+
+
{interpretation.title}
+
{interpretation.body}
+
+
+
+ {/* Step 2 — colours */}
+
+
Step 2: Observed colours
+
+ Type the colour name in plain English ("yellowish-green", "blue", "pale violet"). Order does not matter.
+
+ );
+}
diff --git a/src/components/optical/RefractometerSimulator.tsx b/src/components/optical/RefractometerSimulator.tsx
index dd970c7..0bd6134 100644
--- a/src/components/optical/RefractometerSimulator.tsx
+++ b/src/components/optical/RefractometerSimulator.tsx
@@ -30,7 +30,11 @@ interface SimulationGem {
export function RefractometerSimulator() {
const { mineralsForRefractometer, dbAvailable, loading } = useCalculatorData();
- // Convert database minerals to simulation format
+ // Standard refractometer scale upper limit (limited by the contact-liquid RI of ~1.81)
+ const REFRACTOMETER_LIMIT = 1.81;
+
+ // Convert database minerals to simulation format. Keep over-the-limit gems
+ // — the OTL state is itself a teaching moment.
const simulationGems = useMemo((): SimulationGem[] => {
if (dbAvailable && mineralsForRefractometer.length > 0) {
return mineralsForRefractometer
@@ -41,7 +45,7 @@ export function RefractometerSimulator() {
ri_high: Number(m.ri_max) || 0,
isotropic: m.optical_character === 'Isotropic',
}))
- .filter(g => g.ri_low > 0 && g.ri_high <= 1.81);
+ .filter(g => g.ri_low > 0);
}
return FALLBACK_GEMS;
}, [dbAvailable, mineralsForRefractometer]);
@@ -50,6 +54,7 @@ export function RefractometerSimulator() {
const [showAnswer, setShowAnswer] = useState(false);
const birefringence = selectedGem.ri_high - selectedGem.ri_low;
+ const isOverTheLimit = selectedGem.ri_low > REFRACTOMETER_LIMIT;
// Calculate visual position (1.40 to 1.80 scale for display)
const scale_min = 1.40;
@@ -132,18 +137,28 @@ export function RefractometerSimulator() {
Click "Show Reading" to see shadow edge
+ ) : isOverTheLimit ? (
+ // Over-the-limit: a continuously bright field, no shadow edge.
+
+
+
Over the limit
+
+ RI {selectedGem.ri_low.toFixed(3)} exceeds the contact-liquid limit (~1.81). The field stays continuously bright with no shadow edge.
+
+
+
) : (
<>
{/* Dark area (low RI side) */}
{/* Shadow edge marker (low) */}
{selectedGem.ri_low.toFixed(3)}
@@ -151,7 +166,7 @@ export function RefractometerSimulator() {
{/* If DR, show high edge */}
- {!selectedGem.isotropic && birefringence > 0.001 && (
+ {!selectedGem.isotropic && birefringence > 0.001 && selectedGem.ri_high <= REFRACTOMETER_LIMIT && (
<>
{
+ it('returns Excellent for textbook Tolkowsky proportions', () => {
+ const r = gradeRoundBrilliant({
+ tablePercent: 57,
+ depthPercent: 61.5,
+ crownAngle: 34.5,
+ pavilionAngle: 40.75,
+ girdleThickness: 'medium',
+ culetSize: 'none',
+ });
+ expect(r.grade).toBe('Excellent');
+ });
+
+ it('downgrades to Good when pavilion angle is steep', () => {
+ const r = gradeRoundBrilliant({
+ tablePercent: 57,
+ depthPercent: 61.5,
+ crownAngle: 34.5,
+ pavilionAngle: 42.8,
+ girdleThickness: 'medium',
+ culetSize: 'none',
+ });
+ expect(r.grade).toBe('Good');
+ expect(r.limitingParameter?.parameter).toBe('Pavilion angle');
+ });
+
+ it('flags extremely-thin girdle as Poor regardless of other proportions', () => {
+ const r = gradeRoundBrilliant({
+ tablePercent: 57,
+ depthPercent: 61.5,
+ girdleThickness: 'extremely-thin',
+ });
+ expect(r.grade).toBe('Poor');
+ expect(r.limitingParameter?.parameter).toBe('Girdle thickness');
+ });
+
+ it('handles partial inputs', () => {
+ const r = gradeRoundBrilliant({ tablePercent: 57 });
+ expect(r.grade).toBe('Excellent');
+ expect(r.parameterGrades).toHaveLength(1);
+ });
+
+ it('returns Poor with no inputs', () => {
+ const r = gradeRoundBrilliant({});
+ expect(r.grade).toBe('Poor');
+ expect(r.limitingParameter).toBeNull();
+ });
+});
diff --git a/src/lib/calculator/cut-grades.ts b/src/lib/calculator/cut-grades.ts
new file mode 100644
index 0000000..86e5e89
--- /dev/null
+++ b/src/lib/calculator/cut-grades.ts
@@ -0,0 +1,198 @@
+/**
+ * GIA-style cut-grade thresholds for round-brilliant diamonds.
+ *
+ * Each parameter (table %, depth %, crown angle, pavilion angle, girdle,
+ * culet) maps to a per-grade range. The overall grade is the worst grade
+ * among contributing parameters, and the parameter that *caused* the worst
+ * grade is the "limiting parameter" surfaced in the UI.
+ *
+ * Data is paraphrased from publicly-documented GIA cut-grading thresholds
+ * (Diamond Quality Document, Cut Grading System); use as educational
+ * reference only — formal grading requires a calibrated proportionscope
+ * and certified gemmologist.
+ */
+
+export type CutGradeBand = 'Excellent' | 'Very Good' | 'Good' | 'Fair' | 'Poor';
+
+export const GRADE_RANK: Record = {
+ Excellent: 4,
+ 'Very Good': 3,
+ Good: 2,
+ Fair: 1,
+ Poor: 0,
+};
+
+export type GirdleThickness =
+ | 'extremely-thin'
+ | 'very-thin'
+ | 'thin'
+ | 'medium'
+ | 'slightly-thick'
+ | 'thick'
+ | 'very-thick'
+ | 'extremely-thick';
+
+export type CuletSize = 'none' | 'very-small' | 'small' | 'medium' | 'slightly-large' | 'large' | 'very-large' | 'extremely-large';
+
+export interface CutGradeInputs {
+ tablePercent?: number;
+ depthPercent?: number;
+ crownAngle?: number;
+ pavilionAngle?: number;
+ girdleThickness?: GirdleThickness;
+ culetSize?: CuletSize;
+}
+
+export interface ParameterGrade {
+ parameter: string;
+ value: string;
+ grade: CutGradeBand;
+ comment: string;
+}
+
+export interface CutGradeResult {
+ grade: CutGradeBand;
+ parameterGrades: ParameterGrade[];
+ /** The parameter that limited the overall grade (lowest band). */
+ limitingParameter: ParameterGrade | null;
+}
+
+interface BandRange {
+ min: number;
+ max: number;
+ grade: CutGradeBand;
+}
+
+/** Round-brilliant diamond table % bands (GIA). */
+const TABLE_BANDS: BandRange[] = [
+ { min: 53, max: 58, grade: 'Excellent' },
+ { min: 51, max: 60, grade: 'Very Good' },
+ { min: 50, max: 64, grade: 'Good' },
+ { min: 47, max: 69, grade: 'Fair' },
+ { min: 0, max: 100, grade: 'Poor' },
+];
+
+/** Round-brilliant total-depth % bands (GIA). */
+const DEPTH_BANDS: BandRange[] = [
+ { min: 59, max: 62.6, grade: 'Excellent' },
+ { min: 58, max: 63.5, grade: 'Very Good' },
+ { min: 56, max: 64.5, grade: 'Good' },
+ { min: 53, max: 66.5, grade: 'Fair' },
+ { min: 0, max: 100, grade: 'Poor' },
+];
+
+/** Crown-angle bands. */
+const CROWN_BANDS: BandRange[] = [
+ { min: 31.5, max: 36.5, grade: 'Excellent' },
+ { min: 26.5, max: 38.5, grade: 'Very Good' },
+ { min: 22.0, max: 40.0, grade: 'Good' },
+ { min: 20.0, max: 41.5, grade: 'Fair' },
+ { min: 0, max: 90, grade: 'Poor' },
+];
+
+/** Pavilion-angle bands (very narrow — pavilion is the sharpest constraint). */
+const PAVILION_BANDS: BandRange[] = [
+ { min: 40.6, max: 41.8, grade: 'Excellent' },
+ { min: 39.8, max: 42.4, grade: 'Very Good' },
+ { min: 39.0, max: 43.0, grade: 'Good' },
+ { min: 38.0, max: 43.8, grade: 'Fair' },
+ { min: 0, max: 90, grade: 'Poor' },
+];
+
+const GIRDLE_GRADES: Record = {
+ 'extremely-thin': 'Poor',
+ 'very-thin': 'Good',
+ thin: 'Excellent',
+ medium: 'Excellent',
+ 'slightly-thick': 'Excellent',
+ thick: 'Very Good',
+ 'very-thick': 'Good',
+ 'extremely-thick': 'Fair',
+};
+
+const CULET_GRADES: Record = {
+ none: 'Excellent',
+ 'very-small': 'Excellent',
+ small: 'Excellent',
+ medium: 'Very Good',
+ 'slightly-large': 'Good',
+ large: 'Fair',
+ 'very-large': 'Poor',
+ 'extremely-large': 'Poor',
+};
+
+function classifyByBands(value: number, bands: BandRange[]): CutGradeBand {
+ for (const band of bands) {
+ if (value >= band.min && value <= band.max) return band.grade;
+ }
+ return 'Poor';
+}
+
+function describe(parameter: string, value: string, grade: CutGradeBand): ParameterGrade {
+ const blurb: Record = {
+ Excellent: 'within ideal range',
+ 'Very Good': 'slightly outside ideal',
+ Good: 'noticeably outside ideal',
+ Fair: 'well outside ideal — visible light leakage likely',
+ Poor: 'severe deviation — significant performance impact',
+ };
+ return {
+ parameter,
+ value,
+ grade,
+ comment: `${parameter} ${value} is ${blurb[grade]}.`,
+ };
+}
+
+/**
+ * Compute a GIA-style cut grade for a round-brilliant diamond.
+ *
+ * The overall grade equals the worst per-parameter grade; the parameter that
+ * limited it is returned so the UI can surface the "limiting parameter"
+ * callout (e.g. "Pavilion angle 42.5° pushes this from Excellent to Good").
+ */
+export function gradeRoundBrilliant(inputs: CutGradeInputs): CutGradeResult {
+ const grades: ParameterGrade[] = [];
+
+ if (inputs.tablePercent !== undefined && !isNaN(inputs.tablePercent)) {
+ grades.push(
+ describe('Table %', `${inputs.tablePercent}%`, classifyByBands(inputs.tablePercent, TABLE_BANDS)),
+ );
+ }
+ if (inputs.depthPercent !== undefined && !isNaN(inputs.depthPercent)) {
+ grades.push(
+ describe('Total depth %', `${inputs.depthPercent}%`, classifyByBands(inputs.depthPercent, DEPTH_BANDS)),
+ );
+ }
+ if (inputs.crownAngle !== undefined && !isNaN(inputs.crownAngle)) {
+ grades.push(
+ describe('Crown angle', `${inputs.crownAngle}°`, classifyByBands(inputs.crownAngle, CROWN_BANDS)),
+ );
+ }
+ if (inputs.pavilionAngle !== undefined && !isNaN(inputs.pavilionAngle)) {
+ grades.push(
+ describe('Pavilion angle', `${inputs.pavilionAngle}°`, classifyByBands(inputs.pavilionAngle, PAVILION_BANDS)),
+ );
+ }
+ if (inputs.girdleThickness) {
+ grades.push(
+ describe('Girdle thickness', inputs.girdleThickness.replace(/-/g, ' '), GIRDLE_GRADES[inputs.girdleThickness]),
+ );
+ }
+ if (inputs.culetSize) {
+ grades.push(describe('Culet size', inputs.culetSize.replace(/-/g, ' '), CULET_GRADES[inputs.culetSize]));
+ }
+
+ if (grades.length === 0) {
+ return { grade: 'Poor', parameterGrades: [], limitingParameter: null };
+ }
+
+ const sorted = [...grades].sort((a, b) => GRADE_RANK[a.grade] - GRADE_RANK[b.grade]);
+ const worst = sorted[0];
+
+ return {
+ grade: worst.grade,
+ parameterGrades: grades,
+ limitingParameter: worst,
+ };
+}
diff --git a/src/lib/calculator/hanneman.test.ts b/src/lib/calculator/hanneman.test.ts
new file mode 100644
index 0000000..33f377d
--- /dev/null
+++ b/src/lib/calculator/hanneman.test.ts
@@ -0,0 +1,83 @@
+import { describe, it, expect } from 'vitest';
+import {
+ inferRIBand,
+ combineBands,
+ filterMineralsByBand,
+ CONTACT_LIQUIDS,
+} from './hanneman';
+import type { Mineral } from '../db';
+
+const minerals: Partial[] = [
+ { id: 'diamond', name: 'Diamond', ri_min: 2.417, ri_max: 2.419 },
+ { id: 'zircon', name: 'Zircon', ri_min: 1.92, ri_max: 1.98 },
+ { id: 'corundum', name: 'Corundum', ri_min: 1.762, ri_max: 1.778 },
+ { id: 'spinel', name: 'Spinel', ri_min: 1.712, ri_max: 1.736 },
+ { id: 'quartz', name: 'Quartz', ri_min: 1.544, ri_max: 1.553 },
+];
+
+describe('inferRIBand', () => {
+ it('returns an upper-open band for higher relief than the densest fluid', () => {
+ const band = inferRIBand({ liquidId: 'methylene-iodide-si', relief: 'higher' });
+ expect(band).not.toBeNull();
+ expect(band!.min).toBeGreaterThan(1.81);
+ expect(band!.max).toBeGreaterThanOrEqual(2.5);
+ });
+
+ it('returns a tight band centred on the liquid for equal relief', () => {
+ const band = inferRIBand({ liquidId: 'methylene-iodide', relief: 'equal' });
+ expect(band).not.toBeNull();
+ expect(band!.min).toBeLessThan(1.74);
+ expect(band!.max).toBeGreaterThan(1.74);
+ });
+
+ it('rejects unknown liquid id', () => {
+ expect(inferRIBand({ liquidId: 'nonsense', relief: 'equal' })).toBeNull();
+ });
+});
+
+describe('combineBands', () => {
+ it('intersects two consistent observations to a tight band', () => {
+ const band = combineBands([
+ { liquidId: 'methylene-iodide', relief: 'higher' },
+ { liquidId: 'methylene-iodide-si', relief: 'lower' },
+ ]);
+ expect(band).not.toBeNull();
+ expect(band!.min).toBeGreaterThanOrEqual(1.74);
+ expect(band!.max).toBeLessThanOrEqual(1.81);
+ });
+
+ it('returns inverted band when observations conflict', () => {
+ const band = combineBands([
+ { liquidId: 'water', relief: 'lower' },
+ { liquidId: 'methylene-iodide-si', relief: 'higher' },
+ ]);
+ expect(band).not.toBeNull();
+ expect(band!.min).toBeGreaterThan(band!.max);
+ });
+});
+
+describe('filterMineralsByBand', () => {
+ it('selects diamond and zircon for the OTL band', () => {
+ const band = inferRIBand({ liquidId: 'methylene-iodide-si', relief: 'higher' })!;
+ const out = filterMineralsByBand(band, minerals as Mineral[]);
+ expect(out.find((m) => m.mineral.name === 'Diamond')).toBeDefined();
+ expect(out.find((m) => m.mineral.name === 'Zircon')).toBeDefined();
+ expect(out.find((m) => m.mineral.name === 'Quartz')).toBeUndefined();
+ });
+
+ it('orders matches by closest centre', () => {
+ // Spinel centre 1.724, Corundum centre 1.770; band centre at 1.74 favours Spinel.
+ const band = inferRIBand({ liquidId: 'methylene-iodide', relief: 'equal' })!;
+ const out = filterMineralsByBand(band, minerals as Mineral[]);
+ expect(out[0].mineral.name).toBe('Spinel');
+ expect(out.find((m) => m.mineral.name === 'Corundum')).toBeDefined();
+ });
+});
+
+describe('CONTACT_LIQUIDS', () => {
+ it('lists liquids in ascending RI order', () => {
+ for (let i = 1; i < CONTACT_LIQUIDS.length; i++) {
+ expect(CONTACT_LIQUIDS[i].ri).toBeGreaterThanOrEqual(CONTACT_LIQUIDS[i - 1].ri);
+ }
+ });
+});
diff --git a/src/lib/calculator/hanneman.ts b/src/lib/calculator/hanneman.ts
new file mode 100644
index 0000000..126d581
--- /dev/null
+++ b/src/lib/calculator/hanneman.ts
@@ -0,0 +1,155 @@
+/**
+ * Hanneman / Hodgkinson short-cut method for over-the-limit (OTL) RI estimation.
+ *
+ * When a gem's refractive index lies above the standard refractometer scale
+ * (~1.81), gemmologists use a "contact-liquid comparison" technique:
+ *
+ * 1. Place the polished stone on a flat surface in a drop of a known liquid.
+ * 2. View the stone from the side, illuminated from behind/below.
+ * 3. Estimate whether the stone's RI is LOWER, EQUAL, or HIGHER than the
+ * liquid by relief, surface visibility, and edge contrast.
+ *
+ * Each liquid has a known RI; comparing the stone to several liquids brackets
+ * the stone's RI band. This file encodes the standard liquid ladder used in
+ * gemmological practice and a pure helper that turns observations into an
+ * RI band + candidate-mineral filter.
+ */
+
+import type { Mineral } from '../db';
+
+export interface ContactLiquid {
+ id: string;
+ name: string;
+ ri: number;
+ /** Short note shown in the picker UI. */
+ note?: string;
+}
+
+/**
+ * Standard contact-liquid ladder, ordered from lowest RI to highest.
+ * Sources: Hodgkinson (Visual Optics), Hanneman (Affordable Gemology).
+ */
+export const CONTACT_LIQUIDS: ContactLiquid[] = [
+ { id: 'water', name: 'Water', ri: 1.33, note: 'Reference; most gems show high relief.' },
+ { id: 'cedar-oil', name: 'Cedarwood oil', ri: 1.515, note: 'Common microscope mounting oil.' },
+ { id: 'mono-bromo', name: 'Monobromonaphthalene', ri: 1.66, note: 'Classic bracketing fluid.' },
+ { id: 'methylene-iodide', name: 'Methylene iodide (di-iodomethane)', ri: 1.74, note: 'Standard refractometer contact fluid.' },
+ { id: 'methylene-iodide-s', name: 'Methylene iodide + sulphur (saturated)', ri: 1.78, note: 'Pushes RI ceiling for high-RI gems.' },
+ { id: 'methylene-iodide-si', name: 'Methylene iodide + sulphur + Sel d\'Iode', ri: 1.81, note: 'Standard upper limit of refractometer fluid.' },
+];
+
+export type Relief = 'lower' | 'equal' | 'higher' | 'unknown';
+
+export interface HannemanCriteria {
+ /** Liquid id from CONTACT_LIQUIDS. */
+ liquidId: string;
+ /** Whether the stone reads as lower-RI, equal, or higher-RI than the liquid. */
+ relief: Relief;
+}
+
+export interface RIBand {
+ /** Lower bound of inferred RI (inclusive). */
+ min: number;
+ /** Upper bound of inferred RI (inclusive). */
+ max: number;
+ /** Human-friendly explanation. */
+ rationale: string;
+}
+
+/**
+ * Convert a single liquid+relief observation to an inferred RI band.
+ *
+ * The width of the band reflects honest uncertainty: side-by-side relief
+ * judgements are good to about ±0.05 RI in skilled hands.
+ */
+export function inferRIBand(criteria: HannemanCriteria): RIBand | null {
+ const liquid = CONTACT_LIQUIDS.find((l) => l.id === criteria.liquidId);
+ if (!liquid) return null;
+
+ const margin = 0.05;
+ switch (criteria.relief) {
+ case 'lower':
+ return {
+ min: 1.0,
+ max: Math.max(1.0, liquid.ri - 0.02),
+ rationale: `Stone shows lower relief than ${liquid.name} (RI ${liquid.ri.toFixed(3)}), so its RI is below ${liquid.ri.toFixed(3)}.`,
+ };
+ case 'equal':
+ return {
+ min: Math.max(1.0, liquid.ri - margin),
+ max: liquid.ri + margin,
+ rationale: `Stone disappears or matches ${liquid.name} (RI ${liquid.ri.toFixed(3)}), so its RI is within ±${margin.toFixed(2)}.`,
+ };
+ case 'higher':
+ return {
+ min: liquid.ri + 0.02,
+ max: 3.0,
+ rationale: `Stone shows higher relief than ${liquid.name} (RI ${liquid.ri.toFixed(3)}), so its RI is above ${liquid.ri.toFixed(3)}.`,
+ };
+ default:
+ return null;
+ }
+}
+
+/**
+ * Combine multiple liquid+relief observations into the tightest consistent band.
+ * Each observation narrows the band; if they conflict, the intersection is empty
+ * and we return `null`.
+ */
+export function combineBands(observations: HannemanCriteria[]): RIBand | null {
+ const bands = observations
+ .map(inferRIBand)
+ .filter((b): b is RIBand => b !== null);
+ if (bands.length === 0) return null;
+
+ let lo = -Infinity;
+ let hi = Infinity;
+ const reasons: string[] = [];
+ for (const band of bands) {
+ lo = Math.max(lo, band.min);
+ hi = Math.min(hi, band.max);
+ reasons.push(band.rationale);
+ }
+ if (lo > hi) {
+ return {
+ min: lo,
+ max: hi,
+ rationale: `Conflicting observations — please re-test. ${reasons.join(' ')}`,
+ };
+ }
+ return {
+ min: Math.max(1.0, lo),
+ max: Math.min(3.0, hi),
+ rationale: reasons.join(' '),
+ };
+}
+
+export interface HannemanMatch {
+ mineral: Mineral;
+ /** True when the stored RI range overlaps the inferred band. */
+ overlap: boolean;
+ /** Centre RI of the stored range (for sorting). */
+ storedCentre: number;
+}
+
+/**
+ * Filter the mineral database to species whose RI range intersects the
+ * inferred band. Sorted by closest match centre.
+ */
+export function filterMineralsByBand(band: RIBand, minerals: Mineral[]): HannemanMatch[] {
+ const bandCentre = (band.min + band.max) / 2;
+ const matches: HannemanMatch[] = [];
+
+ for (const m of minerals) {
+ if (m.ri_min === undefined || m.ri_max === undefined) continue;
+ const overlap = m.ri_min <= band.max && m.ri_max >= band.min;
+ if (!overlap) continue;
+ const centre = (m.ri_min + m.ri_max) / 2;
+ matches.push({ mineral: m, overlap: true, storedCentre: centre });
+ }
+
+ matches.sort(
+ (a, b) => Math.abs(a.storedCentre - bandCentre) - Math.abs(b.storedCentre - bandCentre),
+ );
+ return matches;
+}
diff --git a/src/lib/optic-sign/optic-character.test.ts b/src/lib/optic-sign/optic-character.test.ts
new file mode 100644
index 0000000..2438c78
--- /dev/null
+++ b/src/lib/optic-sign/optic-character.test.ts
@@ -0,0 +1,101 @@
+import { describe, it, expect } from 'vitest';
+import {
+ parseOpticalCharacter,
+ uniaxialSign,
+ biaxialSign,
+ biaxial2V,
+ characterMatches,
+} from './optic-character';
+
+describe('parseOpticalCharacter', () => {
+ it('parses isotropic', () => {
+ expect(parseOpticalCharacter('Isotropic')).toMatchObject({ kind: 'isotropic', sign: 'n/a' });
+ });
+ it('parses uniaxial positive', () => {
+ expect(parseOpticalCharacter('Uniaxial +')).toMatchObject({ kind: 'uniaxial', sign: '+' });
+ });
+ it('parses uniaxial negative', () => {
+ expect(parseOpticalCharacter('Uniaxial -')).toMatchObject({ kind: 'uniaxial', sign: '-' });
+ });
+ it('parses biaxial both signs', () => {
+ expect(parseOpticalCharacter('Biaxial + or -')).toMatchObject({
+ kind: 'biaxial',
+ sign: '+/-',
+ });
+ });
+ it('treats AGG as aggregate', () => {
+ expect(parseOpticalCharacter('AGG').kind).toBe('aggregate');
+ });
+ it('treats opaque metallic as opaque', () => {
+ expect(parseOpticalCharacter('Opaque (metallic)').kind).toBe('opaque');
+ });
+ it('returns unknown for empty string', () => {
+ expect(parseOpticalCharacter('')).toEqual({ kind: 'unknown', sign: 'n/a' });
+ });
+ it('handles "Isotropic to near-isotropic (AGG)" as isotropic', () => {
+ expect(parseOpticalCharacter('Isotropic to near-isotropic (AGG)').kind).toBe('isotropic');
+ });
+});
+
+describe('uniaxialSign', () => {
+ it('returns + when epsilon > omega (e.g. quartz: 1.544 / 1.553)', () => {
+ expect(uniaxialSign(1.544, 1.553)).toBe('+');
+ });
+ it('returns - when epsilon < omega (e.g. ruby: 1.770 / 1.762)', () => {
+ expect(uniaxialSign(1.77, 1.762)).toBe('-');
+ });
+});
+
+describe('biaxialSign', () => {
+ it('returns + when β closer to α (forsterite-end peridot ~ 1.635 / 1.651 / 1.670)', () => {
+ // γ-β = 0.019, β-α = 0.016 → β closer to α → biaxial positive
+ expect(biaxialSign(1.635, 1.651, 1.67)).toBe('+');
+ });
+ it('returns - when β closer to γ', () => {
+ // γ-β = 0.005, β-α = 0.030 → β closer to γ → biaxial negative
+ expect(biaxialSign(1.5, 1.53, 1.535)).toBe('-');
+ });
+});
+
+describe('biaxial2V', () => {
+ it('computes a sane positive angle from valid indices', () => {
+ const v = biaxial2V(1.635, 1.651, 1.67);
+ expect(v).not.toBeNull();
+ expect(v!).toBeGreaterThan(0);
+ expect(v!).toBeLessThan(90);
+ });
+ it('returns null on degenerate input', () => {
+ expect(biaxial2V(1.5, 1.5, 1.5)).toBeNull();
+ });
+});
+
+describe('characterMatches', () => {
+ it('matches identical character + sign', () => {
+ expect(
+ characterMatches('uniaxial', '+', { kind: 'uniaxial', sign: '+' }),
+ ).toBe(true);
+ });
+ it('rejects character mismatch', () => {
+ expect(
+ characterMatches('uniaxial', '+', { kind: 'biaxial', sign: '+' }),
+ ).toBe(false);
+ });
+ it('rejects sign mismatch', () => {
+ expect(
+ characterMatches('uniaxial', '+', { kind: 'uniaxial', sign: '-' }),
+ ).toBe(false);
+ });
+ it('accepts +/- references for either sign', () => {
+ expect(
+ characterMatches('uniaxial', '+', { kind: 'uniaxial', sign: '+/-' }),
+ ).toBe(true);
+ expect(
+ characterMatches('uniaxial', '-', { kind: 'uniaxial', sign: '+/-' }),
+ ).toBe(true);
+ });
+ it('accepts unknown observed sign as wildcard', () => {
+ expect(
+ characterMatches('biaxial', 'n/a', { kind: 'biaxial', sign: '-' }),
+ ).toBe(true);
+ });
+});
diff --git a/src/lib/optic-sign/optic-character.ts b/src/lib/optic-sign/optic-character.ts
new file mode 100644
index 0000000..b7b5ace
--- /dev/null
+++ b/src/lib/optic-sign/optic-character.ts
@@ -0,0 +1,138 @@
+/**
+ * Optic-character + 2V reasoner.
+ *
+ * Inputs from the user:
+ * - Optic character observed in the polariscope (isotropic / uniaxial / biaxial / aggregate).
+ * - For uniaxial: ω and ε refractive indices.
+ * - For biaxial: α, β, γ refractive indices (β optional — derived if missing).
+ *
+ * Outputs:
+ * - Computed optic sign and birefringence.
+ * - 2V band for biaxial gems (Mallard formula approximation).
+ * - Ranked candidate species filtered against the database, parsing the
+ * `optical_character` text field for each family.
+ *
+ * The mineral database stores `optical_character` as freeform text — see
+ * MineralFamily.optical_character. Values seen in the source YAML include:
+ * "Isotropic", "Uniaxial +", "Uniaxial -", "Uniaxial - (occasionally +)",
+ * "Biaxial +", "Biaxial -", "Biaxial + or -", "AGG", "Aggregate",
+ * "Isotropic to near-isotropic (AGG)", "Opaque (metallic)".
+ *
+ * This pure-TS layer parses that text into a discriminated `OpticalCharacter`
+ * so we don't need to touch the SQLite schema for v1.
+ */
+
+export type OpticCharacterKind =
+ | 'isotropic'
+ | 'uniaxial'
+ | 'biaxial'
+ | 'aggregate'
+ | 'opaque'
+ | 'unknown';
+
+export type OpticSign = '+' | '-' | '+/-' | 'n/a';
+
+export interface OpticalCharacter {
+ kind: OpticCharacterKind;
+ sign: OpticSign;
+ /** Original raw text from the database. */
+ raw?: string;
+}
+
+/**
+ * Parse the freeform `optical_character` field into a discriminated record.
+ *
+ * Heuristic rules (case-insensitive):
+ * - Contains "isotropic" → kind isotropic, sign n/a.
+ * - Contains "uniaxial" → kind uniaxial. Sign from "+ or -" / "+" / "-".
+ * - Contains "biaxial" → kind biaxial. Sign from "+ or -" / "+" / "-".
+ * - Contains "aggregate" / "AGG" → kind aggregate.
+ * - Contains "opaque" → kind opaque.
+ * - Otherwise unknown.
+ */
+export function parseOpticalCharacter(text: string | undefined | null): OpticalCharacter {
+ if (!text || !text.trim()) return { kind: 'unknown', sign: 'n/a' };
+ const raw = text.trim();
+ const lower = raw.toLowerCase();
+
+ let sign: OpticSign = 'n/a';
+ if (/[+\-] *or *[+\-]|both signs|\+\/-/.test(lower)) sign = '+/-';
+ else if (/(?:^|[^a-z])(uniaxial|biaxial)\s*\+/.test(lower)) sign = '+';
+ else if (/(?:^|[^a-z])(uniaxial|biaxial)\s*-/.test(lower)) sign = '-';
+
+ if (lower.includes('isotropic')) return { kind: 'isotropic', sign: 'n/a', raw };
+ if (lower.includes('uniaxial')) return { kind: 'uniaxial', sign, raw };
+ if (lower.includes('biaxial')) return { kind: 'biaxial', sign, raw };
+ if (lower.includes('aggregate') || /\bagg\b/.test(lower)) {
+ return { kind: 'aggregate', sign: 'n/a', raw };
+ }
+ if (lower.includes('opaque')) return { kind: 'opaque', sign: 'n/a', raw };
+ return { kind: 'unknown', sign: 'n/a', raw };
+}
+
+/**
+ * Determine optic sign from uniaxial RI readings.
+ * Uniaxial positive: ε > ω (epsilon, the extraordinary ray, is larger).
+ * Uniaxial negative: ε < ω.
+ */
+export function uniaxialSign(omega: number, epsilon: number): OpticSign {
+ const diff = epsilon - omega;
+ if (Math.abs(diff) < 0.001) return 'n/a';
+ return diff > 0 ? '+' : '-';
+}
+
+/**
+ * Determine optic sign from biaxial RI readings.
+ * Biaxial positive: γ - β > β - α (β closer to α).
+ * Biaxial negative: γ - β < β - α (β closer to γ).
+ */
+export function biaxialSign(alpha: number, beta: number, gamma: number): OpticSign {
+ if (alpha > gamma) {
+ // Caller swapped them; fix.
+ [alpha, gamma] = [gamma, alpha];
+ }
+ const upper = gamma - beta;
+ const lower = beta - alpha;
+ if (Math.abs(upper - lower) < 0.0005) return '+/-';
+ return upper > lower ? '+' : '-';
+}
+
+/**
+ * Approximate 2V using the Mallard formula:
+ * cos²(V_z) = (γ² · (β² - α²)) / (β² · (γ² - α²))
+ *
+ * Returns Vz in degrees (the optic axial angle measured from γ for biaxial+
+ * or from α for biaxial-). Caller decides which axis the angle refers to.
+ *
+ * Returns null if the inputs are degenerate.
+ */
+export function biaxial2V(alpha: number, beta: number, gamma: number): number | null {
+ if (alpha > gamma) [alpha, gamma] = [gamma, alpha];
+ if (gamma <= alpha || beta <= alpha || beta >= gamma) return null;
+ const num = gamma * gamma * (beta * beta - alpha * alpha);
+ const den = beta * beta * (gamma * gamma - alpha * alpha);
+ if (den === 0) return null;
+ const cosSq = num / den;
+ if (cosSq < 0 || cosSq > 1) return null;
+ const vz = Math.acos(Math.sqrt(cosSq)) * (180 / Math.PI);
+ return vz;
+}
+
+/**
+ * Compatibility check between an observed character and a parsed reference.
+ * Returns true if the species is compatible with the user's observation.
+ */
+export function characterMatches(
+ observedKind: OpticCharacterKind,
+ observedSign: OpticSign | undefined,
+ reference: OpticalCharacter,
+): boolean {
+ if (reference.kind === 'unknown') return false;
+ // Aggregate often masks other characters — keep flexible.
+ if (observedKind === 'aggregate') return reference.kind === 'aggregate';
+ if (reference.kind === 'aggregate') return false;
+ if (reference.kind !== observedKind) return false;
+ if (!observedSign || observedSign === 'n/a' || reference.sign === 'n/a') return true;
+ if (reference.sign === '+/-' || observedSign === '+/-') return true;
+ return reference.sign === observedSign;
+}
diff --git a/src/lib/pleochroism/match-pleochroism.test.ts b/src/lib/pleochroism/match-pleochroism.test.ts
new file mode 100644
index 0000000..8c2c692
--- /dev/null
+++ b/src/lib/pleochroism/match-pleochroism.test.ts
@@ -0,0 +1,100 @@
+import { describe, it, expect } from 'vitest';
+import { matchPleochroism, interpretColourCount } from './match-pleochroism';
+import type { Mineral } from '../db';
+
+const fixture: Partial[] = [
+ {
+ id: 'andalusite',
+ name: 'Andalusite',
+ pleochroism_color1: 'yellowish-brown',
+ pleochroism_color2: 'greenish-brown',
+ pleochroism_color3: 'reddish-brown',
+ pleochroism_strength: 'very_strong',
+ },
+ {
+ id: 'tanzanite',
+ name: 'Tanzanite',
+ pleochroism_color1: 'blue',
+ pleochroism_color2: 'purple',
+ pleochroism_color3: 'bronze',
+ pleochroism_strength: 'very_strong',
+ },
+ {
+ id: 'ruby',
+ name: 'Ruby',
+ pleochroism_color1: 'red',
+ pleochroism_color2: 'orangy-red',
+ pleochroism_strength: 'strong',
+ },
+ {
+ id: 'diamond',
+ name: 'Diamond',
+ pleochroism_strength: 'none',
+ },
+ {
+ id: 'quartz',
+ name: 'Quartz',
+ pleochroism_color1: 'colourless',
+ pleochroism_color2: 'pale violet',
+ pleochroism_strength: 'weak',
+ },
+];
+
+describe('matchPleochroism', () => {
+ it('ranks andalusite top for the canonical brown trichroism', () => {
+ const r = matchPleochroism(
+ {
+ colourCount: 3,
+ colours: ['yellowish-brown', 'greenish-brown', 'reddish-brown'],
+ strength: 'very_strong',
+ },
+ fixture as Mineral[],
+ );
+ expect(r[0].mineral.name).toBe('Andalusite');
+ });
+
+ it('excludes dichroic gems when 3 colours observed', () => {
+ const r = matchPleochroism(
+ {
+ colourCount: 3,
+ colours: ['red', 'orangy-red'],
+ strength: 'strong',
+ },
+ fixture as Mineral[],
+ );
+ expect(r.find((m) => m.mineral.name === 'Ruby')).toBeUndefined();
+ });
+
+ it('matches ruby on 2-colour red observation', () => {
+ const r = matchPleochroism(
+ {
+ colourCount: 2,
+ colours: ['red', 'orangy-red'],
+ strength: 'strong',
+ },
+ fixture as Mineral[],
+ );
+ expect(r[0].mineral.name).toBe('Ruby');
+ });
+
+ it('skips minerals with no stored pleochroism colours', () => {
+ const r = matchPleochroism(
+ {
+ colourCount: 1,
+ colours: ['white'],
+ strength: 'unknown',
+ },
+ fixture as Mineral[],
+ );
+ expect(r.find((m) => m.mineral.name === 'Diamond')).toBeUndefined();
+ });
+});
+
+describe('interpretColourCount', () => {
+ it('returns biaxial systems for 3 colours', () => {
+ expect(interpretColourCount(3).body).toMatch(/biaxial/);
+ });
+ it('returns uniaxial systems for 2 colours', () => {
+ expect(interpretColourCount(2).body).toMatch(/uniaxial/);
+ });
+});
diff --git a/src/lib/pleochroism/match-pleochroism.ts b/src/lib/pleochroism/match-pleochroism.ts
new file mode 100644
index 0000000..d6ae4e0
--- /dev/null
+++ b/src/lib/pleochroism/match-pleochroism.ts
@@ -0,0 +1,250 @@
+/**
+ * Pure matcher used by the Pleochroism Reasoner widget.
+ *
+ * Given the number of distinct colours the user observed in the dichroscope
+ * (1, 2, or 3), the colours themselves, and an optional perceived strength,
+ * rank candidate gem species in the database by how well they fit.
+ *
+ * The "number of colours" carries a strong gemmological signal:
+ * 1 → isotropic (cubic / amorphous) OR observed only along the optic axis
+ * 2 → dichroic, i.e. uniaxial (trigonal / tetragonal / hexagonal)
+ * 3 → trichroic, i.e. biaxial (orthorhombic / monoclinic / triclinic)
+ */
+
+import type { Mineral } from '../db';
+
+export type ObservedColourCount = 1 | 2 | 3;
+
+export type PleochroismStrength =
+ | 'unknown'
+ | 'weak'
+ | 'moderate'
+ | 'strong'
+ | 'very_strong';
+
+export interface PleochroismCriteria {
+ /** How many distinct colours the user saw through the dichroscope. */
+ colourCount: ObservedColourCount;
+ /** Up to three observed colour names, lowercase / trimmed. */
+ colours: string[];
+ /** Optional perceived strength. */
+ strength: PleochroismStrength;
+}
+
+export interface PleochroismMatch {
+ mineral: Mineral;
+ /** 0..1 — how well the gem fits the reported observation. */
+ score: number;
+ /** Human-friendly reason text used in the UI. */
+ reason: string;
+ /** Number of distinct colours stored in the DB for this gem (1, 2 or 3). */
+ storedColourCount: 1 | 2 | 3;
+}
+
+const STRENGTH_RANK: Record = {
+ none: 0,
+ weak: 1,
+ moderate: 2,
+ strong: 3,
+ very_strong: 4,
+};
+
+/** Normalise a colour string for fuzzy comparison. */
+function normaliseColour(s: string): string {
+ return s
+ .toLowerCase()
+ .trim()
+ .replace(/[\s\-_/]+/g, ' ')
+ .replace(/\bish\b/g, '')
+ .replace(/\s+/g, ' ');
+}
+
+/** Tokenise a colour name into bare colour words: "yellowish-green" → ["yellow","green"]. */
+function colourTokens(s: string): string[] {
+ const root = normaliseColour(s)
+ .replace(/(?:bluish|greenish|yellowish|reddish|pinkish|orangish|brownish|purplish|violetish)/g, (m) =>
+ m.replace(/ish$/, ''),
+ );
+ return root.split(' ').filter(Boolean);
+}
+
+/**
+ * Compare one observed colour to one stored colour.
+ * Returns 1.0 for exact / strong overlap, 0.5 for partial token overlap, 0 otherwise.
+ */
+function colourSimilarity(observed: string, stored: string | undefined | null): number {
+ if (!stored) return 0;
+ const a = normaliseColour(observed);
+ const b = normaliseColour(stored);
+ if (!a || !b) return 0;
+ if (a === b) return 1;
+ if (a.includes(b) || b.includes(a)) return 0.85;
+
+ const ta = new Set(colourTokens(a));
+ const tb = new Set(colourTokens(b));
+ let shared = 0;
+ ta.forEach((t) => {
+ if (tb.has(t)) shared++;
+ });
+ if (shared === 0) return 0;
+ const denom = Math.max(ta.size, tb.size);
+ return Math.min(0.6, shared / denom);
+}
+
+/**
+ * Score the match between observed colours and the gem's stored 2-3 colours.
+ * Uses greedy assignment: each observed colour pairs with its best remaining stored colour.
+ */
+function colourSetSimilarity(observed: string[], stored: (string | undefined | null)[]): number {
+ const cleanStored = stored.filter((s): s is string => Boolean(s && s.trim()));
+ if (observed.length === 0 || cleanStored.length === 0) return 0;
+
+ const remaining = [...cleanStored];
+ let total = 0;
+ for (const obs of observed) {
+ let bestIdx = -1;
+ let bestSim = 0;
+ remaining.forEach((s, i) => {
+ const sim = colourSimilarity(obs, s);
+ if (sim > bestSim) {
+ bestSim = sim;
+ bestIdx = i;
+ }
+ });
+ if (bestIdx >= 0) {
+ total += bestSim;
+ remaining.splice(bestIdx, 1);
+ }
+ }
+ return total / observed.length;
+}
+
+function storedColourCountOf(m: Mineral): 1 | 2 | 3 | 0 {
+ if (m.pleochroism_color3 && m.pleochroism_color3.trim()) return 3;
+ if (m.pleochroism_color2 && m.pleochroism_color2.trim()) return 2;
+ if (m.pleochroism_color1 && m.pleochroism_color1.trim()) return 1;
+ return 0;
+}
+
+/**
+ * Match observed pleochroism against the mineral database.
+ *
+ * @param criteria what the user observed
+ * @param minerals mineral list (already loaded by the caller, e.g. via getAllMinerals)
+ * @returns matches ranked by score, descending, capped at 50
+ */
+export function matchPleochroism(
+ criteria: PleochroismCriteria,
+ minerals: Mineral[],
+): PleochroismMatch[] {
+ const observedColours = criteria.colours
+ .map((c) => c.trim())
+ .filter(Boolean);
+
+ const matches: PleochroismMatch[] = [];
+
+ for (const mineral of minerals) {
+ const storedCount = storedColourCountOf(mineral);
+ if (storedCount === 0) continue;
+
+ // Step 1 — colour-count match.
+ // 1 observed: ANY pleochroic gem could read as 1 if viewed along optic axis;
+ // so we don't penalise but we DO prefer exact-count matches.
+ // 2 observed: dichroic or trichroic-viewed-from-side; dichroic gems score higher.
+ // 3 observed: only trichroic gems (storedCount === 3).
+ let countScore: number;
+ if (criteria.colourCount === storedCount) {
+ countScore = 1.0;
+ } else if (criteria.colourCount === 3 && storedCount < 3) {
+ // Three observed colours but DB knows only 2 → impossible match.
+ continue;
+ } else if (criteria.colourCount === 2 && storedCount === 3) {
+ // Possible if viewed off the optic-plane bisector — partial credit.
+ countScore = 0.6;
+ } else if (criteria.colourCount === 1) {
+ // Saw one colour — could be any gem viewed favourably; mild penalty for richer ones.
+ countScore = storedCount === 1 ? 1.0 : 0.4;
+ } else {
+ countScore = 0.3;
+ }
+
+ // Step 2 — colour similarity.
+ const stored = [
+ mineral.pleochroism_color1,
+ mineral.pleochroism_color2,
+ mineral.pleochroism_color3,
+ ];
+ const colourScore =
+ observedColours.length === 0 ? 1 : colourSetSimilarity(observedColours, stored);
+
+ // Step 3 — strength similarity (optional).
+ let strengthScore = 1;
+ if (criteria.strength !== 'unknown' && mineral.pleochroism_strength) {
+ const obsRank = STRENGTH_RANK[criteria.strength] ?? 2;
+ const dbRank = STRENGTH_RANK[mineral.pleochroism_strength] ?? 2;
+ const diff = Math.abs(obsRank - dbRank);
+ strengthScore = Math.max(0, 1 - diff * 0.25);
+ }
+
+ // Weighted overall score: colour overlap matters most, count next, strength last.
+ const score = colourScore * 0.6 + countScore * 0.3 + strengthScore * 0.1;
+ if (score < 0.2) continue;
+
+ const reasonParts: string[] = [];
+ reasonParts.push(
+ storedCount === 3
+ ? 'Trichroic — biaxial (orthorhombic, monoclinic, or triclinic).'
+ : storedCount === 2
+ ? 'Dichroic — uniaxial (trigonal, tetragonal, or hexagonal).'
+ : 'Single observed colour — pleochroism not detectable.',
+ );
+ if (mineral.pleochroism_strength) {
+ reasonParts.push(
+ `Strength: ${mineral.pleochroism_strength.replace('_', ' ')}.`,
+ );
+ }
+ if (mineral.pleochroism_notes) {
+ reasonParts.push(mineral.pleochroism_notes);
+ }
+
+ matches.push({
+ mineral,
+ score,
+ reason: reasonParts.join(' '),
+ storedColourCount: storedCount as 1 | 2 | 3,
+ });
+ }
+
+ matches.sort((a, b) => b.score - a.score);
+ return matches.slice(0, 50);
+}
+
+/**
+ * Educational interpretation of the colour count itself, independent of any match.
+ * Used in the UI as a "what your observation implies" callout.
+ */
+export function interpretColourCount(count: ObservedColourCount): {
+ title: string;
+ body: string;
+} {
+ switch (count) {
+ case 1:
+ return {
+ title: 'One colour observed',
+ body:
+ 'The gem may be isotropic (cubic or amorphous — diamond, garnet, spinel, glass, opal), or anisotropic but viewed along its optic axis. Rotate the dichroscope and the stone; if no second colour appears at any orientation, isotropic is most likely.',
+ };
+ case 2:
+ return {
+ title: 'Two colours observed (dichroic)',
+ body:
+ 'The gem is uniaxial: trigonal, tetragonal, or hexagonal. Examples include corundum (ruby/sapphire), tourmaline, beryl (emerald/aquamarine), zircon, and quartz.',
+ };
+ case 3:
+ return {
+ title: 'Three colours observed (trichroic)',
+ body:
+ 'The gem is biaxial: orthorhombic, monoclinic, or triclinic. Examples include andalusite, iolite, tanzanite, kunzite, topaz, and peridot.',
+ };
+ }
+}
diff --git a/src/lib/spectroscope/match-bands.test.ts b/src/lib/spectroscope/match-bands.test.ts
new file mode 100644
index 0000000..0eaad92
--- /dev/null
+++ b/src/lib/spectroscope/match-bands.test.ts
@@ -0,0 +1,34 @@
+import { describe, it, expect } from 'vitest';
+import { matchBands } from './match-bands';
+
+describe('matchBands', () => {
+ it('ranks ruby top when chrome doublet observed', () => {
+ const r = matchBands([693, 555, 476], 5);
+ expect(r[0].reference.name).toMatch(/Ruby/);
+ expect(r[0].hasSelective).toBe(true);
+ });
+
+ it('ranks almandine top for the 504 / 527 / 576 trio', () => {
+ const r = matchBands([504, 527, 576], 4);
+ expect(r[0].reference.name).toMatch(/Almandine/);
+ });
+
+ it('returns empty for no observations', () => {
+ expect(matchBands([], 5)).toEqual([]);
+ });
+
+ it('respects tolerance — 700 nm does not match the 692 ruby line at ±5', () => {
+ const r = matchBands([700], 5);
+ expect(r.find((m) => m.reference.name.match(/Ruby/))).toBeUndefined();
+ });
+
+ it('matches blue sapphire on the 450 nm Fe³⁺ line', () => {
+ const r = matchBands([450], 4);
+ expect(r[0].reference.name).toMatch(/sapphire/i);
+ });
+
+ it('matches zircon on the 653 nm uranium line', () => {
+ const r = matchBands([653], 4);
+ expect(r[0].reference.name).toMatch(/Zircon/);
+ });
+});
diff --git a/src/lib/spectroscope/match-bands.ts b/src/lib/spectroscope/match-bands.ts
new file mode 100644
index 0000000..56d6eea
--- /dev/null
+++ b/src/lib/spectroscope/match-bands.ts
@@ -0,0 +1,104 @@
+/**
+ * Pure matcher for the Spectroscope Band Matcher widget.
+ *
+ * Inputs: a set of observed band wavelengths (the user ticks them off in the
+ * UI) plus an optional tolerance in nm. For each species in the reference
+ * table, we count how many bands match, weighted by intensity and
+ * "selective" flag, and divide by the species' total band weight to get a
+ * coverage score. The result is a ranked candidate list.
+ */
+
+import {
+ SPECTROSCOPE_REFERENCE,
+ type AbsorptionBand,
+ type SpectroscopeReference,
+} from './reference-bands';
+
+const INTENSITY_WEIGHT = { weak: 1, moderate: 2, strong: 3 } as const;
+
+export interface BandMatch {
+ reference: SpectroscopeReference;
+ /** 0..1 — fraction of the species' weighted bands the user observed. */
+ coverage: number;
+ /** Bands that matched (with the observed wavelength that triggered them). */
+ matched: { observed: number; band: AbsorptionBand }[];
+ /** True if at least one matched band was flagged `selective`. */
+ hasSelective: boolean;
+ /** Reasoning text for the UI. */
+ reason: string;
+}
+
+function bandWavelengths(b: AbsorptionBand): number[] {
+ return [b.wavelength, ...(b.also ?? [])];
+}
+
+function nearest(observed: number, target: number): number {
+ return Math.abs(observed - target);
+}
+
+/**
+ * Match observed bands against the reference table.
+ * @param observed list of wavelengths the user ticked
+ * @param tolerance ± nm window for considering a band "matched" (default 5 nm)
+ */
+export function matchBands(observed: number[], tolerance = 5): BandMatch[] {
+ if (observed.length === 0) return [];
+
+ const out: BandMatch[] = [];
+ for (const reference of SPECTROSCOPE_REFERENCE) {
+ let totalWeight = 0;
+ let matchedWeight = 0;
+ let hasSelective = false;
+ const matched: BandMatch['matched'] = [];
+
+ for (const band of reference.bands) {
+ const w = INTENSITY_WEIGHT[band.intensity] * (band.selective ? 2 : 1);
+ totalWeight += w;
+
+ const targets = bandWavelengths(band);
+ let bestObs: number | null = null;
+ let bestDist = Infinity;
+ for (const obs of observed) {
+ for (const target of targets) {
+ const d = nearest(obs, target);
+ if (d <= tolerance && d < bestDist) {
+ bestDist = d;
+ bestObs = obs;
+ }
+ }
+ }
+ if (bestObs !== null) {
+ matchedWeight += w;
+ matched.push({ observed: bestObs, band });
+ if (band.selective) hasSelective = true;
+ }
+ }
+
+ if (matchedWeight === 0) continue;
+ const coverage = matchedWeight / totalWeight;
+
+ const reasonParts: string[] = [];
+ reasonParts.push(`${matched.length} of ${reference.bands.length} reference bands matched.`);
+ if (hasSelective) {
+ reasonParts.push('Includes a selective (diagnostic) band.');
+ }
+ if (reference.observationNotes) {
+ reasonParts.push(reference.observationNotes);
+ }
+
+ out.push({
+ reference,
+ coverage,
+ matched,
+ hasSelective,
+ reason: reasonParts.join(' '),
+ });
+ }
+
+ out.sort((a, b) => {
+ // Selective matches dominate.
+ if (a.hasSelective !== b.hasSelective) return a.hasSelective ? -1 : 1;
+ return b.coverage - a.coverage;
+ });
+ return out;
+}
diff --git a/src/lib/spectroscope/reference-bands.ts b/src/lib/spectroscope/reference-bands.ts
new file mode 100644
index 0000000..02b4676
--- /dev/null
+++ b/src/lib/spectroscope/reference-bands.ts
@@ -0,0 +1,217 @@
+/**
+ * Reference absorption-band data for the most-tested gem species.
+ *
+ * Wavelengths are in nanometres, sourced from standard gemmological reference
+ * texts (GIA Coloured Stones manual, Hodgkinson's Visual Optics, Webster's
+ * Gems). Intensity is qualitative: `weak` (faint band, easy to miss),
+ * `moderate` (clearly visible), `strong` (sharp dark line).
+ *
+ * `selective` flags bands that uniquely identify the species (e.g. the
+ * 504 nm line in almandine garnet, the chrome doublet at 692/694 nm in ruby).
+ *
+ * Stored separately from the SQLite mineral database — adding a column there
+ * is a multi-day data-authoring effort. This TypeScript table is the
+ * pragmatic v1 implementation; a future migration can populate
+ * `minerals.absorption_bands_json` from this same data.
+ */
+
+export interface AbsorptionBand {
+ /** Wavelength in nanometres. Use a single number for sharp lines; for broad bands list the centre. */
+ wavelength: number;
+ /** Optional secondary wavelength for doublets / triplets. */
+ also?: number[];
+ intensity: 'weak' | 'moderate' | 'strong';
+ /** Cause of the band (Cr³⁺, Fe²⁺, didymium, etc.). */
+ cause?: string;
+ /** True when this band alone is highly diagnostic. */
+ selective?: boolean;
+ /** Short note explaining the band. */
+ note?: string;
+}
+
+export interface SpectroscopeReference {
+ /** Mineral family id, matches `mineral_families.id`. */
+ familyId: string;
+ /** Display name. */
+ name: string;
+ bands: AbsorptionBand[];
+ /** Light-source caveats or viewing tips. */
+ observationNotes?: string;
+}
+
+export const SPECTROSCOPE_REFERENCE: SpectroscopeReference[] = [
+ // Chromophore: Cr³⁺ (chromium) — ruby, red spinel, alexandrite, emerald.
+ {
+ familyId: 'corundum',
+ name: 'Ruby (Cr-corundum)',
+ bands: [
+ { wavelength: 692, also: [694], intensity: 'strong', cause: 'Cr³⁺ doublet (R-lines)', selective: true, note: 'Sharp red doublet at 692/694 nm — the textbook ruby fingerprint.' },
+ { wavelength: 660, intensity: 'weak', cause: 'Cr³⁺' },
+ { wavelength: 555, intensity: 'moderate', cause: 'Cr³⁺ broad absorption', note: 'Wide green absorption band.' },
+ { wavelength: 476, intensity: 'moderate', cause: 'Cr³⁺' },
+ { wavelength: 468, intensity: 'moderate', cause: 'Cr³⁺' },
+ ],
+ observationNotes: 'Use a strong incandescent or fibre-optic source; the doublet is much easier in the red transmission band.',
+ },
+ {
+ familyId: 'spinel',
+ name: 'Red spinel (Cr-spinel)',
+ bands: [
+ { wavelength: 685, intensity: 'strong', cause: 'Cr³⁺ "organ-pipe"', selective: true, note: 'Series of fine lines 670–690 nm called the organ-pipe spectrum.' },
+ { wavelength: 656, intensity: 'moderate', cause: 'Cr³⁺' },
+ { wavelength: 540, intensity: 'moderate', cause: 'Cr³⁺' },
+ ],
+ },
+ {
+ familyId: 'chrysoberyl',
+ name: 'Alexandrite (Cr-chrysoberyl)',
+ bands: [
+ { wavelength: 680, also: [678], intensity: 'strong', cause: 'Cr³⁺ doublet', selective: true },
+ { wavelength: 645, intensity: 'weak', cause: 'Cr³⁺' },
+ { wavelength: 580, intensity: 'moderate', cause: 'Cr³⁺' },
+ { wavelength: 468, intensity: 'moderate', cause: 'Cr³⁺' },
+ ],
+ observationNotes: 'Colour change is best seen by switching incandescent ↔ daylight, but the spectrum is identical in both.',
+ },
+ {
+ familyId: 'beryl',
+ name: 'Emerald (Cr-beryl)',
+ bands: [
+ { wavelength: 683, also: [680], intensity: 'strong', cause: 'Cr³⁺ doublet', selective: true },
+ { wavelength: 637, intensity: 'weak', cause: 'Cr³⁺' },
+ { wavelength: 606, intensity: 'weak', cause: 'Cr³⁺' },
+ { wavelength: 477, intensity: 'moderate', cause: 'Cr³⁺ broad absorption' },
+ ],
+ observationNotes: 'Polariscope ω-ray gives strongest red doublet.',
+ },
+ // Chromophore: Fe³⁺ — blue sapphire, aquamarine.
+ {
+ familyId: 'corundum-blue',
+ name: 'Blue sapphire (Fe-corundum)',
+ bands: [
+ { wavelength: 450, intensity: 'strong', cause: 'Fe³⁺', selective: true, note: 'Diagnostic 450 nm line in iron-rich blue sapphires.' },
+ { wavelength: 460, intensity: 'moderate', cause: 'Fe³⁺' },
+ { wavelength: 470, intensity: 'weak', cause: 'Fe³⁺' },
+ ],
+ observationNotes: 'Synthetic flame-fusion sapphire often lacks the 450 nm line — useful diagnostic.',
+ },
+ // Almandine garnet — Fe²⁺.
+ {
+ familyId: 'almandine',
+ name: 'Almandine garnet (Fe²⁺)',
+ bands: [
+ { wavelength: 504, intensity: 'strong', cause: 'Fe²⁺', selective: true, note: 'The 504 nm line is the strongest Fe²⁺ band in almandine.' },
+ { wavelength: 527, intensity: 'strong', cause: 'Fe²⁺' },
+ { wavelength: 576, intensity: 'moderate', cause: 'Fe²⁺' },
+ { wavelength: 423, intensity: 'weak', cause: 'Fe²⁺' },
+ ],
+ observationNotes: 'Strongest broad-band Fe²⁺ spectrum among gems; visible even in dark stones.',
+ },
+ // Didymium-bearing rare-earth gems.
+ {
+ familyId: 'apatite',
+ name: 'Yellow apatite (didymium)',
+ bands: [
+ { wavelength: 580, intensity: 'strong', cause: 'didymium (Pr+Nd)', selective: true },
+ { wavelength: 525, intensity: 'moderate', cause: 'didymium' },
+ { wavelength: 512, intensity: 'moderate', cause: 'didymium' },
+ { wavelength: 491, intensity: 'weak', cause: 'didymium' },
+ ],
+ observationNotes: 'Sharp didymium lines also seen in some sphene and rare yellow zircon.',
+ },
+ {
+ familyId: 'sphene',
+ name: 'Sphene / titanite',
+ bands: [
+ { wavelength: 586, intensity: 'moderate', cause: 'didymium' },
+ { wavelength: 536, intensity: 'weak', cause: 'didymium' },
+ ],
+ },
+ // Zircon — characteristic uranium-bearing spectrum.
+ {
+ familyId: 'zircon',
+ name: 'Zircon (high type)',
+ bands: [
+ { wavelength: 653, intensity: 'strong', cause: 'U⁴⁺', selective: true, note: 'Classic uranium-line at 653.5 nm — strongest in heat-treated blue zircon.' },
+ { wavelength: 659, intensity: 'moderate', cause: 'U⁴⁺' },
+ { wavelength: 691, intensity: 'weak', cause: 'U⁴⁺' },
+ { wavelength: 588, intensity: 'weak', cause: 'U⁴⁺' },
+ { wavelength: 562, intensity: 'weak', cause: 'U⁴⁺' },
+ { wavelength: 537, intensity: 'weak', cause: 'U⁴⁺' },
+ { wavelength: 484, intensity: 'weak', cause: 'U⁴⁺' },
+ ],
+ observationNotes: 'Low-type (metamict) zircon shows weakened or no spectrum.',
+ },
+ // Peridot — Fe²⁺ triplet.
+ {
+ familyId: 'peridot',
+ name: 'Peridot (forsterite-fayalite)',
+ bands: [
+ { wavelength: 493, intensity: 'strong', cause: 'Fe²⁺', selective: true, note: '"Three-lock-and-key" pattern: 493 / 473 / 453 nm.' },
+ { wavelength: 473, intensity: 'strong', cause: 'Fe²⁺' },
+ { wavelength: 453, intensity: 'strong', cause: 'Fe²⁺' },
+ ],
+ },
+ // Diamond — N3 / N2 systems.
+ {
+ familyId: 'diamond',
+ name: 'Cape-series diamond (N3)',
+ bands: [
+ { wavelength: 415, intensity: 'strong', cause: 'N3 centre', selective: true, note: 'Cape line at 415.5 nm — diagnostic of natural type-Ia diamond.' },
+ { wavelength: 478, intensity: 'weak', cause: 'N3 / H3' },
+ { wavelength: 504, intensity: 'weak', cause: 'H3 (irradiated)' },
+ ],
+ observationNotes: 'Best seen at low temperature with a strong UV-blocked white source.',
+ },
+ // Synthetic/treated diamond — different N-V centres.
+ // Tourmaline — Cr/Fe/Mn pleochroic.
+ {
+ familyId: 'tourmaline',
+ name: 'Cr-tourmaline (chrome dravite)',
+ bands: [
+ { wavelength: 685, intensity: 'moderate', cause: 'Cr³⁺ doublet', note: 'Less sharp than ruby.' },
+ { wavelength: 460, intensity: 'moderate', cause: 'Cr³⁺' },
+ ],
+ },
+ // Demantoid garnet — Cr + Fe.
+ {
+ familyId: 'andradite',
+ name: 'Demantoid garnet',
+ bands: [
+ { wavelength: 444, intensity: 'strong', cause: 'Fe³⁺', selective: true, note: 'Diagnostic Fe³⁺ band at 440 nm distinguishes demantoid from chrysoberyl/peridot.' },
+ { wavelength: 622, intensity: 'weak', cause: 'Cr³⁺' },
+ { wavelength: 685, intensity: 'weak', cause: 'Cr³⁺' },
+ ],
+ },
+ // Chrysoberyl yellow — Fe³⁺.
+ {
+ familyId: 'chrysoberyl-yellow',
+ name: 'Yellow chrysoberyl (Fe)',
+ bands: [
+ { wavelength: 444, intensity: 'strong', cause: 'Fe³⁺', selective: true },
+ ],
+ },
+ // Aquamarine — light Fe²⁺.
+ {
+ familyId: 'beryl-aqua',
+ name: 'Aquamarine (Fe-beryl)',
+ bands: [
+ { wavelength: 537, intensity: 'weak', cause: 'Fe²⁺' },
+ { wavelength: 456, intensity: 'weak', cause: 'Fe³⁺' },
+ { wavelength: 427, intensity: 'weak', cause: 'Fe³⁺' },
+ ],
+ observationNotes: 'Spectrum is faint; lighter stones may show no bands.',
+ },
+];
+
+/** All distinct wavelengths in the reference set, sorted ascending. */
+export function getAllReferenceBands(): number[] {
+ const seen = new Set();
+ for (const ref of SPECTROSCOPE_REFERENCE) {
+ for (const b of ref.bands) {
+ seen.add(b.wavelength);
+ if (b.also) for (const w of b.also) seen.add(w);
+ }
+ }
+ return Array.from(seen).sort((a, b) => a - b);
+}
diff --git a/src/lib/treatments/wizard.test.ts b/src/lib/treatments/wizard.test.ts
new file mode 100644
index 0000000..472ebdb
--- /dev/null
+++ b/src/lib/treatments/wizard.test.ts
@@ -0,0 +1,93 @@
+import { describe, it, expect } from 'vitest';
+import { runWizard, cluesForKind, CLUES } from './wizard';
+
+describe('runWizard', () => {
+ it('flags heat treatment when silk halos and chalky SWUV observed in corundum', () => {
+ const verdicts = runWizard({
+ gemKind: 'corundum',
+ selectedClueIds: ['silk-halo', 'chalky-swuv'],
+ });
+ const heat = verdicts.find((v) => v.treatment === 'heat');
+ expect(heat).toBeDefined();
+ expect(heat!.confidence).toMatch(/likely|very likely/);
+ expect(heat!.supportingClueIds).toContain('silk-halo');
+ expect(heat!.supportingClueIds).toContain('chalky-swuv');
+ });
+
+ it('rules out heat when sharp intact silk is observed', () => {
+ const verdicts = runWizard({
+ gemKind: 'corundum',
+ selectedClueIds: ['rutile-silk-intact'],
+ });
+ const heat = verdicts.find((v) => v.treatment === 'heat');
+ expect(heat).toBeDefined();
+ expect(heat!.score).toBeLessThan(0);
+ expect(heat!.contradictingClueIds).toContain('rutile-silk-intact');
+ });
+
+ it('flags glass / oil filling when flash effect and surface bubbles seen', () => {
+ const verdicts = runWizard({
+ gemKind: 'emerald',
+ selectedClueIds: ['flash-effect', 'surface-bubbles', 'sweat-test'],
+ });
+ const top = verdicts[0];
+ expect(['oil-resin', 'glass-filling']).toContain(top.treatment);
+ expect(top.confidence).toBe('very likely');
+ });
+
+ it('flags HPHT for diamond decolourisation', () => {
+ const verdicts = runWizard({
+ gemKind: 'diamond',
+ selectedClueIds: ['diamond-brown-to-colourless', 'graining-strong'],
+ });
+ expect(verdicts[0].treatment).toBe('hpht');
+ expect(verdicts[0].confidence).toBe('very likely');
+ });
+
+ it('skips clues that do not apply to the chosen gem kind', () => {
+ const verdicts = runWizard({
+ gemKind: 'topaz',
+ selectedClueIds: ['silk-halo', 'colour-fades-light'],
+ });
+ expect(verdicts.find((v) => v.treatment === 'heat')).toBeUndefined();
+ const irr = verdicts.find((v) => v.treatment === 'irradiation');
+ expect(irr).toBeDefined();
+ expect(irr!.supportingClueIds).toContain('colour-fades-light');
+ });
+
+ it('returns empty list when no clues selected', () => {
+ expect(runWizard({ gemKind: 'corundum', selectedClueIds: [] })).toEqual([]);
+ });
+
+ it('flags bleaching+resin for acid-etched jadeite', () => {
+ const verdicts = runWizard({
+ gemKind: 'jadeite',
+ selectedClueIds: ['jadeite-acid-etch', 'porous-or-low-density'],
+ });
+ const bleach = verdicts.find((v) => v.treatment === 'bleaching');
+ expect(bleach).toBeDefined();
+ expect(bleach!.confidence).toMatch(/likely|very likely/);
+ });
+});
+
+describe('cluesForKind', () => {
+ it('filters out gem-specific clues that do not apply', () => {
+ const topazClues = cluesForKind('topaz');
+ expect(topazClues.find((c) => c.id === 'silk-halo')).toBeUndefined();
+ expect(topazClues.find((c) => c.id === 'colour-fades-light')).toBeDefined();
+ });
+
+ it('returns generic clues regardless of kind', () => {
+ const list = cluesForKind('opal');
+ expect(list.find((c) => c.id === 'iridescent-surface')).toBeDefined();
+ expect(list.find((c) => c.id === 'colour-removed-acetone')).toBeDefined();
+ });
+
+ it('every clue with no applicableTo restriction appears for any gem', () => {
+ const generic = CLUES.filter((c) => !c.applicableTo);
+ const pearlList = cluesForKind('pearl');
+ for (const g of generic) {
+ expect(pearlList.find((c) => c.id === g.id)).toBeDefined();
+ }
+ });
+});
diff --git a/src/lib/treatments/wizard.ts b/src/lib/treatments/wizard.ts
new file mode 100644
index 0000000..1f6378f
--- /dev/null
+++ b/src/lib/treatments/wizard.ts
@@ -0,0 +1,239 @@
+/**
+ * Treatment-detection wizard reasoning.
+ *
+ * Maps observed clues to candidate treatments per species. Evidence-weighted:
+ * each clue contributes a positive (supports), neutral (consistent), or
+ * negative (rules out) signal toward each treatment hypothesis.
+ *
+ * Source: GIA gem identification course material, Hughes' Ruby & Sapphire,
+ * standard CIBJO Blue Book treatment definitions.
+ */
+
+export type GemKind =
+ | 'corundum'
+ | 'emerald'
+ | 'beryl-other'
+ | 'tourmaline'
+ | 'topaz'
+ | 'quartz'
+ | 'amber'
+ | 'turquoise'
+ | 'jadeite'
+ | 'opal'
+ | 'pearl'
+ | 'diamond';
+
+export type TreatmentId =
+ | 'heat'
+ | 'oil-resin'
+ | 'lattice-diffusion'
+ | 'surface-diffusion'
+ | 'irradiation'
+ | 'coating'
+ | 'glass-filling'
+ | 'dye'
+ | 'bleaching'
+ | 'hpht'
+ | 'flux-healing';
+
+export interface ClueDef {
+ id: string;
+ label: string;
+ description?: string;
+ /** Per-treatment effect: positive supports, negative rules out, 0 means no impact. */
+ effects: Partial>;
+ /** Restrict the clue to certain gem kinds (display filter). */
+ applicableTo?: GemKind[];
+}
+
+export const TREATMENT_LABELS: Record = {
+ heat: 'Heat treatment',
+ 'oil-resin': 'Oiling / resin filling',
+ 'lattice-diffusion': 'Lattice (Be / Ti) diffusion',
+ 'surface-diffusion': 'Surface diffusion (colour skin)',
+ irradiation: 'Irradiation',
+ coating: 'Surface coating / lacquer',
+ 'glass-filling': 'Lead-glass filling',
+ dye: 'Dyeing',
+ bleaching: 'Bleaching',
+ hpht: 'HPHT colour modification',
+ 'flux-healing': 'Flux healing of fractures',
+};
+
+export const CLUES: ClueDef[] = [
+ {
+ id: 'silk-halo',
+ label: 'Discoid halos / disrupted silk under magnification',
+ description: 'Snowball-like discs around former rutile silk; classic of heated corundum.',
+ effects: { heat: 3, 'lattice-diffusion': 1 },
+ applicableTo: ['corundum'],
+ },
+ {
+ id: 'rutile-silk-intact',
+ label: 'Sharp, intact fine rutile silk',
+ description: 'Long undamaged silk needles indicate the stone has not been strongly heated.',
+ effects: { heat: -3, 'lattice-diffusion': -2 },
+ applicableTo: ['corundum'],
+ },
+ {
+ id: 'colour-concentration-rim',
+ label: 'Colour concentration at facet edges / culet (rim of colour)',
+ description: 'Colour follows facet edges from a thin diffused layer.',
+ effects: { 'surface-diffusion': 4, 'lattice-diffusion': 2, dye: 1 },
+ applicableTo: ['corundum'],
+ },
+ {
+ id: 'chalky-swuv',
+ label: 'Chalky / cloudy short-wave UV reaction',
+ description: 'Chalky blue-white SW glow develops in heat-treated sapphires (especially geuda-derived).',
+ effects: { heat: 3, 'lattice-diffusion': 2 },
+ applicableTo: ['corundum'],
+ },
+ {
+ id: 'flash-effect',
+ label: 'Flash effect (blue/orange flash on tilt)',
+ description: 'Blue / orange flash of light along fractures viewed at certain angles.',
+ effects: { 'oil-resin': 4, 'glass-filling': 4 },
+ applicableTo: ['emerald', 'corundum'],
+ },
+ {
+ id: 'surface-bubbles',
+ label: 'Round gas bubbles in surface-reaching fissures',
+ description: 'Trapped during glass / resin filling.',
+ effects: { 'glass-filling': 4, 'oil-resin': 2 },
+ applicableTo: ['corundum', 'emerald', 'turquoise'],
+ },
+ {
+ id: 'sweat-test',
+ label: 'Residue / sweating after gentle warming',
+ description: 'Oil or resin sweats out of fissures when stone is warmed.',
+ effects: { 'oil-resin': 4 },
+ applicableTo: ['emerald', 'beryl-other'],
+ },
+ {
+ id: 'colour-zoning-strong',
+ label: 'Strong straight or angular colour zoning',
+ description: 'Natural growth zoning; rules out many homogenised treatments.',
+ effects: { heat: -1, 'lattice-diffusion': -1 },
+ },
+ {
+ id: 'colour-zoning-curved',
+ label: 'Curved (Plato) colour bands',
+ description: 'Curved striae indicate flame-fusion synthesis, not treatment.',
+ effects: {},
+ },
+ {
+ id: 'fingerprint-healed',
+ label: 'Healed fingerprint inclusions / partial healing',
+ description: 'Re-healed fractures from flux-assisted heating.',
+ effects: { 'flux-healing': 3, heat: 2 },
+ applicableTo: ['corundum'],
+ },
+ {
+ id: 'flux-residue',
+ label: 'Yellow / orange flux residue in fissures',
+ description: 'Borax-like residue trapped during flux heating.',
+ effects: { 'flux-healing': 4, heat: 1 },
+ applicableTo: ['corundum'],
+ },
+ {
+ id: 'colour-fades-light',
+ label: 'Colour fades with prolonged light or heat',
+ effects: { irradiation: 3, dye: 2, coating: 2 },
+ applicableTo: ['topaz', 'tourmaline', 'beryl-other', 'quartz', 'turquoise'],
+ },
+ {
+ id: 'colour-removed-acetone',
+ label: 'Colour rubs / dissolves with acetone or alcohol swab',
+ effects: { dye: 4, coating: 3 },
+ },
+ {
+ id: 'iridescent-surface',
+ label: 'Iridescent or oily surface sheen under reflection',
+ effects: { coating: 4 },
+ },
+ {
+ id: 'diamond-brown-to-colourless',
+ label: 'Diamond changed from brown to colourless / yellow → colourless',
+ effects: { hpht: 4 },
+ applicableTo: ['diamond'],
+ },
+ {
+ id: 'graining-strong',
+ label: 'Strong internal graining / strain (HPHT diamond)',
+ effects: { hpht: 2 },
+ applicableTo: ['diamond'],
+ },
+ {
+ id: 'porous-or-low-density',
+ label: 'Porous / chalky surface or unusually low SG',
+ effects: { dye: 2, bleaching: 3, 'oil-resin': 2 },
+ applicableTo: ['turquoise', 'jadeite'],
+ },
+ {
+ id: 'jadeite-acid-etch',
+ label: 'Jadeite — acid-etched / honeycomb surface texture',
+ effects: { bleaching: 4, 'oil-resin': 3 },
+ applicableTo: ['jadeite'],
+ },
+];
+
+export interface WizardCriteria {
+ gemKind: GemKind;
+ selectedClueIds: string[];
+}
+
+export interface TreatmentVerdict {
+ treatment: TreatmentId;
+ label: string;
+ /** Aggregate evidence score; positive favours, negative argues against. */
+ score: number;
+ /** Verbal confidence band based on score. */
+ confidence: 'unlikely' | 'possible' | 'likely' | 'very likely';
+ /** Clues contributing positively to this verdict. */
+ supportingClueIds: string[];
+ /** Clues contributing negatively. */
+ contradictingClueIds: string[];
+}
+
+const ALL_TREATMENTS: TreatmentId[] = Object.keys(TREATMENT_LABELS) as TreatmentId[];
+
+export function runWizard(criteria: WizardCriteria): TreatmentVerdict[] {
+ const verdicts: Record = {} as Record;
+ for (const t of ALL_TREATMENTS) {
+ verdicts[t] = {
+ treatment: t,
+ label: TREATMENT_LABELS[t],
+ score: 0,
+ confidence: 'unlikely',
+ supportingClueIds: [],
+ contradictingClueIds: [],
+ };
+ }
+
+ for (const clueId of criteria.selectedClueIds) {
+ const clue = CLUES.find((c) => c.id === clueId);
+ if (!clue) continue;
+ if (clue.applicableTo && !clue.applicableTo.includes(criteria.gemKind)) continue;
+ for (const [tid, weight] of Object.entries(clue.effects) as [TreatmentId, number][]) {
+ verdicts[tid].score += weight;
+ if (weight > 0) verdicts[tid].supportingClueIds.push(clueId);
+ else if (weight < 0) verdicts[tid].contradictingClueIds.push(clueId);
+ }
+ }
+
+ for (const v of Object.values(verdicts)) {
+ if (v.score >= 6) v.confidence = 'very likely';
+ else if (v.score >= 3) v.confidence = 'likely';
+ else if (v.score >= 1) v.confidence = 'possible';
+ else v.confidence = 'unlikely';
+ }
+
+ return Object.values(verdicts)
+ .filter((v) => v.score > 0 || v.contradictingClueIds.length > 0)
+ .sort((a, b) => b.score - a.score);
+}
+
+export function cluesForKind(kind: GemKind): ClueDef[] {
+ return CLUES.filter((c) => !c.applicableTo || c.applicableTo.includes(kind));
+}
diff --git a/src/lib/uv-fluorescence/parse-fluorescence.test.ts b/src/lib/uv-fluorescence/parse-fluorescence.test.ts
new file mode 100644
index 0000000..162e74e
--- /dev/null
+++ b/src/lib/uv-fluorescence/parse-fluorescence.test.ts
@@ -0,0 +1,62 @@
+import { describe, it, expect } from 'vitest';
+import { parseFluorescence, scoreUvMatch } from './parse-fluorescence';
+
+describe('parseFluorescence', () => {
+ it('returns inert for both bands when text is just "inert"', () => {
+ const fl = parseFluorescence('Inert');
+ expect(fl?.lwuv?.intensity).toBe('inert');
+ expect(fl?.swuv?.intensity).toBe('inert');
+ });
+
+ it('extracts LW colour and intensity', () => {
+ const fl = parseFluorescence('LW: red strong; SW: inert');
+ expect(fl?.lwuv?.color).toBe('red');
+ expect(fl?.lwuv?.intensity).toBe('strong');
+ expect(fl?.swuv?.intensity).toBe('inert');
+ });
+
+ it('handles "long-wave" / "short-wave" wording', () => {
+ const fl = parseFluorescence('Long-wave UV strong red, short-wave weak');
+ expect(fl?.lwuv?.intensity).toBe('strong');
+ expect(fl?.swuv?.intensity).toBe('weak');
+ });
+
+ it('returns null for empty input', () => {
+ expect(parseFluorescence('')).toBeNull();
+ expect(parseFluorescence(null)).toBeNull();
+ });
+
+ it('detects phosphorescence', () => {
+ const fl = parseFluorescence('LW: blue moderate; phosphoresces yellow');
+ expect(fl?.phosphorescence).toMatch(/phosphoresc/);
+ });
+});
+
+describe('scoreUvMatch', () => {
+ it('returns 0 for null fluorescence', () => {
+ expect(
+ scoreUvMatch(
+ { lwuvIntensity: 'strong', lwuvColor: 'red', swuvIntensity: 'inert', swuvColor: '' },
+ null,
+ ),
+ ).toBe(0);
+ });
+
+ it('scores high for matching colour and intensity', () => {
+ const fl = parseFluorescence('LW: red strong; SW: inert')!;
+ const score = scoreUvMatch(
+ { lwuvIntensity: 'strong', lwuvColor: 'red', swuvIntensity: 'inert', swuvColor: '' },
+ fl,
+ );
+ expect(score).toBeGreaterThan(0.8);
+ });
+
+ it('scores low for mismatched colour', () => {
+ const fl = parseFluorescence('LW: red strong; SW: inert')!;
+ const score = scoreUvMatch(
+ { lwuvIntensity: 'strong', lwuvColor: 'green', swuvIntensity: 'inert', swuvColor: '' },
+ fl,
+ );
+ expect(score).toBeLessThanOrEqual(0.7);
+ });
+});
diff --git a/src/lib/uv-fluorescence/parse-fluorescence.ts b/src/lib/uv-fluorescence/parse-fluorescence.ts
new file mode 100644
index 0000000..ac58379
--- /dev/null
+++ b/src/lib/uv-fluorescence/parse-fluorescence.ts
@@ -0,0 +1,140 @@
+/**
+ * UV-fluorescence parser.
+ *
+ * The mineral database stores fluorescence as a freeform text field
+ * ("LWUV: red strong; SWUV: weak red; phosphoresces"). To support a UV
+ * lookup widget without a SQL schema migration, we parse that string into
+ * structured `{lwuv, swuv, phosphorescence}` triples, then match against
+ * user-reported observations.
+ *
+ * The parser is forgiving — it handles many trade abbreviations (LW/SW,
+ * 365 nm/254 nm, "long-wave", "short-wave") and conservatively returns
+ * `undefined` per field when uncertain.
+ */
+
+export type UvIntensity = 'inert' | 'weak' | 'moderate' | 'strong' | 'very_strong' | 'unknown';
+
+export interface UvFluorescence {
+ lwuv?: { color?: string; intensity: UvIntensity };
+ swuv?: { color?: string; intensity: UvIntensity };
+ phosphorescence?: string;
+}
+
+const INTENSITY_RE = /\b(inert|none|weak|moderate|medium|strong|very[\s-]?strong|intense)\b/i;
+
+const COLOR_RE =
+ /\b(red(?:dish)?|orange|yellow(?:ish)?|green(?:ish)?|blue(?:ish)?|violet|purple|pink|white|chalky|cream|salmon|brown(?:ish)?)\b/i;
+
+function intensityFrom(s: string): UvIntensity {
+ const m = s.match(INTENSITY_RE);
+ if (!m) return 'unknown';
+ const w = m[1].toLowerCase().replace(/[\s-]/g, '');
+ if (w === 'inert' || w === 'none') return 'inert';
+ if (w === 'weak') return 'weak';
+ if (w === 'moderate' || w === 'medium') return 'moderate';
+ if (w === 'verystrong' || w === 'intense') return 'very_strong';
+ if (w === 'strong') return 'strong';
+ return 'unknown';
+}
+
+function colorFrom(s: string): string | undefined {
+ const m = s.match(COLOR_RE);
+ return m ? m[0].toLowerCase() : undefined;
+}
+
+/**
+ * Parse a freeform fluorescence text into structured fields.
+ * Examples handled:
+ * "LW: red strong; SW: inert"
+ * "Long-wave UV strong red, short-wave weak"
+ * "365 nm: red moderate; 254 nm: chalky white weak"
+ * "Inert"
+ */
+export function parseFluorescence(text: string | null | undefined): UvFluorescence | null {
+ if (!text || !text.trim()) return null;
+ const t = text.toLowerCase();
+
+ if (/\b(inert|none)\b/i.test(t) && !/\b(lw|sw|long|short|365|254)\b/i.test(t)) {
+ return { lwuv: { intensity: 'inert' }, swuv: { intensity: 'inert' } };
+ }
+
+ // Split on common separators that signal LW vs SW segments.
+ const segments = t
+ .split(/[;.\n]|(?:,| and )\s*(?=(?:lw|sw|long|short|365|254))/i)
+ .map((s) => s.trim())
+ .filter(Boolean);
+
+ let lwuv: UvFluorescence['lwuv'];
+ let swuv: UvFluorescence['swuv'];
+ let phosphorescence: string | undefined;
+
+ for (const seg of segments) {
+ if (/phosphoresc/.test(seg)) phosphorescence = seg.trim();
+
+ const isLW = /\b(lw|long[-\s]?wave|365)/.test(seg);
+ const isSW = /\b(sw|short[-\s]?wave|254)/.test(seg);
+ const intensity = intensityFrom(seg);
+ const color = colorFrom(seg);
+
+ if (isLW && intensity !== 'unknown') lwuv = { intensity, color };
+ if (isSW && intensity !== 'unknown') swuv = { intensity, color };
+ // Untagged segment with both intensity+color: assume LW (the trade default).
+ if (!isLW && !isSW && intensity !== 'unknown' && !lwuv) {
+ lwuv = { intensity, color };
+ }
+ }
+
+ if (!lwuv && !swuv && !phosphorescence) return null;
+ return { lwuv, swuv, phosphorescence };
+}
+
+export interface UvObservation {
+ lwuvIntensity: UvIntensity;
+ lwuvColor: string;
+ swuvIntensity: UvIntensity;
+ swuvColor: string;
+}
+
+/**
+ * Score how well a mineral's parsed fluorescence matches an observation.
+ * Returns 0..1; values below 0.25 are filtered out by callers.
+ */
+export function scoreUvMatch(obs: UvObservation, fl: UvFluorescence | null): number {
+ if (!fl) return 0;
+ let score = 0;
+ let weight = 0;
+
+ const cmpField = (
+ obsIntensity: UvIntensity,
+ obsColor: string,
+ field: { intensity: UvIntensity; color?: string } | undefined,
+ ) => {
+ if (obsIntensity === 'unknown') return;
+ weight += 1;
+ if (!field) return;
+ // Intensity similarity (rank distance).
+ const rank: Record = {
+ inert: 0,
+ weak: 1,
+ moderate: 2,
+ strong: 3,
+ very_strong: 4,
+ unknown: 2,
+ };
+ const intensityDiff = Math.abs(rank[obsIntensity] - rank[field.intensity]);
+ const intensityScore = Math.max(0, 1 - intensityDiff * 0.25);
+ let colorScore = 0.5;
+ if (obsColor && field.color) {
+ colorScore = obsColor.toLowerCase() === field.color.toLowerCase() ? 1 : 0;
+ } else if (!obsColor) {
+ colorScore = 1;
+ }
+ score += 0.4 * intensityScore + 0.6 * colorScore;
+ };
+
+ cmpField(obs.lwuvIntensity, obs.lwuvColor, fl.lwuv);
+ cmpField(obs.swuvIntensity, obs.swuvColor, fl.swuv);
+
+ if (weight === 0) return 0;
+ return score / weight;
+}
diff --git a/src/pages/tools/conversions.astro b/src/pages/tools/conversions.astro
index 29e8341..17c02c3 100644
--- a/src/pages/tools/conversions.astro
+++ b/src/pages/tools/conversions.astro
@@ -8,14 +8,14 @@ import { ConversionTools } from '../../components/calculator';
Unit Conversions
- Convert between carats, grams, millimeters, inches, Celsius, Fahrenheit, and calculate price per carat with markup.
+ Convert between carats, grams, millimeters, inches, Celsius, and Fahrenheit.