@@ -246,6 +246,38 @@ <h3 style="font-size:1rem;margin:1rem 0 0.5rem;">Detail per crop × country × m
246246 </ p >
247247 </ div >
248248
249+ < div class ="card ">
250+ < h2 > Research Q3 — Which crop is easier to predict (fair comparison)?</ h2 >
251+ < p class ="muted ">
252+ Pooling all countries biases crop rankings (e.g. maize-heavy regions may be harder).
253+ Here we keep only < strong > countries with both crops</ strong > and compare NRMSE within the same
254+ country and model — a paired crop×country comparison.
255+ </ p >
256+ < div class ="controls ">
257+ < span class ="muted "> Horizon</ span >
258+ < div class ="toolbar " id ="rq3-horizon-toolbar ">
259+ < button type ="button " data-horizon ="eos " class ="active "> End of season</ button >
260+ < button type ="button " data-horizon ="mid "> Mid-season</ button >
261+ </ div >
262+ < label > Crop pair
263+ < select id ="rq3-pair-filter "> </ select >
264+ </ label >
265+ </ div >
266+ < p class ="muted " id ="crop-pair-note "> </ p >
267+ < div class ="stat-grid " id ="crop-pair-stats "> </ div >
268+ < h3 style ="font-size:1rem;margin:1rem 0 0.5rem; "> By model (paired countries only)</ h3 >
269+ < div class ="table-scroll " id ="crop-pair-summary-wrap "> </ div >
270+ < h3 style ="font-size:1rem;margin:1rem 0 0.5rem; "> Detail per country × model</ h3 >
271+ < div class ="controls ">
272+ < label > Model < input type ="search " id ="crop-detail-model-filter " placeholder ="Filter… " /> </ label >
273+ < label > Country
274+ < select id ="crop-detail-country-filter "> < option value =""> All</ option > </ select >
275+ </ label >
276+ </ div >
277+ < div class ="table-scroll " id ="crop-pair-detail-wrap "> </ div >
278+ < p class ="muted note " id ="crop-pair-delta-note "> </ p >
279+ </ div >
280+
249281 < p class ="muted ">
250282 < a href ="model_families.html "> Model families (paper summary)</ a > ·
251283 < a href ="index.html "> ← Back to country dashboards</ a >
@@ -774,6 +806,150 @@ <h3 style="font-size:1rem;margin:1rem 0 0.5rem;">Detail per crop × country × m
774806 countryFilter . addEventListener ( "change" , renderHorizonDetail ) ;
775807 modelFilter . addEventListener ( "input" , renderHorizonDetail ) ;
776808 renderHorizonDetail ( ) ;
809+
810+ // Crop pair comparison (RQ3)
811+ let rq3Horizon = "eos" ;
812+ let rq3PairKey = "" ;
813+ const rq3HorizonToolbar = document . getElementById ( "rq3-horizon-toolbar" ) ;
814+ const rq3PairFilter = document . getElementById ( "rq3-pair-filter" ) ;
815+ const cropPairNote = document . getElementById ( "crop-pair-note" ) ;
816+ const cropPairStats = document . getElementById ( "crop-pair-stats" ) ;
817+ const cropPairDeltaNote = document . getElementById ( "crop-pair-delta-note" ) ;
818+ const cropDetailCountryFilter = document . getElementById ( "crop-detail-country-filter" ) ;
819+ const cropDetailModelFilter = document . getElementById ( "crop-detail-model-filter" ) ;
820+ const cpsWrap = document . getElementById ( "crop-pair-summary-wrap" ) ;
821+ const cpdWrap = document . getElementById ( "crop-pair-detail-wrap" ) ;
822+
823+ function currentCropPairSlice ( ) {
824+ const hz = ( DATA . crop_comparison || { } ) [ rq3Horizon ] || { } ;
825+ const keys = Object . keys ( hz ) . sort ( ) ;
826+ if ( ! rq3PairKey && keys . length ) rq3PairKey = keys [ 0 ] ;
827+ if ( rq3PairKey && ! hz [ rq3PairKey ] && keys . length ) rq3PairKey = keys [ 0 ] ;
828+ return hz [ rq3PairKey ] || null ;
829+ }
830+
831+ function populateCropPairOptions ( ) {
832+ const hz = ( DATA . crop_comparison || { } ) [ rq3Horizon ] || { } ;
833+ const keys = Object . keys ( hz ) . sort ( ) ;
834+ rq3PairFilter . innerHTML = keys . length
835+ ? keys . map ( k => {
836+ const p = hz [ k ] ;
837+ const label = `${ p . crop_a } vs ${ p . crop_b } ` ;
838+ return `<option value="${ k } ">${ label } </option>` ;
839+ } ) . join ( "" )
840+ : `<option value="">No crop pairs</option>` ;
841+ if ( keys . length && ( ! rq3PairKey || ! hz [ rq3PairKey ] ) ) rq3PairKey = keys [ 0 ] ;
842+ rq3PairFilter . value = rq3PairKey || "" ;
843+ }
844+
845+ let cpsSort = "weighted_delta_nrmse" ;
846+ let cpsDir = "desc" ;
847+ let cpdSort = "delta_nrmse" ;
848+ let cpdDir = "desc" ;
849+
850+ function renderCropPairComparison ( ) {
851+ const slice = currentCropPairSlice ( ) ;
852+ const overall = slice ? ( slice . overall || { } ) : { } ;
853+ const cropA = overall . crop_a || "crop A" ;
854+ const cropB = overall . crop_b || "crop B" ;
855+ const cropALabel = cropA . charAt ( 0 ) . toUpperCase ( ) + cropA . slice ( 1 ) ;
856+ const cropBLabel = cropB . charAt ( 0 ) . toUpperCase ( ) + cropB . slice ( 1 ) ;
857+
858+ cropPairNote . textContent = overall . interpretation || "No paired crop comparison for this horizon." ;
859+ cropPairDeltaNote . textContent = slice
860+ ? `Δ NRMSE = ${ cropBLabel } − ${ cropALabel } . Positive ⇒ ${ cropALabel } has lower NRMSE. `
861+ + `${ cropALabel } win % = share of country×model pairs where ${ cropALabel } wins.`
862+ : "" ;
863+
864+ if ( ! overall . n_pairs ) {
865+ cropPairStats . innerHTML = `<p class="muted">No paired comparisons available.</p>` ;
866+ cpsWrap . innerHTML = "" ;
867+ cpdWrap . innerHTML = "" ;
868+ return ;
869+ }
870+
871+ const countries = overall . paired_countries || [ ] ;
872+ cropPairStats . innerHTML = `
873+ <div class="stat"><div class="label">Paired countries</div><div class="value">${ overall . n_countries || countries . length } </div></div>
874+ <div class="stat"><div class="label">Country×model pairs</div><div class="value">${ overall . n_pairs } </div></div>
875+ <div class="stat"><div class="label">${ cropALabel } wins</div><div class="value good">${ pct ( overall . crop_a_win_rate ) } </div></div>
876+ <div class="stat"><div class="label">Mean ${ cropALabel } NRMSE</div><div class="value">${ fmt ( overall . mean_crop_a_nrmse ) } </div></div>
877+ <div class="stat"><div class="label">Mean ${ cropBLabel } NRMSE</div><div class="value">${ fmt ( overall . mean_crop_b_nrmse ) } </div></div>
878+ <div class="stat"><div class="label">Weighted Δ NRMSE</div><div class="value">${ fmt ( overall . weighted_delta_nrmse ) } </div></div>` ;
879+
880+ const cpsCols = [
881+ { key : "model" , label : "Model" } ,
882+ { key : "n_countries" , label : "Countries" } ,
883+ { key : "n_pairs" , label : "Pairs" } ,
884+ { key : "crop_a_win_rate" , label : `${ cropALabel } win %` , format : pct } ,
885+ { key : "weighted_delta_nrmse" , label : "Weighted Δ NRMSE" , bg : fixedDeltaBg } ,
886+ { key : "mean_delta_nrmse" , label : "Mean Δ NRMSE" , bg : fixedDeltaBg } ,
887+ { key : "mean_crop_a_nrmse" , label : `Mean ${ cropALabel } NRMSE` , bg : fixedNrmseBg } ,
888+ { key : "mean_crop_b_nrmse" , label : `Mean ${ cropBLabel } NRMSE` , bg : fixedNrmseBg } ,
889+ ] ;
890+ const summaryRows = [ ...( slice . summary || [ ] ) ] . sort ( ( a , b ) => {
891+ const dir = cpsDir === "asc" ? 1 : - 1 ;
892+ const cmp = compareSortValues ( a , b , cpsSort ) ;
893+ if ( cmp !== 0 ) return dir * cmp ;
894+ return a . model . localeCompare ( b . model ) ;
895+ } ) ;
896+ renderSortableTable ( cpsWrap , cpsCols , summaryRows , cpsSort , cpsDir , ( key ) => {
897+ if ( cpsSort === key ) cpsDir = cpsDir === "asc" ? "desc" : "asc" ;
898+ else { cpsSort = key ; cpsDir = key === "crop_a_win_rate" || key . includes ( "delta" ) ? "desc" : "asc" ; }
899+ renderCropPairComparison ( ) ;
900+ } ) ;
901+
902+ const detailCountries = [ ...new Set ( ( slice . detail || [ ] ) . map ( r => r . country ) ) ] . sort ( ) ;
903+ cropDetailCountryFilter . innerHTML = `<option value="">All</option>` +
904+ detailCountries . map ( c => `<option value="${ c } ">${ c } </option>` ) . join ( "" ) ;
905+
906+ const cpdCols = [
907+ { key : "country" , label : "Country" } ,
908+ { key : "model" , label : "Model" } ,
909+ { key : "crop_a_nrmse" , label : `${ cropALabel } NRMSE` , bg : fixedNrmseBg } ,
910+ { key : "crop_b_nrmse" , label : `${ cropBLabel } NRMSE` , bg : fixedNrmseBg } ,
911+ { key : "delta_nrmse" , label : "Δ NRMSE" , bg : fixedDeltaBg } ,
912+ { key : "crop_a_r2" , label : `${ cropALabel } R²` } ,
913+ { key : "crop_b_r2" , label : `${ cropBLabel } R²` } ,
914+ { key : "crop_a_samples" , label : `${ cropALabel } samples` , format : ( v ) => v == null ? "—" : String ( v ) } ,
915+ { key : "crop_b_samples" , label : `${ cropBLabel } samples` , format : ( v ) => v == null ? "—" : String ( v ) } ,
916+ ] ;
917+ const cq = cropDetailCountryFilter . value ;
918+ const mq = cropDetailModelFilter . value . trim ( ) . toLowerCase ( ) ;
919+ const detailRows = ( slice . detail || [ ] ) . filter ( r => {
920+ if ( cq && r . country !== cq ) return false ;
921+ if ( mq && ! r . model . toLowerCase ( ) . includes ( mq ) ) return false ;
922+ return true ;
923+ } ) . sort ( ( a , b ) => {
924+ const dir = cpdDir === "asc" ? 1 : - 1 ;
925+ const cmp = compareSortValues ( a , b , cpdSort ) ;
926+ if ( cmp !== 0 ) return dir * cmp ;
927+ return a . model . localeCompare ( b . model ) ;
928+ } ) ;
929+ renderSortableTable ( cpdWrap , cpdCols , detailRows , cpdSort , cpdDir , ( key ) => {
930+ if ( cpdSort === key ) cpdDir = cpdDir === "asc" ? "desc" : "asc" ;
931+ else { cpdSort = key ; cpdDir = key === "country" || key === "model" ? "asc" : "desc" ; }
932+ renderCropPairComparison ( ) ;
933+ } ) ;
934+ }
935+
936+ rq3HorizonToolbar . querySelectorAll ( "button" ) . forEach ( btn => {
937+ btn . addEventListener ( "click" , ( ) => {
938+ rq3HorizonToolbar . querySelectorAll ( "button" ) . forEach ( b => b . classList . remove ( "active" ) ) ;
939+ btn . classList . add ( "active" ) ;
940+ rq3Horizon = btn . dataset . horizon ;
941+ populateCropPairOptions ( ) ;
942+ renderCropPairComparison ( ) ;
943+ } ) ;
944+ } ) ;
945+ rq3PairFilter . addEventListener ( "change" , ( ) => {
946+ rq3PairKey = rq3PairFilter . value ;
947+ renderCropPairComparison ( ) ;
948+ } ) ;
949+ cropDetailCountryFilter . addEventListener ( "change" , renderCropPairComparison ) ;
950+ cropDetailModelFilter . addEventListener ( "input" , renderCropPairComparison ) ;
951+ populateCropPairOptions ( ) ;
952+ renderCropPairComparison ( ) ;
777953 </ script >
778954</ body >
779955</ html >
0 commit comments